A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Consolidate claude subprocess calls, add currency denominations, polish UI

The claude -p invocations were scattered across three files, each doing
its own Popen/stdin/stderr plumbing. Now claude.py has three clean
entrypoints: stream_with_tools (DM engine), run_with_tools (planner/
seeder/ticker), and run_prompt (utility formatting like /status). The
callers just call the function they need.

Also replaced the single "gold" integer with a proper 5e purse tracking
all five denominations (cp, sp, ep, gp, pp) — the DM was narrating
coppers and silvers flying around but had no way to persist them.

Other changes:
- /status and /me slash commands use Haiku to format the character sheet
instead of fragile regex parsing
- /me prompt tightened so it doesn't render as a 4-screen D&D Beyond
knockoff (Rich's markdown renderer is generous with spacing)
- Roll notifications now show the reason: [Rolling Investigation...]
instead of generic [Rolling...]
- Prompts moved to prompts/ directory with a CLAUDE.md convention note
- File structure in CLAUDE.md updated to reflect current modules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+364 -255
+10 -3
CLAUDE.md
··· 30 30 storied/ 31 31 ├── design/ # Architecture and design docs 32 32 ├── plans/ # Implementation plans (in-progress work) 33 + ├── prompts/ # All LLM prompts (system prompts, formatting instructions) 33 34 ├── src/storied/ # Python package 34 35 │ ├── engine.py # DM engine core 35 - │ ├── world.py # World file I/O 36 - │ ├── tui.py # Textual interface 37 - │ └── main.py # Entry point 36 + │ ├── claude.py # Claude subprocess driver (stream_with_tools, run_with_tools, run_prompt) 37 + │ ├── tools.py # MCP tool implementations 38 + │ ├── planner.py # World seeding, planning, ticking 39 + │ ├── cli.py # CLI entry point 40 + │ └── log.py # Campaign log and timekeeping 38 41 ├── rules/ # Base game system (5e SRD) 39 42 ├── worlds/ # World-specific content (gitignored) 40 43 ├── players/ # Player/character state (gitignored) 41 44 ├── tests/ # Test suite 42 45 └── pyproject.toml 43 46 ``` 47 + 48 + ## Prompts 49 + 50 + All 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 `tools.py` are the exception since they're tightly coupled to the function signatures and JSON schemas. 44 51 45 52 ## Content Layers 46 53
+10
prompts/character-sheet.md
··· 1 + Format a character sheet for a Rich/Textual terminal. Output is rendered as markdown via Rich. Include everything: identity, HP/AC/speed, abilities (one line, like "STR 11 (+0) | DEX 18 (+4) | ..."), proficiencies, features, equipment, purse, backstory, and notes. 2 + 3 + Keep it dense and compact — Rich renders markdown with generous spacing, so less structure means a tighter display: 4 + - NO tables (Rich renders them with heavy borders), NO horizontal rules, NO code blocks 5 + - Use **bold** for labels, bullet lists for items 6 + - One ## heading per section, no sub-headings 7 + - Abilities on a single line, not a table 8 + - Proficiencies as a comma-separated line, not individual bullets 9 + - Backstory as a short paragraph, not a section with sub-parts 10 + - Skip any section that has no content
+1 -1
prompts/dm-system.md
··· 211 211 The player's character sheet is provided below. Use update_character to persist changes so progress is saved between sessions: 212 212 213 213 - **After damage/healing**: `{"hp.current": 5}` 214 - - **After spending/gaining gold**: `{"gold": 25}` 214 + - **After spending/gaining coins**: `{"purse.gp": 25}` or `{"purse.sp": 10, "purse.cp": 50}` 215 215 - **After using abilities**: `{"features.0.uses": 0}` (e.g., Second Wind) 216 216 - **After gaining/losing equipment**: `{"section.Equipment": "- Longsword\n- New shield"}` 217 217 - **After leveling up**: `{"level": 2, "hp.max": 20}`
+7
prompts/status.md
··· 1 + Format a compact character status for a Rich/Textual terminal. Show: 2 + - Name, race, class, level on one line 3 + - HP as current/max, AC, speed 4 + - Purse with all non-zero denominations (pp, gp, ep, sp, cp) 5 + - Carried equipment/gear as a short list 6 + 7 + Output is rendered as markdown via Rich. Be brief — this is a quick-reference glance, not a full sheet.
+14 -7
src/storied/character.py
··· 170 170 ac: int, 171 171 background: str | None = None, 172 172 speed: int = 30, 173 - gold: int = 0, 173 + purse: dict[str, int] | None = None, 174 174 equipment: list[str] | None = None, 175 175 features: list[str] | None = None, 176 176 proficiencies: str | None = None, ··· 190 190 ac: Armor class 191 191 background: Background (e.g., "Soldier", "Acolyte") 192 192 speed: Movement speed in feet 193 - gold: Starting gold 193 + purse: Starting coins as {cp, sp, ep, gp, pp} (defaults to all zeros) 194 194 equipment: List of equipment items 195 195 features: List of class/racial features 196 196 proficiencies: Proficiency description ··· 211 211 "ac": ac, 212 212 "speed": speed, 213 213 "abilities": abilities, 214 - "gold": gold, 214 + "purse": purse or {"cp": 0, "sp": 0, "ep": 0, "gp": 0, "pp": 0}, 215 215 } 216 216 217 217 # Build body sections ··· 272 272 ability_strs.append(f"{ability[:3].upper()} {score} ({mod_str})") 273 273 lines.append(" | ".join(ability_strs) + "\n") 274 274 275 - # Gold 276 - gold = data.get("gold") 277 - if gold is not None: 278 - lines.append(f"**Gold:** {gold}\n") 275 + # Purse (supports legacy "gold" field) 276 + purse = data.get("purse") 277 + if purse and isinstance(purse, dict): 278 + coins = [] 279 + for denom in ["pp", "gp", "ep", "sp", "cp"]: 280 + amount = purse.get(denom, 0) 281 + if amount: 282 + coins.append(f"{amount} {denom}") 283 + lines.append(f"**Purse:** {', '.join(coins) if coins else 'empty'}\n") 284 + elif (gold := data.get("gold")) is not None: 285 + lines.append(f"**Purse:** {gold} gp\n") 279 286 280 287 # Include the markdown body (equipment, features, notes, etc.) 281 288 body = data.get("body", "")
+227 -29
src/storied/claude.py
··· 1 1 """Subprocess driver for claude -p (Claude Code in pipe mode). 2 2 3 - Shared logic for launching claude -p, formatting NDJSON input, and parsing 4 - NDJSON stream-json output events. 3 + Three entrypoints for different use cases: 4 + - stream_with_tools(): Streaming responses with MCP tools (DM engine) 5 + - run_with_tools(): Blocking run with MCP tools (planner, seeder, ticker) 6 + - run_prompt(): Simple text in → text out (utility formatting) 5 7 """ 6 8 7 9 import json 8 10 import os 9 11 import shutil 12 + import subprocess 13 + from collections.abc import Callable, Iterator 10 14 from dataclasses import dataclass, field 15 + from pathlib import Path 16 + from threading import Thread 11 17 12 18 13 19 # -- Stream event types ------------------------------------------------------- ··· 43 49 type StreamEvent = TextDelta | ToolStart | ToolInputDelta | ToolStop | Result 44 50 45 51 46 - # -- Builders ------------------------------------------------------------------ 52 + # -- Internal helpers --------------------------------------------------------- 47 53 48 54 49 - def build_mcp_config(url: str) -> str: 55 + def _find_claude() -> str: 56 + """Find the claude CLI on PATH or raise.""" 57 + claude_path = shutil.which("claude") 58 + if not claude_path: 59 + raise FileNotFoundError( 60 + "Claude Code CLI not found on PATH. " 61 + "Install it with: npm install -g @anthropic-ai/claude-code" 62 + ) 63 + return claude_path 64 + 65 + 66 + def _build_env() -> dict[str, str]: 67 + """Build the environment for claude subprocesses.""" 68 + env = {**os.environ} 69 + oauth_token = os.environ.get("STORIED_CLAUDE_CODE_OAUTH_TOKEN", "") 70 + if oauth_token: 71 + env["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token 72 + return env 73 + 74 + 75 + def _build_mcp_config(url: str) -> str: 50 76 """Build inline JSON for --mcp-config pointing to an HTTP MCP server.""" 51 77 config = { 52 78 "mcpServers": { ··· 59 85 return json.dumps(config) 60 86 61 87 62 - def build_claude_args( 88 + def _build_tool_args( 63 89 model: str, 64 90 system_prompt: str, 65 91 mcp_config: str, 66 92 *, 67 93 effort: str = "medium", 68 - session_id: str | None = None, 69 94 resume_session_id: str | None = None, 70 95 persist_session: bool = True, 71 96 ) -> list[str]: 72 - """Build the argument list for claude -p.""" 73 - claude_path = shutil.which("claude") 74 - if not claude_path: 75 - raise FileNotFoundError( 76 - "Claude Code CLI not found on PATH. " 77 - "Install it with: npm install -g @anthropic-ai/claude-code" 78 - ) 79 - 97 + """Build args for a claude -p call with MCP tools and stream-json I/O.""" 80 98 args = [ 81 - claude_path, 99 + _find_claude(), 82 100 "--tools", "", 83 101 "-p", 84 102 "--input-format", "stream-json", ··· 100 118 101 119 if resume_session_id: 102 120 args.extend(["--resume", resume_session_id]) 103 - elif session_id: 104 - args.extend(["--session-id", session_id]) 105 121 106 122 return args 107 123 108 124 109 - def build_env() -> dict[str, str]: 110 - """Build the environment for the claude subprocess.""" 111 - env = {**os.environ} 112 - oauth_token = os.environ.get("STORIED_CLAUDE_CODE_OAUTH_TOKEN", "") 113 - if oauth_token: 114 - env["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token 115 - return env 116 - 117 - 118 - def format_user_message(text: str) -> bytes: 125 + def _format_user_message(text: str) -> bytes: 119 126 """Format a user message as NDJSON for claude -p stdin.""" 120 127 envelope = { 121 128 "type": "user", ··· 127 134 return json.dumps(envelope).encode() + b"\n" 128 135 129 136 130 - # -- Event parsing ------------------------------------------------------------- 137 + def _drain_stderr(stderr, lines: list[str]) -> None: 138 + """Read all lines from stderr into the list (runs in background thread).""" 139 + for raw in stderr: 140 + lines.append(raw.decode(errors="replace")) 131 141 132 142 133 - def parse_event(line: str) -> StreamEvent | None: 143 + def _parse_event(line: str) -> StreamEvent | None: 134 144 """Parse a single NDJSON line into a typed StreamEvent.""" 135 145 line = line.strip() 136 146 if not line: ··· 177 187 return ToolStop() 178 188 179 189 return None 190 + 191 + 192 + # -- Public entrypoints ------------------------------------------------------- 193 + 194 + 195 + def stream_with_tools( 196 + system_prompt: str, 197 + user_message: str, 198 + mcp_url: str, 199 + *, 200 + model: str = "claude-opus-4-6", 201 + effort: str = "medium", 202 + resume_session_id: str | None = None, 203 + cwd: Path | None = None, 204 + ) -> Iterator[StreamEvent]: 205 + """Stream a claude -p response with MCP tools. 206 + 207 + Yields StreamEvent objects (TextDelta, ToolStart, ToolInputDelta, 208 + ToolStop, Result) as they arrive. The caller handles the agentic 209 + loop presentation; Claude Code handles tool execution via MCP. 210 + 211 + Used by the DM engine for real-time gameplay. 212 + """ 213 + mcp_config = _build_mcp_config(mcp_url) 214 + args = _build_tool_args( 215 + model=model, 216 + system_prompt=system_prompt, 217 + mcp_config=mcp_config, 218 + effort=effort, 219 + resume_session_id=resume_session_id, 220 + ) 221 + 222 + proc = subprocess.Popen( 223 + args, 224 + stdin=subprocess.PIPE, 225 + stdout=subprocess.PIPE, 226 + stderr=subprocess.PIPE, 227 + env=_build_env(), 228 + cwd=str(cwd) if cwd else None, 229 + ) 230 + 231 + assert proc.stdin is not None 232 + proc.stdin.write(_format_user_message(user_message)) 233 + proc.stdin.close() 234 + 235 + stderr_lines: list[str] = [] 236 + assert proc.stderr is not None 237 + stderr_thread = Thread( 238 + target=_drain_stderr, args=(proc.stderr, stderr_lines), daemon=True 239 + ) 240 + stderr_thread.start() 241 + 242 + assert proc.stdout is not None 243 + for raw_line in proc.stdout: 244 + line = raw_line.decode(errors="replace") 245 + event = _parse_event(line) 246 + if event is not None: 247 + yield event 248 + 249 + proc.wait() 250 + stderr_thread.join() 251 + 252 + 253 + def run_with_tools( 254 + system_prompt: str, 255 + user_message: str, 256 + mcp_url: str, 257 + *, 258 + model: str = "claude-opus-4-6", 259 + effort: str = "high", 260 + on_tool_start: Callable[[str], None] | None = None, 261 + cwd: Path | None = None, 262 + ) -> Result | None: 263 + """Run claude -p with MCP tools to completion and return the Result. 264 + 265 + Blocks until the subprocess finishes. Counts tool calls and reports 266 + them via on_tool_start callback (receives short tool name). 267 + 268 + Used by the planner, seeder, and world ticker. 269 + """ 270 + mcp_config = _build_mcp_config(mcp_url) 271 + args = _build_tool_args( 272 + model=model, 273 + system_prompt=system_prompt, 274 + mcp_config=mcp_config, 275 + effort=effort, 276 + persist_session=False, 277 + ) 278 + 279 + proc = subprocess.Popen( 280 + args, 281 + stdin=subprocess.PIPE, 282 + stdout=subprocess.PIPE, 283 + stderr=subprocess.PIPE, 284 + env=_build_env(), 285 + cwd=str(cwd) if cwd else None, 286 + ) 287 + 288 + assert proc.stdin is not None 289 + proc.stdin.write(_format_user_message(user_message)) 290 + proc.stdin.close() 291 + 292 + stderr_lines: list[str] = [] 293 + assert proc.stderr is not None 294 + stderr_thread = Thread( 295 + target=_drain_stderr, args=(proc.stderr, stderr_lines), daemon=True 296 + ) 297 + stderr_thread.start() 298 + 299 + result: Result | None = None 300 + tool_calls = 0 301 + assert proc.stdout is not None 302 + 303 + for raw_line in proc.stdout: 304 + line = raw_line.decode(errors="replace") 305 + event = _parse_event(line) 306 + if event is None: 307 + continue 308 + 309 + match event: 310 + case ToolStart(name=name): 311 + tool_calls += 1 312 + short = name.rsplit("__", 1)[-1] if "__" in name else name 313 + if on_tool_start: 314 + on_tool_start(short) 315 + 316 + case Result() as r: 317 + result = r 318 + 319 + proc.wait(timeout=600) 320 + stderr_thread.join() 321 + 322 + if result: 323 + result.usage["tool_calls"] = tool_calls 324 + 325 + return result 326 + 327 + 328 + def run_prompt( 329 + system_prompt: str, 330 + user_message: str, 331 + *, 332 + model: str = "claude-haiku-4-5-20251001", 333 + timeout: int = 30, 334 + ) -> str | None: 335 + """Run a simple prompt through claude -p and return the text response. 336 + 337 + Plain text in, plain text out. No MCP tools, no streaming, no session. 338 + Used for utility formatting (e.g., /status, /me). 339 + """ 340 + try: 341 + claude_path = _find_claude() 342 + except FileNotFoundError: 343 + return None 344 + 345 + args = [ 346 + claude_path, "-p", 347 + "--model", model, 348 + "--system-prompt", system_prompt, 349 + "--no-session-persistence", 350 + "--dangerously-skip-permissions", 351 + ] 352 + 353 + try: 354 + result = subprocess.run( 355 + args, 356 + input=user_message.encode(), 357 + capture_output=True, 358 + timeout=timeout, 359 + env=_build_env(), 360 + ) 361 + if result.returncode == 0: 362 + return result.stdout.decode().strip() 363 + except (subprocess.TimeoutExpired, FileNotFoundError): 364 + pass 365 + return None 366 + 367 + 368 + # -- Backward compatibility --------------------------------------------------- 369 + # These aliases keep existing imports working during migration. 370 + 371 + build_mcp_config = _build_mcp_config 372 + build_env = _build_env 373 + format_user_message = _format_user_message 374 + parse_event = _parse_event 375 + 376 + # build_claude_args is still used by tests 377 + build_claude_args = _build_tool_args
+25 -30
src/storied/cli.py
··· 11 11 # Slash commands available during play 12 12 SLASH_COMMANDS = { 13 13 "/help": "Show this help message", 14 - "/status": "Show character status (HP, AC, gold)", 14 + "/status": "Quick glance at stats, gear, and purse", 15 + "/me": "Full character sheet", 15 16 "/save": "Save session state (without quitting)", 16 17 "/context": "Show token usage", 17 18 "/dm": "Say something out-of-character to the DM (e.g. /dm less combat please)", 18 19 "/note": "Add a note to your character sheet (e.g. /note remember the prayer words)", 19 20 } 21 + 22 + def _format_character_display(char_file_content: str, system_prompt: str) -> str | None: 23 + """Quick Haiku call to format character data for display.""" 24 + from storied.claude import run_prompt 25 + 26 + return run_prompt(system_prompt, char_file_content) 20 27 21 28 22 29 def cmd_srd_download(args: argparse.Namespace) -> int: ··· 411 418 console.print() 412 419 continue 413 420 414 - # Handle /status command 415 - if action.strip().lower() == "/status": 416 - from storied.character import load_character as load_char 417 - 418 - char = load_char(player_id) 419 - if char: 420 - hp = char.get("hp", {}) 421 - hp_cur = hp.get("current", "?") 422 - hp_max = hp.get("max", "?") 423 - ac = char.get("ac", "?") 424 - gold = char.get("gold", 0) 425 - level = char.get("level", 1) 426 - name = char.get("name", "Unknown") 427 - char_class = char.get("class", "") 428 - race = char.get("race", "") 429 - 430 - # HP bar 431 - if isinstance(hp_cur, (int, float)) and isinstance(hp_max, (int, float)) and hp_max > 0: 432 - bar_width = 20 433 - filled = int((hp_cur / hp_max) * bar_width) 434 - bar_color = "green" if hp_cur > hp_max * 0.5 else "yellow" if hp_cur > hp_max * 0.25 else "red" 435 - bar = f"[{bar_color}]{'█' * filled}[/{bar_color}][dim]{'░' * (bar_width - filled)}[/dim]" 436 - hp_str = f"{bar} {hp_cur}/{hp_max}" 421 + # Handle /status and /me commands 422 + if action.strip().lower() in ("/status", "/me"): 423 + is_full = action.strip().lower() == "/me" 424 + char_path = Path.cwd() / "players" / player_id / "character.md" 425 + if char_path.exists(): 426 + console.print() 427 + console.print("[dim]Loading character...[/dim]") 428 + from storied.engine import load_prompt 429 + prompt_name = "character-sheet" if is_full else "status" 430 + prompt = load_prompt(prompt_name) 431 + formatted = _format_character_display( 432 + char_path.read_text(), prompt, 433 + ) 434 + if formatted: 435 + console.print() 436 + console.print(Markdown(formatted)) 437 437 else: 438 - hp_str = f"{hp_cur}/{hp_max}" 439 - 440 - console.print() 441 - console.print(f"[bold]{name}[/bold] [dim]{race} {char_class} {level}[/dim]") 442 - console.print(f" HP {hp_str}") 443 - console.print(f" AC [cyan]{ac}[/cyan] Gold [yellow]{gold}[/yellow]") 438 + console.print("[dim]Could not format character sheet.[/dim]") 444 439 console.print() 445 440 else: 446 441 console.print("[dim]No character loaded.[/dim]")
+11 -68
src/storied/engine.py
··· 2 2 3 3 import json 4 4 import re 5 - import subprocess 6 5 from collections.abc import Iterator 7 6 from datetime import UTC, datetime 8 7 from pathlib import Path 9 - from threading import Thread 10 8 11 9 import yaml 12 10 ··· 17 15 ToolInputDelta, 18 16 ToolStart, 19 17 ToolStop, 20 - build_claude_args, 21 - build_env, 22 - build_mcp_config, 23 - format_user_message, 24 - parse_event, 18 + stream_with_tools, 25 19 ) 26 20 from storied.mcp_server import start_server as start_mcp_server 27 21 from storied.content import ContentResolver ··· 40 34 prompts_path = Path(__file__).parent.parent.parent / "prompts" 41 35 path = prompts_path / f"{name}.md" 42 36 return path.read_text() 43 - 44 - 45 - def _drain_stderr(stderr, lines: list[str]) -> None: 46 - """Read all lines from stderr into the list.""" 47 - for raw in stderr: 48 - lines.append(raw.decode(errors="replace")) 49 37 50 38 51 39 def _extract_roll_reason(tool_json: str) -> str | None: ··· 354 342 def stream_action(self, player_input: str) -> Iterator[str]: 355 343 """Stream DM response for real-time output. 356 344 357 - Launches a claude -p subprocess, sends the user message, and yields 358 - text chunks as they arrive. Claude Code handles the agentic loop 359 - (tool calls via MCP) internally. 345 + Uses stream_with_tools() to drive a claude -p subprocess. Yields 346 + text chunks and tool notifications as they arrive. 360 347 """ 361 348 self._log_transcript("player_input", {"content": player_input}) 362 349 ··· 366 353 if context: 367 354 system_prompt += "\n\n---\n\n" + context 368 355 369 - mcp_config = build_mcp_config(self._mcp.url) 370 - 371 - args = build_claude_args( 372 - model=self.model, 373 - system_prompt=system_prompt, 374 - mcp_config=mcp_config, 375 - effort="medium", 376 - resume_session_id=self._session_id, 377 - ) 378 - 379 - env = build_env() 380 - 381 - proc = subprocess.Popen( 382 - args, 383 - stdin=subprocess.PIPE, 384 - stdout=subprocess.PIPE, 385 - stderr=subprocess.PIPE, 386 - env=env, 387 - cwd=str(self.base_path), 388 - ) 389 - 390 - # Write user message and close stdin 391 - assert proc.stdin is not None 392 - proc.stdin.write(format_user_message(player_input)) 393 - proc.stdin.close() 394 - 395 - # Drain stderr in background to prevent deadlock 396 - stderr_lines: list[str] = [] 397 - assert proc.stderr is not None 398 - stderr_thread = Thread( 399 - target=_drain_stderr, args=(proc.stderr, stderr_lines), daemon=True 400 - ) 401 - stderr_thread.start() 402 - 403 - # Parse NDJSON stream from stdout 404 356 current_tool_json = "" 405 357 current_tool_name = "" 406 358 deferred_notification = False 407 - assert proc.stdout is not None 408 - 409 - for raw_line in proc.stdout: 410 - line = raw_line.decode(errors="replace") 411 - self._log_transcript("stream_event", {"raw": line.strip()}) 412 359 413 - event = parse_event(line) 414 - if event is None: 415 - continue 416 - 360 + for event in stream_with_tools( 361 + system_prompt=system_prompt, 362 + user_message=player_input, 363 + mcp_url=self._mcp.url, 364 + model=self.model, 365 + resume_session_id=self._session_id, 366 + cwd=self.base_path, 367 + ): 417 368 match event: 418 369 case TextDelta(text=text): 419 370 yield text ··· 464 415 "session_id": r.session_id, 465 416 "usage": r.usage, 466 417 }) 467 - 468 - proc.wait() 469 - stderr_thread.join() 470 - 471 - if proc.returncode and proc.returncode != 0: 472 - stderr_text = "".join(stderr_lines).strip() 473 - if stderr_text: 474 - yield f"\n[Error: {stderr_text[:200]}]\n" 475 418 476 419 def reset(self) -> None: 477 420 """Reset the conversation state."""
+25 -98
src/storied/planner.py
··· 1 1 """World planner — enriches thin entities near the player's current position.""" 2 2 3 - import subprocess 4 3 import time 5 4 from collections.abc import Callable 6 5 from dataclasses import dataclass, field ··· 8 7 from threading import Thread 9 8 10 9 from storied.character import format_character_context, load_character 11 - from storied.claude import ( 12 - Result, 13 - ToolStart, 14 - build_claude_args, 15 - build_env, 16 - build_mcp_config, 17 - format_user_message, 18 - parse_event, 19 - ) 10 + from storied.claude import Result, run_with_tools 20 11 from storied.engine import load_prompt 21 12 from storied.log import CampaignLog 22 13 from storied.mcp_server import start_server as start_mcp_server ··· 26 17 resolve_wiki_link, 27 18 ) 28 19 from storied.tools import _load_entity 29 - 30 - MAX_TURNS = 20 31 20 32 21 33 22 def entity_richness(path: Path) -> float: ··· 175 164 dry_run: bool = False 176 165 177 166 178 - def _drain_stderr(stderr, lines: list[str]) -> None: 179 - """Read all lines from stderr into the list.""" 180 - for raw in stderr: 181 - lines.append(raw.decode(errors="replace")) 182 - 183 - 184 - def _run_claude_collect( 185 - system_prompt: str, 186 - user_message: str, 187 - model: str, 188 - mcp_config: str, 189 - base_path: Path, 190 - on_progress: Callable[[str], None] | None = None, 191 - ) -> Result | None: 192 - """Run claude -p, collect tool events for progress, return the Result.""" 193 - args = build_claude_args( 194 - model=model, 195 - system_prompt=system_prompt, 196 - mcp_config=mcp_config, 197 - effort="high", 198 - persist_session=False, 199 - ) 200 - 201 - proc = subprocess.Popen( 202 - args, 203 - stdin=subprocess.PIPE, 204 - stdout=subprocess.PIPE, 205 - stderr=subprocess.PIPE, 206 - env=build_env(), 207 - cwd=str(base_path), 208 - ) 209 - 210 - assert proc.stdin is not None 211 - proc.stdin.write(format_user_message(user_message)) 212 - proc.stdin.close() 213 - 214 - stderr_lines: list[str] = [] 215 - assert proc.stderr is not None 216 - stderr_thread = Thread( 217 - target=_drain_stderr, args=(proc.stderr, stderr_lines), daemon=True 218 - ) 219 - stderr_thread.start() 220 - 221 - result: Result | None = None 222 - tool_calls = 0 223 - assert proc.stdout is not None 224 - 225 - for raw_line in proc.stdout: 226 - line = raw_line.decode(errors="replace") 227 - event = parse_event(line) 228 - if event is None: 229 - continue 230 - 231 - match event: 232 - case ToolStart(name=name): 233 - tool_calls += 1 234 - short = name.rsplit("__", 1)[-1] if "__" in name else name 235 - if on_progress: 236 - on_progress(f" [{short}]") 237 - 238 - case Result() as r: 239 - result = r 240 - 241 - proc.wait(timeout=600) 242 - stderr_thread.join() 243 - 244 - if result: 245 - result.usage["tool_calls"] = tool_calls 246 - 247 - return result 248 - 249 167 250 168 def plan_world( 251 169 world_id: str = "default", ··· 310 228 mcp = start_mcp_server( 311 229 world_id, player_id, base_path, "planner", campaign_log, 312 230 ) 313 - mcp_config = build_mcp_config(mcp.url) 314 231 315 232 progress(f"Planning with {model}...") 316 233 317 - claude_result = _run_claude_collect( 234 + def on_tool(name: str) -> None: 235 + if on_progress: 236 + on_progress(f" [{name}]") 237 + 238 + claude_result = run_with_tools( 318 239 system_prompt=system_prompt, 319 240 user_message=context, 241 + mcp_url=mcp.url, 320 242 model=model, 321 - mcp_config=mcp_config, 322 - base_path=base_path, 323 - on_progress=on_progress, 243 + on_tool_start=on_tool, 244 + cwd=base_path, 324 245 ) 325 246 326 247 if claude_result: ··· 372 293 mcp = start_mcp_server( 373 294 world_id, player_id, base_path, "seeder", campaign_log, 374 295 ) 375 - mcp_config = build_mcp_config(mcp.url) 376 296 377 297 progress(f"Seeding with {model}...") 378 298 379 - claude_result = _run_claude_collect( 299 + def on_tool(name: str) -> None: 300 + if on_progress: 301 + on_progress(f" [{name}]") 302 + 303 + claude_result = run_with_tools( 380 304 system_prompt=system_prompt, 381 305 user_message=context, 306 + mcp_url=mcp.url, 382 307 model=model, 383 - mcp_config=mcp_config, 384 - base_path=base_path, 385 - on_progress=on_progress, 308 + on_tool_start=on_tool, 309 + cwd=base_path, 386 310 ) 387 311 388 312 seed_result = SeedResult(elapsed=time.monotonic() - start_time) ··· 507 431 mcp = start_mcp_server( 508 432 world_id, player_id, base_path, "planner", campaign_log, 509 433 ) 510 - mcp_config = build_mcp_config(mcp.url) 511 434 512 435 progress(f"Ticking with {model}...") 513 436 514 - claude_result = _run_claude_collect( 437 + def on_tool(name: str) -> None: 438 + if on_progress: 439 + on_progress(f" [{name}]") 440 + 441 + claude_result = run_with_tools( 515 442 system_prompt=system_prompt, 516 443 user_message=context, 444 + mcp_url=mcp.url, 517 445 model=model, 518 - mcp_config=mcp_config, 519 - base_path=base_path, 520 - on_progress=on_progress, 446 + on_tool_start=on_tool, 447 + cwd=base_path, 521 448 ) 522 449 523 450 result = TickResult(
+21 -7
src/storied/tools.py
··· 140 140 ) -> str: 141 141 """Update the player's character sheet to persist changes. 142 142 143 - Call this after HP changes, equipment gained/lost, gold spent, level ups, etc. 143 + Call this after HP changes, equipment gained/lost, coins spent, level ups, etc. 144 144 This ensures progress is saved and survives between sessions. 145 145 146 146 Args: 147 147 updates: Fields to update. Use dot notation for nested fields. 148 148 Examples: 149 149 - {"hp.current": 5} - set current HP to 5 150 - - {"gold": 25} - set gold to 25 150 + - {"purse.gp": 25} - set gold to 25 151 + - {"purse.sp": 10, "purse.cp": 50} - set silver and copper 151 152 - {"level": 2, "hp.max": 20} - level up 152 153 For markdown sections, use "section.Name": 153 154 - {"section.Equipment": "- Longsword\\n- New shield"} ··· 170 171 ac: int, 171 172 background: str | None = None, 172 173 speed: int = 30, 173 - gold: int = 0, 174 + purse: dict[str, int] | None = None, 174 175 equipment: list[str] | None = None, 175 176 features: list[str] | None = None, 176 177 proficiencies: str | None = None, ··· 195 196 ac: Armor class 196 197 background: Background (e.g., "Soldier", "Sage", "Criminal") 197 198 speed: Movement speed in feet (default 30) 198 - gold: Starting gold pieces 199 + purse: Starting coins by denomination: 200 + {"cp": 0, "sp": 0, "ep": 0, "gp": 15, "pp": 0} 201 + Denominations: cp (copper), sp (silver), ep (electrum), 202 + gp (gold), pp (platinum). Omit denominations for 0. 199 203 equipment: List of equipment items 200 204 features: List of racial and class features 201 205 proficiencies: Description of proficiencies (armor, weapons, tools, saves, skills) ··· 215 219 ac=ac, 216 220 background=background, 217 221 speed=speed, 218 - gold=gold, 222 + purse=purse, 219 223 equipment=equipment, 220 224 features=features, 221 225 proficiencies=proficiencies, ··· 786 790 "ac": {"type": "integer", "description": "Armor class"}, 787 791 "background": {"type": "string", "description": "Background (e.g., 'Soldier', 'Sage')"}, 788 792 "speed": {"type": "integer", "description": "Movement speed in feet"}, 789 - "gold": {"type": "integer", "description": "Starting gold pieces"}, 793 + "purse": { 794 + "type": "object", 795 + "description": "Starting coins: {cp, sp, ep, gp, pp}. Omit denominations for 0.", 796 + "properties": { 797 + "cp": {"type": "integer", "description": "Copper pieces"}, 798 + "sp": {"type": "integer", "description": "Silver pieces"}, 799 + "ep": {"type": "integer", "description": "Electrum pieces"}, 800 + "gp": {"type": "integer", "description": "Gold pieces"}, 801 + "pp": {"type": "integer", "description": "Platinum pieces"}, 802 + }, 803 + }, 790 804 "equipment": { 791 805 "type": "array", 792 806 "items": {"type": "string"}, ··· 1024 1038 ac=tool_input["ac"], 1025 1039 background=tool_input.get("background"), 1026 1040 speed=tool_input.get("speed", 30), 1027 - gold=tool_input.get("gold", 0), 1041 + purse=tool_input.get("purse"), 1028 1042 equipment=tool_input.get("equipment"), 1029 1043 features=tool_input.get("features"), 1030 1044 proficiencies=tool_input.get("proficiencies"),
+6 -5
tests/test_character.py
··· 40 40 }, 41 41 hp_max=12, 42 42 ac=16, 43 - gold=50, 43 + purse={"cp": 0, "sp": 0, "ep": 0, "gp": 50, "pp": 0}, 44 44 base_path=player_base, 45 45 ) 46 46 return load_character("test-player", player_base) ··· 120 120 class TestUpdateCharacter: 121 121 """Tests for character updates.""" 122 122 123 - def test_update_gold(self, player_base: Path, basic_character: dict): 123 + def test_update_purse(self, player_base: Path, basic_character: dict): 124 124 result = update_character( 125 125 player_id="test-player", 126 - updates={"gold": 100}, 126 + updates={"purse.gp": 100, "purse.sp": 25}, 127 127 base_path=player_base, 128 128 ) 129 129 130 - assert "gold = 100" in result 130 + assert "purse.gp = 100" in result 131 131 char = load_character("test-player", player_base) 132 - assert char["gold"] == 100 132 + assert char["purse"]["gp"] == 100 133 + assert char["purse"]["sp"] == 25 133 134 134 135 def test_update_hp_current(self, player_base: Path, basic_character: dict): 135 136 result = update_character(
+2 -2
tests/test_planner.py
··· 370 370 ) 371 371 assert len(result.candidates) == 0 372 372 373 - @patch("storied.planner.subprocess.Popen") 373 + @patch("storied.claude.subprocess.Popen") 374 374 def test_plan_world_calls_claude(self, mock_popen: MagicMock, populated_world: Path): 375 375 save_session( 376 376 "default", ··· 408 408 assert result.output_tokens == 200 409 409 mock_popen.assert_called_once() 410 410 411 - @patch("storied.planner.subprocess.Popen") 411 + @patch("storied.claude.subprocess.Popen") 412 412 def test_plan_world_counts_tool_calls(self, mock_popen: MagicMock, populated_world: Path): 413 413 save_session( 414 414 "default",
+5 -5
tests/test_seeder.py
··· 82 82 hp_max=12, 83 83 ac=16, 84 84 background="Soldier", 85 - gold=50, 85 + purse={"gp": 50}, 86 86 equipment=["Longsword", "Chain mail", "Shield"], 87 87 backstory="A former soldier haunted by a battle gone wrong.", 88 88 base_path=tmp_path, ··· 93 93 class TestSeedWorld: 94 94 """Tests for the seed_world orchestrator.""" 95 95 96 - @patch("storied.planner.subprocess.Popen") 96 + @patch("storied.claude.subprocess.Popen") 97 97 def test_seed_world_calls_claude( 98 98 self, mock_popen: MagicMock, character_world: Path 99 99 ): ··· 124 124 # Should include --system-prompt and --mcp-config 125 125 assert any("--system-prompt" in str(a) for a in args_list) 126 126 127 - @patch("storied.planner.subprocess.Popen") 127 + @patch("storied.claude.subprocess.Popen") 128 128 def test_seed_world_counts_tool_calls( 129 129 self, mock_popen: MagicMock, character_world: Path 130 130 ): ··· 169 169 170 170 assert result.tool_calls == 2 171 171 172 - @patch("storied.planner.subprocess.Popen") 172 + @patch("storied.claude.subprocess.Popen") 173 173 def test_seed_world_returns_result( 174 174 self, mock_popen: MagicMock, character_world: Path 175 175 ): ··· 198 198 assert result.output_tokens == 100 199 199 assert result.elapsed > 0 200 200 201 - @patch("storied.planner.subprocess.Popen") 201 + @patch("storied.claude.subprocess.Popen") 202 202 def test_seed_world_reports_progress( 203 203 self, mock_popen: MagicMock, character_world: Path 204 204 ):