Skip to content

Commit 62f44fc

Browse files
authored
Merge pull request #5 from hanamorix/week-4-dream
feat: Week 4 — dream engine + LLM bridge (Claude CLI default)
2 parents ea38701 + b60405b commit 62f44fc

13 files changed

Lines changed: 2709 additions & 2 deletions

File tree

brain/bridge/__init__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""LLM provider abstraction for companion-emergence engines.
2+
3+
Exports LLMProvider ABC + three concrete providers + factory.
4+
See docs/superpowers/specs/2026-04-23-week-4-dream-engine-design.md.
5+
"""
6+
7+
from brain.bridge.provider import (
8+
ClaudeCliProvider,
9+
FakeProvider,
10+
LLMProvider,
11+
OllamaProvider,
12+
get_provider,
13+
)
14+
15+
__all__ = [
16+
"ClaudeCliProvider",
17+
"FakeProvider",
18+
"LLMProvider",
19+
"OllamaProvider",
20+
"get_provider",
21+
]

brain/bridge/provider.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""LLM provider abstraction — ABC + concrete providers + factory."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import json
7+
import subprocess
8+
from abc import ABC, abstractmethod
9+
10+
_DEFAULT_TIMEOUT_SECONDS = 300
11+
12+
13+
class LLMProvider(ABC):
14+
"""Abstract LLM provider. Subclasses implement `generate` and `name`."""
15+
16+
@abstractmethod
17+
def generate(self, prompt: str, *, system: str | None = None) -> str:
18+
"""Return the LLM's completion for the given prompt."""
19+
20+
@abstractmethod
21+
def name(self) -> str:
22+
"""Return a short provider name (e.g. 'fake', 'claude-cli:sonnet')."""
23+
24+
25+
class FakeProvider(LLMProvider):
26+
"""Deterministic hash-based echo provider for tests — zero network calls."""
27+
28+
def generate(self, prompt: str, *, system: str | None = None) -> str:
29+
seed_input = (system or "").encode("utf-8") + b"||" + prompt.encode("utf-8")
30+
h = hashlib.sha256(seed_input).hexdigest()[:16]
31+
return f"DREAM: test dream {h} — an associative thread"
32+
33+
def name(self) -> str:
34+
return "fake"
35+
36+
37+
class ClaudeCliProvider(LLMProvider):
38+
"""Shells out to `claude -p <prompt> --output-format json`.
39+
40+
Uses Hana's Claude Code subscription — no per-token API billing.
41+
Per the feedback memory: this is the default Claude path for
42+
companion-emergence and Hana's other projects.
43+
"""
44+
45+
def __init__(
46+
self,
47+
model: str = "sonnet",
48+
timeout_seconds: int = _DEFAULT_TIMEOUT_SECONDS,
49+
) -> None:
50+
self._model = model
51+
self._timeout = timeout_seconds
52+
53+
def generate(self, prompt: str, *, system: str | None = None) -> str:
54+
cmd = ["claude", "-p", prompt, "--output-format", "json", "--model", self._model]
55+
if system is not None:
56+
cmd.extend(["--system-prompt", system])
57+
58+
try:
59+
result = subprocess.run(
60+
cmd,
61+
capture_output=True,
62+
text=True,
63+
timeout=self._timeout,
64+
check=False,
65+
)
66+
except subprocess.TimeoutExpired as exc:
67+
raise TimeoutError(
68+
f"ClaudeCliProvider: subprocess timed out after {self._timeout}s"
69+
) from exc
70+
71+
if result.returncode != 0:
72+
raise RuntimeError(
73+
f"ClaudeCliProvider failed (exit {result.returncode}): {result.stderr.strip()}"
74+
)
75+
76+
try:
77+
payload = json.loads(result.stdout)
78+
return str(payload["result"])
79+
except (json.JSONDecodeError, KeyError, TypeError) as exc:
80+
raise RuntimeError(
81+
f"ClaudeCliProvider: unexpected output format: {result.stdout[:200]!r}"
82+
) from exc
83+
84+
def name(self) -> str:
85+
return f"claude-cli:{self._model}"
86+
87+
88+
class OllamaProvider(LLMProvider):
89+
"""Placeholder for local Ollama integration.
90+
91+
Stub until Hana's local Ollama stack is back up. Fill in by:
92+
1. Replacing raise with an httpx POST to {host}/api/generate
93+
2. Parsing the streamed/non-streamed response
94+
3. Adding the httpx dep to pyproject
95+
"""
96+
97+
def __init__(self, model: str = "nell-dpo", host: str = "http://localhost:11434") -> None:
98+
self._model = model
99+
self._host = host
100+
101+
def generate(self, prompt: str, *, system: str | None = None) -> str:
102+
raise NotImplementedError(
103+
"OllamaProvider is a stub; fill in when the local Ollama stack is available."
104+
)
105+
106+
def name(self) -> str:
107+
return f"ollama:{self._model}"
108+
109+
110+
def get_provider(name: str) -> LLMProvider:
111+
"""Resolve a provider identifier to an instance. Raises ValueError on unknown."""
112+
if name == "fake":
113+
return FakeProvider()
114+
if name == "claude-cli":
115+
return ClaudeCliProvider()
116+
if name == "ollama":
117+
return OllamaProvider()
118+
raise ValueError(f"Unknown provider: {name!r}")

brain/cli.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@
1212
from collections.abc import Callable
1313

1414
from brain import __version__
15+
from brain.bridge.provider import get_provider
16+
from brain.engines.dream import DreamEngine
17+
from brain.memory.hebbian import HebbianMatrix
18+
from brain.memory.store import MemoryStore
1519
from brain.migrator.cli import build_parser as _build_migrate_parser
20+
from brain.paths import get_persona_dir
1621

1722
# Subcommands the framework plans to ship. Each is a stub in Week 1;
1823
# filled in across Weeks 2-8 as respective modules come online.
1924
_STUB_COMMANDS: tuple[str, ...] = (
2025
"supervisor",
21-
"dream",
2226
"heartbeat",
2327
"reflex",
2428
"status",
@@ -47,6 +51,52 @@ def _handler(args: argparse.Namespace) -> int:
4751
return _handler
4852

4953

54+
def _dream_handler(args: argparse.Namespace) -> int:
55+
"""Dispatch `nell dream` to the DreamEngine."""
56+
persona_dir = get_persona_dir(args.persona)
57+
if not persona_dir.exists():
58+
raise FileNotFoundError(
59+
f"No persona directory at {persona_dir} — "
60+
f"run `nell migrate --install-as {args.persona}` first."
61+
)
62+
# Nested try/finally so a HebbianMatrix open failure still closes the
63+
# already-open MemoryStore connection. Inline contextmanager would be
64+
# prettier but stores don't implement __enter__/__exit__ yet.
65+
store = MemoryStore(db_path=persona_dir / "memories.db")
66+
try:
67+
hebbian = HebbianMatrix(db_path=persona_dir / "hebbian.db")
68+
try:
69+
provider = get_provider(args.provider)
70+
engine = DreamEngine(
71+
store=store,
72+
hebbian=hebbian,
73+
embeddings=None,
74+
provider=provider,
75+
log_path=persona_dir / "dreams.log.jsonl",
76+
)
77+
result = engine.run_cycle(
78+
seed_id=args.seed,
79+
lookback_hours=args.lookback,
80+
depth=args.depth,
81+
decay_per_hop=args.decay,
82+
neighbour_limit=args.limit,
83+
dry_run=args.dry_run,
84+
)
85+
finally:
86+
hebbian.close()
87+
finally:
88+
store.close()
89+
90+
if args.dry_run:
91+
print("Dry run — no writes.")
92+
print(f"Seed: {result.seed.id} ({result.seed.content[:80]})")
93+
print(f"Neighbours: {len(result.neighbours)}")
94+
print(f"Prompt preview:\n{result.prompt[:400]}")
95+
else:
96+
print(result.dream_text or "")
97+
return 0
98+
99+
50100
def _build_parser() -> argparse.ArgumentParser:
51101
"""Construct the top-level argparse parser with all stub subcommands."""
52102
parser = argparse.ArgumentParser(
@@ -69,6 +119,32 @@ def _build_parser() -> argparse.ArgumentParser:
69119

70120
_build_migrate_parser(subparsers)
71121

122+
dream_sub = subparsers.add_parser(
123+
"dream",
124+
help="Run one dream cycle against a persona's memory store.",
125+
)
126+
dream_sub.add_argument("--persona", default="nell", help="Persona name (default: nell).")
127+
dream_sub.add_argument(
128+
"--seed", default=None, help="Explicit seed memory id (default: auto-select)."
129+
)
130+
dream_sub.add_argument(
131+
"--provider",
132+
default="claude-cli",
133+
help="LLM provider: claude-cli (default), fake, ollama.",
134+
)
135+
dream_sub.add_argument("--dry-run", action="store_true", help="Skip LLM call and store writes.")
136+
dream_sub.add_argument(
137+
"--lookback", type=int, default=24, help="Hours of history to consider (default: 24)."
138+
)
139+
dream_sub.add_argument(
140+
"--depth", type=int, default=2, help="Spreading-activation depth (default: 2)."
141+
)
142+
dream_sub.add_argument("--decay", type=float, default=0.5, help="Per-hop decay (default: 0.5).")
143+
dream_sub.add_argument(
144+
"--limit", type=int, default=8, help="Max neighbours in prompt (default: 8)."
145+
)
146+
dream_sub.set_defaults(func=_dream_handler)
147+
72148
return parser
73149

74150

brain/engines/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Cognitive engines for companion-emergence.
2+
3+
Dreams consolidate associative patterns; heartbeat/reflex/research
4+
follow in later weeks. See:
5+
docs/superpowers/specs/2026-04-23-week-4-dream-engine-design.md
6+
"""
7+
8+
from brain.engines.dream import DreamEngine, DreamResult, NoSeedAvailable
9+
10+
__all__ = ["DreamEngine", "DreamResult", "NoSeedAvailable"]

0 commit comments

Comments
 (0)