A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Replace LLM-formatted /status and /me with deterministic rendering

The character display was shelling out to Haiku every time you typed /me
or /status, which took a few seconds and gave slightly different results
each time. The character file is already well-structured YAML frontmatter
plus markdown sections, so we can just parse and render it directly.

/status shows the compact glance (identity, vitals, abilities, purse,
equipment one-liner). /me shows the full sheet with all sections using
bold labels instead of ## headings to cut Rich's generous vertical
spacing.

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

+121 -35
-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
-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.
+111
src/storied/character.py
··· 235 235 return f"Created character '{name}' - a level {level} {race} {char_class}!" 236 236 237 237 238 + _ABILITY_ORDER = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"] 239 + 240 + 241 + def _ability_line(abilities: dict[str, int]) -> str: 242 + """Format abilities as a single pipe-separated line.""" 243 + parts = [] 244 + for ability in _ABILITY_ORDER: 245 + score = abilities.get(ability, 10) 246 + mod = (score - 10) // 2 247 + mod_str = f"+{mod}" if mod >= 0 else str(mod) 248 + parts.append(f"{ability[:3].upper()} {score} ({mod_str})") 249 + return " | ".join(parts) 250 + 251 + 252 + def _purse_line(data: dict) -> str | None: 253 + """Format purse as a string, or None if empty/missing.""" 254 + purse = data.get("purse") 255 + if purse and isinstance(purse, dict): 256 + coins = [] 257 + for denom in ["pp", "gp", "ep", "sp", "cp"]: 258 + amount = purse.get(denom, 0) 259 + if amount: 260 + coins.append(f"{amount} {denom}") 261 + return ", ".join(coins) if coins else "empty" 262 + if (gold := data.get("gold")) is not None: 263 + return f"{gold} gp" 264 + return None 265 + 266 + 267 + def _parse_sections(body: str) -> list[tuple[str, str]]: 268 + """Split markdown body into (section_name, content) pairs.""" 269 + sections: list[tuple[str, str]] = [] 270 + current_name: str | None = None 271 + current_lines: list[str] = [] 272 + 273 + for line in body.splitlines(): 274 + if line.startswith("## "): 275 + if current_name is not None: 276 + sections.append((current_name, "\n".join(current_lines).strip())) 277 + current_name = line[3:].strip() 278 + current_lines = [] 279 + elif current_name is not None: 280 + current_lines.append(line) 281 + 282 + if current_name is not None: 283 + sections.append((current_name, "\n".join(current_lines).strip())) 284 + 285 + return sections 286 + 287 + 288 + def format_status(data: dict, *, include_equipment: bool = True) -> str: 289 + """Compact character status for /status — renders instantly via Rich Markdown.""" 290 + name = data.get("name", "Unknown") 291 + race = data.get("race", "") 292 + char_class = data.get("class", "") 293 + level = data.get("level", 1) 294 + background = data.get("background") 295 + 296 + identity = f"**{name}** — {race} {char_class} {level}" 297 + if background: 298 + identity += f" ({background})" 299 + 300 + hp = data.get("hp", {}) 301 + if isinstance(hp, dict): 302 + hp_str = f"{hp.get('current', '?')}/{hp.get('max', '?')}" 303 + else: 304 + hp_str = str(hp) 305 + 306 + ac = data.get("ac", "?") 307 + speed = data.get("speed", 30) 308 + vitals = f"HP {hp_str} | AC {ac} | Speed {speed} ft" 309 + 310 + lines = [identity, vitals] 311 + 312 + abilities = data.get("abilities", {}) 313 + if abilities: 314 + lines.append("") 315 + lines.append(_ability_line(abilities)) 316 + 317 + purse = _purse_line(data) 318 + if purse: 319 + lines.append("") 320 + lines.append(f"**Purse:** {purse}") 321 + 322 + if include_equipment: 323 + sections = _parse_sections(data.get("body", "")) 324 + for section_name, content in sections: 325 + if section_name.lower() == "equipment": 326 + items = [line.lstrip("- ").strip() for line in content.splitlines() if line.strip()] 327 + if items: 328 + lines.append(f"**Equipment:** {', '.join(items)}") 329 + break 330 + 331 + return "\n".join(lines) 332 + 333 + 334 + def format_sheet(data: dict) -> str: 335 + """Full character sheet for /me — renders instantly via Rich Markdown.""" 336 + lines = [format_status(data, include_equipment=False)] 337 + 338 + sections = _parse_sections(data.get("body", "")) 339 + for section_name, content in sections: 340 + if not content: 341 + continue 342 + lines.append("") 343 + lines.append(f"**{section_name}**") 344 + lines.append(content) 345 + 346 + return "\n".join(lines) 347 + 348 + 238 349 def format_character_context(data: dict) -> str: 239 350 """Format character data for inclusion in the system prompt. 240 351
+10 -18
src/storied/cli.py
··· 19 19 "/note": "Add a note to your character sheet (e.g. /note remember the prayer words)", 20 20 } 21 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 22 + def _format_character_display(player_id: str, full: bool) -> str | None: 23 + """Format character data for /status or /me display.""" 24 + from storied.character import format_sheet, format_status, load_character 25 25 26 - return run_prompt(system_prompt, char_file_content) 26 + data = load_character(player_id) 27 + if data is None: 28 + return None 29 + return format_sheet(data) if full else format_status(data) 27 30 28 31 29 32 def cmd_srd_download(args: argparse.Namespace) -> int: ··· 421 424 # Handle /status and /me commands 422 425 if action.strip().lower() in ("/status", "/me"): 423 426 is_full = action.strip().lower() == "/me" 424 - char_path = Path.cwd() / "players" / player_id / "character.md" 425 - if char_path.exists(): 427 + formatted = _format_character_display(player_id, full=is_full) 428 + if formatted: 426 429 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 - else: 438 - console.print("[dim]Could not format character sheet.[/dim]") 430 + console.print(Markdown(formatted)) 439 431 console.print() 440 432 else: 441 433 console.print("[dim]No character loaded.[/dim]")