frankyy03 commited on
Commit
897d5bd
·
verified ·
1 Parent(s): c7274f9

Deploy The Echo (MockLLM path): Gradio app + echo package

Browse files
README.md CHANGED
@@ -1,13 +1,21 @@
1
  ---
2
  title: The Echo
3
- emoji: 🌖
4
- colorFrom: pink
5
- colorTo: red
6
  sdk: gradio
7
  sdk_version: 6.16.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: The Echo
3
+ emoji: 🌳
4
+ colorFrom: yellow
5
+ colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.16.0
 
8
  app_file: app.py
9
  pinned: false
10
+ short_description: an agentic tree of the lives you didn't live
11
  ---
12
 
13
+ # The Echo
14
+
15
+ An agentic tree of the lives you didn't live. One fork in a life grows a tree of
16
+ alternate selves; each echo speaks back in a subtly altered version of your own
17
+ voice.
18
+
19
+ This Space currently runs on the offline **MockLLM** path — placeholder lives
20
+ that exercise the full UX (plant a seed, grow branches, walk the tree). The real
21
+ small-model generation is wired in separately.
app.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """HF Spaces entrypoint. The real app lives in the `echo` package."""
2
+ from echo.app import build_demo
3
+
4
+ demo = build_demo()
5
+
6
+ if __name__ == "__main__":
7
+ demo.launch()
echo/CLAUDE.md ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ Guidance for working on **The Echo** — an agentic tree of the lives you didn't live.
4
+
5
+ ## What this project is
6
+
7
+ The Echo takes one fork in a person's life ("I left Brazil instead of staying")
8
+ and grows a **tree of alternate selves**. Each node is a coherent alternate life;
9
+ each branch is a dramatic turning point. The user navigates the tree, and each
10
+ echo speaks back in a subtly altered version of their own voice.
11
+
12
+ It is built for the **Thousand Token Wood** track of a small-model hackathon
13
+ (all models ≤32B, Gradio + HF Spaces, demo video + social post). The award
14
+ surface it targets: Thousand Token Wood (delight), Best Agent (true multi-agent +
15
+ tools), Off-Brand (the living tree UI), Best Demo (voice + emotional reveal), and
16
+ Tiny Titan (if a ≤4B model holds coherence).
17
+
18
+ **This is an agentic inference system, not an RL project.** There is no training
19
+ loop, no reward, no policy gradient. The "intelligence" is three coordinated
20
+ agents using a frozen LLM zero-shot. Do not add RL unless explicitly asked.
21
+
22
+ ## The core idea that must never break
23
+
24
+ The single hardest, most important property: **"the same you" stays recognizable
25
+ across every branch.** A child life is always generated as a *causal consequence*
26
+ of its parent's `WorldState` — never invented from scratch. Age math must add up,
27
+ relationships and dependents must persist or change for a reason, carried losses
28
+ must echo forward. The `Verifier` exists to enforce this. If a change would let
29
+ branches drift into unrelated people, it is wrong.
30
+
31
+ ## Architecture (dependency order — leaves first)
32
+
33
+ ```
34
+ core/world_state.py data: WorldState, LifeFacts, EmotionalTone (no deps)
35
+ core/tree.py LifeTree: branching graph + history (-> world_state)
36
+ llm/client.py LLMClient; MockLLM (offline) + LocalLLM (HF model)
37
+ tools/research.py world-grounding (real place/era detail) (-> world_state)
38
+ tools/voice.py TTS; pitch shifts by emotional valence (-> world_state)
39
+ agents/base.py thin Agent base (-> llm)
40
+ agents/curator.py grows child life from parent (causal) (-> base, world_state)
41
+ agents/screenwriter.py plans the next two dramatic forks (-> base, world_state)
42
+ agents/verifier.py audits branch coherence; triggers regen (-> base, world_state)
43
+ core/orchestrator.py conductor: curator→verifier loop, voice, plan (-> everything)
44
+ smoke_test.py full pipeline with mocks, no GPU (-> everything)
45
+ ```
46
+
47
+ When rewriting files, follow this same order: leaves before roots, so the
48
+ project never sits in a broken state.
49
+
50
+ ## The agentic loop (one user choice → one new node)
51
+
52
+ 1. **Curator** reads parent + full branch history (+ research grounding) → child `WorldState`
53
+ 2. **Verifier** audits child vs branch; orchestrator regenerates up to `max_regen` times
54
+ 3. **Voice tool** synthesizes the child's spoken line (pitch nudged by valence)
55
+ 4. **Screenwriter** plans the child's next two forks
56
+ 5. node added to `LifeTree`
57
+
58
+ The orchestrator is UI-agnostic. The (future) Gradio app calls `seed()` once,
59
+ then `choose_fork(node_id, i)` per click, and renders `graph()`.
60
+
61
+ ## Hard rules
62
+
63
+ - **Keep `MockLLM` and the mock tools working at all times.** `python -m
64
+ echo.smoke_test` must pass with zero ML dependencies installed. This is the
65
+ project's correctness gate. Every change is validated here first.
66
+ - **Lazy-import heavy deps.** `torch`, `transformers`, `peft`, `bitsandbytes`,
67
+ TTS backends are imported *inside* the methods that need them, never at module
68
+ top level. Importing any `echo` module must stay cheap and dependency-free.
69
+ - **Never import `orchestrator` from `core/__init__.py`.** It pulls in agents +
70
+ tools, and `tools/voice.py` imports `core.world_state` — importing the
71
+ orchestrator at package load creates a circular import. Import it explicitly:
72
+ `from echo.core.orchestrator import Orchestrator`.
73
+ - **Agents stay single-purpose and thin.** One responsibility each. This is what
74
+ makes the ≤4B (Tiny Titan) path viable — small focused calls instead of one
75
+ giant generation. Don't merge agents.
76
+ - **All model access goes through `LLMClient`.** Agents never touch
77
+ `transformers` directly. Swapping 14B ↔ 3B must remain a one-line config
78
+ change.
79
+ - **Favor concrete, mundane, specific detail over vague profundity** in any
80
+ prompt or generated content. Specificity is what makes a life feel real;
81
+ vagueness is the main failure mode.
82
+
83
+ ## Quantization & LoRA (planned, on the `LocalLLM`)
84
+
85
+ These are the next technical additions and belong only in `llm/client.py`:
86
+
87
+ - **4-bit quantization** via `BitsAndBytesConfig` (bitsandbytes): `load_in_4bit`,
88
+ NF4, double quantization. This is what lets a 14B fit on a free-tier GPU and
89
+ makes the honest small-model fit real.
90
+ - **Optional LoRA adapters** for light *supervised* fine-tuning of an agent (e.g.
91
+ teaching the Curator format + tone). This is SFT, not RL. Per-agent adapters
92
+ are loadable but training is out of scope unless asked.
93
+
94
+ The `MockLLM` must remain untouched by either — it stays the offline test path.
95
+
96
+ ## Tiny Titan experiment
97
+
98
+ The orchestrator tracks `GrowthStats.regen_rate` (regenerations / nodes grown).
99
+ This is the metric that decides 14B vs ≤4B: run the same pipeline on both and
100
+ compare how often each model breaks branch coherence. A ≤4B model with a low
101
+ regeneration rate wins the honest-fit argument and the Tiny Titan award.
102
+
103
+ ## Tone & safety of the experience
104
+
105
+ Dramatic forks touch real emotion (loss, regret). The framing must be **wonder,
106
+ not regret** — "look how many vibrant lives lived inside one choice," never "look
107
+ what you lost." The closing artifact (`final_map_summary`) deliberately ends with
108
+ gentleness ("none of them are more real than the one you're in"). Keep that
109
+ spirit in any new generated content. This is a toy that should leave people
110
+ lighter, not heavier.
111
+
112
+ ## How to verify any change
113
+
114
+ ```bash
115
+ python -m echo.smoke_test # must print "ALL STAGES PASSED ✓"
116
+ ```
117
+
118
+ If that passes, the agentic loop, tree, tools, and serialization are all intact.
119
+ Only after that should you test with a real model.
120
+
121
+ ## Commands
122
+
123
+ ```bash
124
+ # offline correctness gate (no GPU, no ML deps)
125
+ python -m echo.smoke_test
126
+
127
+ # real model run (requires: pip install -r requirements.txt)
128
+ python -c "
129
+ from echo.llm.client import LocalLLM, LLMConfig
130
+ from echo.tools.research import MockResearch
131
+ from echo.tools.voice import MockVoice
132
+ from echo.core.orchestrator import Orchestrator
133
+ llm = LocalLLM(LLMConfig(model_name='Qwen/Qwen2.5-3B-Instruct')); llm.load()
134
+ orch = Orchestrator(llm, MockResearch(), MockVoice())
135
+ root = orch.seed('I stayed instead of moving abroad', base_age=24)
136
+ print(root.facts.constraints_text())
137
+ "
138
+ ```
echo/README.md ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The Echo — An Agentic Tree of the Lives You Didn't Live
2
+
3
+ You name one fork in your life. The Echo grows a *tree* of alternate selves —
4
+ each branch a dramatic turn, each node a coherent life that speaks to you in
5
+ your own voice. You navigate the branches; the AI lives them.
6
+
7
+ This is an **agentic** system, not a chatbot: three specialized agents plus
8
+ tools cooperate to keep "the same you" recognizable across every branch. That
9
+ coherence is the magic — and the technical "how is it doing that?".
10
+
11
+ ## Why agentic (and why it helps the small-model fit)
12
+ Each agent does ONE small, focused job, so a ≤4B model can handle each step even
13
+ when it would fail doing everything at once. This is what makes the **Tiny
14
+ Titan** experiment realistic — and the orchestrator measures the regeneration
15
+ rate so you can compare 14B vs 3B with data.
16
+
17
+ ## Module map (separated by function)
18
+
19
+ ### `core/` — state & control (no ML deps)
20
+ | File | Function |
21
+ |------|----------|
22
+ | `world_state.py` | `WorldState`: the structured, checkable memory of one alternate life (facts, emotion, voice). |
23
+ | `tree.py` | `LifeTree`: the branching graph; branch-history reconstruction; export for the visual map. |
24
+ | `orchestrator.py` | The conductor: Curator→Verifier loop, voice, Screenwriter planning, telemetry. |
25
+
26
+ ### `agents/` — the three minds
27
+ | File | Function |
28
+ |------|----------|
29
+ | `curator.py` | Grows a child life as a *causal consequence* of its parent. Keeps "the same you". |
30
+ | `screenwriter.py` | Plans the next two dramatic, life-specific forks. The narrative agency. |
31
+ | `verifier.py` | Audits each new branch for contradictions; triggers regeneration. The polish. |
32
+
33
+ ### `tools/` — what the agents can do
34
+ | File | Function |
35
+ |------|----------|
36
+ | `research.py` | World-grounding (real location/era detail) so lives feel anchored, not vague. |
37
+ | `voice.py` | TTS: each echo speaks; pitch shifts subtly by emotional valence. |
38
+
39
+ ### `llm/` — the brain, swappable
40
+ | File | Function |
41
+ |------|----------|
42
+ | `client.py` | `LLMClient` interface; `MockLLM` (offline/testing) + `LocalLLM` (Qwen 3B/14B). |
43
+
44
+ ### Top level
45
+ | File | Function |
46
+ |------|----------|
47
+ | `smoke_test.py` | Runs the entire agentic loop with mocks — no GPU, no ML deps. |
48
+
49
+ ## Verify (no GPU)
50
+ ```bash
51
+ python -m echo.smoke_test
52
+ ```
53
+
54
+ ## Run with a real model
55
+ ```python
56
+ from echo.llm.client import LocalLLM, LLMConfig
57
+ from echo.tools.research import MockResearch
58
+ from echo.tools.voice import PiperVoice
59
+ from echo.core.orchestrator import Orchestrator
60
+
61
+ llm = LocalLLM(LLMConfig(model_name="Qwen/Qwen2.5-3B-Instruct")); llm.load()
62
+ orch = Orchestrator(llm, MockResearch(), PiperVoice("voices/en.onnx"))
63
+ root = orch.seed("I stayed instead of moving abroad", base_age=24)
64
+ child = orch.choose_fork(root.node_id, fork_index=0, years=5)
65
+ ```
66
+
67
+ ## Gradio integration (next)
68
+ The app calls `orch.seed(...)` once, then `orch.choose_fork(node_id, i)` per
69
+ click, and renders `orch.graph()` as the gold/dark tree (D3 / vis-network).
70
+ Audio comes from each node's `voice_audio_path`. Nothing in core changes.
71
+
72
+ ### Award surface
73
+ Thousand Token Wood (delight) · Best Agent (true multi-agent + tools) ·
74
+ Off-Brand (the living tree UI) · Best Demo (voice + emotional reveal) ·
75
+ Tiny Titan (if the ≤4B regeneration rate holds).
echo/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """The Echo: an agentic tree of the lives you didn't live."""
2
+ __version__ = "0.1.0"
3
+
echo/agents/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """The three specialized agents."""
2
+ from .base import Agent
3
+ from .curator import Curator
4
+ from .screenwriter import Screenwriter
5
+ from .verifier import Verifier, VerdictResult
echo/agents/base.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ echo/agents/base.py
3
+ -------------------
4
+ Common base for the three specialized agents (Curator, Screenwriter,
5
+ Verifier). Each agent owns a system prompt and a single responsibility; the
6
+ orchestrator composes them into the branch-generation loop.
7
+
8
+ Keeping agents thin and single-purpose is what makes the ≤4B (Tiny Titan)
9
+ experiment viable: each call is a small, focused task rather than one giant
10
+ do-everything generation.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from abc import ABC
16
+
17
+ from ..llm.client import LLMClient
18
+
19
+
20
+ # Shared prompt fragments. Split so the auditor (Verifier) and the fork-planner
21
+ # (Screenwriter) only carry what actually applies to them — the voice register
22
+ # has no surface to land on outside the Curator's summary/voice_line, and giving
23
+ # it to other agents would just burn tokens (this targets Thousand Token Wood).
24
+ #
25
+ # Composition:
26
+ # Curator = HOUSE_STYLE_WORLD + HOUSE_STYLE_VOICE + responsibility + gold
27
+ # Screenwriter = HOUSE_STYLE_WORLD + responsibility
28
+ # Verifier = standalone (no house style)
29
+ HOUSE_STYLE_WORLD = (
30
+ "WORLD RULES (everything you write belongs to ONE specific, continuous "
31
+ "person):\n"
32
+ "- Favor concrete, mundane, specific detail over vague profundity. Name the "
33
+ "street, the object, the small habit. Specificity makes a life real; "
34
+ "vagueness is the failure mode.\n"
35
+ "- Never write 'unknown', a blank field, or a placeholder. Commit to a "
36
+ "specific city and a specific occupation.\n"
37
+ "- Stay true to this life's established facts — age, relationships, "
38
+ "dependents, carried losses. Anything new must be a plausible consequence "
39
+ "of what came before, never a different person.\n"
40
+ "- Write in English.\n"
41
+ )
42
+
43
+ HOUSE_STYLE_VOICE = (
44
+ "VOICE (for the summary and the spoken voice_line):\n"
45
+ "- Second person for the summary (\"You are 41, in Porto...\"). First person "
46
+ "for the voice_line — this self speaking to the one who never lived this "
47
+ "life.\n"
48
+ "- The register is raw and ambivalent: pride and loss can share one breath, "
49
+ "unresolved. Do not tie it in a bow.\n"
50
+ "- But every voice_line ends on a thread of light — one small thing still "
51
+ "alive. Never pure despair.\n"
52
+ "- Earn feeling through a concrete detail, not an abstract emotion word.\n"
53
+ )
54
+
55
+
56
+ class Agent(ABC):
57
+ #: subclasses set a SYSTEM prompt; the word "curator"/"screenwriter"/
58
+ #: "verifier" in it also drives MockLLM routing.
59
+ SYSTEM: str = ""
60
+
61
+ def __init__(self, llm: LLMClient):
62
+ self.llm = llm
63
+
64
+ def _complete_json(self, user: str) -> dict:
65
+ return self.llm.complete_json(self.SYSTEM, user)
echo/agents/curator.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ echo/agents/curator.py
3
+ ----------------------
4
+ The Curator builds a child WorldState as a *causal consequence* of its parent.
5
+
6
+ It is the agent most responsible for the illusion that it's "the same you"
7
+ across branches: it receives the full branch narrative (root -> parent) plus
8
+ the chosen fork, and must produce a life that could plausibly have grown from
9
+ that history. It never invents from scratch.
10
+
11
+ Optionally consumes grounding facts from the research tool (real-world detail
12
+ for the chosen location/era) to kill vagueness.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Optional
18
+
19
+ from .base import Agent, HOUSE_STYLE_WORLD, HOUSE_STYLE_VOICE
20
+ from ..core.world_state import WorldState, LifeFacts, EmotionalTone
21
+
22
+
23
+ _CURATOR_GOLD = (
24
+ "GOLD EXAMPLE — match this texture and JSON shape, never reuse its content:\n"
25
+ "{\n"
26
+ ' "age": 41,\n'
27
+ ' "location": "Porto, Portugal",\n'
28
+ ' "occupation": "night-shift baker",\n'
29
+ ' "relationships": ["divorced from Inês, still text on Sundays"],\n'
30
+ ' "dependents": ["a son, Tomás, 9"],\n'
31
+ ' "scars": ["the Lisbon bakery you lost to the bank"],\n'
32
+ ' "triumphs": ["Tomás learned to swim this July"],\n'
33
+ ' "possessions": ["a flour-dusted radio stuck on one station"],\n'
34
+ ' "valence": -0.1,\n'
35
+ ' "dominant_feeling": "tired tenderness",\n'
36
+ ' "voice_hint": "low, even, a little hoarse before dawn",\n'
37
+ ' "summary": "You are 41, in Porto, baking through the night so Tomás '
38
+ 'wakes to warm bread.",\n'
39
+ ' "voice_line": "I lost the Lisbon shop and I lost her, but the boy ate '
40
+ 'warm bread this morning and never knew a thing."\n'
41
+ "}\n\n"
42
+ "The voice_line earns its weight from one concrete image. Never write the "
43
+ "abstract version:\n"
44
+ ' ✗ "It was hard, but it became a journey of self-discovery worth taking."\n'
45
+ ' ✓ "...the boy ate warm bread this morning and never knew a thing."'
46
+ )
47
+
48
+
49
+ class Curator(Agent):
50
+ SYSTEM = (
51
+ HOUSE_STYLE_WORLD + "\n" + HOUSE_STYLE_VOICE + "\n"
52
+ "You are the Curator. You grow ONE human life forward across alternate "
53
+ "timelines. Given the branch history and the choice that now diverges "
54
+ "this life, write the NEXT state as a believable causal consequence of "
55
+ "the parent. The new age must equal the parent's age plus the years "
56
+ "that pass. Respond ONLY as JSON with keys: age, location, occupation, "
57
+ "relationships, dependents, scars, triumphs, possessions, valence, "
58
+ "dominant_feeling, voice_hint, summary, voice_line.\n\n"
59
+ + _CURATOR_GOLD
60
+ )
61
+
62
+ def grow(self, parent: WorldState, branch_narrative: str,
63
+ chosen_fork: str, years: int,
64
+ grounding: Optional[str] = None) -> WorldState:
65
+ user = (
66
+ f"BRANCH HISTORY (must stay consistent with all of this):\n"
67
+ f"{branch_narrative}\n\n"
68
+ f"PARENT FACTS: {parent.facts.constraints_text()}\n"
69
+ f"PARENT EMOTION: {parent.tone.dominant_feeling} "
70
+ f"(valence {parent.tone.valence})\n\n"
71
+ f"THE CHOICE THAT NOW DIVERGES THIS LIFE: {chosen_fork}\n"
72
+ f"YEARS THAT PASS: {years}\n"
73
+ )
74
+ if grounding:
75
+ user += f"\nREAL-WORLD GROUNDING (use to add authentic detail):\n{grounding}\n"
76
+ if parent.facts.location == "unknown" or parent.facts.occupation == "unknown":
77
+ # Origin node: nothing is established yet. The model must INVENT a
78
+ # concrete grounding rather than inherit the "unknown" placeholder.
79
+ user += (
80
+ "\nThis is the ORIGIN of the life — no location or occupation is "
81
+ "fixed yet. Establish a SPECIFIC city and occupation that fit the "
82
+ "diverging choice; do not write 'unknown'."
83
+ )
84
+ user += (
85
+ "\nWrite the resulting life. The new age must equal parent age + "
86
+ f"{years}. Make one big dramatic turn land — a triumph or a wound."
87
+ )
88
+
89
+ data = self._complete_json(user)
90
+ return self._assemble(parent, chosen_fork, years, data)
91
+
92
+ # ------------------------------------------------------------- assembly
93
+ def _assemble(self, parent: WorldState, fork: str, years: int,
94
+ data: dict) -> WorldState:
95
+ """Build a WorldState from model JSON, with safe fallbacks."""
96
+ facts = LifeFacts(
97
+ age=int(data.get("age", parent.facts.age + years)),
98
+ location=str(data.get("location", parent.facts.location)),
99
+ occupation=str(data.get("occupation", parent.facts.occupation)),
100
+ relationships=_as_list(data.get("relationships")),
101
+ dependents=_as_list(data.get("dependents")),
102
+ scars=_as_list(data.get("scars")) or list(parent.facts.scars),
103
+ triumphs=_as_list(data.get("triumphs")),
104
+ possessions=_as_list(data.get("possessions")),
105
+ )
106
+ tone = EmotionalTone(
107
+ valence=float(data.get("valence", 0.0)),
108
+ dominant_feeling=str(data.get("dominant_feeling", "uncertain")),
109
+ voice_hint=str(data.get("voice_hint", "")),
110
+ )
111
+ return WorldState(
112
+ node_id=WorldState.new_id(),
113
+ parent_id=parent.node_id,
114
+ depth=parent.depth + 1,
115
+ divergence=fork,
116
+ years_elapsed=years,
117
+ facts=facts,
118
+ tone=tone,
119
+ summary=str(data.get("summary", "")),
120
+ voice_line=str(data.get("voice_line", "")),
121
+ )
122
+
123
+
124
+ def _as_list(v) -> list[str]:
125
+ if v is None:
126
+ return []
127
+ if isinstance(v, list):
128
+ return [str(x) for x in v]
129
+ return [str(v)]
echo/agents/screenwriter.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ echo/agents/screenwriter.py
3
+ ---------------------------
4
+ The Screenwriter looks at a life and PLANS its next two dramatic forks. This is
5
+ the agentic narrative drive: the system proposes the decisive turning points
6
+ that fit *this specific* life, rather than asking the user an open question.
7
+
8
+ It reads the current WorldState (and branch history) and returns two mutually
9
+ distinct, high-stakes choices — the kind that would genuinely reshape a life.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .base import Agent, HOUSE_STYLE_WORLD
15
+ from ..core.world_state import WorldState
16
+
17
+
18
+ class Screenwriter(Agent):
19
+ SYSTEM = (
20
+ HOUSE_STYLE_WORLD + "\n"
21
+ "You are the Screenwriter. You design the next TWO forks this specific "
22
+ "life could face — decisive, concrete turning points, true to who this "
23
+ "life has made them. The two must pull in genuinely different "
24
+ "directions (stay vs leave, hold vs risk, reach toward vs turn away) — "
25
+ "never two flavors of the same move. Each fork is a short, vivid action "
26
+ "this person could actually take, grounded in their real situation, not "
27
+ "an abstract theme:\n"
28
+ ' ✗ "embrace change"\n'
29
+ ' ✓ "sell the flat and move to her city"\n'
30
+ 'Respond ONLY as JSON: {"forks": ["choice A", "choice B"]}.'
31
+ )
32
+
33
+ def plan(self, state: WorldState, branch_narrative: str) -> list[str]:
34
+ user = (
35
+ f"CURRENT LIFE: {state.facts.constraints_text()}\n"
36
+ f"EMOTION: {state.tone.dominant_feeling} (valence {state.tone.valence})\n"
37
+ f"BRANCH HISTORY:\n{branch_narrative}\n\n"
38
+ "Propose the next two decisive forks for THIS life."
39
+ )
40
+ data = self._complete_json(user)
41
+ forks = data.get("forks", [])
42
+ forks = [str(f) for f in forks][:2]
43
+ # fallback so the loop never stalls
44
+ while len(forks) < 2:
45
+ forks.append("let everything change" if not forks
46
+ else "hold on to what you have")
47
+ return forks
echo/agents/verifier.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ echo/agents/verifier.py
3
+ -----------------------
4
+ The Verifier checks a freshly-grown child WorldState against its branch history
5
+ for contradictions (impossible age math, relationships that vanished, a dead
6
+ parent reappearing, location/era inconsistencies). If it fails, the orchestrator
7
+ regenerates.
8
+
9
+ This cheap consistency loop is the polish that turns "plausible chatbot output"
10
+ into "how is it keeping all of this straight?" — the technical aha. It also
11
+ provides the metric for the Tiny Titan experiment: regeneration rate by model
12
+ size.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ from .base import Agent
20
+ from ..core.world_state import WorldState
21
+
22
+
23
+ @dataclass
24
+ class VerdictResult:
25
+ consistent: bool
26
+ reason: str = ""
27
+
28
+
29
+ class Verifier(Agent):
30
+ SYSTEM = (
31
+ "You are the Verifier. You audit a newly written life-state against the "
32
+ "established history of its branch and decide if it is consistent. Flag "
33
+ "a contradiction only when something genuinely breaks: age that doesn't "
34
+ "match the years elapsed, a relationship or dependent that appears or "
35
+ "vanishes with no cause, a carried loss silently erased, or a "
36
+ "location/era that's impossible. Do NOT flag a change that the choice "
37
+ "taken and the passing years plausibly explain — growth, moves, new "
38
+ "love, and healing are allowed. Be strict about broken facts, generous "
39
+ "about "
40
+ "plausible change. Respond ONLY as JSON: "
41
+ '{"consistent": true/false, "reason": "<short; name the specific '
42
+ 'contradiction>"}.'
43
+ )
44
+
45
+ def check(self, child: WorldState, parent: WorldState,
46
+ branch_narrative: str) -> VerdictResult:
47
+ # 1) cheap deterministic checks first (no LLM needed)
48
+ hard = self._hard_checks(child, parent)
49
+ if hard is not None:
50
+ return hard
51
+
52
+ # 2) soft semantic check via LLM
53
+ user = (
54
+ f"BRANCH HISTORY:\n{branch_narrative}\n\n"
55
+ f"PARENT: {parent.facts.constraints_text()}\n"
56
+ f"NEW STATE: {child.facts.constraints_text()}\n"
57
+ f"CHOICE TAKEN: {child.divergence} (+{child.years_elapsed}y)\n\n"
58
+ "Is the NEW STATE consistent with everything above?"
59
+ )
60
+ data = self._complete_json(user)
61
+ return VerdictResult(
62
+ consistent=bool(data.get("consistent", True)),
63
+ reason=str(data.get("reason", "")),
64
+ )
65
+
66
+ def _hard_checks(self, child: WorldState, parent: WorldState):
67
+ """Deterministic guards that never need a model."""
68
+ expected_age = parent.facts.age + child.years_elapsed
69
+ if abs(child.facts.age - expected_age) > 1:
70
+ return VerdictResult(False,
71
+ f"age {child.facts.age} != expected {expected_age}")
72
+ if child.years_elapsed < 0:
73
+ return VerdictResult(False, "negative time")
74
+ # a carried scar from the parent should not silently disappear if it was
75
+ # a permanent loss (heuristic: parent dependents shouldn't vanish)
76
+ for dep in parent.facts.dependents:
77
+ # children don't un-exist; allow them to grow but not disappear
78
+ base = dep.split(",")[0].strip().lower()
79
+ if base and not any(base in d.lower() for d in child.facts.dependents):
80
+ # not necessarily fatal (estrangement), so soft-flag only if
81
+ # the time elapsed is small
82
+ if child.years_elapsed <= 2:
83
+ return VerdictResult(False, f"dependent '{base}' vanished")
84
+ return None
echo/app.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ echo/app.py
3
+ -----------
4
+ The Gradio front-end: plant a seed, watch the tree of alternate lives grow, and
5
+ walk it. UI-only — it drives the orchestrator and renders LifeTree.to_graph().
6
+
7
+ First cut runs entirely on MockLLM + mock tools, so it launches offline with no
8
+ GPU and no ML deps. Swapping in the real model later is a one-line change in
9
+ `_new_orchestrator()`.
10
+
11
+ Run it:
12
+ python -m echo.app
13
+
14
+ Design notes:
15
+ * The tree visual is a read-only vis-network embedded via <iframe srcdoc>. We do
16
+ NOT try to route SVG clicks back into Python (fragile in Gradio); all
17
+ interaction is native components. Node selection is the dropdown.
18
+ * gr.HTML strips <script>, so the vis-network document is wrapped in an iframe
19
+ srcdoc, which renders its own document (scripts included).
20
+ * gr.State holds the whole Orchestrator. Fine with MockLLM (tiny). When the real
21
+ LocalLLM is plugged in (roadmap item 3), the loaded model must NOT live in
22
+ State — keep only the serialized LifeTree in State and hold the model in a
23
+ module-global. (Document that in CLAUDE.md when implementing item 3.)
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import html as _html
29
+ import json
30
+
31
+ import gradio as gr
32
+
33
+ # Import the orchestrator explicitly — never via core/__init__ (circular import).
34
+ from .core.orchestrator import Orchestrator
35
+ from .llm.client import MockLLM
36
+ from .tools.research import MockResearch
37
+ from .tools.voice import MockVoice
38
+
39
+
40
+ _DEFAULT_SEED = "I stayed instead of moving abroad"
41
+ _AUDIO_EXTS = (".wav", ".mp3", ".ogg", ".flac")
42
+
43
+
44
+ def _new_orchestrator() -> Orchestrator:
45
+ """The one place that wires the engine. Swap MockLLM -> LocalLLM here."""
46
+ return Orchestrator(MockLLM(), MockResearch(), MockVoice())
47
+
48
+
49
+ # --------------------------------------------------------------- tree renderer
50
+ def _valence_color(v: float) -> str:
51
+ """Continuous gradient: cold slate (struggling) -> warm gold (flourishing)."""
52
+ v = max(-1.0, min(1.0, float(v)))
53
+ t = (v + 1.0) / 2.0
54
+ cold = (0x3A, 0x4A, 0x5A)
55
+ gold = (0xE8, 0xB8, 0x4B)
56
+ rgb = tuple(round(cold[i] + (gold[i] - cold[i]) * t) for i in range(3))
57
+ return "#%02X%02X%02X" % rgb
58
+
59
+
60
+ def _render_tree_html(graph: dict, active_id: str) -> str:
61
+ """Build the vis-network document and wrap it in an iframe srcdoc."""
62
+ nodes = []
63
+ for n in graph["nodes"]:
64
+ is_active = n["id"] == active_id
65
+ node = {
66
+ "id": n["id"],
67
+ "label": n.get("label") or "…",
68
+ "title": (n.get("summary") or "")[:120], # tooltip; capped for size
69
+ "color": {
70
+ "background": _valence_color(n["valence"]),
71
+ "border": "#F2D27A" if is_active else "#1c2530",
72
+ },
73
+ "borderWidth": 4 if is_active else 1,
74
+ "shadow": bool(is_active),
75
+ "font": {"color": "#e8e6df", "size": 13},
76
+ }
77
+ if n.get("has_voice"):
78
+ # a subtle dashed ring marks nodes that have a spoken line
79
+ node["shapeProperties"] = {"borderDashes": [4, 3]}
80
+ nodes.append(node)
81
+
82
+ edges = [
83
+ {
84
+ "from": e["from"],
85
+ "to": e["to"],
86
+ "label": (e.get("label") or "")[:35], # fork text; capped for size
87
+ "arrows": "to",
88
+ "font": {"color": "#9aa7b0", "size": 10, "strokeWidth": 0},
89
+ "color": {"color": "#44525e"},
90
+ }
91
+ for e in graph["edges"]
92
+ ]
93
+
94
+ doc = (
95
+ "<!DOCTYPE html><html><head>"
96
+ '<script src="https://unpkg.com/vis-network/standalone/umd/'
97
+ 'vis-network.min.js"></script>'
98
+ "<style>html,body{margin:0;height:100%;background:#11151a;}"
99
+ "#net{width:100%;height:100%;}</style></head>"
100
+ '<body><div id="net"></div><script>'
101
+ "const nodes=new vis.DataSet(" + json.dumps(nodes) + ");"
102
+ "const edges=new vis.DataSet(" + json.dumps(edges) + ");"
103
+ "new vis.Network(document.getElementById('net'),{nodes,edges},{"
104
+ "layout:{hierarchical:{direction:'UD',sortMethod:'directed',"
105
+ "levelSeparation:95,nodeSpacing:150}},"
106
+ "physics:false,interaction:{hover:true,dragNodes:false,zoomView:true},"
107
+ "nodes:{shape:'dot',size:16}});"
108
+ "</script></body></html>"
109
+ )
110
+ srcdoc = _html.escape(doc, quote=True)
111
+ return (
112
+ f'<iframe srcdoc="{srcdoc}" '
113
+ 'style="width:100%;height:520px;border:0;border-radius:12px;'
114
+ 'background:#11151a;"></iframe>'
115
+ )
116
+
117
+
118
+ _EMPTY_TREE = (
119
+ "<div style='height:520px;display:flex;align-items:center;"
120
+ "justify-content:center;background:#11151a;border-radius:12px;"
121
+ "color:#6b7680;font-family:sans-serif;'>Plante a árvore para começar.</div>"
122
+ )
123
+
124
+
125
+ # ------------------------------------------------------------------ rendering
126
+ def _render(orch: Orchestrator, active_id: str):
127
+ """Compute every panel + tree output for a given active node (7 outputs)."""
128
+ tree = orch.tree
129
+ node = tree.nodes[active_id]
130
+
131
+ html = _render_tree_html(orch.graph(), active_id)
132
+
133
+ # dropdown: depth · occupation · dominant_feeling (read from node, not graph)
134
+ choices = [
135
+ (f"{n.depth} · {n.facts.occupation} · {n.tone.dominant_feeling}", n.node_id)
136
+ for n in sorted(tree.nodes.values(), key=lambda x: (x.depth, x.node_id))
137
+ ]
138
+
139
+ summary = f"### {node.facts.constraints_text()}\n\n{node.summary}"
140
+ if node.voice_line:
141
+ summary += f"\n\n> *“{node.voice_line}”*"
142
+
143
+ forks = node.pending_forks
144
+ fa = (gr.update(value=forks[0], visible=True) if len(forks) > 0
145
+ else gr.update(visible=False))
146
+ fb = (gr.update(value=forks[1], visible=True) if len(forks) > 1
147
+ else gr.update(visible=False))
148
+
149
+ path = node.voice_audio_path
150
+ audio_val = path if (path and path.lower().endswith(_AUDIO_EXTS)) else None
151
+
152
+ branch = "```\n" + tree.branch_narrative(active_id) + "\n```"
153
+
154
+ return (
155
+ gr.update(value=html),
156
+ gr.update(choices=choices, value=active_id),
157
+ gr.update(value=summary),
158
+ gr.update(value=audio_val),
159
+ fa,
160
+ fb,
161
+ gr.update(value=branch),
162
+ )
163
+
164
+
165
+ _BLANK = (gr.update(),) * 7
166
+
167
+
168
+ # -------------------------------------------------------------------- handlers
169
+ def plant(seed: str, base_age):
170
+ orch = _new_orchestrator()
171
+ root = orch.seed((seed or "").strip() or _DEFAULT_SEED, base_age=int(base_age or 24))
172
+ return (orch, root.node_id, *_render(orch, root.node_id))
173
+
174
+
175
+ def select_node(orch, selected_id):
176
+ if orch is None or not selected_id:
177
+ return (selected_id, *_BLANK)
178
+ return (selected_id, *_render(orch, selected_id))
179
+
180
+
181
+ def choose(orch, active_id, i: int):
182
+ if orch is None or not active_id:
183
+ return (orch, active_id, *_BLANK)
184
+ node = orch.tree.nodes.get(active_id)
185
+ if node is None or i >= len(node.pending_forks):
186
+ return (orch, active_id, *_BLANK)
187
+ child = orch.choose_fork(active_id, i)
188
+ return (orch, child.node_id, *_render(orch, child.node_id))
189
+
190
+
191
+ def show_final(orch):
192
+ if orch is None:
193
+ return gr.update(value="*Plante a árvore primeiro.*")
194
+ return gr.update(value=orch.final_map_summary())
195
+
196
+
197
+ # ------------------------------------------------------------------ blocks/app
198
+ def build_demo() -> gr.Blocks:
199
+ with gr.Blocks(title="The Echo") as demo:
200
+ gr.Markdown("# The Echo\n*the lives you didn't live*")
201
+ orch_state = gr.State()
202
+ active_state = gr.State()
203
+
204
+ with gr.Row():
205
+ with gr.Column(scale=3):
206
+ tree_html = gr.HTML(_EMPTY_TREE)
207
+ with gr.Column(scale=2):
208
+ seed_in = gr.Textbox(label="A bifurcação", value=_DEFAULT_SEED,
209
+ lines=2)
210
+ age_in = gr.Number(label="Idade base", value=24, precision=0)
211
+ plant_btn = gr.Button("Plantar a árvore", variant="primary")
212
+ node_dd = gr.Dropdown(label="Você está aqui", choices=[],
213
+ interactive=True)
214
+ summary_md = gr.Markdown()
215
+ audio = gr.Audio(label="A voz deste eco", interactive=False)
216
+ with gr.Row():
217
+ fork_a_btn = gr.Button("…", visible=False)
218
+ fork_b_btn = gr.Button("…", visible=False)
219
+ branch_md = gr.Markdown()
220
+
221
+ with gr.Accordion("O mapa final", open=False):
222
+ final_btn = gr.Button("Ver o mapa final")
223
+ final_md = gr.Markdown()
224
+
225
+ common = [tree_html, node_dd, summary_md, audio,
226
+ fork_a_btn, fork_b_btn, branch_md]
227
+
228
+ plant_btn.click(plant, [seed_in, age_in],
229
+ [orch_state, active_state, *common])
230
+ node_dd.change(select_node, [orch_state, node_dd],
231
+ [active_state, *common])
232
+ fork_a_btn.click(lambda o, a: choose(o, a, 0),
233
+ [orch_state, active_state],
234
+ [orch_state, active_state, *common])
235
+ fork_b_btn.click(lambda o, a: choose(o, a, 1),
236
+ [orch_state, active_state],
237
+ [orch_state, active_state, *common])
238
+ final_btn.click(show_final, [orch_state], [final_md])
239
+
240
+ return demo
241
+
242
+
243
+ def main() -> None:
244
+ build_demo().launch(theme=gr.themes.Base())
245
+
246
+
247
+ if __name__ == "__main__":
248
+ main()
echo/core/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """Core: world state, tree, and the agentic orchestrator.
2
+
3
+ Note: world_state and tree are safe leaf modules. Orchestrator pulls in agents
4
+ and tools, so it is NOT imported at package load to avoid a circular import
5
+ (tools.voice -> core.world_state). Import it explicitly:
6
+ from echo.core.orchestrator import Orchestrator
7
+ """
8
+ from .world_state import WorldState, LifeFacts, EmotionalTone, root_state
9
+ from .tree import LifeTree
echo/core/orchestrator.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ echo/core/orchestrator.py
3
+ -------------------------
4
+ The conductor. Wires Curator + Screenwriter + Verifier + tools into the loop
5
+ that grows the tree of alternate lives.
6
+
7
+ Branch-growth loop (one user choice -> one new node):
8
+ 1. Curator reads parent + branch history (+ research grounding) -> child
9
+ 2. Verifier audits child vs branch -> regenerate up to N times if needed
10
+ 3. Voice tool speaks the child's line
11
+ 4. Screenwriter plans the child's next two forks
12
+ 5. node added to the tree
13
+
14
+ The orchestrator is UI-agnostic: the Gradio app calls seed() once, then
15
+ choose_fork() each time the user clicks a branch. Everything it returns is
16
+ plain data the front-end renders.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+ from typing import Optional
23
+
24
+ from .world_state import WorldState, root_state
25
+ from .tree import LifeTree
26
+ from ..agents.curator import Curator
27
+ from ..agents.screenwriter import Screenwriter
28
+ from ..agents.verifier import Verifier
29
+ from ..tools.research import ResearchTool
30
+ from ..tools.voice import VoiceTool
31
+ from ..llm.client import LLMClient
32
+
33
+
34
+ @dataclass
35
+ class GrowthStats:
36
+ """Telemetry — powers the Tiny Titan experiment (regeneration rate)."""
37
+ nodes_grown: int = 0
38
+ regenerations: int = 0
39
+
40
+ @property
41
+ def regen_rate(self) -> float:
42
+ denom = self.nodes_grown + self.regenerations
43
+ return self.regenerations / denom if denom else 0.0
44
+
45
+
46
+ class Orchestrator:
47
+ def __init__(self, llm: LLMClient, research: ResearchTool, voice: VoiceTool,
48
+ out_dir: str = "echo_out", max_regen: int = 2,
49
+ base_year: int = 2025):
50
+ self.curator = Curator(llm)
51
+ self.screenwriter = Screenwriter(llm)
52
+ self.verifier = Verifier(llm)
53
+ self.research = research
54
+ self.voice = voice
55
+ self.out_dir = out_dir
56
+ self.max_regen = max_regen
57
+ self.base_year = base_year
58
+ self.tree: Optional[LifeTree] = None
59
+ self.stats = GrowthStats()
60
+
61
+ # ------------------------------------------------------------------ seed
62
+ def seed(self, seed_choice: str, base_age: int = 30) -> WorldState:
63
+ """Plant the tree: create the root life and its first two forks."""
64
+ self.tree = LifeTree(seed_choice=seed_choice)
65
+ root = root_state(seed_choice, base_age)
66
+
67
+ # The root itself is curated from the seed (depth 0, 0 years elapsed).
68
+ grounding = self.research.ground(root.facts.location, self.base_year)
69
+ curated = self.curator.grow(
70
+ parent=root,
71
+ branch_narrative=f"Origin: {seed_choice}",
72
+ chosen_fork=seed_choice,
73
+ years=0,
74
+ grounding=grounding,
75
+ )
76
+ curated.parent_id = None
77
+ curated.depth = 0
78
+ curated.divergence = seed_choice
79
+ self._voice(curated)
80
+ curated.pending_forks = self.screenwriter.plan(
81
+ curated, self.tree.branch_narrative(curated.node_id)
82
+ if curated.node_id in self.tree.nodes else f"Origin: {seed_choice}"
83
+ )
84
+ self.tree.add(curated)
85
+ # plan forks again now that it's in the tree (branch narrative valid)
86
+ curated.pending_forks = self.screenwriter.plan(
87
+ curated, self.tree.branch_narrative(curated.node_id))
88
+ self.stats.nodes_grown += 1
89
+ return curated
90
+
91
+ # ----------------------------------------------------------- grow branch
92
+ def choose_fork(self, parent_id: str, fork_index: int,
93
+ years: int = 5) -> WorldState:
94
+ """User picks one of a node's pending forks -> grow the child node."""
95
+ assert self.tree is not None, "call seed() first"
96
+ parent = self.tree.nodes[parent_id]
97
+ fork = parent.pending_forks[fork_index]
98
+ narrative = self.tree.branch_narrative(parent_id)
99
+
100
+ child = self._grow_verified(parent, narrative, fork, years)
101
+ self._voice(child)
102
+ child.pending_forks = self.screenwriter.plan(
103
+ child, self.tree.branch_narrative(parent_id) + f"\n-> {fork}")
104
+ self.tree.add(child)
105
+ self.stats.nodes_grown += 1
106
+ return child
107
+
108
+ # ----------------------------------------------------- internal helpers
109
+ def _grow_verified(self, parent: WorldState, narrative: str,
110
+ fork: str, years: int) -> WorldState:
111
+ """Curator + Verifier loop with bounded regeneration."""
112
+ location_year = self.base_year + (parent.depth + 1) * years
113
+ grounding = self.research.ground(parent.facts.location, location_year)
114
+
115
+ child = self.curator.grow(parent, narrative, fork, years, grounding)
116
+ for _ in range(self.max_regen):
117
+ verdict = self.verifier.check(child, parent, narrative)
118
+ if verdict.consistent:
119
+ break
120
+ self.stats.regenerations += 1
121
+ # regenerate with the contradiction noted in the grounding hint
122
+ child = self.curator.grow(
123
+ parent, narrative, fork, years,
124
+ grounding=(grounding or "") +
125
+ f"\nFIX: previous attempt was inconsistent ({verdict.reason}).")
126
+ return child
127
+
128
+ def _voice(self, state: WorldState) -> None:
129
+ if state.voice_line:
130
+ state.voice_audio_path = self.voice.speak(state, self.out_dir)
131
+
132
+ # ----------------------------------------------------------------- views
133
+ def graph(self) -> dict:
134
+ return self.tree.to_graph() if self.tree else {"nodes": [], "edges": []}
135
+
136
+ def final_map_summary(self) -> str:
137
+ """The closing artifact: a gentle celebration of the lives explored."""
138
+ if not self.tree:
139
+ return ""
140
+ n = self.tree.size
141
+ flourishing = sum(1 for x in self.tree.nodes.values()
142
+ if x.tone.is_flourishing)
143
+ return (f"You explored {n} lives that all lived inside one choice. "
144
+ f"{flourishing} of them were flourishing. "
145
+ f"None of them are more real than the one you're in.")
echo/core/tree.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ echo/core/tree.py
3
+ -----------------
4
+ The branching tree of alternate lives. Holds every WorldState node and the
5
+ parent/child links the UI renders (gold for flourishing, dark for struggling).
6
+
7
+ Pure data structure + traversal helpers. The orchestrator mutates it; the
8
+ Gradio front-end reads it to draw the D3/vis-network graph.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import Optional
15
+
16
+ from .world_state import WorldState
17
+
18
+
19
+ @dataclass
20
+ class LifeTree:
21
+ seed_choice: str
22
+ nodes: dict[str, WorldState] = field(default_factory=dict)
23
+ root_id: Optional[str] = None
24
+
25
+ # ---------------------------------------------------------------- build
26
+ def add(self, state: WorldState) -> None:
27
+ self.nodes[state.node_id] = state
28
+ if state.parent_id is None:
29
+ self.root_id = state.node_id
30
+
31
+ def children(self, node_id: str) -> list[WorldState]:
32
+ return [n for n in self.nodes.values() if n.parent_id == node_id]
33
+
34
+ def path_to_root(self, node_id: str) -> list[WorldState]:
35
+ """Ancestor chain from root -> node (inclusive). The branch's history."""
36
+ chain: list[WorldState] = []
37
+ cur = self.nodes.get(node_id)
38
+ while cur is not None:
39
+ chain.append(cur)
40
+ cur = self.nodes.get(cur.parent_id) if cur.parent_id else None
41
+ return list(reversed(chain))
42
+
43
+ def branch_narrative(self, node_id: str) -> str:
44
+ """Human-readable history of a branch, fed to the Curator as context."""
45
+ chain = self.path_to_root(node_id)
46
+ lines = []
47
+ for n in chain:
48
+ if n.depth == 0:
49
+ lines.append(f"Origin: {n.divergence}")
50
+ else:
51
+ lines.append(
52
+ f"+{n.years_elapsed}y -> {n.divergence} "
53
+ f"(now: {n.facts.constraints_text()})"
54
+ )
55
+ return "\n".join(lines)
56
+
57
+ # --------------------------------------------------------------- export
58
+ def to_graph(self) -> dict:
59
+ """Serialize to nodes/edges for the front-end visualization."""
60
+ graph_nodes = []
61
+ graph_edges = []
62
+ for n in self.nodes.values():
63
+ graph_nodes.append({
64
+ "id": n.node_id,
65
+ "label": n.facts.occupation or "…",
66
+ "summary": n.summary,
67
+ "valence": n.tone.valence,
68
+ "flourishing": n.tone.is_flourishing,
69
+ "depth": n.depth,
70
+ "has_voice": n.voice_audio_path is not None,
71
+ })
72
+ if n.parent_id:
73
+ graph_edges.append({
74
+ "from": n.parent_id, "to": n.node_id,
75
+ "label": n.divergence[:40],
76
+ })
77
+ return {"nodes": graph_nodes, "edges": graph_edges,
78
+ "root": self.root_id, "seed": self.seed_choice}
79
+
80
+ @property
81
+ def size(self) -> int:
82
+ return len(self.nodes)
83
+
84
+ @property
85
+ def max_depth(self) -> int:
86
+ return max((n.depth for n in self.nodes.values()), default=0)
echo/core/world_state.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ echo/core/world_state.py
3
+ ------------------------
4
+ The persistent, structured memory of a single alternate life (one node in the
5
+ tree). This is what makes "the same you" recognizable across branches: every
6
+ new branch is generated as a *causal consequence* of its parent's WorldState,
7
+ not invented from scratch.
8
+
9
+ Pure data. No LLM, no I/O. The Curator agent reads a parent WorldState and
10
+ writes a child; the Verifier checks a child against its parent using these
11
+ fields.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field, asdict
17
+ from typing import Optional
18
+ import uuid
19
+
20
+
21
+ @dataclass
22
+ class LifeFacts:
23
+ """The concrete, checkable facts about this version of the person."""
24
+ age: int
25
+ location: str # city / country right now
26
+ occupation: str
27
+ relationships: list[str] = field(default_factory=list) # e.g. "married to Sofia"
28
+ dependents: list[str] = field(default_factory=list) # e.g. "daughter, 4"
29
+ scars: list[str] = field(default_factory=list) # losses / regrets carried
30
+ triumphs: list[str] = field(default_factory=list) # what went right
31
+ possessions: list[str] = field(default_factory=list) # grounding mundane detail
32
+
33
+ def constraints_text(self) -> str:
34
+ """A compact statement of facts the next branch must NOT contradict."""
35
+ parts = [f"age {self.age}", f"in {self.location}", f"works as {self.occupation}"]
36
+ if self.relationships:
37
+ parts.append("relationships: " + "; ".join(self.relationships))
38
+ if self.dependents:
39
+ parts.append("dependents: " + "; ".join(self.dependents))
40
+ if self.scars:
41
+ parts.append("carries: " + "; ".join(self.scars))
42
+ return " | ".join(parts)
43
+
44
+
45
+ @dataclass
46
+ class EmotionalTone:
47
+ """Where this life sits emotionally — drives the gold/dark visual + voice."""
48
+ valence: float # -1.0 (devastated) .. +1.0 (thriving)
49
+ dominant_feeling: str # e.g. "restless pride", "quiet grief"
50
+ voice_hint: str = "" # guidance for TTS (pace, warmth, weariness)
51
+
52
+ @property
53
+ def is_flourishing(self) -> bool:
54
+ return self.valence >= 0.25
55
+
56
+ @property
57
+ def is_struggling(self) -> bool:
58
+ return self.valence <= -0.25
59
+
60
+
61
+ @dataclass
62
+ class WorldState:
63
+ """One node: a complete snapshot of an alternate life."""
64
+ node_id: str
65
+ parent_id: Optional[str]
66
+ depth: int
67
+ # the choice that CREATED this branch (what diverged from the parent)
68
+ divergence: str
69
+ # how many years passed since the parent node
70
+ years_elapsed: int
71
+ facts: LifeFacts
72
+ tone: EmotionalTone
73
+ # a short second-person summary the UI shows ("You are 34, in Lisbon...")
74
+ summary: str = ""
75
+ # the spoken message this echo leaves (filled by the voice tool)
76
+ voice_line: str = ""
77
+ voice_audio_path: Optional[str] = None
78
+ # the two planned next forks (filled by the Screenwriter)
79
+ pending_forks: list[str] = field(default_factory=list)
80
+
81
+ def to_dict(self) -> dict:
82
+ return asdict(self)
83
+
84
+ @staticmethod
85
+ def new_id() -> str:
86
+ return uuid.uuid4().hex[:8]
87
+
88
+
89
+ def root_state(seed_choice: str, base_age: int = 30) -> WorldState:
90
+ """
91
+ Create the minimal root before the Curator fills it. The Curator will
92
+ overwrite facts/tone; this just establishes the tree's origin.
93
+ """
94
+ return WorldState(
95
+ node_id=WorldState.new_id(),
96
+ parent_id=None,
97
+ depth=0,
98
+ divergence=seed_choice,
99
+ years_elapsed=0,
100
+ facts=LifeFacts(age=base_age, location="unknown",
101
+ occupation="unknown"),
102
+ tone=EmotionalTone(valence=0.0, dominant_feeling="uncertain"),
103
+ )
echo/llm/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """LLM clients: deterministic mock + local HF model."""
2
+ from .client import LLMClient, MockLLM, LocalLLM, LLMConfig
echo/llm/client.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ echo/llm/client.py
3
+ ------------------
4
+ A thin LLM interface with two implementations:
5
+
6
+ * MockLLM — deterministic, dependency-free. Lets the WHOLE agentic pipeline
7
+ run and be tested without a GPU. It returns plausible structured
8
+ JSON so the orchestrator, agents, tools, and tree all exercise
9
+ their real code paths.
10
+ * LocalLLM — wraps a HuggingFace causal model (Qwen2.5-3B/14B etc.). Lazy
11
+ imports torch/transformers so importing this module is cheap.
12
+
13
+ Every agent talks to an LLMClient, never to transformers directly, so swapping
14
+ the 14B vs the ≤4B model (the Tiny Titan experiment) is a one-line change.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import hashlib
21
+ import random
22
+ from abc import ABC, abstractmethod
23
+ from dataclasses import dataclass
24
+
25
+
26
+ @dataclass
27
+ class LLMConfig:
28
+ model_name: str = "Qwen/Qwen2.5-3B-Instruct"
29
+ max_new_tokens: int = 512
30
+ temperature: float = 0.9
31
+ device: str = "cuda"
32
+ dtype: str = "bfloat16"
33
+
34
+
35
+ class LLMClient(ABC):
36
+ @abstractmethod
37
+ def complete(self, system: str, user: str, json_mode: bool = False) -> str:
38
+ ...
39
+
40
+ def complete_json(self, system: str, user: str) -> dict:
41
+ """Complete and parse JSON, tolerant of fences / preamble."""
42
+ raw = self.complete(system, user, json_mode=True)
43
+ return _safe_json(raw)
44
+
45
+
46
+ def _safe_json(text: str) -> dict:
47
+ try:
48
+ start = text.index("{")
49
+ end = text.rindex("}") + 1
50
+ return json.loads(text[start:end])
51
+ except (ValueError, json.JSONDecodeError):
52
+ return {}
53
+
54
+
55
+ # --------------------------------------------------------------------- mock
56
+ class MockLLM(LLMClient):
57
+ """
58
+ Deterministic stand-in. Produces structured life-fragments seeded by the
59
+ prompt hash, so the same branch always yields the same result (good for
60
+ tests) while different branches diverge.
61
+ """
62
+
63
+ _CITIES = ["Lisbon", "Tokyo", "Berlin", "São Paulo", "Reykjavik",
64
+ "Montreal", "Nairobi", "Hanoi"]
65
+ _JOBS = ["marine biologist", "bakery owner", "session guitarist",
66
+ "ER nurse", "patent lawyer", "documentary editor",
67
+ "high-school teacher", "startup founder"]
68
+ _FEELINGS = ["restless pride", "quiet grief", "stubborn hope",
69
+ "weary contentment", "sharp loneliness", "fierce joy"]
70
+ _SCARS = ["a friendship that never healed", "the move that cost you a parent",
71
+ "a business that folded", "a love you let leave"]
72
+ _TRIUMPHS = ["a book finally finished", "a child who adores you",
73
+ "a city that became home", "a fear you outgrew"]
74
+
75
+ def __init__(self, seed: int = 0):
76
+ self.seed = seed
77
+
78
+ def _rng(self, *parts: str) -> random.Random:
79
+ h = hashlib.sha256(("|".join(parts) + str(self.seed)).encode()).hexdigest()
80
+ return random.Random(int(h[:8], 16))
81
+
82
+ def complete(self, system: str, user: str, json_mode: bool = False) -> str:
83
+ r = self._rng(system[:40], user)
84
+ role = _detect_role(system)
85
+
86
+ if role == "curator":
87
+ payload = {
88
+ "age": r.randint(28, 52),
89
+ "location": r.choice(self._CITIES),
90
+ "occupation": r.choice(self._JOBS),
91
+ "relationships": [r.choice(["married", "newly single",
92
+ "in a long-distance love"])],
93
+ "dependents": r.choice([[], ["a daughter, 6"], ["a son, 11"]]),
94
+ "scars": [r.choice(self._SCARS)],
95
+ "triumphs": [r.choice(self._TRIUMPHS)],
96
+ "possessions": [r.choice(["a secondhand piano", "a dog named Argo",
97
+ "a balcony of herbs"])],
98
+ "valence": round(r.uniform(-0.8, 0.8), 2),
99
+ "dominant_feeling": r.choice(self._FEELINGS),
100
+ "voice_hint": r.choice(["slow and warm", "clipped, tired",
101
+ "bright, breathless"]),
102
+ "summary": "You wake in a life that turned on a single choice.",
103
+ "voice_line": "I still think about the version of us that stayed.",
104
+ }
105
+ return json.dumps(payload)
106
+
107
+ if role == "screenwriter":
108
+ forks = [
109
+ r.choice(["take the offer abroad", "stay for someone sick",
110
+ "sell everything and travel", "say yes to the proposal"]),
111
+ r.choice(["walk away from it all", "bet the savings on a dream",
112
+ "reconcile with an old enemy", "have the child"]),
113
+ ]
114
+ return json.dumps({"forks": forks})
115
+
116
+ if role == "verifier":
117
+ # mock: pass most of the time, occasionally flag
118
+ ok = r.random() > 0.15
119
+ return json.dumps({"consistent": ok,
120
+ "reason": "" if ok else "age contradicts parent"})
121
+
122
+ return json.dumps({"text": "…"})
123
+
124
+
125
+ def _detect_role(system: str) -> str:
126
+ s = system.lower()
127
+ if "curator" in s:
128
+ return "curator"
129
+ if "screenwriter" in s or "fork" in s:
130
+ return "screenwriter"
131
+ if "verifier" in s or "consisten" in s:
132
+ return "verifier"
133
+ return "generic"
134
+
135
+
136
+ # -------------------------------------------------------------------- local
137
+ class LocalLLM(LLMClient):
138
+ """Real model. Heavy deps imported lazily in .load()."""
139
+
140
+ def __init__(self, cfg: LLMConfig):
141
+ self.cfg = cfg
142
+ self.model = None
143
+ self.tokenizer = None
144
+
145
+ def load(self) -> None:
146
+ import torch
147
+ from transformers import AutoModelForCausalLM, AutoTokenizer
148
+ self.tokenizer = AutoTokenizer.from_pretrained(self.cfg.model_name)
149
+ self.model = AutoModelForCausalLM.from_pretrained(
150
+ self.cfg.model_name,
151
+ dtype=getattr(torch, self.cfg.dtype),
152
+ device_map=self.cfg.device,
153
+ )
154
+
155
+ def complete(self, system: str, user: str, json_mode: bool = False) -> str:
156
+ import torch
157
+ msgs = [{"role": "system", "content": system},
158
+ {"role": "user", "content": user}]
159
+ inputs = self.tokenizer.apply_chat_template(
160
+ msgs, add_generation_prompt=True, return_tensors="pt",
161
+ return_dict=True,
162
+ ).to(self.cfg.device)
163
+ prompt_len = inputs["input_ids"].shape[1]
164
+ with torch.no_grad():
165
+ out = self.model.generate(
166
+ **inputs, max_new_tokens=self.cfg.max_new_tokens,
167
+ do_sample=True, temperature=self.cfg.temperature,
168
+ pad_token_id=self.tokenizer.eos_token_id,
169
+ )
170
+ return self.tokenizer.decode(out[0, prompt_len:],
171
+ skip_special_tokens=True)
echo/requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # The core agentic pipeline + smoke_test run with ZERO of these (MockLLM +
2
+ # mock tools). Install these only to plug in a real model / TTS / search.
3
+ torch>=2.1
4
+ transformers>=4.44
5
+ accelerate>=0.30
6
+ # Voice (pick one):
7
+ # piper-tts
8
+ # TTS # coqui
9
+ # Front-end:
10
+ gradio>=4.0
echo/smoke_test.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ echo/smoke_test.py
3
+ ------------------
4
+ Runs the FULL agentic pipeline with MockLLM + mock tools — no GPU, no ML deps.
5
+ Proves Curator/Screenwriter/Verifier/orchestrator/tree/tools all interoperate.
6
+
7
+ Run: python -m echo.smoke_test
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .llm.client import MockLLM
13
+ from .tools.research import MockResearch
14
+ from .tools.voice import MockVoice
15
+ from .core.orchestrator import Orchestrator
16
+
17
+
18
+ def main():
19
+ print("=" * 60)
20
+ print("The Echo — agentic pipeline smoke test (no ML deps)")
21
+ print("=" * 60)
22
+
23
+ llm = MockLLM(seed=7)
24
+ orch = Orchestrator(llm, MockResearch(), MockVoice(), out_dir="/tmp/echo_out")
25
+
26
+ # 1. seed the tree
27
+ seed = "I left Brazil to study abroad instead of staying"
28
+ root = orch.seed(seed, base_age=24)
29
+ print(f"\n[1] Seed: {seed!r}")
30
+ print(f" Root life: {root.facts.constraints_text()}")
31
+ print(f" Voice line: {root.voice_line!r}")
32
+ print(f" Audio: {root.voice_audio_path}")
33
+ print(f" Planned forks: {root.pending_forks}")
34
+
35
+ # 2. explore several branches (simulate the user navigating the tree)
36
+ print("\n[2] Exploring branches...")
37
+ cur = root
38
+ for step in range(4):
39
+ if not cur.pending_forks:
40
+ break
41
+ child = orch.choose_fork(cur.node_id, fork_index=step % 2, years=5)
42
+ print(f" depth {child.depth}: chose {child.divergence!r}")
43
+ print(f" -> {child.facts.constraints_text()}")
44
+ print(f" tone: {child.tone.dominant_feeling} "
45
+ f"(valence {child.tone.valence}, "
46
+ f"{'gold' if child.tone.is_flourishing else 'dark'})")
47
+ cur = child
48
+
49
+ # 3. branch into a second direction from root
50
+ print("\n[3] Branching the OTHER way from root...")
51
+ other = orch.choose_fork(root.node_id, fork_index=1, years=8)
52
+ print(f" depth {other.depth}: {other.divergence!r}")
53
+ print(f" -> {other.facts.constraints_text()}")
54
+
55
+ # 4. tree + telemetry
56
+ g = orch.graph()
57
+ print(f"\n[4] Tree: {len(g['nodes'])} nodes, {len(g['edges'])} edges, "
58
+ f"max depth {orch.tree.max_depth}")
59
+ print(f" Regeneration rate: {orch.stats.regen_rate:.0%} "
60
+ f"({orch.stats.regenerations} regens / "
61
+ f"{orch.stats.nodes_grown} grown)")
62
+
63
+ # 5. closing artifact
64
+ print(f"\n[5] Final map:\n {orch.final_map_summary()}")
65
+
66
+ # 6. verify graph serialization (what Gradio will consume)
67
+ assert all("valence" in n for n in g["nodes"]), "graph nodes well-formed"
68
+ print(f"\n[6] Graph serialization OK — ready for the front-end.")
69
+
70
+ print("\n" + "=" * 60)
71
+ print("ALL STAGES PASSED ✓ (agentic loop verified)")
72
+ print("=" * 60)
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()
echo/tools/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Tools: world-grounding research and voice synthesis."""
2
+ from .research import ResearchTool, MockResearch, WebResearch
3
+ from .voice import VoiceTool, MockVoice, PiperVoice
echo/tools/research.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ echo/tools/research.py
3
+ ----------------------
4
+ The world-grounding tool. Given a location + era, it returns real-world detail
5
+ (cost of living, cultural moment, climate, notable events) that the Curator
6
+ weaves in so an alternate life feels *anchored* rather than hallucinated. This
7
+ grounding is the moment a judge thinks "wait, how does it know that?"
8
+
9
+ ResearchTool is an interface with:
10
+ * MockResearch — deterministic offline facts (for GPU-free testing),
11
+ * WebResearch — wraps a real search call (web_search / an API) at deploy time.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from abc import ABC, abstractmethod
17
+
18
+
19
+ class ResearchTool(ABC):
20
+ @abstractmethod
21
+ def ground(self, location: str, year: int, theme: str = "") -> str:
22
+ """Return a short bundle of grounding facts for location/year."""
23
+ ...
24
+
25
+
26
+ class MockResearch(ResearchTool):
27
+ """Offline, deterministic grounding. Enough to exercise the pipeline."""
28
+
29
+ _NOTES = {
30
+ "Lisbon": "steep tiled streets, cheap pastéis, a tech-expat wave, the 28 tram",
31
+ "Tokyo": "rail punctual to the second, conbini at every corner, quiet density",
32
+ "Berlin": "late winters, techno after-hours, a city still half rebuilt",
33
+ "São Paulo": "endless gray sprawl, helicopters over traffic, rain that floods",
34
+ "Reykjavik": "winter dark by 3pm, geothermal pools, wool against the wind",
35
+ "Montreal": "bilingual signs, brutal Februaries, bagels better than New York",
36
+ "Nairobi": "matatus in bright livery, high-altitude light, a startup hum",
37
+ "Hanoi": "scooters like a river, broth at dawn, humidity that never lifts",
38
+ }
39
+
40
+ def ground(self, location: str, year: int, theme: str = "") -> str:
41
+ note = self._NOTES.get(location, "a place with its own particular light")
42
+ return f"{location} (~{year}): {note}."
43
+
44
+
45
+ class WebResearch(ResearchTool):
46
+ """
47
+ Deploy-time grounding via a real search function injected at construction.
48
+ `search_fn(query) -> str` keeps this decoupled from any specific API and
49
+ lets the Gradio app pass in web_search or a cached corpus.
50
+ """
51
+
52
+ def __init__(self, search_fn):
53
+ self.search_fn = search_fn
54
+
55
+ def ground(self, location: str, year: int, theme: str = "") -> str:
56
+ q = f"{location} {year} daily life cost of living culture {theme}".strip()
57
+ try:
58
+ return str(self.search_fn(q))[:600]
59
+ except Exception:
60
+ return f"{location} (~{year})."
echo/tools/voice.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ echo/tools/voice.py
3
+ -------------------
4
+ The voice tool gives each echo a spoken line — the same "you" at different ages
5
+ and emotional registers. Hearing an alternate self speak is the visceral beat
6
+ that text can't reach.
7
+
8
+ VoiceTool interface:
9
+ * MockVoice — writes a tiny placeholder file path, no audio deps (testing).
10
+ * PiperVoice — wraps a local TTS (Piper/Coqui) at deploy time, with the
11
+ WorldState's voice_hint shaping pace/warmth, and an optional
12
+ pitch shift so each branch's voice differs subtly.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from abc import ABC, abstractmethod
19
+
20
+ from ..core.world_state import WorldState # module import (not the core package) avoids a cycle
21
+
22
+
23
+ class VoiceTool(ABC):
24
+ @abstractmethod
25
+ def speak(self, state: WorldState, out_dir: str) -> str:
26
+ """Synthesize state.voice_line; return the audio file path."""
27
+ ...
28
+
29
+
30
+ class MockVoice(VoiceTool):
31
+ """Writes a placeholder .txt 'audio' marker so the pipeline runs offline."""
32
+
33
+ def speak(self, state: WorldState, out_dir: str) -> str:
34
+ os.makedirs(out_dir, exist_ok=True)
35
+ path = os.path.join(out_dir, f"voice_{state.node_id}.txt")
36
+ with open(path, "w") as f:
37
+ f.write(f"[VOICE hint={state.tone.voice_hint!r}] {state.voice_line}")
38
+ return path
39
+
40
+
41
+ class PiperVoice(VoiceTool):
42
+ """
43
+ Deploy-time TTS. Lazy-imports the synth backend. A small pitch offset keyed
44
+ to the branch makes parallel selves sound like the same person tuned
45
+ differently (older/wearier/brighter).
46
+ """
47
+
48
+ def __init__(self, model_path: str, base_pitch: float = 1.0):
49
+ self.model_path = model_path
50
+ self.base_pitch = base_pitch
51
+
52
+ def speak(self, state: WorldState, out_dir: str) -> str:
53
+ os.makedirs(out_dir, exist_ok=True)
54
+ path = os.path.join(out_dir, f"voice_{state.node_id}.wav")
55
+ # pitch nudged by emotional valence: down when struggling, up when light
56
+ pitch = self.base_pitch + 0.04 * state.tone.valence
57
+ self._synthesize(state.voice_line, path, pitch, state.tone.voice_hint)
58
+ return path
59
+
60
+ def _synthesize(self, text: str, path: str, pitch: float, hint: str) -> None:
61
+ # Lazy import keeps the package importable without TTS installed.
62
+ try:
63
+ from piper import PiperVoice as _Piper # type: ignore
64
+ except Exception:
65
+ # graceful fallback: write a marker instead of crashing the demo
66
+ with open(path + ".txt", "w") as f:
67
+ f.write(f"[TTS pitch={pitch:.2f} hint={hint!r}] {text}")
68
+ return
69
+ voice = _Piper.load(self.model_path)
70
+ with open(path, "wb") as f:
71
+ voice.synthesize(text, f, length_scale=1.0, sentence_silence=0.3)