A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Switch to Haiku 4.5, fix cost tracking, add slash commands

Trying out Haiku 4.5 as the default model to reduce costs during
development. Playtesting suggests it's surprisingly capable as a DM.

Fixed the /context cost calculation - it was hardcoded to Sonnet pricing
and missing output tokens entirely. Now uses a pricing table keyed by
model ID and includes both input and output in the totals.

Added /help and /save slash commands with tab completion. Tried adding
/character and /inventory too but the LLM actually does a better job
presenting that info, so dropped them.

Also added guidance in the DM prompt for handling time skips - the DM
was narrating nighttime while the clock said 13:30 because it was
summing activity durations instead of calculating to the target time.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+90 -15
+14
prompts/dm-system.md
··· 350 350 | Long rest | 8 hours | 351 351 | Walking across town | 15-30 min | 352 352 | Travel between towns | hours to days | 353 + 354 + ## Time Skips 355 + 356 + When the player asks to "fast forward", "skip to", or "wait until" a specific time: 357 + 358 + 1. **Calculate from current time to target**, not from activity durations 359 + 2. **Log the full skip** with `mark_time` 360 + 361 + Example: At 07:30, player says "let's fast forward to nighttime" 362 + - Nighttime ≈ 20:00-21:00 363 + - Skip = ~13 hours 364 + - `mark_time("Set up camp, rested through the day", "13 hours")` 365 + 366 + Don't narrate nighttime while the clock says 13:30. The clock and narrative must match.
+75 -14
src/storied/cli.py
··· 8 8 9 9 import argcomplete 10 10 11 + # Pricing per million tokens (USD) 12 + MODEL_PRICING: dict[str, dict[str, float]] = { 13 + "claude-haiku-4-5-20251001": { 14 + "input": 1.00, 15 + "output": 5.00, 16 + "cache_read": 0.10, 17 + "cache_write": 1.25, 18 + }, 19 + "claude-sonnet-4-5-20250929": { 20 + "input": 3.00, 21 + "output": 15.00, 22 + "cache_read": 0.30, 23 + "cache_write": 3.75, 24 + }, 25 + "claude-opus-4-5-20251101": { 26 + "input": 5.00, 27 + "output": 25.00, 28 + "cache_read": 0.50, 29 + "cache_write": 6.25, 30 + }, 31 + } 32 + DEFAULT_PRICING = MODEL_PRICING["claude-sonnet-4-5-20250929"] 33 + 34 + # Slash commands available during play 35 + SLASH_COMMANDS = { 36 + "/help": "Show this help message", 37 + "/save": "Save session state (without quitting)", 38 + "/context": "Show token usage and costs", 39 + } 40 + 11 41 12 42 def cmd_srd_download(args: argparse.Namespace) -> int: 13 43 """Download the SRD PDF.""" ··· 193 223 readline.set_history_length(1000) 194 224 atexit.register(readline.write_history_file, history_file) 195 225 226 + # Set up slash command autocomplete 227 + def slash_completer(text: str, state: int) -> str | None: 228 + if text.startswith("/"): 229 + matches = [cmd for cmd in SLASH_COMMANDS if cmd.startswith(text)] 230 + return matches[state] if state < len(matches) else None 231 + return None 232 + 233 + readline.set_completer_delims(readline.get_completer_delims().replace("/", "")) 234 + readline.set_completer(slash_completer) 235 + readline.parse_and_bind("tab: complete") 236 + 196 237 console = Console() 197 238 world_id = args.world if args.world else "default" 198 239 player_id = "default" ··· 365 406 366 407 # Calculate cache hit rate 367 408 total_processed = cache_read + cache_create + total_input 409 + total_output = stats["total_output"] 368 410 if total_processed > 0: 369 411 hit_rate = (cache_read / total_processed) * 100 370 412 371 - # Calculate cost savings (Sonnet pricing) 372 - # Without cache: all tokens at $3/MTok 373 - # With cache: reads at $0.30/MTok, writes at $3.75/MTok, rest at $3/MTok 374 - cost_without = total_processed * 3.0 / 1_000_000 413 + # Get pricing for current model 414 + pricing = MODEL_PRICING.get(engine.model, DEFAULT_PRICING) 415 + 416 + # Calculate costs with and without caching 417 + cost_without = ( 418 + total_processed * pricing["input"] / 1_000_000 + 419 + total_output * pricing["output"] / 1_000_000 420 + ) 375 421 cost_with = ( 376 - cache_read * 0.30 / 1_000_000 + 377 - cache_create * 3.75 / 1_000_000 + 378 - total_input * 3.0 / 1_000_000 422 + cache_read * pricing["cache_read"] / 1_000_000 + 423 + cache_create * pricing["cache_write"] / 1_000_000 + 424 + total_input * pricing["input"] / 1_000_000 + 425 + total_output * pricing["output"] / 1_000_000 379 426 ) 380 427 savings_pct = ((cost_without - cost_with) / cost_without) * 100 if cost_without > 0 else 0 381 428 382 429 console.print( 383 - f" [dim]Tokens: {total_processed:,} processed " 384 - f"({cache_read:,} cached, {cache_create:,} written, {total_input:,} new)[/dim]" 430 + f" [dim]Tokens: {total_processed:,} in " 431 + f"({cache_read:,} cached, {cache_create:,} written, {total_input:,} new) · " 432 + f"{total_output:,} out[/dim]" 385 433 ) 386 434 console.print( 387 435 f" [dim]Cache hit rate: [green]{hit_rate:.1f}%[/green] · " 388 - f"Cost savings: [green]{savings_pct:.0f}%[/green] " 389 - f"(${cost_with:.4f} vs ${cost_without:.4f})[/dim]" 390 - ) 391 - console.print( 392 - f" [dim]Output: {stats['total_output']:,} tokens[/dim]" 436 + f"Savings: [green]{savings_pct:.0f}%[/green] · " 437 + f"Cost: [green]${cost_with:.3f}[/green] (${cost_without:.3f} without cache)[/dim]" 393 438 ) 394 439 else: 395 440 console.print( ··· 399 444 400 445 console.print() 401 446 continue 447 + 448 + # Handle /help command 449 + if action.strip().lower() == "/help": 450 + console.print() 451 + console.print("[bold]Available Commands[/bold]") 452 + console.print() 453 + for cmd, desc in SLASH_COMMANDS.items(): 454 + console.print(f" [cyan]{cmd}[/cyan] {desc}") 455 + console.print() 456 + continue 457 + 458 + # Handle /save command (asks DM to save without quitting) 459 + if action.strip().lower() == "/save": 460 + console.print() 461 + console.print("[dim]Saving session...[/dim]") 462 + action = "[System: Player requested a session save. Please save the current game state using set_scene, but do not end the session. Briefly confirm what was saved.]" 402 463 403 464 try: 404 465 console.print(Rule(style="dim blue"))
+1 -1
src/storied/engine.py
··· 66 66 world_id: str = "default", 67 67 player_id: str = "default", 68 68 base_path: Path | None = None, 69 - model: str = "claude-sonnet-4-5-20250929", 69 + model: str = "claude-haiku-4-5-20251001", 70 70 prompt_name: str = "dm-system", 71 71 transcript_path: Path | None = None, 72 72 ):