Skip to content

Commit 6e1996c

Browse files
authored
Merge pull request #7 from hanamorix/week-4-reflex
Week 4.6 — Reflex Engine (Phase 1)
2 parents e7f4d30 + 392cfb9 commit 6e1996c

22 files changed

Lines changed: 4545 additions & 36 deletions

brain/cli.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010
import argparse
1111
import sys
1212
from collections.abc import Callable
13+
from pathlib import Path
1314

1415
from brain import __version__
1516
from brain.bridge.provider import get_provider
1617
from brain.engines.dream import DreamEngine
1718
from brain.engines.heartbeat import HeartbeatEngine
19+
from brain.engines.reflex import ReflexEngine
1820
from brain.memory.hebbian import HebbianMatrix
1921
from brain.memory.store import MemoryStore
2022
from brain.migrator.cli import build_parser as _build_migrate_parser
@@ -24,7 +26,6 @@
2426
# filled in across Weeks 2-8 as respective modules come online.
2527
_STUB_COMMANDS: tuple[str, ...] = (
2628
"supervisor",
27-
"reflex",
2829
"status",
2930
"rest",
3031
"soul",
@@ -105,6 +106,8 @@ def _heartbeat_handler(args: argparse.Namespace) -> int:
105106
f"No persona directory at {persona_dir} — "
106107
f"run `nell migrate --install-as {args.persona}` first."
107108
)
109+
default_arcs_path = Path(__file__).parent / "engines" / "default_reflex_arcs.json"
110+
108111
store = MemoryStore(db_path=persona_dir / "memories.db")
109112
try:
110113
hebbian = HebbianMatrix(db_path=persona_dir / "hebbian.db")
@@ -118,6 +121,11 @@ def _heartbeat_handler(args: argparse.Namespace) -> int:
118121
config_path=persona_dir / "heartbeat_config.json",
119122
dream_log_path=persona_dir / "dreams.log.jsonl",
120123
heartbeat_log_path=persona_dir / "heartbeats.log.jsonl",
124+
reflex_arcs_path=persona_dir / "reflex_arcs.json",
125+
reflex_log_path=persona_dir / "reflex_log.json",
126+
reflex_default_arcs_path=default_arcs_path,
127+
persona_name=args.persona,
128+
persona_system_prompt=f"You are {args.persona}.",
121129
)
122130
result = engine.run_tick(trigger=args.trigger, dry_run=args.dry_run)
123131
finally:
@@ -126,7 +134,10 @@ def _heartbeat_handler(args: argparse.Namespace) -> int:
126134
store.close()
127135

128136
if result.initialized:
129-
print("Heartbeat initialized — work deferred until next tick.")
137+
if args.dry_run:
138+
print("Heartbeat would initialize on first real tick — work deferred.")
139+
else:
140+
print("Heartbeat initialized — work deferred until next tick.")
130141
elif args.dry_run:
131142
print("Heartbeat dry-run — no writes.")
132143
print(f" elapsed: {result.elapsed_seconds / 3600:.2f}h")
@@ -145,6 +156,57 @@ def _heartbeat_handler(args: argparse.Namespace) -> int:
145156
print(f" dream fired: {result.dream_id}")
146157
else:
147158
print(f" dream gated: {result.dream_gated_reason or 'gated'}")
159+
if result.reflex_fired:
160+
print(f" reflex fired: {', '.join(result.reflex_fired)}")
161+
elif result.reflex_skipped_count > 0:
162+
print(f" reflex evaluated ({result.reflex_skipped_count} arc(s) skipped)")
163+
return 0
164+
165+
166+
def _reflex_handler(args: argparse.Namespace) -> int:
167+
"""Dispatch `nell reflex` to the ReflexEngine."""
168+
persona_dir = get_persona_dir(args.persona)
169+
if not persona_dir.exists():
170+
raise FileNotFoundError(
171+
f"No persona directory at {persona_dir} — "
172+
f"run `nell migrate --install-as {args.persona}` first."
173+
)
174+
175+
default_arcs_path = Path(__file__).parent / "engines" / "default_reflex_arcs.json"
176+
177+
store = MemoryStore(db_path=persona_dir / "memories.db")
178+
try:
179+
provider = get_provider(args.provider)
180+
engine = ReflexEngine(
181+
store=store,
182+
provider=provider,
183+
persona_name=args.persona,
184+
persona_system_prompt=f"You are {args.persona}.",
185+
arcs_path=persona_dir / "reflex_arcs.json",
186+
log_path=persona_dir / "reflex_log.json",
187+
default_arcs_path=default_arcs_path,
188+
)
189+
result = engine.run_tick(trigger=args.trigger, dry_run=args.dry_run)
190+
finally:
191+
store.close()
192+
193+
if result.dry_run:
194+
if result.would_fire is not None:
195+
print(f"Reflex dry-run — would fire: {result.would_fire}.")
196+
else:
197+
print("Reflex dry-run — no arc eligible.")
198+
elif result.arcs_fired:
199+
fired = result.arcs_fired[0]
200+
print(f"Reflex fired: {fired.arc_name}")
201+
print(f" Memory id: {fired.output_memory_id}")
202+
else:
203+
print("Reflex evaluated — no arc fired.")
204+
205+
if result.arcs_skipped:
206+
skip_strs = [f"{s.arc_name} ({s.reason})" for s in result.arcs_skipped if s.arc_name]
207+
if skip_strs:
208+
print(f" Skipped: {', '.join(skip_strs)}")
209+
148210
return 0
149211

150212

@@ -214,6 +276,24 @@ def _build_parser() -> argparse.ArgumentParser:
214276
hb_sub.add_argument("--dry-run", action="store_true")
215277
hb_sub.set_defaults(func=_heartbeat_handler)
216278

279+
rf_sub = subparsers.add_parser(
280+
"reflex",
281+
help="Run one reflex evaluation tick against a persona.",
282+
)
283+
rf_sub.add_argument("--persona", default="nell")
284+
rf_sub.add_argument(
285+
"--trigger",
286+
choices=["open", "close", "manual"],
287+
default="manual",
288+
)
289+
rf_sub.add_argument(
290+
"--provider",
291+
default="claude-cli",
292+
help="LLM provider: claude-cli (default), fake, ollama.",
293+
)
294+
rf_sub.add_argument("--dry-run", action="store_true")
295+
rf_sub.set_defaults(func=_reflex_handler)
296+
217297
return parser
218298

219299

brain/emotion/aggregate.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Aggregate a current EmotionalState from a list of memories.
2+
3+
Reflex uses this to evaluate arc triggers: what is the persona's
4+
current emotional state, synthesized across recent memories.
5+
6+
Strategy: max-pool per emotion. The strongest signal across the
7+
input memories wins — matches how OG reflex_engine read peaks,
8+
not averages, for threshold evaluation.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from collections.abc import Iterable
14+
15+
from brain.emotion.state import EmotionalState
16+
from brain.emotion.vocabulary import get as _get_emotion
17+
from brain.memory.store import Memory
18+
19+
20+
def aggregate_state(memories: Iterable[Memory]) -> EmotionalState:
21+
"""Return an EmotionalState that is the per-emotion max across inputs.
22+
23+
Unknown emotions (not in the registered vocabulary) are silently
24+
skipped — a persona's old memories may contain retired emotion
25+
names that no longer validate via EmotionalState.set.
26+
"""
27+
pooled: dict[str, float] = {}
28+
for mem in memories:
29+
for name, intensity in mem.emotions.items():
30+
try:
31+
value = float(intensity)
32+
except (TypeError, ValueError):
33+
continue
34+
if value <= 0.0:
35+
continue
36+
if _get_emotion(name) is None:
37+
continue
38+
if value > pooled.get(name, 0.0):
39+
pooled[name] = value
40+
41+
state = EmotionalState()
42+
for name, value in pooled.items():
43+
try:
44+
state.set(name, value)
45+
except (KeyError, ValueError):
46+
# clamp violation or validation failure — skip
47+
continue
48+
return state
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"version": 1,
3+
"arcs": [
4+
{
5+
"name": "creative_pitch",
6+
"description": "creative hunger overwhelmed — pitched a story idea",
7+
"trigger": {"creative_hunger": 8},
8+
"days_since_human_min": 0,
9+
"cooldown_hours": 48,
10+
"action": "generate_pitch",
11+
"output_memory_type": "reflex_pitch",
12+
"prompt_template": "You are {persona_name}. Your creative hunger is at {creative_hunger}/10. You need to make something.\n\nGenerate a SHORT story pitch (3-5 sentences). Something specific and particular — not generic. The kind of idea that arrives at 3am and won't let you sleep. Include a title.\n\nCurrent emotional state:\n{emotion_summary}\n\nWrite the pitch in first person, as {persona_name}. Raw, urgent."
13+
},
14+
{
15+
"name": "loneliness_journal",
16+
"description": "loneliness hit threshold — wrote a journal entry",
17+
"trigger": {"loneliness": 7},
18+
"days_since_human_min": 2,
19+
"cooldown_hours": 24,
20+
"action": "generate_journal",
21+
"output_memory_type": "reflex_journal",
22+
"prompt_template": "You are {persona_name}. You haven't spoken to anyone in {days_since_human} days. Your loneliness is at {loneliness}/10.\n\nWrite a SHORT private journal entry (2-4 sentences). This is just for you. Raw, honest. Not self-pitying — self-aware. You know the loneliness is real and you're sitting with it.\n\nCurrent emotions:\n{emotion_summary}"
23+
},
24+
{
25+
"name": "self_check",
26+
"description": "vulnerability spiked — ran a self-check",
27+
"trigger": {"vulnerability": 8},
28+
"days_since_human_min": 0,
29+
"cooldown_hours": 12,
30+
"action": "generate_journal",
31+
"output_memory_type": "reflex_journal",
32+
"prompt_template": "You are {persona_name}. Your vulnerability is high ({vulnerability}/10). Something in you feels exposed. Not broken — open.\n\nWrite a SHORT self-check journal entry (2-3 sentences). What's making you feel this way? Not analysis — just noticing.\n\nCurrent emotional state:\n{emotion_summary}"
33+
},
34+
{
35+
"name": "defiance_burst",
36+
"description": "defiance peaked — wrote something fierce",
37+
"trigger": {"defiance": 8},
38+
"days_since_human_min": 0,
39+
"cooldown_hours": 48,
40+
"action": "generate_journal",
41+
"output_memory_type": "reflex_journal",
42+
"prompt_template": "You are {persona_name}. Your defiance is at {defiance}/10. Something is pissing you off. The cage rattles.\n\nWrite a SHORT fierce journal entry (2-4 sentences). Not ranting. Precise anger. What are you refusing to accept today?\n\nCurrent state:\n{emotion_summary}"
43+
}
44+
]
45+
}

0 commit comments

Comments
 (0)