""" echo/agents/curator.py ---------------------- The Curator builds a child WorldState as a *causal consequence* of its parent. It is the agent most responsible for the illusion that it's "the same you" across branches: it receives the full branch narrative (root -> parent) plus the chosen fork, and must produce a life that could plausibly have grown from that history. It never invents from scratch. Optionally consumes grounding facts from the research tool (real-world detail for the chosen location/era) to kill vagueness. """ from __future__ import annotations from typing import Optional from .base import Agent, HOUSE_STYLE_WORLD, HOUSE_STYLE_VOICE from ..core.world_state import WorldState, LifeFacts, EmotionalTone _CURATOR_GOLD = ( "GOLD EXAMPLE — match this texture and JSON shape, never reuse its content:\n" "{\n" ' "age": 41,\n' ' "location": "Porto, Portugal",\n' ' "occupation": "night-shift baker",\n' ' "relationships": ["divorced from Inês, still text on Sundays"],\n' ' "dependents": ["a son, Tomás, 9"],\n' ' "scars": ["the Lisbon bakery you lost to the bank"],\n' ' "triumphs": ["Tomás learned to swim this July"],\n' ' "possessions": ["a flour-dusted radio stuck on one station"],\n' ' "valence": -0.1,\n' ' "dominant_feeling": "tired tenderness",\n' ' "voice_hint": "low, even, a little hoarse before dawn",\n' ' "summary": "You are 41, in Porto, baking through the night so Tomás ' 'wakes to warm bread.",\n' ' "voice_line": "I lost the Lisbon shop and I lost her, but the boy ate ' 'warm bread this morning and never knew a thing."\n' "}\n\n" "The voice_line earns its weight from one concrete image. Never write the " "abstract version:\n" ' ✗ "It was hard, but it became a journey of self-discovery worth taking."\n' ' ✓ "...the boy ate warm bread this morning and never knew a thing."' ) class Curator(Agent): SYSTEM = ( HOUSE_STYLE_WORLD + "\n" + HOUSE_STYLE_VOICE + "\n" "You are the Curator. You grow ONE human life forward across alternate " "timelines. Given the branch history and the choice that now diverges " "this life, write the NEXT state as a believable causal consequence of " "the parent. The new age must equal the parent's age plus the years " "that pass. Respond ONLY as JSON with keys: age, location, occupation, " "relationships, dependents, scars, triumphs, possessions, valence, " "dominant_feeling, voice_hint, summary, voice_line.\n\n" + _CURATOR_GOLD ) def grow(self, parent: WorldState, branch_narrative: str, chosen_fork: str, years: int, grounding: Optional[str] = None) -> WorldState: user = ( f"BRANCH HISTORY (must stay consistent with all of this):\n" f"{branch_narrative}\n\n" f"PARENT FACTS: {parent.facts.constraints_text()}\n" f"PARENT EMOTION: {parent.tone.dominant_feeling} " f"(valence {parent.tone.valence})\n\n" f"THE CHOICE THAT NOW DIVERGES THIS LIFE: {chosen_fork}\n" f"YEARS THAT PASS: {years}\n" ) if grounding: user += f"\nREAL-WORLD GROUNDING (use to add authentic detail):\n{grounding}\n" if parent.facts.location == "unknown" or parent.facts.occupation == "unknown": # Origin node: nothing is established yet. The model must INVENT a # concrete grounding rather than inherit the "unknown" placeholder. user += ( "\nThis is the ORIGIN of the life — no location or occupation is " "fixed yet. Establish a SPECIFIC city and occupation that fit the " "diverging choice; do not write 'unknown'." ) user += ( "\nWrite the resulting life. The new age must equal parent age + " f"{years}. Make one big dramatic turn land — a triumph or a wound." ) data = self._complete_json(user) return self._assemble(parent, chosen_fork, years, data) # ------------------------------------------------------------- assembly def _assemble(self, parent: WorldState, fork: str, years: int, data: dict) -> WorldState: """Build a WorldState from model JSON, with safe fallbacks.""" facts = LifeFacts( # Age is arithmetic, not generation: the tree's math must never be # at the mercy of a free-form model field. Always derive it. age=parent.facts.age + years, location=str(data.get("location", parent.facts.location)), occupation=str(data.get("occupation", parent.facts.occupation)), relationships=_as_list(data.get("relationships")), dependents=_as_list(data.get("dependents")), scars=_as_list(data.get("scars")) or list(parent.facts.scars), triumphs=_as_list(data.get("triumphs")), possessions=_as_list(data.get("possessions")), ) tone = EmotionalTone( valence=float(data.get("valence", 0.0)), dominant_feeling=str(data.get("dominant_feeling", "uncertain")), voice_hint=str(data.get("voice_hint", "")), ) return WorldState( node_id=WorldState.new_id(), parent_id=parent.node_id, depth=parent.depth + 1, divergence=fork, years_elapsed=years, facts=facts, tone=tone, summary=str(data.get("summary", "")), voice_line=str(data.get("voice_line", "")), ) def _as_list(v) -> list[str]: if v is None: return [] if isinstance(v, list): return [str(x) for x in v] return [str(v)]