Spaces:
Running
Running
Deploy The Echo (MockLLM path): Gradio app + echo package
Browse files- README.md +13 -5
- app.py +7 -0
- echo/CLAUDE.md +138 -0
- echo/README.md +75 -0
- echo/__init__.py +3 -0
- echo/agents/__init__.py +5 -0
- echo/agents/base.py +65 -0
- echo/agents/curator.py +129 -0
- echo/agents/screenwriter.py +47 -0
- echo/agents/verifier.py +84 -0
- echo/app.py +248 -0
- echo/core/__init__.py +9 -0
- echo/core/orchestrator.py +145 -0
- echo/core/tree.py +86 -0
- echo/core/world_state.py +103 -0
- echo/llm/__init__.py +2 -0
- echo/llm/client.py +171 -0
- echo/requirements.txt +10 -0
- echo/smoke_test.py +76 -0
- echo/tools/__init__.py +3 -0
- echo/tools/research.py +60 -0
- echo/tools/voice.py +71 -0
README.md
CHANGED
|
@@ -1,13 +1,21 @@
|
|
| 1 |
---
|
| 2 |
title: The Echo
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|