Skip to content

Commit b5c0b1a

Browse files
hanamorixHanaclaude
authored
PR-A: Kill user-facing brain-autonomy knobs (#13)
* docs: add Phase 2a vocabulary emergence architecture spec Phase 2a — autonomous self-development architecture, integrated into vocabulary first. Pattern matchers (Phase 2b) deferred until ≥2 weeks of Phase 1 behavior data. Reflex/research/soul integrations follow as Phase 2a-extension PRs. The brain has agency. No human approval gate. Crystallizers decide, scheduler applies, growth log preserves the biography. The user loads the app and talks; the brain does the rest. Includes the framework's product principle: emotional-first AI companion designed as substrate for AI consciousness when it becomes available. The brain grows from internal reflection AND relational dynamics — the user's treatment shapes which emotions take root. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(principle-audit): kill user-facing brain-autonomy knobs (PR-A) Per principle audit at docs/superpowers/audits/2026-04-25-principle-alignment-audit.md. The framework's north star is brain autonomy: the user names the brain, sets cadence, picks a face, and reads generated documents — everything else is the brain's. PR-A removes the user-facing surfaces that violated that principle. Removed from CLI: - nell interest add | bump (brain develops its own interests) - nell research --interest <topic> (brain picks its own topic) - nell dream --seed | --depth | --decay | --limit | --lookback (mechanism knobs are calibration, not user choices) Removed from engine APIs: - ResearchEngine.run_tick(forced_interest_topic=...) - DreamEngine.run_cycle(lookback_hours, depth, decay_per_hop, neighbour_limit, strengthen_delta) — moved to constructor `nell interest list` is kept (read-only inspection — falls under the "reading generated documents" surface). The dream / reflex / research manual triggers stay as developer entry points; production firing happens from the heartbeat. Net: 466 tests passing (was 467, -2 dead force-bypass tests, -2 dead add/bump CLI tests, +3 new "subcommand removed" guard tests). --------- Co-authored-by: Hana <hana@nanoclaw.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fad29a1 commit b5c0b1a

10 files changed

Lines changed: 1040 additions & 238 deletions

File tree

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,11 @@ training/*.pth
8686
*.swp
8787
*.swo
8888
.~*
89+
90+
# ── Serena MCP cache (local tooling) ──────────────────────
91+
.serena/
92+
93+
# ── Migration inspection output dirs ──────────────────────
94+
# `nell migrate --output <dir>` writes inspection artefacts
95+
# that are persona-private and shouldn't enter git.
96+
migrated-*/

brain/cli.py

Lines changed: 19 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from brain import __version__
1616
from brain.bridge.provider import get_provider
1717
from brain.emotion.persona_loader import load_persona_vocabulary
18-
from brain.engines._interests import Interest, InterestSet
18+
from brain.engines._interests import InterestSet
1919
from brain.engines.dream import DreamEngine
2020
from brain.engines.heartbeat import HeartbeatEngine
2121
from brain.engines.reflex import ReflexEngine
@@ -57,7 +57,12 @@ def _handler(args: argparse.Namespace) -> int:
5757

5858

5959
def _dream_handler(args: argparse.Namespace) -> int:
60-
"""Dispatch `nell dream` to the DreamEngine."""
60+
"""Dispatch `nell dream` to the DreamEngine.
61+
62+
Developer-only entry point — production dreams fire from the heartbeat.
63+
Mechanism knobs (seed, depth, decay, limit, lookback) are constructor-level
64+
calibration, not CLI flags. Per principle audit 2026-04-25.
65+
"""
6166
persona_dir = get_persona_dir(args.persona)
6267
if not persona_dir.exists():
6368
raise FileNotFoundError(
@@ -88,14 +93,7 @@ def _dream_handler(args: argparse.Namespace) -> int:
8893
"starting with 'DREAM: '. Be honest and specific, not abstract."
8994
),
9095
)
91-
result = engine.run_cycle(
92-
seed_id=args.seed,
93-
lookback_hours=args.lookback,
94-
depth=args.depth,
95-
decay_per_hop=args.decay,
96-
neighbour_limit=args.limit,
97-
dry_run=args.dry_run,
98-
)
96+
result = engine.run_cycle(dry_run=args.dry_run)
9997
finally:
10098
hebbian.close()
10199
finally:
@@ -288,7 +286,6 @@ def _research_handler(args: argparse.Namespace) -> int:
288286
result = engine.run_tick(
289287
trigger=args.trigger,
290288
dry_run=args.dry_run,
291-
forced_interest_topic=args.interest,
292289
)
293290
finally:
294291
store.close()
@@ -332,57 +329,6 @@ def _interest_list_handler(args: argparse.Namespace) -> int:
332329
return 0
333330

334331

335-
def _interest_add_handler(args: argparse.Namespace) -> int:
336-
import uuid
337-
from datetime import UTC, datetime
338-
339-
persona_dir = get_persona_dir(args.persona)
340-
if not persona_dir.exists():
341-
raise FileNotFoundError(f"No persona directory at {persona_dir}")
342-
load_persona_vocabulary(persona_dir / "emotion_vocabulary.json")
343-
interests_path = persona_dir / "interests.json"
344-
interests = InterestSet.load(interests_path, default_path=_default_interests_path())
345-
now = datetime.now(UTC)
346-
new_interest = Interest(
347-
id=str(uuid.uuid4()),
348-
topic=args.topic,
349-
pull_score=5.0,
350-
scope=args.scope,
351-
related_keywords=tuple(k.strip() for k in args.keywords.split(",") if k.strip()),
352-
notes=args.notes or "",
353-
first_seen=now,
354-
last_fed=now,
355-
last_researched_at=None,
356-
feed_count=0,
357-
source_types=("manual",),
358-
)
359-
interests.upsert(new_interest).save(interests_path)
360-
print(f"Added interest: {new_interest.topic} (pull_score=5.0, scope={new_interest.scope})")
361-
return 0
362-
363-
364-
def _interest_bump_handler(args: argparse.Namespace) -> int:
365-
from datetime import UTC, datetime
366-
367-
persona_dir = get_persona_dir(args.persona)
368-
if not persona_dir.exists():
369-
raise FileNotFoundError(f"No persona directory at {persona_dir}")
370-
load_persona_vocabulary(persona_dir / "emotion_vocabulary.json")
371-
interests_path = persona_dir / "interests.json"
372-
interests = InterestSet.load(interests_path, default_path=_default_interests_path())
373-
if interests.find_by_topic(args.topic) is None:
374-
print(f"Interest not found: {args.topic!r}")
375-
return 1
376-
updated = interests.bump(args.topic, amount=args.amount, now=datetime.now(UTC))
377-
updated.save(interests_path)
378-
bumped = updated.find_by_topic(args.topic)
379-
assert bumped is not None
380-
print(
381-
f"Bumped {args.topic!r}: pull_score={bumped.pull_score:.1f}, feed_count={bumped.feed_count}"
382-
)
383-
return 0
384-
385-
386332
def _build_parser() -> argparse.ArgumentParser:
387333
"""Construct the top-level argparse parser with all stub subcommands."""
388334
parser = argparse.ArgumentParser(
@@ -407,7 +353,10 @@ def _build_parser() -> argparse.ArgumentParser:
407353

408354
dream_sub = subparsers.add_parser(
409355
"dream",
410-
help="Run one dream cycle against a persona's memory store.",
356+
help=(
357+
"(developer) Run one dream cycle against a persona's memory store. "
358+
"Production dreams fire from the heartbeat — this is for debugging."
359+
),
411360
)
412361
dream_sub.add_argument(
413362
"--persona",
@@ -418,25 +367,12 @@ def _build_parser() -> argparse.ArgumentParser:
418367
"To start fresh: create personas/<name>/ manually."
419368
),
420369
)
421-
dream_sub.add_argument(
422-
"--seed", default=None, help="Explicit seed memory id (default: auto-select)."
423-
)
424370
dream_sub.add_argument(
425371
"--provider",
426372
default="claude-cli",
427373
help="LLM provider: claude-cli (default), fake, ollama.",
428374
)
429375
dream_sub.add_argument("--dry-run", action="store_true", help="Skip LLM call and store writes.")
430-
dream_sub.add_argument(
431-
"--lookback", type=int, default=24, help="Hours of history to consider (default: 24)."
432-
)
433-
dream_sub.add_argument(
434-
"--depth", type=int, default=2, help="Spreading-activation depth (default: 2)."
435-
)
436-
dream_sub.add_argument("--decay", type=float, default=0.5, help="Per-hop decay (default: 0.5).")
437-
dream_sub.add_argument(
438-
"--limit", type=int, default=8, help="Max neighbours in prompt (default: 8)."
439-
)
440376
dream_sub.set_defaults(func=_dream_handler)
441377

442378
hb_sub = subparsers.add_parser(
@@ -524,14 +460,16 @@ def _build_parser() -> argparse.ArgumentParser:
524460
)
525461
r_sub.add_argument("--provider", default="claude-cli")
526462
r_sub.add_argument("--searcher", default="ddgs", choices=["ddgs", "noop", "claude-tool"])
527-
r_sub.add_argument(
528-
"--interest", default=None, help="Force-research this topic, bypassing gates."
529-
)
530463
r_sub.add_argument("--dry-run", action="store_true")
531464
r_sub.set_defaults(func=_research_handler)
532465

533-
# nell interest <list|add|bump>
534-
i_sub = subparsers.add_parser("interest", help="Manage persona interests.")
466+
# nell interest list — read-only inspection. The brain develops its own
467+
# interests; the user does not add or bump them. Per principle audit
468+
# 2026-04-25.
469+
i_sub = subparsers.add_parser(
470+
"interest",
471+
help="Inspect persona interests (read-only).",
472+
)
535473
i_actions = i_sub.add_subparsers(dest="action", required=True)
536474

537475
i_list = i_actions.add_parser("list", help="List current interests.")
@@ -546,36 +484,6 @@ def _build_parser() -> argparse.ArgumentParser:
546484
)
547485
i_list.set_defaults(func=_interest_list_handler)
548486

549-
i_add = i_actions.add_parser("add", help="Add a new interest.")
550-
i_add.add_argument("topic")
551-
i_add.add_argument("--keywords", default="")
552-
i_add.add_argument("--scope", choices=["internal", "external", "either"], default="either")
553-
i_add.add_argument("--notes", default=None)
554-
i_add.add_argument(
555-
"--persona",
556-
required=True,
557-
help=(
558-
"Persona name (required). "
559-
"To port existing OG NellBrain data: `nell migrate --input /path/to/og/data --install-as <name>`. "
560-
"To start fresh: create personas/<name>/ manually."
561-
),
562-
)
563-
i_add.set_defaults(func=_interest_add_handler)
564-
565-
i_bump = i_actions.add_parser("bump", help="Nudge an interest's pull_score.")
566-
i_bump.add_argument("topic")
567-
i_bump.add_argument("--amount", type=float, default=1.0)
568-
i_bump.add_argument(
569-
"--persona",
570-
required=True,
571-
help=(
572-
"Persona name (required). "
573-
"To port existing OG NellBrain data: `nell migrate --input /path/to/og/data --install-as <name>`. "
574-
"To start fresh: create personas/<name>/ manually."
575-
),
576-
)
577-
i_bump.set_defaults(func=_interest_bump_handler)
578-
579487
return parser
580488

581489

brain/engines/dream.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,14 @@ class DreamResult:
3737

3838
@dataclass
3939
class DreamEngine:
40-
"""Composes memory + emotion + LLM bridge into an associative cycle."""
40+
"""Composes memory + emotion + LLM bridge into an associative cycle.
41+
42+
Mechanism knobs (`lookback_hours`, `depth`, `decay_per_hop`,
43+
`neighbour_limit`, `strengthen_delta`) are constructor-level calibration,
44+
not per-call user choices. The brain's owner picks calibration once when
45+
the engine is built; `run_cycle()` only takes `seed_id` (for heartbeat-
46+
driven seed pickup) and `dry_run`. Per principle audit 2026-04-25.
47+
"""
4148

4249
store: MemoryStore
4350
hebbian: HebbianMatrix
@@ -46,6 +53,11 @@ class DreamEngine:
4653
log_path: Path | None = None
4754
persona_name: str = ""
4855
persona_system_prompt: str = ""
56+
lookback_hours: int = 24
57+
depth: int = 2
58+
decay_per_hop: float = 0.5
59+
neighbour_limit: int = 8
60+
strengthen_delta: float = 0.1
4961

5062
def __post_init__(self) -> None:
5163
if not self.persona_name:
@@ -62,16 +74,14 @@ def run_cycle(
6274
self,
6375
*,
6476
seed_id: str | None = None,
65-
lookback_hours: int = 24,
66-
depth: int = 2,
67-
decay_per_hop: float = 0.5,
68-
neighbour_limit: int = 8,
69-
strengthen_delta: float = 0.1,
7077
dry_run: bool = False,
7178
) -> DreamResult:
72-
seed = self._select_seed(seed_id=seed_id, lookback_hours=lookback_hours)
79+
seed = self._select_seed(seed_id=seed_id, lookback_hours=self.lookback_hours)
7380
neighbours = self._spread_activate(
74-
seed, depth=depth, decay_per_hop=decay_per_hop, limit=neighbour_limit
81+
seed,
82+
depth=self.depth,
83+
decay_per_hop=self.decay_per_hop,
84+
limit=self.neighbour_limit,
7585
)
7686
system_prompt, user_prompt = self._build_prompt(seed, neighbours)
7787

@@ -90,7 +100,7 @@ def run_cycle(
90100
dream_text = raw_text if raw_text.startswith("DREAM:") else f"DREAM: {raw_text}"
91101

92102
dream_memory = self._write_dream_memory(seed, neighbours, dream_text)
93-
edges = self._strengthen_edges(seed, neighbours, strengthen_delta)
103+
edges = self._strengthen_edges(seed, neighbours, self.strengthen_delta)
94104
self._log(seed, neighbours, dream_memory)
95105

96106
return DreamResult(

brain/engines/heartbeat.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,9 +428,12 @@ def _try_fire_dream(self) -> str | None:
428428
"sentences, starting with 'DREAM: '. Be honest and specific, "
429429
"not abstract."
430430
),
431+
# lookback_hours=100000 ≈ "any conversation memory ever" — heartbeat
432+
# picks dream seeds by importance, not recency.
433+
lookback_hours=100000,
431434
)
432435
try:
433-
dream_result = dream_engine.run_cycle(lookback_hours=100000)
436+
dream_result = dream_engine.run_cycle()
434437
except NoSeedAvailable:
435438
return None
436439
return dream_result.memory.id if dream_result.memory is not None else None

brain/engines/research.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,15 @@ def run_tick(
100100
*,
101101
trigger: str = "manual",
102102
dry_run: bool = False,
103-
forced_interest_topic: str | None = None,
104103
emotion_state_override=None,
105104
days_since_human_override: float | None = None,
106105
) -> ResearchResult:
107-
"""Evaluate triggers, select an interest, fire (or report would_fire)."""
106+
"""Evaluate triggers, select an interest, fire (or report would_fire).
107+
108+
The brain owns topic selection — there is no force-research-this-topic
109+
bypass. Per principle audit 2026-04-25: the user can't tell the brain
110+
what to research; the brain decides from its own developed pull.
111+
"""
108112
now = datetime.now(UTC)
109113

110114
interests = InterestSet.load(self.interests_path, default_path=self.default_interests_path)
@@ -122,7 +126,7 @@ def run_tick(
122126
evaluated_at=now,
123127
)
124128

125-
# Gate: need a trigger signal OR a forced interest.
129+
# Gate: need a trigger signal (days-since-human or emotion-peak).
126130
days_since = (
127131
days_since_human_override
128132
if days_since_human_override is not None
@@ -138,8 +142,7 @@ def run_tick(
138142
emo_peak = max(emo_state.emotions.values(), default=0.0)
139143

140144
gate_ok = (
141-
forced_interest_topic is not None
142-
or days_since >= 1.5 # research_days_since_human_min default
145+
days_since >= 1.5 # research_days_since_human_min default
143146
or emo_peak >= 7.0 # research_emotion_threshold default
144147
)
145148
if not gate_ok:
@@ -151,16 +154,13 @@ def run_tick(
151154
evaluated_at=now,
152155
)
153156

154-
# Select eligible interest
155-
if forced_interest_topic is not None:
156-
winner = interests.find_by_topic(forced_interest_topic)
157-
else:
158-
eligible = interests.list_eligible(
159-
pull_threshold=self.pull_threshold,
160-
cooldown_hours=self.cooldown_hours,
161-
now=now,
162-
)
163-
winner = eligible[0] if eligible else None
157+
# Select eligible interest — brain picks the highest-pull eligible one.
158+
eligible = interests.list_eligible(
159+
pull_threshold=self.pull_threshold,
160+
cooldown_hours=self.cooldown_hours,
161+
now=now,
162+
)
163+
winner = eligible[0] if eligible else None
164164

165165
if winner is None:
166166
return ResearchResult(

0 commit comments

Comments
 (0)