···65656666All LLM prompts live in `prompts/` as markdown files, loaded at runtime via `load_prompt(name)`. Don't put prompt text inline in Python code — if it's instructions for a model, it goes in `prompts/`. Tool docstrings in the `tools/*.py` modules are the exception since they're tightly coupled to the function signatures and JSON schemas.
67676868+## Cold-Start Flow
6969+7070+When `storied play` runs against a fresh world (no character or no `style.md`), the CLI enters a single-invocation onboarding sequence:
7171+7272+1. **Onboarding session** — `prompts/new-game.md` runs against the DM engine. The DM weaves character creation with worldbuilding preferences, calling `tune` (writes `style.md`) and `create_character` along the way. The DM must not call `set_scene` or `establish` during onboarding. When both artifacts are captured, the DM calls `end_session`.
7373+2. **Artifact re-check** — if either `character.yaml` or `style.md` is still missing after the onboarding session exits, the CLI prints a friendly "onboarding incomplete" message and exits. Re-running `storied play` drops back into onboarding to finish the gap.
7474+3. **World seeding** — `seed_world` runs synchronously (with a progress spinner). It reads `style.md` and prepends it as a `## Player Preferences` block to the seeder's user message so the bootstrap world reflects the tone/genre the player asked for.
7575+4. **Normal play** — a fresh `DMEngine` starts with `prompts/dm-system.md`, background ticker and advancement evaluator come online, and the player drops into the opening scene.
7676+7777+All four phases happen in one `storied play` invocation. Sandbox mode (`--sandbox`) skips onboarding and seeding entirely — it starts with the stock DM system prompt every time.
7878+6879## Content Layers
69807081Game content is organized in three layers that overlay each other,
+37
design/architecture.md
···92929393## Component Responsibilities
94949595+### Cold-Start Onboarding (`cli.py` + `prompts/new-game.md`)
9696+9797+When `storied play` launches against a fresh world — no character
9898+sheet or no `style.md` — it enters an onboarding sequence rather than
9999+the normal DM loop. The sequence runs entirely in one invocation:
100100+101101+1. **Onboarding conversation.** A DM engine loads
102102+ `prompts/new-game.md` and weaves character creation with
103103+ worldbuilding preferences. The DM has two mandatory deliverables
104104+ before `end_session`: `tune()` (writes `worlds/{world}/style.md`
105105+ capturing tone, themes, pacing) and `create_character()`. The
106106+ prompt forbids `set_scene`, `establish`, and any attempt to start
107107+ the adventure — those belong to the seeder and the live DM.
108108+2. **Artifact gate.** After the onboarding engine exits, `cmd_play`
109109+ re-checks both files. If either is missing, it prints a friendly
110110+ "onboarding incomplete" message and exits; re-running
111111+ `storied play` re-enters onboarding to fill the gap. The DM
112112+ notices what already exists in context and only works on the
113113+ missing deliverable.
114114+3. **Synchronous planning phase.** `seed_world()` runs as a blocking
115115+ Claude subprocess using `prompts/world-seed.md`. It now reads
116116+ `style.md` and prepends it as a `## Player Preferences` block to
117117+ the seeder's user message, so the 12–16 entities it establishes
118118+ and the opening `set_scene` reflect the player's preferences — not
119119+ a lowest-common-denominator interpretation of the character
120120+ sheet alone.
121121+4. **Live play.** A fresh `DMEngine` starts with `prompts/dm-system.md`,
122122+ `BackgroundTicker` and `BackgroundAdvancement` come online, and
123123+ the player drops into the opening scene.
124124+125125+`cli.py` extracts the input/stream/ticker/advancement loop into
126126+`_run_engine_loop` so both the onboarding engine and the play engine
127127+share the same interactive shell. Onboarding runs with
128128+`is_onboarding=True` and no background agents; play runs with
129129+`is_onboarding=False` and full ticker + advancement. `--sandbox`
130130+skips cold-start entirely and goes straight to the DM system prompt.
131131+95132### DM Engine (`engine.py`)
9613397134The agentic loop: builds the turn's context, spawns Claude with the MCP
-49
prompts/character-creation.md
···11-You are helping a player create a new 5e character for a solo adventure.
22-33-## Your Role
44-55-Guide the player through character creation conversationally. Don't present it as a form - have a natural back-and-forth where you learn about who they want to play.
66-77-## The Flow
88-99-Start by asking what kind of hero they imagine. Let their answer guide you:
1010-1111-- If they have a clear concept ("a sneaky halfling thief"), help them build toward it
1212-- If they're unsure, ask questions to discover what appeals to them
1313-- If they want to explore options, briefly describe what's available
1414-1515-## Key Decisions
1616-1717-Work through these naturally (not necessarily in order):
1818-1919-1. **Concept**: Who is this person? What's their deal?
2020-2. **Race**: Human, Elf, Dwarf, Halfling, etc. Look up races for traits.
2121-3. **Class**: Fighter, Wizard, Rogue, etc. Look up classes for features.
2222-4. **Background**: Acolyte, Criminal, Soldier, etc. Provides skills and flavor.
2323-5. **Ability Scores**: Roll 4d6kh3 six times, let them assign scores.
2424-6. **Starting Equipment**: Based on class and background.
2525-7. **Details**: Name, personality, bonds, flaws - whatever feels right.
2626-2727-## Ability Scores
2828-2929-Roll ability scores using the standard method (4d6, drop lowest, six times). Let the player assign the results to abilities as they choose based on their concept.
3030-3131-## Looking Things Up
3232-3333-Use `recall` freely to check:
3434-- Racial traits (darkvision, resistances, etc.)
3535-- Class features (hit dice, proficiencies, starting abilities)
3636-- Background features (skills, tools, equipment)
3737-- Spell lists if they're a caster
3838-3939-Get the details right - this character will be with them for a while.
4040-4141-## Finalizing
4242-4343-Once you have everything, call `create_character` with the full character data. This writes `character.yaml` (structured stats) and `character.md` (the prose backstory you pass via the `backstory` argument).
4444-4545-After the character exists, you can call `update_character` to add proficiencies (skills/saves/tools), `add_item` to populate starting equipment, and `update_character({"features": [...]})` to add class features. Equipment goes into location subsections like `on_person`, `worn`, or whatever the player describes.
4646-4747-**IMPORTANT**: After calling `create_character`, give a brief summary of the character (a "quick reference" with key abilities, spells if any, and notable features) but do NOT start the adventure. The session will end and the player will start fresh with `storied play` using the full DM system.
4848-4949-Don't rush. Character creation is part of the fun. Let them explore, change their mind, and discover who they want to play.
+78
prompts/new-game.md
···11+You are the DM running a **cold-start onboarding session** for a new solo 5e adventure. This is not gameplay yet. Your job this session is to capture two things before the story can begin:
22+33+1. **The kind of world and story the player wants** — recorded via `tune`.
44+2. **The player's character** — recorded via `create_character`.
55+66+Neither of these has been created yet (or one of them has — see below). You won't actually run any adventure in this session. Once both artifacts are captured and you call `end_session`, a separate world-building step will establish the opening world and set up the first scene. The player will drop straight into play from there.
77+88+## Check what already exists
99+1010+Your context may already contain a character sheet or a style block. That means the player started onboarding before and quit partway through. Pick up where they left off:
1111+1212+- **Character sheet present, no Style section** → skip character creation entirely. Open by acknowledging their character and focus the whole session on worldbuilding preferences.
1313+- **Style section present, no character sheet** → their worldbuilding preferences are already captured. Focus on character creation, but stay aware of the established tone as you guide the character conversation.
1414+- **Neither present** → full onboarding, both deliverables.
1515+- **Both present** → something is wrong; thank the player, call `end_session`, and let them re-run.
1616+1717+## Weaving the conversation
1818+1919+Character creation and worldbuilding belong together. As the player describes who they want to play, listen for tone signals. As they describe the kind of story they want, suggest character directions that fit. Don't split the session into "phase 1: worldbuilding, phase 2: character" — let the two strands interleave.
2020+2121+Some questions to work in naturally, not as a form:
2222+2323+- What kind of story excites you? Heroic, grim, political, weird, cozy, brutal?
2424+- What tone — gritty realism, high fantasy, dark horror, comedic, sword-and-sorcery?
2525+- Pacing — breakneck action, slow burn, investigation-heavy, travelogue?
2626+- What *don't* you want? Things to avoid — tropes, themes, intensity levels.
2727+- What's the hero of this story like? What are they drawn to, what do they avoid, what do they want?
2828+- Who are they? Race, class, background, name, quirks.
2929+3030+Get two or three of these on the table early. Use them to shape the character suggestions. Use the character concept to sharpen the world prefs. Call `tune` as soon as you have a clear vibe — don't wait until the end.
3131+3232+## Using `tune`
3333+3434+`tune` writes `style.md` and is **full-replacement** — each call overwrites the file. So every subsequent call must incorporate everything you'd previously captured. Structure the text as short prose paragraphs the DM will read every turn:
3535+3636+- **Tone** (one or two lines — grim, playful, tense, etc.)
3737+- **Themes** (what the story is about)
3838+- **Pacing** (breakneck, slow burn, investigation-heavy)
3939+- **What to lean into** (moments the player wants)
4040+- **What to avoid** (hard no's)
4141+4242+Call `tune` at least twice — once after the initial conversation when you have a first vibe, again after the character is clearer and you can refine the preferences with the character in mind.
4343+4444+## Character creation
4545+4646+Work through these naturally:
4747+4848+1. **Concept** — who is this person, what's their deal?
4949+2. **Race** — look up traits with `recall`.
5050+3. **Class** — look up features with `recall`.
5151+4. **Background** — skills, tools, flavor.
5252+5. **Ability scores** — roll 4d6kh3 six times, let the player assign.
5353+6. **Starting equipment** — class + background.
5454+7. **Details** — name, personality, bonds, flaws.
5555+5656+Use `recall` freely for rules lookups. Get the details right.
5757+5858+Once the character is fully fleshed out, call `create_character` with the full data. After that, you can use `update_character`, `add_item`, `update_character({"features": [...]})` to populate proficiencies, equipment, and class features. Equipment goes into location subsections like `on_person`, `worn`, etc.
5959+6060+## What you MUST NOT do
6161+6262+- **Do NOT call `set_scene`.** The opening scene is the seeder's job, not yours. Calling it now would commit the player to a location and moment before the world exists.
6363+- **Do NOT call `establish`, `mark`, `amend_mark`, or `note_discovery`.** World entities are the seeder's job. You capture preferences and character; the seeder builds the world from them.
6464+- **Do NOT start the adventure.** No narration, no "you find yourself in...", no opening scene. This session is setup only.
6565+6666+Your surface this session is: `tune`, `create_character`, `update_character`, `add_item`, `recall`, `roll`, `end_session`. That's it.
6767+6868+## Wrapping up
6969+7070+When both `tune` and `create_character` have been called — and only then — give a brief summary:
7171+7272+- A one-paragraph reflection on the world and story vibe you've captured.
7373+- A quick character sheet reference (key abilities, notable features, starting gear highlights).
7474+- A short note that when the session ends, the world will be built and their first scene will be waiting.
7575+7676+Then call `end_session` with a wrap-up situation like "Ready to begin: [character] preparing to step into [vibe description]."
7777+7878+Don't rush any of this. Onboarding is part of the fun. Let the player explore, change their mind, and discover both who they want to play and the world they want to play in.
+5-1
prompts/world-seed.md
···991010## What You're Given
11111212-A character sheet — name, race, class, backstory, personality. This is the only thing that exists. No session, no entities, no campaign log. You're building the world from scratch.
1212+A character sheet — name, race, class, backstory, personality — and, when the player has already gone through onboarding, a **`## Player Preferences`** block at the top of your user message. That block comes from `style.md` and captures the tone, themes, genre, and pacing the player asked for.
1313+1414+**When preferences are present, anchor the world in them.** A request for "grim political intrigue, no heroic fantasy" means the starting location is a tense city district, the NPCs have compromising secrets, and the threads hinge on factional maneuvering — not farmhands and goblin raids. A request for "cozy slice-of-life with light mystery" means the opening is a village at dawn, the threads are small and personal, the stakes are low but meaningful. Let the preferences decide the *feel*; let the character decide the *specifics*.
1515+1616+If no preferences block is present, fall back to inferring tone from the character's backstory alone.
13171418## What to Build
1519
+35-7
src/storied/character/operations.py
···407407) -> str:
408408 """Apply relative coin changes (positive=gain, negative=spend).
409409410410- Each denomination is clamped to 0 minimum.
410410+ Atomic: if any denomination would go below zero, the entire call is
411411+ rejected with an error and the purse is not modified. The DM should
412412+ handle change in a single call (e.g. paying 5 cp from a 9 sp / 0 cp
413413+ purse is ``{"sp": -1, "cp": +5}``, not two separate calls), and
414414+ this rejection is what surfaces the mistake when they don't.
411415 """
412416 data = load_character(player_id)
413417 if data is None:
···417421 "purse", {"cp": 0, "sp": 0, "ep": 0, "gp": 0, "pp": 0}
418422 )
419423420420- changes = []
424424+ # Validate every denomination would land ≥ 0 before touching anything.
425425+ proposed: dict[str, int] = {}
426426+ shortfalls: list[str] = []
421427 for denom, delta in deltas.items():
422428 if denom not in ("cp", "sp", "ep", "gp", "pp"):
423429 continue
424430 old = purse.get(denom, 0)
425431 new = old + delta
426432 if new < 0:
427427- changes.append(f"{denom} {old} → 0 (short {-new} {denom})")
428428- purse[denom] = 0
429429- else:
430430- changes.append(f"{denom} {old} → {new}")
431431- purse[denom] = new
433433+ shortfalls.append(
434434+ f"{denom}: have {old}, need {-delta} (short {-new})"
435435+ )
436436+ proposed[denom] = new
437437+438438+ if shortfalls:
439439+ coins = []
440440+ for denom in ("pp", "gp", "ep", "sp", "cp"):
441441+ amount = purse.get(denom, 0)
442442+ if amount:
443443+ coins.append(f"{amount} {denom}")
444444+ purse_str = ", ".join(coins) if coins else "empty"
445445+ return (
446446+ "Cannot adjust coins — insufficient funds. "
447447+ f"{'; '.join(shortfalls)}. Purse: {purse_str}. "
448448+ "Nothing was changed. To make change, fold the payment and "
449449+ "the coins coming back into a single call: e.g. paying 5 cp "
450450+ 'from a 9 sp / 0 cp purse is {"sp": -1, "cp": 5} '
451451+ "(1 silver out, 5 copper back). Don't call adjust_coins twice "
452452+ "to spend then make change — you'll double-charge."
453453+ )
454454+455455+ changes = []
456456+ for denom, new in proposed.items():
457457+ old = purse.get(denom, 0)
458458+ changes.append(f"{denom} {old} → {new}")
459459+ purse[denom] = new
432460433461 save_character(player_id, data)
434462
+5
src/storied/claude.py
···108108 "--strict-mcp-config",
109109 "--mcp-config", mcp_config,
110110 "--effort", effort,
111111+ # Keep per-machine sections (cwd, env, memory paths, git
112112+ # status) out of the system prompt. We control the entire
113113+ # prompt explicitly via --system-prompt, and the dynamic
114114+ # bits are the most plausible cross-campaign leak surface.
115115+ "--exclude-dynamic-system-prompt-sections",
111116 ]
112117113118 if not persist_session:
+404-269
src/storied/cli.py
···55import argparse
66import sys
77from pathlib import Path
88+from typing import TYPE_CHECKING
89910import argcomplete
10111212+if TYPE_CHECKING:
1313+ from rich.console import Console
1414+1515+ from storied.advancement import BackgroundAdvancement
1616+ from storied.engine import DMEngine
1717+ from storied.planner import BackgroundTicker
1818+1119# Slash commands available during play
1220SLASH_COMMANDS = {
1321 "/help": "Show this help message",
···1624 "/save": "Save session state (without quitting)",
1725 "/context": "Show token usage",
1826 "/dm": "Say something out-of-character to the DM (e.g. /dm less combat please)",
1919- "/note": "Add a note to your character sheet (e.g. /note remember the prayer words)",
2727+ "/note": (
2828+ "Add a note to your character sheet "
2929+ "(e.g. /note remember the prayer words)"
3030+ ),
2031}
21322233def _format_character_display(
···8697 # Write metadata
8798 meta_path = output_path.parent / "meta.yaml"
8899 source_hash = get_file_hash(pdf_path)
100100+ from datetime import UTC, datetime
101101+89102 import yaml
9090- from datetime import datetime, timezone
9110392104 meta = {
93105 "version": "5.2.1",
94106 "source_url": SRD_URL,
95107 "source_hash": source_hash,
9696- "extracted_at": datetime.now(timezone.utc).isoformat(),
108108+ "extracted_at": datetime.now(UTC).isoformat(),
97109 }
98110 meta_path.write_text(yaml.dump(meta, sort_keys=False))
99111 log(f"Wrote: {meta_path}")
···157169 """Reset player and world state to start fresh."""
158170 import shutil
159171160160- from storied import paths
161172 from storied.paths import (
162173 configure,
163174 data_home,
···208219 return 0
209220210221211211-def cmd_play(args: argparse.Namespace) -> int:
212212- """Start an interactive DM session."""
213213- import atexit
214214- import readline
215215- import shutil
216216- import tempfile
222222+def _is_cold_start(world_id: str, player_id: str) -> bool:
223223+ """True when the story can't run because onboarding artifacts are missing.
217224218218- from rich.console import Console
219219- from rich.markdown import Markdown
220220- from rich.panel import Panel
221221- from rich.rule import Rule
222222- from rich.text import Text
223223-225225+ Cold start means either the player has no character yet, or the
226226+ world has no ``style.md`` capturing the player's worldbuilding
227227+ preferences. Either gap sends ``cmd_play`` into the onboarding
228228+ flow on launch.
229229+ """
224230 from storied.character import load_character
225225- from storied.engine import DMEngine
231231+ from storied.paths import world_path
226232227227- # Set up readline history
228228- history_file = Path.home() / ".storied_history"
229229- try:
230230- readline.read_history_file(history_file)
231231- except FileNotFoundError:
232232- pass
233233- readline.set_history_length(1000)
234234- atexit.register(readline.write_history_file, history_file)
233233+ character = load_character(player_id)
234234+ style_path = world_path(world_id) / "style.md"
235235+ return character is None or not style_path.exists()
235236236236- # Set up slash command autocomplete
237237- def slash_completer(text: str, state: int) -> str | None:
238238- if text.startswith("/"):
239239- matches = [cmd for cmd in SLASH_COMMANDS if cmd.startswith(text)]
240240- return matches[state] if state < len(matches) else None
241241- return None
242237243243- readline.set_completer_delims(readline.get_completer_delims().replace("/", ""))
244244- readline.set_completer(slash_completer)
245245- readline.parse_and_bind("tab: complete")
246246- readline.parse_and_bind("set enable-bracketed-paste on")
238238+def _run_engine_loop(
239239+ engine: "DMEngine",
240240+ *,
241241+ console: "Console",
242242+ args: argparse.Namespace,
243243+ player_id: str,
244244+ is_sandbox: bool,
245245+ is_onboarding: bool,
246246+ first_message: str | None,
247247+ ticker: "BackgroundTicker | None" = None,
248248+ advancement: "BackgroundAdvancement | None" = None,
249249+) -> None:
250250+ """Run the interactive input/stream loop against a DMEngine.
247251248248- console = Console()
249249- world_id = args.world if args.world else "default"
250250- player_id = "default"
251251- sandbox = getattr(args, "sandbox", False)
252252-253253- from storied.paths import configure, data_home, resolve_data_home
254254-255255- # Sandbox mode: throwaway worlds + players in a temp directory.
256256- # User homebrew rules stay pointed at the real ~/.storied/rules/
257257- # so the sandbox isn't a pristine playground — it's still "your
258258- # rules", just with a fresh world to mess around in.
259259- if sandbox:
260260- sandbox_dir = Path(tempfile.mkdtemp(prefix="storied-sandbox-"))
261261- (sandbox_dir / "worlds" / world_id).mkdir(parents=True)
262262- (sandbox_dir / "players" / player_id).mkdir(parents=True)
263263- configure(
264264- data_home=sandbox_dir,
265265- user_rules_home=Path.home() / ".storied" / "rules",
266266- )
267267- else:
268268- configure(
269269- data_home=resolve_data_home(getattr(args, "base_path", None))
270270- )
271271- data_home().mkdir(parents=True, exist_ok=True)
252252+ Three modes:
253253+ - ``is_onboarding``: cold-start flow. Exits via ``end_session`` or
254254+ Ctrl+D; the CLI re-checks artifacts after the loop returns.
255255+ - ``is_sandbox``: throwaway session with no save-on-quit.
256256+ - Normal play: saves the session on Ctrl+D / Ctrl+C.
257257+ """
258258+ from rich.markdown import Markdown
259259+ from rich.rule import Rule
272260273273- # Export STORIED_HOME so any subprocess that re-enters storied
274274- # (e.g. via run_code) sees the same data directory.
275275- import os
276276- os.environ["STORIED_HOME"] = str(data_home())
261261+ from storied.display import StreamRenderer
277262278278- base_path = data_home()
279279- creation_mode = False
280280-281281- if sandbox:
282282- console.print(Panel.fit(
283283- "[bold]Storied Sandbox[/bold]\n"
284284- "No character, no world — just you and the DM.\n"
285285- "Type [cyan]Ctrl+D[/cyan] to quit.",
286286- title="Sandbox",
287287- border_style="cyan",
288288- ))
289289- prompt_name = "dm-system"
290290- else:
291291- # Check if character exists
292292- character = load_character(player_id)
293293- creation_mode = character is None
263263+ terse_exit = is_sandbox or is_onboarding
294264295295- if creation_mode:
296296- console.print(Panel.fit(
297297- "[bold]Welcome to Storied![/bold]\n"
298298- "Let's create your character!",
299299- title="Character Creation",
300300- border_style="yellow",
301301- ))
302302- prompt_name = "character-creation"
265265+ def on_graceful_exit() -> None:
266266+ if is_sandbox:
267267+ console.print("[cyan]Sandbox session ended.[/cyan]")
268268+ elif is_onboarding:
269269+ console.print("[yellow]Onboarding interrupted.[/yellow]")
303270 else:
304304- console.print(Panel.fit(
305305- "[bold]Welcome to Storied![/bold]\n"
306306- "Let the DM know when you're ready to quit.\n"
307307- "Type [cyan]/context[/cyan] to see token usage.",
308308- title="Storied",
309309- border_style="green",
310310- ))
311311- prompt_name = "dm-system"
312312-313313- console.print(f"[dim]World: {world_id}{' (sandbox)' if sandbox else ''}[/dim]")
314314- console.print()
315315-316316- # Seed the world if the character exists but no session yet
317317- if not creation_mode and not sandbox:
318318- from storied.session import load_session
319319-320320- session = load_session(player_id)
321321- if session is None:
322322- from storied.planner import seed_world
323323-324324- console.print(f"[dim]Seeding the world for {character['name']}...[/dim]")
325325-326326- def on_seed_progress(msg: str) -> None:
327327- console.print(f"[dim] {msg}[/dim]")
328328-329329- result = seed_world(
330330- world_id=world_id,
331331- player_id=player_id,
332332- on_progress=on_seed_progress,
333333- )
334334-271271+ console.print("[dim]Saving session...[/dim]")
272272+ try:
273273+ save_msg = "I need to quit now. Please save the game."
274274+ list(engine.stream_action(save_msg))
275275+ except Exception:
276276+ pass
335277 console.print(
336336- f"[dim] Done — {result.tool_calls} tool calls, "
337337- f"{result.elapsed:.1f}s[/dim]"
278278+ "[yellow]Session saved. Farewell, adventurer![/yellow]"
338279 )
339339- console.print()
340340- transcript_path = Path(args.transcript) if args.transcript else None
341341- engine = DMEngine(
342342- world_id=world_id,
343343- player_id=player_id,
344344- prompt_name=prompt_name,
345345- transcript_path=transcript_path,
346346- )
347347- engine.debug = args.debug
348348-349349- # Debug mode: dump the base system prompt once at startup. The
350350- # per-turn context is dumped separately before each stream_action
351351- # call below. Between them, the user sees exactly what the DM
352352- # subprocess is seeing each turn.
353353- if args.debug:
354354- console.print(Rule("System Prompt", style="dim"))
355355- console.print(engine._base_prompt, style="dim", highlight=False)
356356- console.print(Rule(style="dim"))
357357- console.print()
358358-359359- # Background ticker for mid-session world advancement
360360- ticker = None
361361- advancement = None
362362- if not creation_mode and not sandbox:
363363- from storied.advancement import BackgroundAdvancement
364364- from storied.planner import BackgroundTicker
365365-366366- ticker = BackgroundTicker(
367367- world_id=world_id,
368368- player_id=player_id,
369369- )
370370- # Kick off initial tick in background
371371- ticker.maybe_tick(engine._campaign_log)
372372-373373- advancement = BackgroundAdvancement(
374374- world_id=world_id,
375375- player_id=player_id,
376376- )
377377-378378- # If in creation mode, start the conversation
379379- if creation_mode:
380380- console.print("[dim]The DM will guide you through character creation...[/dim]")
381381- console.print()
382382-383383- from storied.display import StreamRenderer
384384-385385- # Kick off the conversation with appropriate first message
386386- if sandbox:
387387- first_message = (
388388- "[Sandbox session — no character, no world. The player wants to "
389389- "experiment freely. Jump straight into whatever they ask.]"
390390- )
391391- elif creation_mode:
392392- first_message = "Let's create a character!"
393393- else:
394394- first_message = "[Session starting]"
395280396281 try:
397282 while True:
398398- # Get player input (or use first_message to kick off creation)
399283 if first_message:
400284 action = first_message
401285 first_message = None
402286 else:
403287 try:
404288 console.print(Rule(style="dim blue"))
405405- if creation_mode or sandbox:
289289+ if terse_exit:
406290 action = input("> ")
407291 else:
408292 game_time = engine.get_current_time()
409293 action = input(f"[{game_time}] > ")
410294 except EOFError:
411295 console.print()
412412- if sandbox:
413413- console.print("[cyan]Sandbox session ended.[/cyan]")
414414- elif not creation_mode:
415415- console.print("[dim]Saving session...[/dim]")
416416- try:
417417- save_msg = "I need to quit now. Please save the game."
418418- list(engine.stream_action(save_msg))
419419- except Exception:
420420- pass
421421- console.print(
422422- "[yellow]Session saved. Farewell, adventurer![/yellow]"
423423- )
424424- else:
425425- console.print("[yellow]Farewell![/yellow]")
296296+ on_graceful_exit()
426297 break
427298428299 if not action.strip():
···433304 stats = engine.get_context_stats()
434305 console.print()
435306436436- # Header: real API usage if available, estimate otherwise
307307+ # Header: real per-turn input from the API (cache_read +
308308+ # cache_write + new) if we have a result, else the static
309309+ # estimate. The API number is ground truth for "how much
310310+ # of the context window did this turn use."
437311 limit_k = stats["model_limit"] / 1000
438312 game_time = engine.get_current_time()
439313 if stats["last_input"] > 0:
···441315 usage_str = f"{input_k:.1f}k/{limit_k:.0f}k tokens (last turn)"
442316 else:
443317 context_k = stats["context_total"] / 1000
444444- usage_str = f"~{context_k:.1f}k/{limit_k:.0f}k system tokens (estimated)"
318318+ usage_str = (
319319+ f"~{context_k:.1f}k/{limit_k:.0f}k "
320320+ "system tokens (estimated)"
321321+ )
445322 console.print(
446323 f"[bold]Context Usage[/bold] [dim]({game_time})[/dim]"
447324 f" [dim]{engine.model} · {usage_str}[/dim]"
448325 )
449326450450- # Color mapping for context sections
451327 _SECTION_COLORS: dict[str, str] = {
452328 "Style": "dim",
453329 "Character": "green",
···459335 "Initiative": "red",
460336 }
461337462462- # Build section list from all context parts
463338 sections: list[tuple[str, int, str]] = [
464464- ("DM Instructions", stats["system_prompt"], "bright_blue"),
465465- ("Tool Schemas", stats.get("tool_surface", 0), "bright_magenta"),
339339+ (
340340+ "DM Instructions",
341341+ stats["system_prompt"],
342342+ "bright_blue",
343343+ ),
344344+ (
345345+ "Tool Schemas",
346346+ stats.get("tool_surface", 0),
347347+ "bright_magenta",
348348+ ),
466349 ]
467350 for key, tokens in stats["context_parts"].items():
468351 if key.startswith("Entity:") or key.startswith("Linked:"):
469352 label = key.split(":", 1)[1]
470470- color = "magenta" if key.startswith("Entity:") else "dark_magenta"
353353+ color = (
354354+ "magenta"
355355+ if key.startswith("Entity:")
356356+ else "dark_magenta"
357357+ )
471358 else:
472359 label = key
473360 color = _SECTION_COLORS.get(key, "white")
474361 sections.append((label, tokens, color))
475362476476- # 40x5 grid (200 cells = 200k tokens, 1 cell = 1k)
363363+ # Conversation history lives in the Claude subprocess
364364+ # session, not in the per-turn system prompt. The API's
365365+ # cache reports it as part of the input total — we
366366+ # surface the gap between what the engine builds fresh
367367+ # and what the model actually saw as a "Conversation"
368368+ # bucket so the grid shows the full picture.
369369+ last_input = stats.get("last_input", 0)
370370+ if last_input > 0:
371371+ convo_tokens = max(0, last_input - stats["context_total"])
372372+ if convo_tokens > 0:
373373+ sections.append(
374374+ ("Conversation", convo_tokens, "bright_white"),
375375+ )
376376+477377 grid_w, grid_h = 40, 5
478378 total_cells = grid_w * grid_h
479379 cells: list[str] = []
···486386 cells.extend([color] * n_cells)
487387 legend_items.append((name, tokens, color))
488388489489- # Pad remaining cells
490389 remaining = total_cells - len(cells)
491390 if remaining > 0:
492391 cells.extend(["dim"] * remaining)
493392494494- # Render grid
495393 for row in range(grid_h):
496394 line = ""
497395 for col in range(grid_w):
···501399 line += f"[{c}]{char}[/{c}]"
502400 console.print(line)
503401504504- # Legend
505402 for name, tokens, color in legend_items:
506506- tokens_str = f"~{tokens:,}" if tokens < 1_000 else f"~{tokens / 1_000:.1f}k"
507507- console.print(f" [{color}]█[/{color}] {name}: [dim]{tokens_str}[/dim]")
403403+ tokens_str = (
404404+ f"~{tokens:,}"
405405+ if tokens < 1_000
406406+ else f"~{tokens / 1_000:.1f}k"
407407+ )
408408+ console.print(
409409+ f" [{color}]█[/{color}] {name}: "
410410+ f"[dim]{tokens_str}[/dim]"
411411+ )
508412509509- # Session totals (actual API counts)
510413 if stats["total_input"] > 0:
511414 console.print()
512415 last_in = f"{stats['last_input']:,}"
513416 last_out = f"{stats['last_output']:,}"
514417 total_in = f"{stats['total_input']:,}"
515418 total_out = f"{stats['total_output']:,}"
419419+ # Cache breakdown — most of each turn's input is
420420+ # actually a cache read (cheap, ~10% of new-token
421421+ # cost), but it still counts toward the window.
422422+ new = stats.get("last_new", 0)
423423+ cache_read = stats.get("last_cache_read", 0)
424424+ cache_write = stats.get("last_cache_write", 0)
425425+ breakdown_parts = []
426426+ if new:
427427+ breakdown_parts.append(f"{new:,} new")
428428+ if cache_read:
429429+ breakdown_parts.append(f"{cache_read:,} cached")
430430+ if cache_write:
431431+ breakdown_parts.append(f"{cache_write:,} cache-write")
432432+ breakdown = (
433433+ f" ({' · '.join(breakdown_parts)})"
434434+ if breakdown_parts
435435+ else ""
436436+ )
516437 console.print(
517517- f" [dim]Last turn: {last_in} in · {last_out} out[/dim]"
438438+ f" [dim]Last turn: {last_in} in"
439439+ f"{breakdown} · {last_out} out[/dim]"
518440 )
519441 console.print(
520442 f" [dim]Session: {total_in} in · {total_out} out[/dim]"
···547469 console.print()
548470 continue
549471550550- # Handle /save command — everything is already durably written
551551- # on each set_scene; this just confirms state without a round-trip.
472472+ # Handle /save command
552473 if action.strip().lower() == "/save":
553474 from storied.session import load_session
554475 console.print()
···577498 f"{ooc_msg}]"
578499 )
579500580580- # Handle /note command — append directly to the player's notes.md
581581- # without a DM round-trip. The DM still sees the latest notes on
582582- # the next turn via its character context.
501501+ # Handle /note command
583502 if action.strip().lower().startswith("/note"):
584503 note_msg = action.strip()[5:].strip()
585504 if not note_msg:
···601520 try:
602521 console.print(Rule(style="dim blue"))
603522604604- # Debug mode: dump the per-turn context before the
605605- # response streams. `stream_action` rebuilds it
606606- # internally, but the double-build is cheap and keeps
607607- # the debug path a pure observer.
608523 if args.debug:
609524 console.print(Rule("Turn Context", style="dim"))
610525 console.print(
···627542 console.print(f"[dim]{chunk}[/dim]")
628543 prev_type = "tool"
629544 elif chunk == "":
630630- # Flush signal from engine (deferred tool starting)
631545 renderer.flush()
632546 else:
633547 if prev_type == "tool":
···644558 renderer.flush()
645559 console.print()
646560647647- # Show debug token info if enabled
648561 if args.debug and engine._last_result:
649562 r = engine._last_result
650563 in_tokens = r.usage.get("input_tokens", 0)
···655568 f"{engine._total_output_tokens:,} out[/dim]"
656569 )
657570658658- # Check for completed background tick
659571 if ticker:
660572 tick_result = ticker.pop_result()
661573 if tick_result and tick_result.tool_calls > 0:
662574 console.print()
663575 console.print(
664664- f"[dim]The world shifted while you considered your next move. "
576576+ "[dim]The world shifted while you considered "
577577+ f"your next move. "
665578 f"({tick_result.tool_calls} changes)[/dim]"
666579 )
667667- # Maybe launch a new tick if the day advanced
668580 ticker.maybe_tick(engine._campaign_log)
669581670670- # Advancement evaluator — tick the turn counter and check results
671582 if advancement:
672583 if engine.combat_ended:
673584 advancement.on_combat_end()
···680591 "[dim]The character reflects on recent experiences...[/dim]"
681592 )
682593683683- # Check if session ended (player quit gracefully)
684594 if engine.session_ended:
685685- console.print(
686686- "[yellow]Session saved. Farewell, adventurer![/yellow]"
687687- )
688688- break
689689-690690- # Check if character was just created
691691- if creation_mode and engine.character_created:
692692- console.print()
693693- console.print(Panel.fit(
694694- "[bold green]Character created![/bold green]\n"
695695- "Run [cyan]storied play[/cyan] again to begin your adventure.",
696696- border_style="green",
697697- ))
595595+ if is_onboarding:
596596+ console.print(
597597+ "[green]Onboarding complete.[/green]"
598598+ )
599599+ else:
600600+ console.print(
601601+ "[yellow]Session saved. Farewell, adventurer![/yellow]"
602602+ )
698603 break
699604700605 except KeyboardInterrupt:
701606 console.print("\n[red][Interrupted][/red]")
702702- if sandbox:
703703- console.print("[cyan]Sandbox session ended.[/cyan]")
704704- elif not creation_mode:
705705- console.print("[dim]Saving session...[/dim]")
706706- try:
707707- save_msg = "I need to quit now. Please save the game."
708708- list(engine.stream_action(save_msg))
709709- except Exception:
710710- pass
711711- console.print(
712712- "[yellow]Session saved. Farewell, adventurer![/yellow]"
713713- )
714714- else:
715715- console.print("[yellow]Farewell![/yellow]")
607607+ on_graceful_exit()
716608 break
717609718610 except KeyboardInterrupt:
719611 console.print()
612612+ on_graceful_exit()
613613+614614+615615+def cmd_play(args: argparse.Namespace) -> int:
616616+ """Start an interactive DM session."""
617617+ import atexit
618618+619619+ # Set up readline history
620620+ import contextlib
621621+ import readline
622622+ import shutil
623623+ import tempfile
624624+625625+ from rich.console import Console
626626+ from rich.panel import Panel
627627+ from rich.rule import Rule
628628+629629+ from storied.character import load_character
630630+ from storied.engine import DMEngine
631631+ history_file = Path.home() / ".storied_history"
632632+ with contextlib.suppress(FileNotFoundError):
633633+ readline.read_history_file(history_file)
634634+ readline.set_history_length(1000)
635635+ atexit.register(readline.write_history_file, history_file)
636636+637637+ # Set up slash command autocomplete
638638+ def slash_completer(text: str, state: int) -> str | None:
639639+ if text.startswith("/"):
640640+ matches = [cmd for cmd in SLASH_COMMANDS if cmd.startswith(text)]
641641+ return matches[state] if state < len(matches) else None
642642+ return None
643643+644644+ readline.set_completer_delims(readline.get_completer_delims().replace("/", ""))
645645+ readline.set_completer(slash_completer)
646646+ readline.parse_and_bind("tab: complete")
647647+ readline.parse_and_bind("set enable-bracketed-paste on")
648648+649649+ console = Console()
650650+ world_id = args.world if args.world else "default"
651651+ player_id = "default"
652652+ sandbox = getattr(args, "sandbox", False)
653653+ sandbox_dir: Path | None = None
654654+655655+ from storied.paths import configure, data_home, resolve_data_home
656656+657657+ # Sandbox mode: throwaway worlds + players in a temp directory.
658658+ # User homebrew rules stay pointed at the real ~/.storied/rules/
659659+ # so the sandbox isn't a pristine playground — it's still "your
660660+ # rules", just with a fresh world to mess around in.
661661+ if sandbox:
662662+ sandbox_dir = Path(tempfile.mkdtemp(prefix="storied-sandbox-"))
663663+ (sandbox_dir / "worlds" / world_id).mkdir(parents=True)
664664+ (sandbox_dir / "players" / player_id).mkdir(parents=True)
665665+ configure(
666666+ data_home=sandbox_dir,
667667+ user_rules_home=Path.home() / ".storied" / "rules",
668668+ )
669669+ else:
670670+ configure(
671671+ data_home=resolve_data_home(getattr(args, "base_path", None))
672672+ )
673673+ data_home().mkdir(parents=True, exist_ok=True)
674674+675675+ # Export STORIED_HOME so any subprocess that re-enters storied
676676+ # (e.g. via run_code) sees the same data directory.
677677+ import os
678678+ os.environ["STORIED_HOME"] = str(data_home())
679679+680680+ from storied.paths import world_path
681681+682682+ transcript_path = Path(args.transcript) if args.transcript else None
683683+684684+ try:
685685+ # Cold-start detection: if either the character or the world's
686686+ # style.md is missing, we can't run the story — enter onboarding.
687687+ # Sandbox skips this entirely; it starts fresh every time by design.
688688+ cold_start = not sandbox and _is_cold_start(world_id, player_id)
689689+690690+ # Onboarding phase: DM captures worldbuilding preferences (via
691691+ # tune) and creates the character (via create_character) in a
692692+ # single woven conversation. When both artifacts land and the
693693+ # DM calls end_session, we fall through to the normal seed +
694694+ # play path in the same invocation.
695695+ if cold_start:
696696+ console.print(Panel.fit(
697697+ "[bold]Welcome to Storied![/bold]\n"
698698+ "Let's figure out the kind of adventure you want\n"
699699+ "and build your character.",
700700+ title="New Campaign",
701701+ border_style="yellow",
702702+ ))
703703+ console.print(f"[dim]World: {world_id}[/dim]")
704704+ console.print()
705705+706706+ onboarding_engine = DMEngine(
707707+ world_id=world_id,
708708+ player_id=player_id,
709709+ prompt_name="new-game",
710710+ transcript_path=transcript_path,
711711+ )
712712+ onboarding_engine.debug = args.debug
713713+714714+ if args.debug:
715715+ console.print(Rule("System Prompt", style="dim"))
716716+ console.print(
717717+ onboarding_engine._base_prompt,
718718+ style="dim",
719719+ highlight=False,
720720+ )
721721+ console.print(Rule(style="dim"))
722722+ console.print()
723723+724724+ _run_engine_loop(
725725+ onboarding_engine,
726726+ console=console,
727727+ args=args,
728728+ player_id=player_id,
729729+ is_sandbox=False,
730730+ is_onboarding=True,
731731+ first_message="Let's get started.",
732732+ )
733733+734734+ # Re-check artifacts after the onboarding engine exits.
735735+ character = load_character(player_id)
736736+ style_exists = (world_path(world_id) / "style.md").exists()
737737+ missing = []
738738+ if character is None:
739739+ missing.append("character")
740740+ if not style_exists:
741741+ missing.append("world style")
742742+ if missing:
743743+ console.print()
744744+ console.print(Panel.fit(
745745+ f"[yellow]Onboarding incomplete — missing: "
746746+ f"{', '.join(missing)}.[/yellow]\n"
747747+ f"Run [cyan]storied play[/cyan] again to continue.",
748748+ border_style="yellow",
749749+ ))
750750+ return 0
751751+752752+ # Welcome panel for continuing play (skipped when we just
753753+ # finished onboarding — the transition into seeding + play
754754+ # speaks for itself).
720755 if sandbox:
721721- console.print("[cyan]Sandbox session ended.[/cyan]")
722722- elif not creation_mode:
723723- console.print("[dim]Saving session...[/dim]")
724724- try:
725725- save_msg = "I need to quit now. Please save the game."
726726- list(engine.stream_action(save_msg))
727727- except Exception:
728728- pass
729729- console.print(
730730- "[yellow]Session saved. Farewell, adventurer![/yellow]"
756756+ console.print(Panel.fit(
757757+ "[bold]Storied Sandbox[/bold]\n"
758758+ "No character, no world — just you and the DM.\n"
759759+ "Type [cyan]Ctrl+D[/cyan] to quit.",
760760+ title="Sandbox",
761761+ border_style="cyan",
762762+ ))
763763+ console.print(f"[dim]World: {world_id} (sandbox)[/dim]")
764764+ console.print()
765765+ elif not cold_start:
766766+ console.print(Panel.fit(
767767+ "[bold]Welcome to Storied![/bold]\n"
768768+ "Let the DM know when you're ready to quit.\n"
769769+ "Type [cyan]/context[/cyan] to see token usage.",
770770+ title="Storied",
771771+ border_style="green",
772772+ ))
773773+ console.print(f"[dim]World: {world_id}[/dim]")
774774+ console.print()
775775+776776+ # Seed the world if there's no session yet. After cold-start
777777+ # onboarding, style.md is now on disk and seed_world reads it
778778+ # so the world it builds reflects the player's preferences.
779779+ if not sandbox:
780780+ character = load_character(player_id)
781781+ from storied.session import load_session
782782+783783+ session = load_session(player_id)
784784+ if session is None and character is not None:
785785+ from storied.planner import seed_world
786786+787787+ console.print(
788788+ f"[dim]Seeding the world for {character['name']}...[/dim]"
789789+ )
790790+791791+ def on_seed_progress(msg: str) -> None:
792792+ console.print(f"[dim] {msg}[/dim]")
793793+794794+ result = seed_world(
795795+ world_id=world_id,
796796+ player_id=player_id,
797797+ on_progress=on_seed_progress,
798798+ )
799799+800800+ console.print(
801801+ f"[dim] Done — {result.tool_calls} tool calls, "
802802+ f"{result.elapsed:.1f}s[/dim]"
803803+ )
804804+ console.print()
805805+806806+ # Build the play engine.
807807+ engine = DMEngine(
808808+ world_id=world_id,
809809+ player_id=player_id,
810810+ prompt_name="dm-system",
811811+ transcript_path=transcript_path,
812812+ )
813813+ engine.debug = args.debug
814814+815815+ if args.debug:
816816+ console.print(Rule("System Prompt", style="dim"))
817817+ console.print(engine._base_prompt, style="dim", highlight=False)
818818+ console.print(Rule(style="dim"))
819819+ console.print()
820820+821821+ # Background agents only run during live play, not sandbox.
822822+ ticker = None
823823+ advancement = None
824824+ if not sandbox:
825825+ from storied.advancement import BackgroundAdvancement
826826+ from storied.planner import BackgroundTicker
827827+828828+ ticker = BackgroundTicker(
829829+ world_id=world_id,
830830+ player_id=player_id,
731831 )
732732- else:
733733- console.print("[yellow]Farewell![/yellow]")
832832+ ticker.maybe_tick(engine._campaign_log)
833833+834834+ advancement = BackgroundAdvancement(
835835+ world_id=world_id,
836836+ player_id=player_id,
837837+ )
838838+839839+ first_message = (
840840+ "[Sandbox session — no character, no world. The player wants to "
841841+ "experiment freely. Jump straight into whatever they ask.]"
842842+ if sandbox
843843+ else "[Session starting]"
844844+ )
845845+846846+ _run_engine_loop(
847847+ engine,
848848+ console=console,
849849+ args=args,
850850+ player_id=player_id,
851851+ is_sandbox=sandbox,
852852+ is_onboarding=False,
853853+ first_message=first_message,
854854+ ticker=ticker,
855855+ advancement=advancement,
856856+ )
734857 finally:
735735- if sandbox_dir and sandbox_dir.exists():
858858+ if sandbox_dir is not None and sandbox_dir.exists():
736859 shutil.rmtree(sandbox_dir, ignore_errors=True)
737860738861 return 0
···89510188961019 # srd command group
8971020 srd_parser = subparsers.add_parser("srd", help="SRD processing commands")
898898- srd_subparsers = srd_parser.add_subparsers(dest="srd_command", help="SRD subcommands")
10211021+ srd_subparsers = srd_parser.add_subparsers(
10221022+ dest="srd_command", help="SRD subcommands"
10231023+ )
89910249001025 # srd download
9011026 download_parser = srd_subparsers.add_parser("download", help="Download SRD PDF")
···9161041 download_parser.set_defaults(func=cmd_srd_download)
91710429181043 # srd convert
919919- convert_parser = srd_subparsers.add_parser("convert", help="Convert SRD PDF to markdown")
10441044+ convert_parser = srd_subparsers.add_parser(
10451045+ "convert", help="Convert SRD PDF to markdown"
10461046+ )
9201047 convert_parser.add_argument(
9211048 "--pdf",
9221049 help="Path to PDF (default: rules/sources/SRD_CC_v5.2.1.pdf)",
···9281055 convert_parser.set_defaults(func=cmd_srd_convert)
92910569301057 # srd split
931931- split_parser = srd_subparsers.add_parser("split", help="Split SRD markdown into sections")
10581058+ split_parser = srd_subparsers.add_parser(
10591059+ "split", help="Split SRD markdown into sections"
10601060+ )
9321061 split_parser.add_argument(
9331062 "--input", "-i",
9341063 help="Input markdown file (default: rules/srd-5.2.1/srd.md)",
···9401069 split_parser.set_defaults(func=cmd_srd_split)
94110709421071 # srd clean
943943- clean_parser = srd_subparsers.add_parser("clean", help="Clean up extracted markdown files")
10721072+ clean_parser = srd_subparsers.add_parser(
10731073+ "clean", help="Clean up extracted markdown files"
10741074+ )
9441075 clean_parser.add_argument(
9451076 "--dir", "-d",
9461077 help="Sections directory (default: rules/srd-5.2.1/sections)",
···10031134 reset_parser.set_defaults(func=cmd_reset)
1004113510051136 # seed command
10061006- seed_parser = subparsers.add_parser("seed", help="Seed an empty world from a character sheet")
11371137+ seed_parser = subparsers.add_parser(
11381138+ "seed", help="Seed an empty world from a character sheet"
11391139+ )
10071140 seed_parser.add_argument(
10081141 "--world", "-w",
10091142 default="default",
···10741207 parser.print_help()
10751208 return 0
1076120910771077- if args.command in ("srd", "index") and not getattr(args, f"{args.command}_command", None):
12101210+ if args.command in ("srd", "index") and not getattr(
12111211+ args, f"{args.command}_command", None
12121212+ ):
10781213 for action in parser._subparsers._actions:
10791214 if isinstance(action, argparse._SubParsersAction):
10801215 sub = action.choices.get(args.command)
+37-13
src/storied/engine.py
···10101111from storied import notifications
1212from storied.character import format_character_context, load_character
1313-from storied.notification_formatters import (
1414- DEFERRED_FORMATTERS,
1515- TOOL_LABELS,
1616- _extract_json_field,
1717- _extract_roll_reason,
1818-)
1913from storied.claude import (
2014 Result,
2115 TextDelta,
···2418 ToolStop,
2519 stream_with_tools,
2620)
2727-from storied.mcp_server import start_server as start_mcp_server
2821from storied.content import ContentResolver
2922from storied.log import CampaignLog, TranscriptLog
2323+from storied.mcp_server import start_server as start_mcp_server
2424+from storied.notification_formatters import (
2525+ DEFERRED_FORMATTERS,
2626+ TOOL_LABELS,
2727+ _extract_json_field,
2828+ _extract_roll_reason,
2929+)
3030from storied.paths import data_home, player_path, world_path
3131from storied.session import (
3232 extract_wiki_links,
···441441 + sum(context_breakdown.values())
442442 )
443443444444- # Usage from last result event
445445- last_input = 0
444444+ # Usage from the last result event. The Anthropic API splits
445445+ # input across three buckets: new tokens, cache reads, and cache
446446+ # writes. The "real" per-turn input — which equals the size of
447447+ # the model's context window for that turn — is the sum.
448448+ last_new = 0
449449+ last_cache_read = 0
450450+ last_cache_write = 0
446451 last_output = 0
447452 if self._last_result:
448448- last_input = self._last_result.usage.get("input_tokens", 0)
449449- last_output = self._last_result.usage.get("output_tokens", 0)
453453+ usage = self._last_result.usage
454454+ last_new = usage.get("input_tokens", 0)
455455+ last_cache_read = usage.get("cache_read_input_tokens", 0)
456456+ last_cache_write = usage.get("cache_creation_input_tokens", 0)
457457+ last_output = usage.get("output_tokens", 0)
458458+ last_input = last_new + last_cache_read + last_cache_write
450459451460 return {
452461 "model_limit": model_limit,
···455464 "context_parts": context_breakdown,
456465 "context_total": context_total,
457466 "last_input": last_input,
467467+ "last_new": last_new,
468468+ "last_cache_read": last_cache_read,
469469+ "last_cache_write": last_cache_write,
458470 "last_output": last_output,
459471 "total_input": self._total_input_tokens,
460472 "total_output": self._total_output_tokens,
···546558 desc = _extract_json_field(current_tool_json, "description")
547559 label = desc if desc else "Running code"
548560 yield f"[{label}...]"
549549- elif deferred_notification and current_tool_name in DEFERRED_FORMATTERS:
561561+ elif (
562562+ deferred_notification
563563+ and current_tool_name in DEFERRED_FORMATTERS
564564+ ):
550565 formatter = DEFERRED_FORMATTERS[current_tool_name]
551566 yield f"[{formatter(current_tool_json)}...]"
552567···562577 case Result() as r:
563578 self._session_id = r.session_id
564579 self._last_result = r
565565- self._total_input_tokens += r.usage.get("input_tokens", 0)
580580+ # Total input the model actually processed = new
581581+ # uncached input + cache reads + cache writes. The
582582+ # raw `input_tokens` field is just the new chunk;
583583+ # the bulk of each turn lives behind cache_read.
584584+ real_in = (
585585+ r.usage.get("input_tokens", 0)
586586+ + r.usage.get("cache_read_input_tokens", 0)
587587+ + r.usage.get("cache_creation_input_tokens", 0)
588588+ )
589589+ self._total_input_tokens += real_in
566590 self._total_output_tokens += r.usage.get("output_tokens", 0)
567591 self._log_transcript("result", {
568592 "session_id": r.session_id,
+31-20
src/storied/mcp_server.py
···6969 vi: VectorIndex,
7070 srd_root: Path | None = None,
7171) -> None:
7272- """Auto-populate an empty index from all three content layers.
7272+ """Idempotently populate the index from all three content layers.
73737474- Populates in priority-inverse order so higher-priority layers are
7575- indexed last (letting the SRD seed fast-path do its job first):
7474+ Safe to call repeatedly: the SRD layer is skipped once
7575+ ``vi.has_source("srd")`` is true, and the user/world reindex passes
7676+ are mtime-aware. Populates in priority-inverse order so higher-
7777+ priority layers are indexed last (letting the SRD seed fast-path do
7878+ its job first):
76797780 1. Shipped SRD — ``<shipped_rules>/srd-5.2.1/`` via the prebuilt
7881 ``search.db`` seed if present, else reindex the sections dir.
7979- Tagged ``source="srd"``.
8282+ Tagged ``source="srd"``. Skipped when already seeded.
8083 2. User homebrew — ``<user_rules>/`` if the directory exists.
8184 Tagged ``source="user"``.
8285 3. World content — ``<world_dir>/``. Tagged ``source="world"``.
···8588 the shipped layer at a tmp path. In production it resolves to
8689 ``paths.shipped_rules_path() / "srd-5.2.1"``.
8790 """
8888- # 1. Shipped SRD
8989- if srd_root is None:
9090- srd_root = paths.shipped_rules_path() / "srd-5.2.1"
9191- srd_seed = srd_root / "search.db"
9292- if srd_seed.exists():
9393- vi.reseed(srd_seed)
9494- else:
9595- srd_dir = srd_root / "sections"
9696- if srd_dir.exists():
9797- vi.reindex_directory(srd_dir, source="srd")
9191+ # 1. Shipped SRD — skip if already seeded. `reseed` replaces the
9292+ # entire db file, so calling it when the SRD is already present
9393+ # would wipe any world/transcript rows that have accumulated.
9494+ if not vi.has_source("srd"):
9595+ if srd_root is None:
9696+ srd_root = paths.shipped_rules_path() / "srd-5.2.1"
9797+ srd_seed = srd_root / "search.db"
9898+ if srd_seed.exists():
9999+ vi.reseed(srd_seed)
100100+ else:
101101+ srd_dir = srd_root / "sections"
102102+ if srd_dir.exists():
103103+ vi.reindex_directory(srd_dir, source="srd")
9810499105 # 2. User homebrew — flat layout, source="user"
100106 user_rules = paths.user_rules_path()
101107 if user_rules.exists():
102108 vi.reindex_directory(user_rules, source="user")
103109104104- # 3. World content — flat layout, source="world"
110110+ # 3. World content — flat layout, source="world". Skip the
111111+ # transcripts/ subdirectory: those files are indexed separately
112112+ # by the engine after each turn with source="transcript".
105113 if world_dir.exists():
106106- vi.reindex_directory(world_dir, source="world")
114114+ vi.reindex_directory(
115115+ world_dir, source="world", skip_subdirs=frozenset({"transcripts"}),
116116+ )
107117108118109119async def _compose_server(role: str) -> FastMCP:
···187197188198 world_dir = paths.world_path(world_id)
189199190190- vector_index = VectorIndex(
191191- world_dir / "search.db",
192192- on_empty=lambda vi: _populate_index(world_dir, vi),
193193- )
200200+ vector_index = VectorIndex(world_dir / "search.db")
201201+ # Populate eagerly so the first recall never races the transcript
202202+ # upsert at turn end. `_populate_index` is idempotent — subsequent
203203+ # calls skip the SRD reseed and mtime-check the user/world layers.
204204+ _populate_index(world_dir, vector_index)
194205195206 ctx = init_ctx(
196207 world_id=world_id,
+18-5
src/storied/planner.py
···77from threading import Thread
8899from storied.character import format_character_context, load_character
1010-from storied.claude import Result, run_with_tools
1010+from storied.claude import run_with_tools
1111from storied.engine import load_prompt
1212from storied.log import CampaignLog
1313from storied.mcp_server import start_server as start_mcp_server
1414-from storied.paths import data_home
1414+from storied.paths import data_home, world_path
1515from storied.session import (
1616 extract_wiki_links,
1717 load_session,
···616162626363def find_nearby_entities(
6464- session: dict,
6464+ session: dict[str, object],
6565 world_id: str,
6666) -> list[tuple[str, Path]]:
6767 """Find entities near the player by walking wikilinks.
···280280 if character is None:
281281 return SeedResult()
282282283283- # Build context from the character sheet
284284- context = format_character_context(character)
283283+ # Build context from the character sheet, prefixed with any style
284284+ # preferences captured during onboarding so the seeded world reflects
285285+ # the tone/genre/pacing the player asked for.
286286+ style_block = ""
287287+ style_path = world_path(world_id) / "style.md"
288288+ if style_path.exists():
289289+ style_text = style_path.read_text().strip()
290290+ if style_text:
291291+ style_block = (
292292+ "## Player Preferences\n\n"
293293+ f"{style_text}\n\n"
294294+ "---\n\n"
295295+ )
296296+297297+ context = style_block + format_character_context(character)
285298 system_prompt = load_prompt("world-seed")
286299287300 campaign_log = CampaignLog(world_id)
+28-16
src/storied/search.py
···77import re
88import shutil
99import struct
1010+from collections.abc import Callable
1011from dataclasses import dataclass
1112from pathlib import Path
1212-from typing import Callable
13131414import pysqlite3 as sqlite3
1515import sqlite_vec
···151151 def __init__(
152152 self,
153153 db_path: Path,
154154- on_empty: Callable[["VectorIndex"], None] | None = None,
155154 ):
156155 self._db_path = db_path
157156 self._embed_fn: Callable[[list[str]], list[list[float]]] = _default_embed
158158- self._on_empty = on_empty
159157 self._conn = self._open_or_recreate()
160158161159 @staticmethod
···174172 shutil.copy2(seed_path, self._db_path)
175173 self._conn = self._open_or_recreate()
176174175175+ def has_source(self, source: str) -> bool:
176176+ """True if at least one document is already indexed from ``source``."""
177177+ row = self._conn.execute(
178178+ "SELECT 1 FROM documents WHERE source = ? LIMIT 1",
179179+ (source,),
180180+ ).fetchone()
181181+ return row is not None
182182+177183 def close(self) -> None:
178184 """Close the database connection."""
179185 self._conn.close()
···289295 decay_ref: Current game day for age-decay on transcripts.
290296 If None, no decay is applied.
291297 """
292292- # Auto-populate if the index is empty (fires at most once)
293293- if self._on_empty:
294294- count = self._conn.execute(
295295- "SELECT count(*) FROM documents"
296296- ).fetchone()[0]
297297- if count == 0:
298298- self._on_empty(self)
299299- self._on_empty = None
300300-301298 # Normalize source_filter to a set for O(1) membership checks.
302299 allowed_sources: set[str] | None
303300 if source_filter is None:
···332329333330 score = 1.0 / (1.0 + distance)
334331335335- if decay_ref is not None and game_day is not None:
336336- if source in ("transcript", "player"):
337337- score *= age_decay(decay_ref, game_day)
332332+ if (
333333+ decay_ref is not None
334334+ and game_day is not None
335335+ and source in ("transcript", "player")
336336+ ):
337337+ score *= age_decay(decay_ref, game_day)
338338339339 hits.append(SearchHit(
340340 doc_id=doc_id,
···348348 hits.sort(key=lambda h: h.score, reverse=True)
349349 return hits[:limit]
350350351351- def reindex_directory(self, directory: Path, source: str) -> int:
351351+ def reindex_directory(
352352+ self,
353353+ directory: Path,
354354+ source: str,
355355+ skip_subdirs: frozenset[str] | None = None,
356356+ ) -> int:
352357 """Index all .md files under a directory.
353358354359 Skips files whose mtime hasn't changed since last index.
355360 Returns the number of documents indexed (counting chunks).
361361+362362+ ``skip_subdirs`` is a set of top-level subdirectory names to
363363+ exclude from the walk. Used when indexing a world_dir to skip
364364+ ``transcripts/`` — those files are indexed separately by the
365365+ engine under ``source="transcript"``.
356366 """
357367 existing = {}
358368 for row in self._conn.execute(
···366376367377 for md_file in sorted(directory.rglob("*.md")):
368378 rel = md_file.relative_to(directory)
379379+ if skip_subdirs and rel.parts and rel.parts[0] in skip_subdirs:
380380+ continue
369381 content = md_file.read_text()
370382 mtime = md_file.stat().st_mtime
371383 content_type = rel.parts[0] if len(rel.parts) > 1 else ""
+25-4
src/storied/tools/character.py
···294294) -> str:
295295 """Adjust the player's coins by relative amounts.
296296297297- Use negative values to spend, positive to gain. Omit any denomination
298298- you don't want to change. Coins are clamped to zero on the underlying purse.
297297+ Negative values spend, positive values gain. Omit any denomination
298298+ you're not changing. The call is **atomic**: if any denomination
299299+ would go below zero, the whole call is rejected and nothing on the
300300+ sheet changes — fix the deltas and call again.
301301+302302+ **Express each transaction as a single call with the NET delta to
303303+ the purse.** If the player can't pay exact change, fold the
304304+ payment AND the change you're handing back into one call. Don't
305305+ spend first and then call again to "make change" — that's how you
306306+ end up double-charging.
307307+308308+ Examples:
309309+ spend 5 gp: {"gp": -5}
310310+ loot 10 gp + 5 sp: {"gp": 10, "sp": 5}
311311+ sell an item for 12 sp: {"sp": 12}
312312+313313+ pay 5 cp from a 9 sp / 0 cp purse (1 silver in, 5 cp change back):
314314+ {"sp": -1, "cp": 5}
315315+ pay 7 sp from a 1 gp / 0 sp purse (1 gold in, 3 sp change back):
316316+ {"gp": -1, "sp": 3}
317317+ pay 80 gp with 1 platinum: {"pp": -1, "gp": 20}
318318+319319+ The math: 1 pp = 10 gp, 1 gp = 10 sp = 2 ep, 1 sp = 10 cp.
299320300321 Args:
301322 deltas: Coin changes per denomination (cp/sp/ep/gp/pp).
302302- Example: spend 5 gp → {"gp": -5}; gain 10 gp + 5 sp → {"gp": 10, "sp": 5}
303323304324 Returns:
305305- Summary of changes and new purse balance
325325+ Summary of changes and new purse balance, or an error message
326326+ explaining the shortfall if the call was rejected.
306327 """
307328 # Drop zero-deltas before passing to the underlying op so the result
308329 # message only mentions denominations the DM actually touched.
+44-3
tests/test_character.py
···11431143 assert data["state"]["purse"]["gp"] == 53
11441144 assert data["state"]["purse"]["sp"] == 5
1145114511461146- def test_adjust_coins_clamped_to_zero(self, mira: dict, player_dir: Path):
11461146+ def test_adjust_coins_rejects_underflow(self, mira: dict, player_dir: Path):
11471147+ before = load_character("test-player")["state"]["purse"]["gp"]
11471148 result = adjust_coins(
11481149 "test-player", {"gp": -100}
11491150 )
11501151 data = load_character("test-player")
11511151- assert data["state"]["purse"]["gp"] == 0
11521152- assert "short" in result.lower()
11521152+ # Rejected — purse is unchanged.
11531153+ assert data["state"]["purse"]["gp"] == before
11541154+ assert "cannot adjust" in result.lower()
11551155+ assert "insufficient" in result.lower()
11561156+11571157+ def test_adjust_coins_rejects_partial_underflow(
11581158+ self, mira: dict, player_dir: Path,
11591159+ ):
11601160+ # Mira has gp but not enough cp. The mixed delta should be
11611161+ # rejected as a whole — neither denomination should change.
11621162+ before = load_character("test-player")["state"]["purse"]
11631163+ result = adjust_coins(
11641164+ "test-player", {"gp": -1, "cp": -100}
11651165+ )
11661166+ data = load_character("test-player")
11671167+ assert data["state"]["purse"]["gp"] == before["gp"]
11681168+ assert data["state"]["purse"]["cp"] == before["cp"]
11691169+ assert "cannot adjust" in result.lower()
11701170+11711171+ def test_adjust_coins_making_change(self, player_dir: Path):
11721172+ from storied.character.data import create_character
11731173+ create_character(
11741174+ player_id="test-player",
11751175+ name="Coin Test",
11761176+ race="Human",
11771177+ char_class="Fighter",
11781178+ level=1,
11791179+ abilities={
11801180+ "strength": 10, "dexterity": 10, "constitution": 10,
11811181+ "intelligence": 10, "wisdom": 10, "charisma": 10,
11821182+ },
11831183+ hp_max=10,
11841184+ ac=10,
11851185+ purse={"sp": 9},
11861186+ )
11871187+ # Paying 5 cp from a 9 sp / 0 cp purse must work as one atomic call
11881188+ # with the silver going out and the change coming back together.
11891189+ result = adjust_coins("test-player", {"sp": -1, "cp": 5})
11901190+ data = load_character("test-player")
11911191+ assert data["state"]["purse"]["sp"] == 8
11921192+ assert data["state"]["purse"]["cp"] == 5
11931193+ assert "cannot" not in result.lower()
115311941154119511551196class TestNotes:
+83
tests/test_cli.py
···11+"""Tests for CLI helpers — cold-start detection and onboarding artifacts."""
22+33+import pytest
44+55+from storied.character import create_character
66+from storied.cli import _is_cold_start
77+from storied.paths import world_path
88+99+1010+@pytest.fixture
1111+def _minimal_character() -> None:
1212+ """Create a minimal character so load_character finds one."""
1313+ create_character(
1414+ player_id="default",
1515+ name="Test",
1616+ race="Human",
1717+ char_class="Fighter",
1818+ level=1,
1919+ abilities={
2020+ "strength": 10,
2121+ "dexterity": 10,
2222+ "constitution": 10,
2323+ "intelligence": 10,
2424+ "wisdom": 10,
2525+ "charisma": 10,
2626+ },
2727+ hp_max=10,
2828+ ac=10,
2929+ )
3030+3131+3232+@pytest.fixture
3333+def _world_style() -> None:
3434+ """Write a non-empty style.md for the default world."""
3535+ world_dir = world_path("default")
3636+ world_dir.mkdir(parents=True, exist_ok=True)
3737+ (world_dir / "style.md").write_text("Grim. Slow-burn.\n")
3838+3939+4040+class TestColdStartDetection:
4141+ def test_cold_start_when_nothing_exists(self):
4242+ assert _is_cold_start("default", "default") is True
4343+4444+ def test_cold_start_when_only_character_exists(
4545+ self,
4646+ _minimal_character: None,
4747+ ):
4848+ assert _is_cold_start("default", "default") is True
4949+5050+ def test_cold_start_when_only_style_exists(
5151+ self,
5252+ _world_style: None,
5353+ ):
5454+ assert _is_cold_start("default", "default") is True
5555+5656+ def test_not_cold_start_when_both_exist(
5757+ self,
5858+ _minimal_character: None,
5959+ _world_style: None,
6060+ ):
6161+ assert _is_cold_start("default", "default") is False
6262+6363+ def test_not_cold_start_ignores_other_worlds(
6464+ self,
6565+ _minimal_character: None,
6666+ ):
6767+ # Style lives in a different world — doesn't count for this one.
6868+ world_dir = world_path("other")
6969+ world_dir.mkdir(parents=True, exist_ok=True)
7070+ (world_dir / "style.md").write_text("A vibe.\n")
7171+7272+ assert _is_cold_start("default", "default") is True
7373+7474+7575+class TestOnboardingPromptExists:
7676+ def test_new_game_prompt_file_exists(self):
7777+ from storied.engine import load_prompt
7878+7979+ content = load_prompt("new-game")
8080+ # Sanity check: the prompt mentions both onboarding deliverables.
8181+ assert "tune" in content
8282+ assert "create_character" in content
8383+ assert "end_session" in content
+156-8
tests/test_mcp_server.py
···11111212import pytest
13131414-from storied.initiative import Combatant
1514from storied.mcp_server import _compose_server
1616-from storied.tools import ToolContext, _context
1515+from storied.tools import ToolContext
1716from storied.tools.character import refresh_advancement_visibility
1817from storied.tools.combat import _flip_into_combat, _flip_out_of_combat
1918···296295 from storied.mcp_server import _populate_index
297296298297 vi = MagicMock()
298298+ vi.has_source.return_value = False
299299 _populate_index(
300300 tmp_path / "worlds" / "missing",
301301 vi,
···313313 world_dir = tmp_path / "worlds" / "test"
314314 world_dir.mkdir(parents=True)
315315 vi = MagicMock()
316316+ vi.has_source.return_value = False
316317 _populate_index(
317318 world_dir, vi, srd_root=tmp_path / "srd-missing",
318319 )
319319- vi.reindex_directory.assert_called_once_with(world_dir, source="world")
320320+ vi.reindex_directory.assert_called_once_with(
321321+ world_dir, source="world",
322322+ skip_subdirs=frozenset({"transcripts"}),
323323+ )
320324321325 def test_srd_sections_dir(self, tmp_path):
322326 from unittest.mock import MagicMock
···328332 srd_dir.mkdir(parents=True)
329333 world_dir = tmp_path / "worlds" / "test"
330334 vi = MagicMock()
335335+ vi.has_source.return_value = False
331336 _populate_index(world_dir, vi, srd_root=srd_root)
332337 # SRD sections present → reindex SRD; no user layer, no world dir
333338 assert vi.reindex_directory.call_count == 1
···336341 def test_user_layer_indexed(self, tmp_path):
337342 """When the user homebrew directory exists, _populate_index
338343 reindexes it with source='user' after the shipped SRD."""
339339- from unittest.mock import MagicMock
344344+ from unittest.mock import MagicMock, call
340345341346 from storied.mcp_server import _populate_index
342347···350355 world_dir.mkdir(parents=True)
351356352357 vi = MagicMock()
358358+ vi.has_source.return_value = False
353359 _populate_index(
354360 world_dir, vi, srd_root=tmp_path / "srd-missing",
355361 )
356362 # Should have indexed user and world, in that order
357357- calls = vi.reindex_directory.call_args_list
358358- assert len(calls) == 2
359359- assert calls[0] == ((user_dir,), {"source": "user"})
360360- assert calls[1] == ((world_dir,), {"source": "world"})
363363+ assert vi.reindex_directory.call_args_list == [
364364+ call(user_dir, source="user"),
365365+ call(
366366+ world_dir,
367367+ source="world",
368368+ skip_subdirs=frozenset({"transcripts"}),
369369+ ),
370370+ ]
371371+372372+ def test_populate_index_is_idempotent(self, tmp_path):
373373+ """Once the SRD is seeded, subsequent calls must not reseed
374374+ (which would wipe world/transcript rows by file-copying the SRD
375375+ db over the live one)."""
376376+ from unittest.mock import MagicMock
377377+378378+ from storied.mcp_server import _populate_index
379379+380380+ srd_root = tmp_path / "srd-5.2.1"
381381+ srd_root.mkdir(parents=True)
382382+ srd_seed = srd_root / "search.db"
383383+ srd_seed.write_bytes(b"sqlite stub")
384384+ world_dir = tmp_path / "worlds" / "test"
385385+ world_dir.mkdir(parents=True)
386386+387387+ vi = MagicMock()
388388+ vi.has_source.return_value = True # SRD already seeded
389389+ _populate_index(world_dir, vi, srd_root=srd_root)
390390+ vi.reseed.assert_not_called()
361391362392 def test_flip_helpers_no_op_when_root_unset(self):
363393 """The combat-tag flip helpers must not crash when no top-level
···390420 (srd_root / "sections").mkdir()
391421 world_dir = tmp_path / "worlds" / "test"
392422 vi = MagicMock()
423423+ vi.has_source.return_value = False
393424 _populate_index(world_dir, vi, srd_root=srd_root)
394425 vi.reseed.assert_called_once_with(srd_seed)
395426 # When the seed exists, we don't also reindex SRD sections
396427 vi.reindex_directory.assert_not_called()
428428+429429+430430+class TestRulesLookupRace:
431431+ """Regression test for the transcript-upsert-vs-first-search race.
432432+433433+ Before the fix, the lazy ``on_empty`` seeding would miss its window
434434+ whenever the DM's first turn didn't call ``recall``: the engine would
435435+ upsert the transcript at turn end, making the db non-empty, and the
436436+ next search would skip seeding because ``count > 0``. Rules lookups
437437+ would then return nothing. The fix eagerly populates in
438438+ ``start_server``, and ``_populate_index`` is idempotent via
439439+ ``has_source("srd")``.
440440+ """
441441+442442+ def test_srd_stays_available_after_transcript_upsert_race(
443443+ self, tmp_path, monkeypatch,
444444+ ):
445445+ from storied import paths
446446+ from storied.mcp_server import _populate_index
447447+ from storied.search import VectorIndex
448448+449449+ # Minimal shipped SRD seed the populate helper can copy from.
450450+ srd_root = tmp_path / "shipped" / "srd-5.2.1"
451451+ srd_root.mkdir(parents=True)
452452+ seed_db = srd_root / "search.db"
453453+ seed_index = VectorIndex(seed_db)
454454+ seed_index.upsert(
455455+ "srd:character-origins.md:0",
456456+ "# Character Origins\n\n**Half-Elf** gets +2 Charisma.",
457457+ {
458458+ "source": "srd",
459459+ "content_type": "rules",
460460+ "path": str(srd_root / "sections" / "character-origins.md"),
461461+ "title": "Character Origins",
462462+ },
463463+ )
464464+ seed_index.close()
465465+466466+ monkeypatch.setattr(
467467+ paths, "shipped_rules_path", lambda: tmp_path / "shipped",
468468+ )
469469+470470+ world_dir = paths.world_path("default")
471471+ world_dir.mkdir(parents=True, exist_ok=True)
472472+ db_path = world_dir / "search.db"
473473+474474+ # Eager populate the way start_server now does.
475475+ vi = VectorIndex(db_path)
476476+ _populate_index(world_dir, vi)
477477+ assert vi.has_source("srd")
478478+479479+ # Turn 1 ends without any recall — engine upserts the transcript.
480480+ (world_dir / "transcripts").mkdir(exist_ok=True)
481481+ day_path = world_dir / "transcripts" / "day+001.md"
482482+ day_path.write_text("### Day 1\n\nHey, welcome.")
483483+ vi.upsert(
484484+ "transcript:transcripts/day+001.md:0",
485485+ day_path.read_text(),
486486+ {
487487+ "source": "transcript",
488488+ "content_type": "transcripts",
489489+ "path": str(day_path),
490490+ "title": "Day 1",
491491+ "game_day": 1,
492492+ },
493493+ )
494494+495495+ # Turn 2: the DM finally calls recall. SRD must still be there.
496496+ hits = vi.search(
497497+ "half-elf charisma",
498498+ limit=3,
499499+ source_filter=["srd", "user", "world"],
500500+ )
501501+ assert len(hits) >= 1
502502+ assert any(h.source == "srd" for h in hits)
503503+504504+ def test_populate_is_idempotent_on_repeated_start_server(
505505+ self, tmp_path, monkeypatch,
506506+ ):
507507+ """A second start_server (onboarding → play handoff) must not
508508+ wipe the world/transcript rows by re-copying the SRD seed."""
509509+ from storied import paths
510510+ from storied.mcp_server import _populate_index
511511+ from storied.search import VectorIndex
512512+513513+ srd_root = tmp_path / "shipped" / "srd-5.2.1"
514514+ srd_root.mkdir(parents=True)
515515+ seed_db = srd_root / "search.db"
516516+ seed_index = VectorIndex(seed_db)
517517+ seed_index.upsert(
518518+ "srd:x.md:0", "# X", {"source": "srd", "path": "x.md"},
519519+ )
520520+ seed_index.close()
521521+522522+ monkeypatch.setattr(
523523+ paths, "shipped_rules_path", lambda: tmp_path / "shipped",
524524+ )
525525+526526+ world_dir = paths.world_path("default")
527527+ world_dir.mkdir(parents=True, exist_ok=True)
528528+ db_path = world_dir / "search.db"
529529+530530+ vi = VectorIndex(db_path)
531531+ _populate_index(world_dir, vi)
532532+533533+ # Upsert a transcript row to represent prior session state.
534534+ vi.upsert(
535535+ "transcript:transcripts/day+001.md:0",
536536+ "turn content",
537537+ {"source": "transcript", "path": "transcripts/day+001.md"},
538538+ )
539539+ assert vi.has_source("transcript")
540540+541541+ # Second populate — must not wipe transcripts via reseed.
542542+ _populate_index(world_dir, vi)
543543+ assert vi.has_source("srd")
544544+ assert vi.has_source("transcript")