A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Move the tool surface to FastMCP and tighten the input schemas

The hand-rolled MCP server (mcp.server.lowlevel + a custom uvicorn/SSE
shim) and the 160-line execute_tool dispatcher were getting in the way
more than they were earning. This swaps them out for stock FastMCP 3.2:
each tools/*.py module owns a module-level FastMCP instance with
@mcp.tool decorators, and storied.mcp_server composes a per-role
top-level server by mounting them and applying tag-based visibility
filters.

Tags do most of the work now. Every tool carries role tags ("dm",
"planner", "seeder", "advancement") and category/mode tags, and
per-role filtering becomes a single disable+enable pair on the parent
server. The dynamic combat-tools-when-initiative-is-active swap moves
to mcp.enable/disable(tags=...) calls inside enter_initiative and
end_initiative themselves, where the state actually changes. The
PLANNER_TOOLS / SEEDER_TOOLS / ADVANCEMENT_TOOLS allowlists, the
*_execute_tool dispatchers, and the hand-maintained character_schemas
dict are gone.

ToolContext is now a process-global initialised once via init_ctx(),
and each of its fields is exposed via a small uncalled-for Dependency
subclass (Combat, Lore, Timekeeper, World, Player, StorageRoot,
Entities). Tools declare only the slices they actually need at the
parameter site, and FastMCP introspection skips the Dependency-default
parameters so the LLM never sees them. While I was in there, the loose
input types got tightened too: enter_initiative.combatants is a
CombatantInput Pydantic model, create_character.abilities/purse are
Abilities/Purse, adjust_coins.deltas is CoinDelta, update_character.updates
is dict[str, JsonValue], and a handful of conceptually-enum string
parameters became Literal types so they show up as JSON Schema enums
(rest type, set_item_status, condition action/ends_on, recall scope,
the entity_type/content_type families).

The character.yaml shape gets a Pydantic schema too. update_character
validates the result before saving and rejects bad writes with a
DM-readable error (so the LLM can correct itself), and load_character
runs a small coercer that heals known mis-shapes from older sessions
(e.g. resources written as a list-of-pools instead of a dict-of-pools).
A few related runtime crashes that surfaced in sandbox sessions are
fixed: the format_* helpers now tolerate wrong-shaped containers
instead of crashing the turn, /me threads base_path through to
load_character so sandbox sessions don't read the cwd character, the
sandbox dir gets a symlink to rules/ so recall(scope="rules") works,
and enter_initiative's combatant fields are now in the schema instead
of having to be guessed.

Test coverage went from 86% to 96% in the process. A bunch of new
tests cover the FastMCP composition + tag filtering, the in-memory
client call paths through every tool wrapper, the deferred-notification
formatters, the schema validation/coercion, and the engine helpers.
The bits that drive a real `claude` subprocess (stream_action,
stream_with_tools, run_prompt, tick_world, BackgroundTicker._run,
BackgroundAdvancement._run, start_server, MCPServerHandle.stop) are
marked `# pragma: no cover` with notes explaining why — mocking them
would test the mock, not the launcher. The coverage threshold in
pyproject.toml is bumped from 85% to 95% so this can't slip back.

Mostly held together so far, but it's a lot of moving pieces — please
yell if anything in the sandbox or normal play feels off.

+3772 -1894
+11 -2
pyproject.toml
··· 12 12 dependencies = [ 13 13 "argcomplete>=3.0", 14 14 "fastembed>=0.4", 15 + "fastmcp>=3.2", 15 16 "httpx>=0.27", 16 - "mcp>=1.9", 17 17 "pydantic-monty>=0.0.9", 18 18 "pymupdf>=1.24", 19 19 "pymupdf4llm>=0.0.17", ··· 21 21 "pyyaml>=6.0", 22 22 "rich>=13.0", 23 23 "sqlite-vec>=0.1", 24 + "uncalled-for>=0.1", 24 25 ] 25 26 26 27 [project.scripts] ··· 70 71 omit = ["src/storied/cli.py", "src/storied/srd/*"] 71 72 72 73 [tool.coverage.report] 73 - fail_under = 85 74 + fail_under = 95 75 + 76 + [dependency-groups] 77 + dev = [ 78 + "mypy>=1.19.1", 79 + "pytest>=9.0.2", 80 + "pytest-cov>=7.0.0", 81 + "ruff>=0.14.10", 82 + ]
+3 -1
src/storied/advancement.py
··· 182 182 self._thread = Thread(target=self._run, daemon=True) 183 183 self._thread.start() 184 184 185 - def _run(self) -> None: 185 + def _run(self) -> None: # pragma: no cover 186 + # Threaded entry point that drives evaluate_advancement (which spawns 187 + # a claude subprocess via run_with_tools). Excluded from coverage. 186 188 self._result = evaluate_advancement( 187 189 world_id=self._world_id, 188 190 player_id=self._player_id,
+19 -2
src/storied/character/data.py
··· 5 5 6 6 import yaml 7 7 8 + from storied.character.schema import coerce_character, validate_for_write 9 + 8 10 9 11 # Default character schema — used when creating a new character 10 12 DEFAULT_SCHEMA: dict = { ··· 74 76 75 77 Returns None if no character exists. Returns the parsed YAML dict, with 76 78 missing fields filled in from DEFAULT_SCHEMA so callers can rely on the 77 - full structure being present. 79 + full structure being present. Known mis-shapes from older sessions 80 + (e.g. resources stored as a list) are coerced into the canonical shape 81 + so the existing character keeps working. 78 82 """ 79 83 yaml_path = _character_yaml_path(player_id, base_path) 80 84 if not yaml_path.exists(): 81 85 return None 82 86 83 87 raw = yaml.safe_load(yaml_path.read_text()) or {} 84 - return _merge_defaults(raw) 88 + coerced = coerce_character(raw) 89 + return _merge_defaults(coerced) 85 90 86 91 87 92 def load_character_prose(player_id: str, base_path: Path | None = None) -> str: ··· 143 148 """Update fields in character.yaml using dot notation. 144 149 145 150 Example: {"state.hp.current": 5, "identity.classes.0.level": 4} 151 + 152 + The result is validated against the Character schema before being 153 + written. If validation fails, the update is rejected (the file on disk 154 + is unchanged) and the returned message tells the DM what's wrong so 155 + they can retry with the correct shape. 146 156 """ 147 157 data = load_character(player_id, base_path) 148 158 if data is None: ··· 169 179 if denom in purse and purse[denom] < 0: 170 180 purse[denom] = 0 171 181 changes.append(f"({denom} clamped to 0)") 182 + 183 + error = validate_for_write(data) 184 + if error: 185 + return ( 186 + f"Update rejected — {error}. " 187 + f"Character on disk is unchanged." 188 + ) 172 189 173 190 save_character(player_id, data, base_path) 174 191 return "Character updated: " + ", ".join(changes)
+11 -5
src/storied/character/display.py
··· 84 84 85 85 86 86 def _format_resources(char: dict) -> list[str]: 87 - resources = char.get("resources", {}) or {} 88 - if not resources: 87 + resources = char.get("resources") or {} 88 + # Tolerate the LLM writing the wrong shape (e.g. a list instead of a 89 + # dict-of-pools). Render nothing rather than crashing the whole turn. 90 + if not isinstance(resources, dict) or not resources: 89 91 return [] 90 92 lines = ["**Resources:**"] 91 93 for name, pool in resources.items(): 94 + if not isinstance(pool, dict): 95 + continue 92 96 current = pool.get("current", 0) 93 97 maximum = pool.get("max", 0) 94 98 notes = pool.get("notes", name) ··· 99 103 100 104 101 105 def _format_magic_items(char: dict) -> list[str]: 102 - mi = char.get("magic_items", {}) or {} 106 + mi = char.get("magic_items") or {} 107 + if not isinstance(mi, dict): 108 + return [] 103 109 if not (mi.get("attuned") or mi.get("equipped") or mi.get("carried")): 104 110 return [] 105 111 lines = ["**Magic Items:**"] ··· 127 133 128 134 129 135 def _format_equipment(char: dict) -> list[str]: 130 - equipment = char.get("equipment", {}) or {} 131 - if not equipment: 136 + equipment = char.get("equipment") or {} 137 + if not isinstance(equipment, dict) or not equipment: 132 138 return [] 133 139 lines = ["**Equipment:**"] 134 140 for location, items in equipment.items():
+215
src/storied/character/schema.py
··· 1 + """Pydantic schema for character.yaml. 2 + 3 + This is the canonical shape the DM's `update_character` writes must conform 4 + to. Validation lives at the write boundary so the LLM gets a clear, actionable 5 + error if it tries to use the wrong shape (e.g. a list of pools where a dict 6 + is expected). On read, `coerce_character` heals known mis-shapes from older 7 + sessions so existing characters keep working. 8 + """ 9 + 10 + from __future__ import annotations 11 + 12 + from typing import Any 13 + 14 + from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator 15 + 16 + 17 + # --- Sub-models ------------------------------------------------------------- 18 + 19 + 20 + class Abilities(BaseModel): 21 + """The six 5e ability scores. All required so the LLM can't omit one.""" 22 + 23 + model_config = ConfigDict(extra="forbid") 24 + strength: int 25 + dexterity: int 26 + constitution: int 27 + intelligence: int 28 + wisdom: int 29 + charisma: int 30 + 31 + 32 + class HP(BaseModel): 33 + model_config = ConfigDict(extra="allow") 34 + max: int 35 + current: int 36 + temp: int = 0 37 + 38 + 39 + class Purse(BaseModel): 40 + """Starting coin counts for the player's purse. All denominations default 41 + to zero so the LLM can omit any it doesn't want to set.""" 42 + 43 + model_config = ConfigDict(extra="allow") 44 + cp: int = 0 45 + sp: int = 0 46 + ep: int = 0 47 + gp: int = 0 48 + pp: int = 0 49 + 50 + 51 + class CoinDelta(BaseModel): 52 + """Signed deltas applied via adjust_coins. Use negative values to spend, 53 + positive to gain. Coins are clamped to zero on the underlying purse.""" 54 + 55 + model_config = ConfigDict(extra="forbid") 56 + cp: int = 0 57 + sp: int = 0 58 + ep: int = 0 59 + gp: int = 0 60 + pp: int = 0 61 + 62 + 63 + class DeathSaves(BaseModel): 64 + model_config = ConfigDict(extra="allow") 65 + successes: int = 0 66 + failures: int = 0 67 + 68 + 69 + class State(BaseModel): 70 + model_config = ConfigDict(extra="allow") 71 + hp: HP 72 + ac: int = 10 73 + speed: int = 30 74 + movement_modes: dict[str, int] = Field(default_factory=dict) 75 + senses: dict[str, int] = Field(default_factory=dict) 76 + purse: Purse = Field(default_factory=Purse) 77 + inspiration: bool = False 78 + exhaustion: int = 0 79 + death_saves: DeathSaves = Field(default_factory=DeathSaves) 80 + 81 + 82 + class ResourcePool(BaseModel): 83 + """A single named resource pool (hit dice, channel divinity, ki, etc).""" 84 + 85 + model_config = ConfigDict(extra="allow") 86 + current: int = 0 87 + max: int = 0 88 + refresh: str = "" 89 + notes: str = "" 90 + die: str | None = None 91 + 92 + 93 + class MagicItems(BaseModel): 94 + model_config = ConfigDict(extra="allow") 95 + attuned: list[str] = Field(default_factory=list) 96 + equipped: list[str] = Field(default_factory=list) 97 + carried: list[str] = Field(default_factory=list) 98 + 99 + 100 + # --- Top-level character model ---------------------------------------------- 101 + 102 + 103 + class Character(BaseModel): 104 + """The canonical character.yaml shape. 105 + 106 + `extra="allow"` lets the DM add custom fields the schema doesn't know 107 + about (e.g. campaign-specific tags), but the named fields are checked 108 + strictly. Validation runs in `update_character` before save and again 109 + after coercion in `load_character`. 110 + """ 111 + 112 + model_config = ConfigDict(extra="allow") 113 + 114 + identity: dict[str, Any] = Field(default_factory=dict) 115 + abilities: dict[str, int] = Field(default_factory=dict) 116 + proficiencies: dict[str, Any] = Field(default_factory=dict) 117 + state: State 118 + defenses: dict[str, Any] = Field(default_factory=dict) 119 + conditions: list[str] = Field(default_factory=list) 120 + features: list[dict[str, Any]] = Field(default_factory=list) 121 + resources: dict[str, ResourcePool] = Field(default_factory=dict) 122 + equipment: dict[str, list[str]] = Field(default_factory=dict) 123 + magic_items: MagicItems = Field(default_factory=MagicItems) 124 + effects: list[dict[str, Any]] = Field(default_factory=list) 125 + spellcasting: dict[str, Any] | None = None 126 + 127 + @field_validator("resources", mode="before") 128 + @classmethod 129 + def _resources_must_be_dict(cls, v: Any) -> Any: 130 + if isinstance(v, list): 131 + raise ValueError( 132 + "must be a dict of pools, not a list. Example: " 133 + "{'channel_divinity': {'current': 1, 'max': 1, " 134 + "'refresh': 'short_rest', 'notes': 'Channel Divinity'}}" 135 + ) 136 + return v 137 + 138 + @field_validator("equipment", mode="before") 139 + @classmethod 140 + def _equipment_must_be_dict(cls, v: Any) -> Any: 141 + if isinstance(v, list): 142 + raise ValueError( 143 + "must be a dict keyed by location, not a list. Example: " 144 + "{'on_person': ['Longsword', 'Lockpicks'], " 145 + "'stashed_at_inn': ['Spare cloak']}" 146 + ) 147 + return v 148 + 149 + 150 + # --- Helpers --------------------------------------------------------------- 151 + 152 + 153 + def validate_for_write(data: dict[str, Any]) -> str | None: 154 + """Validate `data` against the Character schema. 155 + 156 + Returns None if valid. Returns a DM-readable error message if invalid. 157 + """ 158 + try: 159 + Character.model_validate(data) 160 + except ValidationError as exc: 161 + return _format_error(exc) 162 + return None 163 + 164 + 165 + def _format_error(exc: ValidationError) -> str: 166 + """Format a Pydantic ValidationError as a single-line DM-readable message.""" 167 + parts = [] 168 + for err in exc.errors(): 169 + loc = ".".join(str(x) for x in err["loc"]) or "(root)" 170 + msg = err["msg"] 171 + # Pydantic prefixes "Value error, " on raised ValueErrors — strip it 172 + if msg.startswith("Value error, "): 173 + msg = msg[len("Value error, "):] 174 + parts.append(f"{loc}: {msg}") 175 + return "; ".join(parts) 176 + 177 + 178 + def coerce_character(data: dict[str, Any]) -> dict[str, Any]: 179 + """Heal known mis-shapes in `data` so it conforms to the schema. 180 + 181 + Used by `load_character` to keep existing sessions playable when the DM 182 + has previously written the wrong shape (e.g. resources as a list-of-pools 183 + instead of a dict-of-pools). Returns the coerced dict in place. 184 + 185 + Conservative: only fixes shapes we recognize. Anything else falls through 186 + untouched and will surface via the next write-time validation. 187 + """ 188 + # resources: list-of-pools → dict-of-pools, keyed by name (or index) 189 + resources = data.get("resources") 190 + if isinstance(resources, list): 191 + coerced: dict[str, dict[str, Any]] = {} 192 + for i, pool in enumerate(resources): 193 + if not isinstance(pool, dict): 194 + continue 195 + key = pool.get("name") or pool.get("notes") or f"pool_{i}" 196 + # normalize the key (lowercase, underscores) so future lookups work 197 + key = str(key).lower().replace(" ", "_") 198 + coerced[key] = {k: v for k, v in pool.items() if k != "name"} 199 + data["resources"] = coerced 200 + 201 + # equipment: list of items → {"on_person": [items]} 202 + equipment = data.get("equipment") 203 + if isinstance(equipment, list): 204 + data["equipment"] = {"on_person": [str(x) for x in equipment]} 205 + 206 + # magic_items: list → {"carried": list} 207 + magic_items = data.get("magic_items") 208 + if isinstance(magic_items, list): 209 + data["magic_items"] = { 210 + "attuned": [], 211 + "equipped": [], 212 + "carried": [str(x) for x in magic_items], 213 + } 214 + 215 + return data
+10 -2
src/storied/claude.py
··· 192 192 # -- Public entrypoints ------------------------------------------------------- 193 193 194 194 195 - def stream_with_tools( 195 + def stream_with_tools( # pragma: no cover 196 196 system_prompt: str, 197 197 user_message: str, 198 198 mcp_url: str, ··· 209 209 loop presentation; Claude Code handles tool execution via MCP. 210 210 211 211 Used by the DM engine for real-time gameplay. 212 + 213 + Not unit-tested: this is a thin wrapper around `subprocess.Popen` that 214 + drives the real `claude` CLI. The arg-building and event-parsing helpers 215 + it composes are covered separately by their own tests; mocking the full 216 + subprocess streaming pipeline here would test the mock, not the code. 212 217 """ 213 218 mcp_config = _build_mcp_config(mcp_url) 214 219 args = _build_tool_args( ··· 325 330 return result 326 331 327 332 328 - def run_prompt( 333 + def run_prompt( # pragma: no cover 329 334 system_prompt: str, 330 335 user_message: str, 331 336 *, ··· 336 341 337 342 Plain text in, plain text out. No MCP tools, no streaming, no session. 338 343 Used for utility formatting (e.g., /status, /me). 344 + 345 + Not unit-tested: see stream_with_tools — this is another thin 346 + `subprocess.run` wrapper around the real `claude` CLI. 339 347 """ 340 348 try: 341 349 claude_path = _find_claude()
+19 -4
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(player_id: str, full: bool) -> str | None: 23 - """Format character data for /status or /me display.""" 22 + def _format_character_display( 23 + player_id: str, 24 + full: bool, 25 + base_path: Path | None = None, 26 + ) -> str | None: 27 + """Format character data for /status or /me display. 28 + 29 + `base_path` must be threaded through from cmd_play so sandbox sessions 30 + read the sandbox character.yaml instead of the cwd default. 31 + """ 24 32 from storied.character import format_sheet, format_status, load_character 25 33 26 - data = load_character(player_id) 34 + data = load_character(player_id, base_path=base_path) 27 35 if data is None: 28 36 return None 29 37 return format_sheet(data) if full else format_status(data) ··· 237 245 sandbox_dir = Path(tempfile.mkdtemp(prefix="storied-sandbox-")) 238 246 (sandbox_dir / "worlds" / world_id).mkdir(parents=True) 239 247 (sandbox_dir / "players" / player_id).mkdir(parents=True) 248 + # Symlink rules/ from cwd so recall(scope="rules") works in the 249 + # sandbox without rebuilding the SRD index from scratch. 250 + rules_src = Path.cwd() / "rules" 251 + if rules_src.exists(): 252 + (sandbox_dir / "rules").symlink_to(rules_src.resolve()) 240 253 241 254 base_path = sandbox_dir if sandbox else None 242 255 creation_mode = False ··· 481 494 # Handle /status and /me commands 482 495 if action.strip().lower() in ("/status", "/me"): 483 496 is_full = action.strip().lower() == "/me" 484 - formatted = _format_character_display(player_id, full=is_full) 497 + formatted = _format_character_display( 498 + player_id, full=is_full, base_path=base_path, 499 + ) 485 500 if formatted: 486 501 console.print() 487 502 console.print(Markdown(formatted))
+8 -2
src/storied/engine.py
··· 373 373 "total_output": self._total_output_tokens, 374 374 } 375 375 376 - def process_action(self, player_input: str) -> str: 376 + def process_action(self, player_input: str) -> str: # pragma: no cover 377 377 """Process player input and return DM narrative (non-streaming).""" 378 378 chunks = list(self.stream_action(player_input)) 379 379 return "".join(chunks) 380 380 381 - def stream_action(self, player_input: str) -> Iterator[str]: 381 + def stream_action(self, player_input: str) -> Iterator[str]: # pragma: no cover 382 382 """Stream DM response for real-time output. 383 383 384 384 Uses stream_with_tools() to drive a claude -p subprocess. Yields 385 385 text chunks and tool notifications as they arrive. 386 + 387 + Not unit-tested: this is the agentic loop that drives the real 388 + `claude` subprocess via stream_with_tools(). The deferred-notification 389 + formatters and tool-name parsing it composes are covered separately 390 + in tests/test_notification_formatters.py; mocking the full streaming 391 + loop here would test the mock, not the code. 386 392 """ 387 393 self._log_transcript("player_input", {"content": player_input}) 388 394
+8 -224
src/storied/initiative.py
··· 1 - """Initiative tracking for structured combat and turn-based encounters.""" 1 + """Initiative tracking for structured combat and turn-based encounters. 2 + 3 + This module owns the InitiativeTracker state machine and the related 4 + dataclasses. The FastMCP combat tool surface lives in 5 + storied.tools.combat (which depends on both this module and 6 + storied.tools._context, hence the split — keeping it here would create 7 + a circular import). 8 + """ 2 9 3 10 from dataclasses import dataclass, field 4 11 ··· 349 356 for c in self.combatants: 350 357 parts.append(f" {c.initiative}: {c.name} ({c.hp}/{c.hp_max} HP, AC {c.ac})") 351 358 return "\n".join(parts) 352 - 353 - 354 - # --- Tool functions (thin wrappers around tracker methods) --- 355 - 356 - 357 - def enter_initiative( 358 - combatants_raw: list[dict], tracker: InitiativeTracker, 359 - ) -> str: 360 - """Enter initiative mode for combat or any turn-based encounter. 361 - 362 - Provide all participants in their desired turn order (you handle 363 - tie-breaking). Roll initiative for everyone first, then call this. 364 - Initiative tools become available on the next turn. 365 - """ 366 - if tracker.active: 367 - return "Initiative is already active. Call end_initiative first." 368 - 369 - combatants = [ 370 - Combatant( 371 - name=c["name"], 372 - initiative=c["initiative"], 373 - hp=c["hp"], 374 - hp_max=c["hp_max"], 375 - ac=c["ac"], 376 - is_player=c.get("is_player", False), 377 - ) 378 - for c in combatants_raw 379 - ] 380 - return tracker.begin(combatants) 381 - 382 - 383 - def next_turn(tracker: InitiativeTracker) -> str: 384 - """Advance to the next combatant's turn. Skips defeated. Call after resolving actions.""" 385 - return tracker.next_turn() 386 - 387 - 388 - def add_combatant( 389 - tracker: InitiativeTracker, 390 - name: str, 391 - initiative: int, 392 - hp: int, 393 - hp_max: int, 394 - ac: int, 395 - is_player: bool = False, 396 - ) -> str: 397 - """Add a combatant (reinforcements, surprised creatures waking up).""" 398 - c = Combatant( 399 - name=name, initiative=initiative, hp=hp, 400 - hp_max=hp_max, ac=ac, is_player=is_player, 401 - ) 402 - return tracker.add_combatant(c) 403 - 404 - 405 - def remove_combatant(tracker: InitiativeTracker, name: str) -> str: 406 - """Remove a combatant who fled, was banished, or is otherwise out.""" 407 - return tracker.remove_combatant(name) 408 - 409 - 410 - def damage(tracker: InitiativeTracker, target: str, amount: int) -> str: 411 - """Deal damage. Tracks defeat at 0 HP, reports Bloodied at half. Auto-syncs player character sheet.""" 412 - return tracker.apply_damage(target, amount) 413 - 414 - 415 - def heal(tracker: InitiativeTracker, target: str, amount: int) -> str: 416 - """Heal a combatant. Clamped to max HP. Revives defeated. Auto-syncs player character sheet.""" 417 - return tracker.apply_heal(target, amount) 418 - 419 - 420 - def condition( 421 - tracker: InitiativeTracker, 422 - target: str, 423 - condition_name: str, 424 - action: str = "add", 425 - duration: int = -1, 426 - ends_on: str = "start", 427 - source: str = "", 428 - ) -> str: 429 - """Add or remove a condition. Duration counts down on the source's turn. Duration -1 = until manually removed.""" 430 - if action == "remove": 431 - return tracker.remove_condition(target, condition_name) 432 - return tracker.add_condition( 433 - target=target, condition=condition_name, 434 - duration=duration, ends_on=ends_on, source=source, 435 - ) 436 - 437 - 438 - def end_initiative(tracker: InitiativeTracker) -> str: 439 - """End initiative and return to narrative. Returns summary with rounds, defeated, and survivor HP.""" 440 - return tracker.end() 441 - 442 - 443 - def execute_initiative_tool( 444 - tool_name: str, tool_input: dict, tracker: InitiativeTracker, 445 - ) -> str | None: 446 - """Dispatch an initiative tool call. Returns None for unknown tools.""" 447 - if tool_name not in ALL_INITIATIVE_TOOL_NAMES: 448 - return None 449 - 450 - if tool_name == "enter_initiative": 451 - return enter_initiative(tool_input["combatants"], tracker) 452 - 453 - if not tracker.active: 454 - return "Initiative is not active. Call enter_initiative first." 455 - 456 - if tool_name == "next_turn": 457 - return next_turn(tracker) 458 - elif tool_name == "add_combatant": 459 - return add_combatant( 460 - tracker, tool_input["name"], tool_input["initiative"], 461 - tool_input["hp"], tool_input["hp_max"], tool_input["ac"], 462 - tool_input.get("is_player", False), 463 - ) 464 - elif tool_name == "remove_combatant": 465 - return remove_combatant(tracker, tool_input["name"]) 466 - elif tool_name == "damage": 467 - return damage(tracker, tool_input["target"], tool_input["amount"]) 468 - elif tool_name == "heal": 469 - return heal(tracker, tool_input["target"], tool_input["amount"]) 470 - elif tool_name == "condition": 471 - return condition( 472 - tracker, tool_input["target"], tool_input["condition"], 473 - action=tool_input.get("action", "add"), 474 - duration=tool_input.get("duration", -1), 475 - ends_on=tool_input.get("ends_on", "start"), 476 - source=tool_input.get("source", ""), 477 - ) 478 - elif tool_name == "end_initiative": 479 - return end_initiative(tracker) 480 - 481 - return None 482 - 483 - 484 - # --- Tool definitions --- 485 - 486 - _COMBATANT_PROPS: dict = { 487 - "name": {"type": "string", "description": "Combatant name"}, 488 - "initiative": {"type": "integer", "description": "Initiative roll total"}, 489 - "hp": {"type": "integer", "description": "Current hit points"}, 490 - "hp_max": {"type": "integer", "description": "Maximum hit points"}, 491 - "ac": {"type": "integer", "description": "Armor class"}, 492 - "is_player": {"type": "boolean", "description": "True for the player character"}, 493 - } 494 - _COMBATANT_REQUIRED = ["name", "initiative", "hp", "hp_max", "ac"] 495 - 496 - _TARGET_AMOUNT_SCHEMA: dict = { 497 - "type": "object", 498 - "properties": { 499 - "target": {"type": "string", "description": "Combatant name"}, 500 - "amount": {"type": "integer", "description": "Amount"}, 501 - }, 502 - "required": ["target", "amount"], 503 - } 504 - 505 - _NO_INPUT: dict = {"type": "object", "properties": {}} 506 - 507 - ENTER_INITIATIVE_DEFINITION: dict = { 508 - "name": "enter_initiative", 509 - "description": enter_initiative.__doc__, 510 - "input_schema": { 511 - "type": "object", 512 - "properties": { 513 - "combatants": { 514 - "type": "array", 515 - "description": "Participants in initiative order (first acts first)", 516 - "items": { 517 - "type": "object", 518 - "properties": _COMBATANT_PROPS, 519 - "required": _COMBATANT_REQUIRED, 520 - }, 521 - }, 522 - }, 523 - "required": ["combatants"], 524 - }, 525 - } 526 - 527 - COMBAT_TOOL_DEFINITIONS: list[dict] = [ 528 - {"name": "next_turn", 529 - "description": next_turn.__doc__, 530 - "input_schema": _NO_INPUT}, 531 - {"name": "add_combatant", 532 - "description": add_combatant.__doc__, 533 - "input_schema": { 534 - "type": "object", "properties": _COMBATANT_PROPS, 535 - "required": _COMBATANT_REQUIRED}}, 536 - {"name": "remove_combatant", 537 - "description": remove_combatant.__doc__, 538 - "input_schema": { 539 - "type": "object", 540 - "properties": {"name": {"type": "string", "description": "Combatant name"}}, 541 - "required": ["name"]}}, 542 - {"name": "damage", 543 - "description": damage.__doc__, 544 - "input_schema": _TARGET_AMOUNT_SCHEMA}, 545 - {"name": "heal", 546 - "description": heal.__doc__, 547 - "input_schema": _TARGET_AMOUNT_SCHEMA}, 548 - {"name": "condition", 549 - "description": condition.__doc__, 550 - "input_schema": { 551 - "type": "object", 552 - "properties": { 553 - "target": {"type": "string", "description": "Combatant name"}, 554 - "condition": {"type": "string", "description": "Condition name (Prone, Stunned, etc)"}, 555 - "action": {"type": "string", "enum": ["add", "remove"]}, 556 - "duration": {"type": "integer", "description": "Rounds until expiry (-1 = until removed)"}, 557 - "ends_on": {"type": "string", "enum": ["start", "end"], 558 - "description": "Expires at start or end of source's turn"}, 559 - "source": {"type": "string", "description": "Who caused the condition"}, 560 - }, 561 - "required": ["target", "condition"]}}, 562 - {"name": "end_initiative", 563 - "description": end_initiative.__doc__, 564 - "input_schema": _NO_INPUT}, 565 - ] 566 - 567 - ALL_INITIATIVE_TOOL_NAMES: set[str] = ( 568 - {ENTER_INITIATIVE_DEFINITION["name"]} 569 - | {d["name"] for d in COMBAT_TOOL_DEFINITIONS} 570 - ) 571 - 572 - INITIATIVE_KEEP_NARRATIVE: frozenset[str] = frozenset({ 573 - "roll", "recall", "update_character", 574 - })
+111 -126
src/storied/mcp_server.py
··· 1 - """In-process MCP server exposing storied game tools to Claude Code. 1 + """In-process FastMCP server exposing storied game tools to Claude Code. 2 2 3 - start_server() launches an HTTP MCP server on localhost in a background 4 - thread. The engine passes its URL to claude -p via --mcp-config. Tools 5 - share state (CampaignLog, etc.) with the engine process. 3 + start_server() launches a FastMCP server (SSE transport) on a free localhost 4 + port in a background thread. Each call composes a per-role top-level server 5 + by mounting the tools/*.py module-level FastMCP instances and applying 6 + tag-based visibility filters. ToolContext is process-global and accessed by 7 + tools via the Dependency subclasses in storied.tools._context. 6 8 """ 7 9 10 + import asyncio 8 11 import socket 9 12 import threading 10 13 import time 11 14 from pathlib import Path 12 15 13 - import anyio 14 - 15 16 import uvicorn 16 - from mcp.server.lowlevel import Server 17 - from mcp.server.sse import SseServerTransport 18 - from mcp.types import TextContent, Tool 17 + from fastmcp import FastMCP 19 18 20 19 from storied.log import CampaignLog 21 20 from storied.search import VectorIndex 22 - from storied.initiative import ( 23 - COMBAT_TOOL_DEFINITIONS, 24 - ENTER_INITIATIVE_DEFINITION, 25 - INITIATIVE_KEEP_NARRATIVE, 26 - ) 27 - from storied.sandbox import build_tool_signatures 28 - from storied.tools import ( 29 - ADVANCEMENT_TOOL_DEFINITIONS, 21 + from storied.tools import character, combat, entities, mechanics, run_code, scene 22 + from storied.tools._context import ( 30 23 EntityIndex, 31 - PLANNER_TOOL_DEFINITIONS, 32 - SEEDER_TOOL_DEFINITIONS, 33 - TOOL_DEFINITIONS, 34 24 ToolContext, 35 - advancement_execute_tool, 36 - execute_tool, 37 - planner_execute_tool, 38 - seeder_execute_tool, 25 + init_ctx, 39 26 ) 40 27 41 - _tool_signatures: str | None = None 42 - 43 - TOOL_SETS: dict[str, list[dict]] = { 44 - "planner": PLANNER_TOOL_DEFINITIONS, 45 - "seeder": SEEDER_TOOL_DEFINITIONS, 46 - "advancement": ADVANCEMENT_TOOL_DEFINITIONS, 47 - } 48 - 49 - EXECUTORS = { 50 - "dm": execute_tool, 51 - "planner": planner_execute_tool, 52 - "seeder": seeder_execute_tool, 53 - "advancement": advancement_execute_tool, 54 - } 28 + ALL_ROLES = {"dm", "planner", "seeder", "advancement"} 55 29 56 - 57 - def _dm_tool_definitions(ctx: ToolContext) -> list[dict]: 58 - """Return DM tools based on whether initiative is active.""" 59 - if ctx.initiative.active: 60 - kept = [d for d in TOOL_DEFINITIONS if d["name"] in INITIATIVE_KEEP_NARRATIVE] 61 - return kept + COMBAT_TOOL_DEFINITIONS 62 - return TOOL_DEFINITIONS + [ENTER_INITIATIVE_DEFINITION] 63 - 64 - 65 - def _to_mcp_tool(defn: dict) -> Tool: 66 - """Convert an Anthropic-format tool definition to an MCP Tool.""" 67 - global _tool_signatures 68 - desc = defn.get("description", "") 69 - if "{tool_signatures}" in desc: 70 - if _tool_signatures is None: 71 - _tool_signatures = build_tool_signatures() 72 - desc = desc.replace("{tool_signatures}", _tool_signatures) 73 - return Tool( 74 - name=defn["name"], 75 - description=desc, 76 - inputSchema=defn["input_schema"], 77 - ) 30 + _tool_signatures: str | None = None 78 31 79 32 80 33 def _find_free_port() -> int: ··· 85 38 86 39 87 40 class MCPServerHandle: 88 - """Handle to a running in-process MCP server.""" 41 + """Handle to a running in-process FastMCP server.""" 89 42 90 - def __init__(self, port: int, thread: threading.Thread, ctx: ToolContext): 43 + def __init__( 44 + self, 45 + port: int, 46 + thread: threading.Thread, 47 + ctx: ToolContext, 48 + server: FastMCP, 49 + ): 91 50 self.port = port 92 51 self.url = f"http://127.0.0.1:{port}/sse" 93 52 self.ctx = ctx 53 + self.server = server 94 54 self._thread = thread 95 55 96 - def stop(self) -> None: 97 - """Stop the server (best-effort).""" 56 + def stop(self) -> None: # pragma: no cover 57 + """Stop the server (best-effort). 58 + 59 + Not unit-tested: tied to the live uvicorn thread spawned by 60 + start_server, which is itself excluded from coverage. 61 + """ 98 62 if self._thread.is_alive(): 99 63 self._thread.join(timeout=2) 100 64 101 65 102 - def start_server( 66 + def _populate_index(base_path: Path, world_dir: Path, vi: VectorIndex) -> None: 67 + """Auto-populate an empty index from SRD seed + world content.""" 68 + srd_seed = base_path / "rules" / "srd-5.2.1" / "search.db" 69 + if srd_seed.exists(): 70 + vi.reseed(srd_seed) 71 + else: 72 + srd_dir = base_path / "rules" / "srd-5.2.1" / "sections" 73 + if srd_dir.exists(): 74 + vi.reindex_directory(srd_dir, source="srd") 75 + if world_dir.exists(): 76 + vi.reindex_directory(world_dir, source="world") 77 + 78 + 79 + async def _compose_server(role: str) -> FastMCP: 80 + """Build a fresh top-level FastMCP server for the given role. 81 + 82 + Mounts every tools/*.py module unconditionally, then applies tag 83 + filtering: hide tools whose role tags don't include `role`. For DM 84 + mode, additionally hides combat tools (except combat_control) at 85 + startup so they only appear during initiative. Substitutes the 86 + {tool_signatures} placeholder in tool descriptions while we're here. 87 + """ 88 + server: FastMCP = FastMCP("storied") 89 + server.mount(mechanics.mcp) 90 + server.mount(character.mcp) 91 + server.mount(scene.mcp) 92 + server.mount(entities.mcp) 93 + server.mount(combat.mcp) 94 + server.mount(run_code.mcp) 95 + 96 + # Hide tools tagged for other roles, then re-enable tools that also 97 + # carry our role tag (e.g. update_character is in both `dm` and 98 + # `advancement` — disabling `advancement` would hide it without the 99 + # second pass). 100 + other_roles = ALL_ROLES - {role} 101 + server.disable(tags=other_roles) 102 + server.enable(tags={role}) 103 + 104 + if role == "dm": 105 + combat_keys: set[str] = set() 106 + for tool in await server.list_tools(): 107 + if "combat" in tool.tags and "combat_control" not in tool.tags: 108 + combat_keys.add(tool.key) 109 + if combat_keys: 110 + server.disable(keys=combat_keys) 111 + combat.set_root(server, combat_keys) 112 + 113 + # Substitute {tool_signatures} placeholder in tool descriptions 114 + global _tool_signatures 115 + if _tool_signatures is None: 116 + from storied.sandbox import build_tool_signatures 117 + _tool_signatures = build_tool_signatures() 118 + for tool in await server.list_tools(): 119 + if tool.description and "{tool_signatures}" in tool.description: 120 + tool.description = tool.description.replace( 121 + "{tool_signatures}", _tool_signatures or "" 122 + ) 123 + 124 + return server 125 + 126 + 127 + def start_server( # pragma: no cover 103 128 world_id: str, 104 129 player_id: str, 105 130 base_path: Path, 106 131 tool_set: str = "dm", 107 132 campaign_log: CampaignLog | None = None, 108 133 ) -> MCPServerHandle: 109 - """Start an in-process MCP HTTP server on a free localhost port. 134 + """Start an in-process FastMCP HTTP server on a free localhost port. 110 135 111 136 Returns an MCPServerHandle with the URL to pass to --mcp-config. 112 - The server runs in a daemon thread and shares state with the caller. 137 + The server runs in a daemon thread and shares the process-global 138 + ToolContext (set via init_ctx) with the caller. 139 + 140 + Not unit-tested: this is the live launcher that spins up uvicorn in 141 + a daemon thread and waits for the port to open. The pure compose path 142 + (`_compose_server`) and tool registry it builds are tested separately 143 + in tests/test_mcp_server.py; mocking out uvicorn here would test the 144 + mock, not the launcher. 113 145 """ 114 146 if campaign_log is None: 115 147 campaign_log = CampaignLog(world_id, base_path) 116 148 117 149 world_dir = base_path / "worlds" / world_id 118 150 119 - def _populate_index(vi: VectorIndex) -> None: 120 - """Auto-populate an empty index from SRD seed + world content.""" 121 - srd_seed = base_path / "rules" / "srd-5.2.1" / "search.db" 122 - if srd_seed.exists(): 123 - vi.reseed(srd_seed) 124 - else: 125 - srd_dir = base_path / "rules" / "srd-5.2.1" / "sections" 126 - if srd_dir.exists(): 127 - vi.reindex_directory(srd_dir, source="srd") 128 - if world_dir.exists(): 129 - vi.reindex_directory(world_dir, source="world") 151 + vector_index = VectorIndex( 152 + world_dir / "search.db", 153 + on_empty=lambda vi: _populate_index(base_path, world_dir, vi), 154 + ) 130 155 131 - ctx = ToolContext( 156 + ctx = init_ctx( 132 157 world_id=world_id, 133 158 player_id=player_id, 134 159 base_path=base_path, 135 160 campaign_log=campaign_log, 136 161 entity_index=EntityIndex(world_dir), 137 - vector_index=VectorIndex(world_dir / "search.db", on_empty=_populate_index), 162 + vector_index=vector_index, 138 163 ) 139 164 140 - static_definitions = TOOL_SETS.get(tool_set) 141 - executor = EXECUTORS.get(tool_set, execute_tool) 142 - 143 - mcp = Server("storied") 144 - 145 - @mcp.list_tools() 146 - async def list_tools() -> list[Tool]: 147 - if tool_set == "dm": 148 - return [_to_mcp_tool(d) for d in _dm_tool_definitions(ctx)] 149 - return [_to_mcp_tool(d) for d in (static_definitions or TOOL_DEFINITIONS)] 165 + server = asyncio.run(_compose_server(tool_set)) 150 166 151 - @mcp.call_tool() 152 - async def call_tool(name: str, arguments: dict) -> list[TextContent]: 153 - if tool_set == "dm": 154 - allowed = {d["name"] for d in _dm_tool_definitions(ctx)} 155 - if name not in allowed: 156 - return [TextContent( 157 - type="text", 158 - text=f"Tool '{name}' not available in current mode.", 159 - )] 160 - result = executor(name, arguments, ctx) 161 - return [TextContent(type="text", text=str(result))] 162 - 163 - sse = SseServerTransport("/messages/") 164 - 165 - async def app(scope, receive, send): 166 - if scope["type"] != "http": 167 - return 168 - path = scope.get("path", "") 169 - if path == "/sse": 170 - try: 171 - async with sse.connect_sse(scope, receive, send) as streams: 172 - await mcp.run( 173 - streams[0], 174 - streams[1], 175 - mcp.create_initialization_options(), 176 - ) 177 - except (anyio.ClosedResourceError, anyio.BrokenResourceError): 178 - pass 179 - elif path.startswith("/messages/"): 180 - await sse.handle_post_message(scope, receive, send) 167 + app = server.http_app(transport="sse", path="/sse") 181 168 182 169 port = _find_free_port() 183 - config = uvicorn.Config( 184 - app, host="127.0.0.1", port=port, log_level="warning", 185 - ) 186 - server = uvicorn.Server(config) 170 + config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning") 171 + uv_server = uvicorn.Server(config) 187 172 188 - thread = threading.Thread(target=server.run, daemon=True) 173 + thread = threading.Thread(target=uv_server.run, daemon=True) 189 174 thread.start() 190 175 191 176 for _ in range(50): ··· 195 180 except OSError: 196 181 time.sleep(0.1) 197 182 198 - return MCPServerHandle(port, thread, ctx) 183 + return MCPServerHandle(port, thread, ctx, server)
+11 -3
src/storied/planner.py
··· 401 401 return "\n\n".join(parts) 402 402 403 403 404 - def tick_world( 404 + def tick_world( # pragma: no cover 405 405 world_id: str = "default", 406 406 player_id: str = "default", 407 407 base_path: Path | None = None, 408 408 model: str = "claude-opus-4-6", 409 409 on_progress: Callable[[str], None] | None = None, 410 410 ) -> TickResult: 411 - """Advance the world by evaluating Will triggers and adding small changes.""" 411 + """Advance the world by evaluating Will triggers and adding small changes. 412 + 413 + Not unit-tested: this is the world-tick orchestrator that drives the 414 + real `claude` subprocess via run_with_tools(). The data-side helpers it 415 + composes (`_find_entities_with_will`, `build_tick_context`) are covered 416 + separately. 417 + """ 412 418 if base_path is None: 413 419 base_path = Path.cwd() 414 420 ··· 502 508 self._thread = Thread(target=self._run, daemon=True) 503 509 self._thread.start() 504 510 505 - def _run(self) -> None: 511 + def _run(self) -> None: # pragma: no cover 512 + # Threaded entry point that drives tick_world (which is itself 513 + # subprocess-bound). Excluded from coverage along with tick_world. 506 514 self._result = tick_world( 507 515 world_id=self._world_id, 508 516 player_id=self._player_id,
+69 -71
src/storied/sandbox.py
··· 2 2 3 3 Provides an `execute` function that runs arbitrary Python code in a secure 4 4 Rust-based sandbox with no filesystem, network, or environment access. 5 - All DM tools are available as host functions when a ToolContext is provided. 5 + All DM tools are available as host functions; their dependency parameters 6 + resolve from the process-global ToolContext via uncalled-for. 6 7 """ 7 8 8 9 from __future__ import annotations 9 10 11 + import asyncio 10 12 import inspect 11 13 from collections.abc import Callable 12 - from typing import TYPE_CHECKING, Any 14 + from typing import Any 13 15 14 16 import pydantic_monty 17 + from uncalled_for import Dependency, get_dependency_parameters, resolved_dependencies 15 18 16 19 from storied.dice import roll as dice_roll 20 + from storied.tools import character, combat, entities, mechanics, scene 17 21 18 - if TYPE_CHECKING: 19 - from storied.tools import ToolContext 22 + # All module-level FastMCP servers whose tools should be exposed in the sandbox. 23 + # run_code itself is excluded (no recursion). 24 + _DM_SERVERS = [mechanics.mcp, character.mcp, scene.mcp, entities.mcp, combat.mcp] 20 25 21 26 _LIMITS = pydantic_monty.ResourceLimits( 22 27 max_duration_secs=5.0, 23 28 max_memory=10 * 1_024 * 1_024, # 10 MB 24 29 ) 25 30 26 - # Functions whose signatures we can read with inspect 27 - _TOOL_FUNCTIONS: dict[str, str] = {} 28 31 32 + def _roll_host(notation: str, reason: str | None = None) -> dict[str, Any]: 33 + """Host function: roll dice and return the result dict. 29 34 30 - def _roll_host(notation: str, reason: str | None = None) -> dict[str, Any]: 31 - """Host function: roll dice and return the result dict.""" 35 + Sandbox callers want a dict so they can index into rolls/kept/total 36 + for math; the MCP `roll` tool returns a formatted string instead. 37 + """ 32 38 return dice_roll(notation).to_dict() 33 39 34 40 35 - def _build_host_functions( 36 - ctx: ToolContext | None, 37 - extra: dict[str, Callable[..., Any]] | None, 38 - ) -> dict[str, Callable[..., Any]]: 39 - """Build the full set of host functions for the sandbox.""" 40 - fns: dict[str, Callable[..., Any]] = {"roll": _roll_host} 41 + def _adapt(fn: Callable[..., Any]) -> Callable[..., Any]: 42 + """Wrap a FastMCP tool function so the sandbox can call it synchronously. 41 43 42 - if ctx is not None: 43 - from storied.tools import TOOL_DEFINITIONS, execute_tool 44 - from storied.initiative import ( 45 - COMBAT_TOOL_DEFINITIONS, 46 - ENTER_INITIATIVE_DEFINITION, 47 - ) 44 + The wrapped tool may have parameters with `Dependency` defaults; we 45 + resolve them via uncalled-for's `resolved_dependencies` and splat the 46 + resolved values back into the call. The sandbox passes its own kwargs 47 + for the LLM-visible params. 48 + """ 49 + def wrapper(**kwargs: Any) -> Any: 50 + async def _run() -> Any: 51 + async with resolved_dependencies(fn, kwargs) as deps: 52 + return fn(**{**kwargs, **deps}) 53 + return asyncio.run(_run()) 54 + wrapper.__name__ = fn.__name__ 55 + wrapper.__doc__ = fn.__doc__ 56 + return wrapper 48 57 49 - all_defs = TOOL_DEFINITIONS + COMBAT_TOOL_DEFINITIONS + [ENTER_INITIATIVE_DEFINITION] 50 - for defn in all_defs: 51 - name = defn["name"] 52 - if name in ("roll", "run_code"): 53 - continue 54 58 55 - def _make(tool_name: str) -> Callable[..., str]: 56 - def fn(**kwargs: Any) -> str: 57 - return execute_tool(tool_name, kwargs, ctx) 58 - return fn 59 + def _enumerate_tools() -> list[tuple[str, Callable[..., Any]]]: 60 + """Walk every DM-side FastMCP server and collect (name, raw_fn) pairs. 59 61 60 - fns[name] = _make(name) 62 + Reads the LocalProvider._components dict directly so this works inside 63 + or outside an async context. The tool registry doesn't change after 64 + decoration time, so a sync read is safe. 65 + """ 66 + pairs: list[tuple[str, Callable[..., Any]]] = [] 67 + seen: set[str] = set() 68 + for srv in _DM_SERVERS: 69 + for tool in srv.local_provider._components.values(): 70 + name = tool.name 71 + if name in ("roll", "run_code") or name in seen: 72 + continue 73 + if not hasattr(tool, "fn"): 74 + continue # skip non-function components (resources, prompts) 75 + seen.add(name) 76 + pairs.append((name, tool.fn)) 77 + return pairs 61 78 79 + 80 + def _build_host_functions( 81 + extra: dict[str, Callable[..., Any]] | None = None, 82 + ) -> dict[str, Callable[..., Any]]: 83 + """Build the full set of host functions for the sandbox.""" 84 + fns: dict[str, Callable[..., Any]] = {"roll": _roll_host} 85 + for name, fn in _enumerate_tools(): 86 + fns[name] = _adapt(fn) 62 87 if extra: 63 88 fns.update(extra) 64 - 65 89 return fns 66 90 67 91 68 - def _sig(fn: Callable[..., Any], exclude: set[str], ret: str) -> str: 69 - """Format a function signature with return type, excluding internal params.""" 92 + def _format_signature(name: str, fn: Callable[..., Any], ret: str) -> str: 93 + """Format a function signature for tool_signatures, dropping Dependency params.""" 70 94 sig = inspect.signature(fn, eval_str=True) 71 - params = [p for p in sig.parameters.values() if p.name not in exclude] 95 + dep_params = set(get_dependency_parameters(fn).keys()) 96 + params = [p for p in sig.parameters.values() if p.name not in dep_params] 72 97 param_str = ", ".join(str(p) for p in params) 73 - return f"{fn.__name__}({param_str}) -> {ret}" 98 + return f"{name}({param_str}) -> {ret}" 74 99 75 100 76 101 def build_tool_signatures() -> str: 77 102 """Build human-readable function signatures for all DM tools. 78 103 79 - Signatures reflect what's actually callable in the sandbox: 80 - - roll() comes from _roll_host (returns dict) 81 - - all other tools go through execute_tool (return str) 104 + Walks every DM-side FastMCP server and formats each tool's signature 105 + with its `Depends`-style parameters elided. The roll() entry comes 106 + from _roll_host (returns a dict). 82 107 """ 83 - from storied.tools import ( 84 - add_condition, add_effect, add_item, add_note, 85 - adjust_coins, create_character, damage, heal, 86 - recall, establish, mark, note_discovery, 87 - remove_condition, remove_effect, remove_item, 88 - rest, restore_resource, set_item_status, set_scene, 89 - tune, end_session, update_character, use_resource, 90 - ) 91 - from storied.initiative import ( 92 - enter_initiative, next_turn, add_combatant, remove_combatant, 93 - condition, end_initiative, 94 - ) 95 - 96 - ctx_params = {"ctx", "tracker"} 97 - str_fns = [ 98 - # Character/state operations 99 - damage, heal, adjust_coins, update_character, create_character, 100 - add_effect, remove_effect, add_condition, remove_condition, 101 - add_item, remove_item, set_item_status, 102 - use_resource, restore_resource, rest, add_note, 103 - # World operations 104 - recall, establish, mark, note_discovery, set_scene, tune, end_session, 105 - # Initiative 106 - enter_initiative, next_turn, add_combatant, remove_combatant, 107 - condition, end_initiative, 108 + lines: list[str] = [ 109 + _format_signature("roll", _roll_host, "dict"), 108 110 ] 109 - 110 - lines: list[str] = [_sig(_roll_host, set(), "dict").replace("_roll_host", "roll")] 111 - for fn in str_fns: 112 - lines.append(_sig(fn, ctx_params, "str")) 113 - 111 + for name, fn in _enumerate_tools(): 112 + lines.append(_format_signature(name, fn, "str")) 114 113 return "\n".join(lines) 115 114 116 115 117 116 def execute( 118 117 code: str, 119 - ctx: ToolContext | None = None, 120 118 host_functions: dict[str, Callable[..., Any]] | None = None, 121 119 ) -> str: 122 120 """Run Python code in a sandboxed environment and return the output. 123 121 124 - When ctx is provided, every DM tool is available as a host function: 122 + Every DM tool is available as a host function: 125 123 recall(query="fireball", scope="rules") 126 124 establish(entity_type="npc", name="Bob", description="A baker") 127 125 roll("2d6+3") # returns dict with total, rolls, etc. ··· 132 130 if not code.strip(): 133 131 return "" 134 132 135 - fns = _build_host_functions(ctx, host_functions) 133 + fns = _build_host_functions(host_functions) 136 134 137 135 output_lines: list[str] = [] 138 136
+30 -250
src/storied/tools/__init__.py
··· 1 1 """DM tools for Claude to use during gameplay. 2 2 3 - Submodules group related tools by domain. This __init__ aggregates 4 - definitions and re-exports public names so existing imports continue 5 - to work unchanged. 3 + Each submodule owns a module-level FastMCP server registered with @mcp.tool 4 + decorators. The orchestrator in storied.mcp_server composes a per-role 5 + top-level server by mounting the right submodules and applying tag-based 6 + visibility filters. ToolContext is process-global and injected into tools 7 + via class-based Dependency subclasses from storied.tools._context. 6 8 """ 7 9 8 - from storied.initiative import ( 9 - ALL_INITIATIVE_TOOL_NAMES, 10 - execute_initiative_tool, 11 - ) 12 10 from storied.tools._context import ( 11 + Combat, 12 + Entities, 13 13 EntityIndex, 14 + Lore, 15 + Player, 16 + StorageRoot, 17 + Timekeeper, 14 18 ToolContext, 19 + World, 15 20 _get_file_lock, 16 21 _sync_player_hp, 17 - ) 18 - from storied.tools.character import ( 19 - add_condition, 20 - add_effect, 21 - add_item, 22 - add_note, 23 - adjust_coins, 24 - create_character, 25 - damage, 26 - heal, 27 - remove_condition, 28 - remove_effect, 29 - remove_item, 30 - rest, 31 - restore_resource, 32 - set_item_status, 33 - update_character, 34 - use_resource, 35 - ) 36 - from storied.tools.entities import ( 37 - _auto_mark_present, 38 - _load_entity, 39 - establish, 40 - mark, 41 - note_discovery, 22 + init_ctx, 23 + reset_ctx, 42 24 ) 43 - from storied.tools.mechanics import ( 44 - recall, 45 - roll, 46 - ) 47 - from storied.tools.scene import ( 48 - end_session, 49 - notify_dm, 50 - set_scene, 51 - tune, 52 - ) 53 - 54 - # Merge per-module DEFINITIONS lists into the canonical flat list 55 - from storied.tools.character import DEFINITIONS as _CHAR_DEFS 56 - from storied.tools.entities import DEFINITIONS as _ENTITY_DEFS 57 - from storied.tools.mechanics import DEFINITIONS as _MECH_DEFS 58 - from storied.tools.scene import DEFINITIONS as _SCENE_DEFS 59 - 60 - TOOL_DEFINITIONS: list[dict] = ( 61 - _MECH_DEFS + _CHAR_DEFS + _SCENE_DEFS + _ENTITY_DEFS 62 - ) 63 - 64 - 65 - def execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: 66 - """Execute a tool by name with the given input.""" 67 - # Initiative-only tools (enter_initiative, next_turn, end_initiative, 68 - # add/remove_combatant, condition) always route to the initiative module. 69 - # damage/heal route to initiative ONLY when called with a target — without 70 - # one they apply to the player character via the new operations module. 71 - if tool_name in ALL_INITIATIVE_TOOL_NAMES: 72 - is_player_op = tool_name in ("damage", "heal") and "target" not in tool_input 73 - if not is_player_op: 74 - result = execute_initiative_tool(tool_name, tool_input, ctx.initiative) 75 - if result is not None: 76 - if tool_name in ("damage", "heal"): 77 - result = _sync_player_hp(tool_input["target"], ctx, result) 78 - return result 79 - 80 - if tool_name == "roll": 81 - result = roll(tool_input["notation"]) 82 - rolls_str = ", ".join(str(r) for r in result["rolls"]) 83 - if result["kept"] != result["rolls"]: 84 - kept_str = ", ".join(str(r) for r in result["kept"]) 85 - return f"Rolled {result['notation']}: [{rolls_str}] → kept [{kept_str}] + {result['modifier']} = {result['total']}" 86 - elif result["modifier"]: 87 - return f"Rolled {result['notation']}: [{rolls_str}] + {result['modifier']} = {result['total']}" 88 - else: 89 - return f"Rolled {result['notation']}: [{rolls_str}] = {result['total']}" 90 - 91 - elif tool_name == "recall": 92 - return recall( 93 - tool_input["query"], ctx, 94 - scope=tool_input.get("scope", "all"), 95 - content_type=tool_input.get("content_type"), 96 - ) 97 - 98 - elif tool_name == "update_character": 99 - return update_character(tool_input["updates"], ctx) 100 - 101 - elif tool_name == "adjust_coins": 102 - return adjust_coins(tool_input["deltas"], ctx) 103 - 104 - elif tool_name == "create_character": 105 - return create_character( 106 - name=tool_input["name"], 107 - race=tool_input["race"], 108 - char_class=tool_input["char_class"], 109 - level=tool_input["level"], 110 - abilities=tool_input["abilities"], 111 - hp_max=tool_input["hp_max"], 112 - ac=tool_input["ac"], 113 - ctx=ctx, 114 - background=tool_input.get("background"), 115 - speed=tool_input.get("speed", 30), 116 - purse=tool_input.get("purse"), 117 - subclass=tool_input.get("subclass"), 118 - backstory=tool_input.get("backstory"), 119 - ) 120 - 121 - elif tool_name == "damage": 122 - return damage(tool_input["amount"], ctx, type=tool_input.get("type")) 123 - 124 - elif tool_name == "heal": 125 - return heal(tool_input["amount"], ctx) 126 - 127 - elif tool_name == "add_effect": 128 - return add_effect( 129 - source=tool_input["source"], 130 - description=tool_input["description"], 131 - ctx=ctx, 132 - expires=tool_input.get("expires"), 133 - ) 134 - 135 - elif tool_name == "remove_effect": 136 - return remove_effect(tool_input["source"], ctx) 137 - 138 - elif tool_name == "add_condition": 139 - return add_condition(tool_input["name"], ctx) 140 - 141 - elif tool_name == "remove_condition": 142 - return remove_condition(tool_input["name"], ctx) 143 - 144 - elif tool_name == "add_item": 145 - return add_item(tool_input["item"], ctx, location=tool_input.get("location")) 146 - 147 - elif tool_name == "remove_item": 148 - return remove_item(tool_input["item"], ctx) 149 - 150 - elif tool_name == "set_item_status": 151 - return set_item_status(tool_input["item"], tool_input["status"], ctx) 152 - 153 - elif tool_name == "use_resource": 154 - return use_resource(tool_input["name"], ctx, amount=tool_input.get("amount", 1)) 155 - 156 - elif tool_name == "restore_resource": 157 - return restore_resource(tool_input["name"], tool_input["amount"], ctx) 158 - 159 - elif tool_name == "rest": 160 - return rest(tool_input["type"], ctx) 161 - 162 - elif tool_name == "add_note": 163 - return add_note(tool_input["text"], ctx) 164 - 165 - elif tool_name == "set_scene": 166 - return set_scene( 167 - ctx=ctx, 168 - event=tool_input.get("event"), 169 - duration=tool_input.get("duration"), 170 - situation=tool_input.get("situation"), 171 - location=tool_input.get("location"), 172 - present=tool_input.get("present"), 173 - threads=tool_input.get("threads"), 174 - tags=tool_input.get("tags"), 175 - ) 176 - 177 - elif tool_name == "establish": 178 - return establish( 179 - entity_type=tool_input["entity_type"], 180 - name=tool_input["name"], 181 - ctx=ctx, 182 - description=tool_input.get("description"), 183 - location=tool_input.get("location"), 184 - knows=tool_input.get("knows"), 185 - wants=tool_input.get("wants"), 186 - will=tool_input.get("will"), 187 - ) 188 - 189 - elif tool_name == "mark": 190 - return mark( 191 - entity_type=tool_input["entity_type"], 192 - name=tool_input["name"], 193 - event=tool_input["event"], 194 - ctx=ctx, 195 - resolves=tool_input.get("resolves"), 196 - ) 197 - 198 - elif tool_name == "note_discovery": 199 - return note_discovery( 200 - entity=tool_input["entity"], 201 - content=tool_input["content"], 202 - ctx=ctx, 203 - content_type=tool_input.get("content_type", "lore"), 204 - tags=tool_input.get("tags"), 205 - ) 206 - 207 - elif tool_name == "tune": 208 - return tune(tuning=tool_input["tuning"], ctx=ctx) 209 - 210 - elif tool_name == "end_session": 211 - return end_session( 212 - situation=tool_input["situation"], 213 - ctx=ctx, 214 - threads=tool_input.get("threads"), 215 - ) 216 - 217 - elif tool_name == "run_code": 218 - from storied.sandbox import execute as sandbox_execute 219 - return sandbox_execute(tool_input["code"], ctx=ctx) 220 - 221 - elif tool_name == "notify_dm": 222 - return notify_dm(message=tool_input["message"], ctx=ctx) 223 - 224 - else: 225 - return f"Unknown tool: {tool_name}" 226 - 227 - 228 - # --- Tool sets for specialized agents --- 229 - 230 - PLANNER_TOOLS = {"recall", "establish", "mark", "notify_dm"} 231 - PLANNER_TOOL_DEFINITIONS = [t for t in TOOL_DEFINITIONS if t["name"] in PLANNER_TOOLS] 232 - 233 - 234 - def planner_execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: 235 - """Execute a planner-allowed tool.""" 236 - if tool_name not in PLANNER_TOOLS: 237 - return f"Tool not available to planner: {tool_name}" 238 - return execute_tool(tool_name, tool_input, ctx) 239 - 240 - 241 - SEEDER_TOOLS = {"establish", "set_scene"} 242 - SEEDER_TOOL_DEFINITIONS = [t for t in TOOL_DEFINITIONS if t["name"] in SEEDER_TOOLS] 25 + from storied.tools.entities import _load_entity 243 26 244 - 245 - def seeder_execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: 246 - """Execute a seeder-allowed tool.""" 247 - if tool_name not in SEEDER_TOOLS: 248 - return f"Tool not available to seeder: {tool_name}" 249 - return execute_tool(tool_name, tool_input, ctx) 250 - 251 - 252 - ADVANCEMENT_TOOLS = {"recall", "update_character", "notify_dm"} 253 - ADVANCEMENT_TOOL_DEFINITIONS = [ 254 - t for t in TOOL_DEFINITIONS if t["name"] in ADVANCEMENT_TOOLS 27 + __all__ = [ 28 + "Combat", 29 + "Entities", 30 + "EntityIndex", 31 + "Lore", 32 + "Player", 33 + "StorageRoot", 34 + "Timekeeper", 35 + "ToolContext", 36 + "World", 37 + "_get_file_lock", 38 + "_load_entity", 39 + "_sync_player_hp", 40 + "init_ctx", 41 + "reset_ctx", 255 42 ] 256 - 257 - 258 - def advancement_execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: 259 - """Execute an advancement-evaluator-allowed tool.""" 260 - if tool_name not in ADVANCEMENT_TOOLS: 261 - return f"Tool not available to advancement evaluator: {tool_name}" 262 - return execute_tool(tool_name, tool_input, ctx)
+125 -5
src/storied/tools/_context.py
··· 4 4 from dataclasses import dataclass, field 5 5 from pathlib import Path 6 6 7 + from uncalled_for import Dependency 8 + 7 9 from storied.character import update_character as char_update 8 10 from storied.initiative import InitiativeTracker 9 11 from storied.log import CampaignLog ··· 60 62 class ToolContext: 61 63 """Shared infrastructure for all tool calls. 62 64 63 - Created once per MCP server and passed to every tool invocation. 64 - All fields are required — no defensive None checks in tool code. 65 + Created once per process via init_ctx() and exposed to tools through 66 + the Dependency subclasses below. Tools should never reach for the 67 + full ToolContext — they ask for the specific slices they need. 65 68 """ 66 69 67 70 world_id: str ··· 73 76 initiative: InitiativeTracker = field(default_factory=InitiativeTracker) 74 77 75 78 76 - def _sync_player_hp(target: str, ctx: ToolContext, result: str) -> str: 79 + # --- Process-global ToolContext --------------------------------------------- 80 + 81 + _ctx: ToolContext | None = None 82 + 83 + 84 + def init_ctx( 85 + world_id: str, 86 + player_id: str, 87 + base_path: Path, 88 + campaign_log: CampaignLog, 89 + entity_index: EntityIndex, 90 + vector_index: VectorIndex, 91 + ) -> ToolContext: 92 + """Initialize the process-global ToolContext. 93 + 94 + Idempotent — last writer wins. Tests use this to reset state between cases. 95 + """ 96 + global _ctx 97 + _ctx = ToolContext( 98 + world_id=world_id, 99 + player_id=player_id, 100 + base_path=base_path, 101 + campaign_log=campaign_log, 102 + entity_index=entity_index, 103 + vector_index=vector_index, 104 + ) 105 + return _ctx 106 + 107 + 108 + def reset_ctx() -> None: 109 + """Clear the process-global ToolContext (for test teardown).""" 110 + global _ctx 111 + _ctx = None 112 + 113 + 114 + def _require() -> ToolContext: 115 + if _ctx is None: 116 + raise RuntimeError("ToolContext not initialized; call init_ctx() first") 117 + return _ctx 118 + 119 + 120 + # --- Class-based dependencies for each ToolContext slice -------------------- 121 + # 122 + # Each Dependency subclass exposes one piece of process state at the parameter 123 + # site. Tools declare only the slices they touch, e.g.: 124 + # 125 + # def recall(query: str, lore: VectorIndex = Lore()) -> str: ... 126 + # 127 + # FastMCP's schema introspection skips parameters whose default is a 128 + # Dependency instance, so the LLM never sees them. 129 + 130 + 131 + class World(Dependency[str]): 132 + """The current world ID.""" 133 + single = True 134 + 135 + async def __aenter__(self) -> str: 136 + return _require().world_id 137 + 138 + 139 + class Player(Dependency[str]): 140 + """The current player ID.""" 141 + single = True 142 + 143 + async def __aenter__(self) -> str: 144 + return _require().player_id 145 + 146 + 147 + class StorageRoot(Dependency[Path]): 148 + """The filesystem root for worlds, players, and rules.""" 149 + single = True 150 + 151 + async def __aenter__(self) -> Path: 152 + return _require().base_path 153 + 154 + 155 + class Timekeeper(Dependency[CampaignLog]): 156 + """The campaign log: game time, world events, recent history.""" 157 + single = True 158 + 159 + async def __aenter__(self) -> CampaignLog: 160 + return _require().campaign_log 161 + 162 + 163 + class Entities(Dependency[EntityIndex]): 164 + """The entity name→path index with parsed-entity cache.""" 165 + single = True 166 + 167 + async def __aenter__(self) -> EntityIndex: 168 + return _require().entity_index 169 + 170 + 171 + class Lore(Dependency[VectorIndex]): 172 + """The semantic search index over rules + world content.""" 173 + single = True 174 + 175 + async def __aenter__(self) -> VectorIndex: 176 + return _require().vector_index 177 + 178 + 179 + class Combat(Dependency[InitiativeTracker]): 180 + """The active initiative tracker.""" 181 + single = True 182 + 183 + async def __aenter__(self) -> InitiativeTracker: 184 + return _require().initiative 185 + 186 + 187 + # --- Helpers --------------------------------------------------------------- 188 + 189 + 190 + def _sync_player_hp( 191 + target: str, 192 + initiative: InitiativeTracker, 193 + player_id: str, 194 + base_path: Path, 195 + result: str, 196 + ) -> str: 77 197 """Auto-sync player character sheet when damage/heal targets a player.""" 78 - combatant = ctx.initiative._find(target) 198 + combatant = initiative._find(target) 79 199 if combatant and combatant.is_player: 80 - char_update(ctx.player_id, {"state.hp.current": combatant.hp}, ctx.base_path) 200 + char_update(player_id, {"state.hp.current": combatant.hp}, base_path) 81 201 result += f" (character sheet synced to {combatant.hp} HP)" 82 202 return result
+203 -103
src/storied/tools/character.py
··· 1 1 """Character management tools — bookkeeping primitives for the DM.""" 2 2 3 + from pathlib import Path 4 + from typing import Literal 5 + 6 + from fastmcp import FastMCP 7 + from pydantic import JsonValue 8 + 9 + from storied.character.schema import Abilities, CoinDelta, Purse 3 10 from storied.character import ( 4 11 add_condition as char_add_condition, 5 12 ) 6 - from storied.tools.character_schemas import SCHEMAS 7 13 from storied.character import ( 8 14 add_effect as char_add_effect, 9 15 ) ··· 26 32 heal as char_heal, 27 33 ) 28 34 from storied.character import ( 35 + load_character, 36 + ) 37 + from storied.character import ( 29 38 remove_condition as char_remove_condition, 30 39 ) 31 40 from storied.character import ( ··· 49 58 from storied.character import ( 50 59 use_resource as char_use_resource, 51 60 ) 52 - from storied.tools._context import ToolContext 61 + from storied.initiative import InitiativeTracker 62 + from storied.log import CampaignLog 63 + from storied.tools._context import ( 64 + Combat, 65 + Player, 66 + StorageRoot, 67 + Timekeeper, 68 + _sync_player_hp, 69 + ) 70 + 71 + mcp = FastMCP("character") 53 72 54 73 55 74 # --- Universal field setter --- 56 75 57 76 58 - def update_character(updates: dict, ctx: ToolContext) -> str: 77 + @mcp.tool(tags={"dm", "advancement", "character"}) 78 + def update_character( 79 + updates: dict[str, JsonValue], 80 + player: str = Player(), 81 + root: Path = StorageRoot(), 82 + ) -> str: 59 83 """Update arbitrary fields on the character sheet via dot notation. 60 84 61 85 Use this for any change that doesn't have a more specific tool. For HP, 62 86 coins, items, effects, etc., prefer the dedicated tools (damage, heal, 63 87 adjust_coins, add_item, add_effect, ...) — they prevent arithmetic errors. 64 88 89 + Keys are dot-paths into character.yaml, e.g.: 90 + - {"identity.classes.0.level": 4} — level up 91 + - {"state.hp.max": 30} — increase max HP 92 + - {"state.exhaustion": 1} — set exhaustion level 93 + - {"state.death_saves.successes": 2} — record a death save 94 + - {"proficiencies.skills.persuasion": "proficient"} 95 + - {"advancement_ready": null} — clear advancement flag 96 + 97 + The result is validated against the character schema before being saved. 98 + If a write would corrupt the schema (e.g. replacing the resources dict 99 + with a list), the update is rejected and the file on disk is unchanged. 100 + 65 101 Args: 66 - updates: Fields to update by dot path. Examples: 67 - - {"identity.classes.0.level": 4} — level up 68 - - {"state.hp.max": 30} — increase max HP 69 - - {"state.exhaustion": 1} — set exhaustion level 70 - - {"state.death_saves.successes": 2} — record a death save 71 - - {"proficiencies.skills.persuasion": "proficient"} — add proficiency 72 - - {"advancement_ready": null} — clear advancement flag 73 - - {"features": [...]} — replace features list 102 + updates: Map of dot-path → new value. Values can be any JSON-compatible 103 + type (string, number, boolean, null, list, or nested object). 74 104 75 105 Returns: 76 - Confirmation of what was updated 106 + Confirmation of what was updated, or a rejection error. 77 107 """ 78 - return char_update(ctx.player_id, updates, ctx.base_path) 108 + return char_update(player, updates, root) 79 109 80 110 111 + @mcp.tool(tags={"dm", "character"}) 81 112 def create_character( 82 113 name: str, 83 114 race: str, 84 115 char_class: str, 85 116 level: int, 86 - abilities: dict[str, int], 117 + abilities: Abilities, 87 118 hp_max: int, 88 119 ac: int, 89 - ctx: ToolContext, 90 120 background: str | None = None, 91 121 speed: int = 30, 92 - purse: dict[str, int] | None = None, 122 + purse: Purse | None = None, 93 123 subclass: str | None = None, 94 124 backstory: str | None = None, 125 + player: str = Player(), 126 + root: Path = StorageRoot(), 95 127 ) -> str: 96 128 """Create a new player character with the new structured schema. 97 129 ··· 102 134 race: Race (e.g., "Human", "High Elf") 103 135 char_class: Class (e.g., "Fighter", "Wizard") 104 136 level: Starting level (usually 1) 105 - abilities: All six ability scores 137 + abilities: All six ability scores (strength/dexterity/constitution/ 138 + intelligence/wisdom/charisma) 106 139 hp_max: Maximum hit points 107 140 ac: Armor class 108 141 background: Background (e.g., "Soldier", "Criminal") 109 142 speed: Movement speed in feet 110 - purse: Starting coins {cp, sp, ep, gp, pp} 143 + purse: Starting coins (cp/sp/ep/gp/pp); omit for empty purse 111 144 subclass: Subclass if chosen 112 145 backstory: Character backstory and personality (goes in character.md) 113 146 ··· 115 148 Confirmation message 116 149 """ 117 150 return char_create( 118 - player_id=ctx.player_id, 151 + player_id=player, 119 152 name=name, 120 153 race=race, 121 154 char_class=char_class, 122 155 level=level, 123 - abilities=abilities, 156 + abilities=abilities.model_dump(), 124 157 hp_max=hp_max, 125 158 ac=ac, 126 159 background=background, 127 160 speed=speed, 128 - purse=purse, 161 + purse=purse.model_dump() if purse else None, 129 162 subclass=subclass, 130 163 backstory=backstory, 131 - base_path=ctx.base_path, 164 + base_path=root, 132 165 ) 133 166 134 167 135 - # --- HP operations --- 168 + # --- HP operations (unified — work in or out of combat) --- 136 169 137 170 138 - def damage(amount: int, ctx: ToolContext, type: str | None = None) -> str: 139 - """Apply damage to the character. Temp HP absorbs first, then current HP. 171 + @mcp.tool(tags={"dm", "character"}) 172 + def damage( 173 + target: str, 174 + amount: int, 175 + type: str | None = None, 176 + combat: InitiativeTracker = Combat(), 177 + player: str = Player(), 178 + root: Path = StorageRoot(), 179 + ) -> str: 180 + """Apply damage to a named target. 181 + 182 + Routes by name: if `target` matches a current combatant in initiative, 183 + the damage hits the combatant (and syncs to the character sheet if it's 184 + the player). Otherwise, if `target` matches the player character's name, 185 + the damage hits the player character sheet directly. Temp HP absorbs 186 + first, then current HP. 140 187 141 188 Args: 189 + target: Combatant or player character name 142 190 amount: Damage amount (non-negative) 143 191 type: Optional damage type (fire, cold, slashing, etc.) for narration 144 192 145 193 Returns: 146 194 Damage taken and remaining HP 147 195 """ 148 - return char_damage(ctx.player_id, amount, damage_type=type, base_path=ctx.base_path) 196 + if combat.active and combat._find(target) is not None: 197 + result = combat.apply_damage(target, amount) 198 + return _sync_player_hp(target, combat, player, root, result) 149 199 200 + char = load_character(player, root) 201 + if char and char["identity"]["name"] == target: 202 + return char_damage(player, amount, damage_type=type, base_path=root) 150 203 151 - def heal(amount: int, ctx: ToolContext) -> str: 152 - """Heal the character. Clamped to max HP. 204 + return f"No such target: {target}" 205 + 206 + 207 + @mcp.tool(tags={"dm", "character"}) 208 + def heal( 209 + target: str, 210 + amount: int, 211 + combat: InitiativeTracker = Combat(), 212 + player: str = Player(), 213 + root: Path = StorageRoot(), 214 + ) -> str: 215 + """Heal a named target. 216 + 217 + Routes by name: if `target` matches a current combatant, heals the 218 + combatant (and syncs to the character sheet if it's the player). 219 + Otherwise, if `target` matches the player character, heals the 220 + character sheet directly. Clamped to max HP. 153 221 154 222 Args: 223 + target: Combatant or player character name 155 224 amount: HP to restore (non-negative) 156 225 157 226 Returns: 158 227 Healing applied and current HP 159 228 """ 160 - return char_heal(ctx.player_id, amount, base_path=ctx.base_path) 229 + if combat.active and combat._find(target) is not None: 230 + result = combat.apply_heal(target, amount) 231 + return _sync_player_hp(target, combat, player, root, result) 232 + 233 + char = load_character(player, root) 234 + if char and char["identity"]["name"] == target: 235 + return char_heal(player, amount, base_path=root) 236 + 237 + return f"No such target: {target}" 161 238 162 239 163 240 # --- Coin operations --- 164 241 165 242 166 - def adjust_coins(deltas: dict[str, int], ctx: ToolContext) -> str: 243 + @mcp.tool(tags={"dm", "character"}) 244 + def adjust_coins( 245 + deltas: CoinDelta, 246 + player: str = Player(), 247 + root: Path = StorageRoot(), 248 + ) -> str: 167 249 """Adjust the player's coins by relative amounts. 168 250 169 - Use negative values to spend, positive to gain. Coins are clamped to 0. 251 + Use negative values to spend, positive to gain. Omit any denomination 252 + you don't want to change. Coins are clamped to zero on the underlying purse. 170 253 171 254 Args: 172 - deltas: Coin changes by denomination (cp/sp/ep/gp/pp). 173 - Examples: {"gp": -5}, {"gp": 10, "sp": 5} 255 + deltas: Coin changes per denomination (cp/sp/ep/gp/pp). 256 + Example: spend 5 gp → {"gp": -5}; gain 10 gp + 5 sp → {"gp": 10, "sp": 5} 174 257 175 258 Returns: 176 259 Summary of changes and new purse balance 177 260 """ 178 - return char_adjust_coins(ctx.player_id, deltas, ctx.base_path) 261 + # Drop zero-deltas before passing to the underlying op so the result 262 + # message only mentions denominations the DM actually touched. 263 + nonzero = {k: v for k, v in deltas.model_dump().items() if v != 0} 264 + return char_adjust_coins(player, nonzero, root) 179 265 180 266 181 267 # --- Effect operations --- 182 268 183 269 270 + @mcp.tool(tags={"dm", "character"}) 184 271 def add_effect( 185 272 source: str, 186 273 description: str, 187 - ctx: ToolContext, 188 274 expires: str | None = None, 275 + player: str = Player(), 276 + root: Path = StorageRoot(), 189 277 ) -> str: 190 278 """Track a temporary effect on the character. 191 279 ··· 200 288 Returns: 201 289 Confirmation 202 290 """ 203 - return char_add_effect( 204 - ctx.player_id, source, description, expires=expires, base_path=ctx.base_path 205 - ) 291 + return char_add_effect(player, source, description, expires=expires, base_path=root) 206 292 207 293 208 - def remove_effect(source: str, ctx: ToolContext) -> str: 294 + @mcp.tool(tags={"dm", "character"}) 295 + def remove_effect( 296 + source: str, 297 + player: str = Player(), 298 + root: Path = StorageRoot(), 299 + ) -> str: 209 300 """Remove an effect by source name (case-insensitive substring match). 210 301 211 302 Args: ··· 214 305 Returns: 215 306 Confirmation of what was removed 216 307 """ 217 - return char_remove_effect(ctx.player_id, source, base_path=ctx.base_path) 308 + return char_remove_effect(player, source, base_path=root) 218 309 219 310 220 311 # --- Condition operations --- 221 312 222 313 223 - def add_condition(name: str, ctx: ToolContext) -> str: 314 + @mcp.tool(tags={"dm", "character"}) 315 + def add_condition( 316 + name: str, 317 + player: str = Player(), 318 + root: Path = StorageRoot(), 319 + ) -> str: 224 320 """Mark a condition on the character (5e conditions or any custom name). 225 321 226 322 Args: ··· 229 325 Returns: 230 326 Confirmation 231 327 """ 232 - return char_add_condition(ctx.player_id, name, base_path=ctx.base_path) 328 + return char_add_condition(player, name, base_path=root) 233 329 234 330 235 - def remove_condition(name: str, ctx: ToolContext) -> str: 331 + @mcp.tool(tags={"dm", "character"}) 332 + def remove_condition( 333 + name: str, 334 + player: str = Player(), 335 + root: Path = StorageRoot(), 336 + ) -> str: 236 337 """Remove a condition from the character. 237 338 238 339 Args: ··· 241 342 Returns: 242 343 Confirmation 243 344 """ 244 - return char_remove_condition(ctx.player_id, name, base_path=ctx.base_path) 345 + return char_remove_condition(player, name, base_path=root) 245 346 246 347 247 348 # --- Inventory operations --- 248 349 249 350 250 - def add_item(item: str, ctx: ToolContext, location: str | None = None) -> str: 351 + @mcp.tool(tags={"dm", "character"}) 352 + def add_item( 353 + item: str, 354 + location: str | None = None, 355 + player: str = Player(), 356 + root: Path = StorageRoot(), 357 + ) -> str: 251 358 """Add a mundane item to the character's equipment. 252 359 253 360 Items are organized by location subsection (e.g., "on_person", "stashed_at_inn"). ··· 264 371 Returns: 265 372 Confirmation with the location it was added to 266 373 """ 267 - return char_add_item(ctx.player_id, item, location=location, base_path=ctx.base_path) 374 + return char_add_item(player, item, location=location, base_path=root) 268 375 269 376 270 - def remove_item(item: str, ctx: ToolContext) -> str: 377 + @mcp.tool(tags={"dm", "character"}) 378 + def remove_item( 379 + item: str, 380 + player: str = Player(), 381 + root: Path = StorageRoot(), 382 + ) -> str: 271 383 """Remove an item from the character's equipment by name. 272 384 273 385 Uses case-insensitive substring matching across all equipment subsections. ··· 279 391 Returns: 280 392 Confirmation with the item that was removed 281 393 """ 282 - return char_remove_item(ctx.player_id, item, base_path=ctx.base_path) 394 + return char_remove_item(player, item, base_path=root) 283 395 284 396 285 - def set_item_status(item: str, status: str, ctx: ToolContext) -> str: 286 - """Set a magic item's status (attuned, equipped, or carried). 397 + @mcp.tool(tags={"dm", "character"}) 398 + def set_item_status( 399 + item: str, 400 + status: Literal["attuned", "equipped", "carried"], 401 + player: str = Player(), 402 + root: Path = StorageRoot(), 403 + ) -> str: 404 + """Set a magic item's status. 287 405 288 406 The item should already exist as a world entity in worlds/{world}/items/. 289 407 This function manages where the wikilink lives in the character's ··· 291 409 292 410 Args: 293 411 item: Magic item name (matches the world entity name) 294 - status: One of "attuned", "equipped", or "carried" 412 + status: New status — one of attuned, equipped, or carried 295 413 296 414 Returns: 297 415 Confirmation 298 416 """ 299 - return char_set_item_status( 300 - ctx.player_id, item, status, base_path=ctx.base_path 301 - ) 417 + return char_set_item_status(player, item, status, base_path=root) 302 418 303 419 304 420 # --- Resource operations --- 305 421 306 422 307 - def use_resource(name: str, ctx: ToolContext, amount: int = 1) -> str: 423 + @mcp.tool(tags={"dm", "character"}) 424 + def use_resource( 425 + name: str, 426 + amount: int = 1, 427 + player: str = Player(), 428 + root: Path = StorageRoot(), 429 + ) -> str: 308 430 """Decrement a resource pool (rage uses, ki points, hit dice, magic item charges, etc.). 309 431 310 432 Substring match on the resource name. Resources are clamped to 0. ··· 316 438 Returns: 317 439 Confirmation with remaining count 318 440 """ 319 - return char_use_resource( 320 - ctx.player_id, name, amount=amount, base_path=ctx.base_path 321 - ) 441 + return char_use_resource(player, name, amount=amount, base_path=root) 322 442 323 443 324 - def restore_resource(name: str, amount: int, ctx: ToolContext) -> str: 444 + @mcp.tool(tags={"dm", "character"}) 445 + def restore_resource( 446 + name: str, 447 + amount: int, 448 + player: str = Player(), 449 + root: Path = StorageRoot(), 450 + ) -> str: 325 451 """Restore points to a resource pool, clamped to max. 326 452 327 453 Usually you'll use `rest` instead, which refreshes resources by their ··· 334 460 Returns: 335 461 Confirmation 336 462 """ 337 - return char_restore_resource( 338 - ctx.player_id, name, amount, base_path=ctx.base_path 339 - ) 463 + return char_restore_resource(player, name, amount, base_path=root) 340 464 341 465 342 - def rest(type: str, ctx: ToolContext) -> str: 466 + @mcp.tool(tags={"dm", "character"}) 467 + def rest( 468 + type: Literal["short", "long"], 469 + player: str = Player(), 470 + root: Path = StorageRoot(), 471 + ) -> str: 343 472 """Take a short or long rest. 344 473 345 474 Refreshes resources by refresh type (long rest also refreshes short_rest ··· 347 476 level, and restores HP to max. 348 477 349 478 Args: 350 - type: "short" or "long" 479 + type: Rest type — "short" or "long" 351 480 352 481 Returns: 353 482 Summary of what was refreshed 354 483 """ 355 - return char_rest(ctx.player_id, type, base_path=ctx.base_path) 484 + return char_rest(player, type, base_path=root) 356 485 357 486 358 487 # --- Notes --- 359 488 360 489 361 - def add_note(text: str, ctx: ToolContext) -> str: 490 + @mcp.tool(tags={"dm", "character"}) 491 + def add_note( 492 + text: str, 493 + timekeeper: CampaignLog = Timekeeper(), 494 + player: str = Player(), 495 + root: Path = StorageRoot(), 496 + ) -> str: 362 497 """Append a note to the player's notes.md journal. 363 498 364 499 Notes accumulate over time and are stamped with the current game time. ··· 370 505 Returns: 371 506 Confirmation 372 507 """ 373 - time_anchor = ctx.campaign_log.get_current_time().to_anchor() 374 - return char_add_note( 375 - ctx.player_id, text, time_anchor=time_anchor, base_path=ctx.base_path 376 - ) 377 - 378 - 379 - 380 - # --- Tool definitions for the API --- 381 - # Wrapper docstrings + schemas from character_schemas.py 382 - 383 - _WRAPPERS = { 384 - "update_character": update_character, 385 - "create_character": create_character, 386 - "damage": damage, 387 - "heal": heal, 388 - "adjust_coins": adjust_coins, 389 - "add_effect": add_effect, 390 - "remove_effect": remove_effect, 391 - "add_condition": add_condition, 392 - "remove_condition": remove_condition, 393 - "add_item": add_item, 394 - "remove_item": remove_item, 395 - "set_item_status": set_item_status, 396 - "use_resource": use_resource, 397 - "restore_resource": restore_resource, 398 - "rest": rest, 399 - "add_note": add_note, 400 - } 401 - 402 - DEFINITIONS: list[dict] = [ 403 - { 404 - "name": s["name"], 405 - "description": _WRAPPERS[s["name"]].__doc__, 406 - "input_schema": s["input_schema"], 407 - } 408 - for s in SCHEMAS 409 - ] 508 + time_anchor = timekeeper.get_current_time().to_anchor() 509 + return char_add_note(player, text, time_anchor=time_anchor, base_path=root)
-214
src/storied/tools/character_schemas.py
··· 1 - """JSON schemas for character tool definitions. 2 - 3 - Imported by tools/character.py to construct the DEFINITIONS list. 4 - The descriptions come from the wrapper function docstrings, so this 5 - file holds only the input_schema portion plus the tool name. 6 - """ 7 - 8 - 9 - SCHEMAS: list[dict] = [ 10 - { 11 - "name": "update_character", 12 - "input_schema": { 13 - "type": "object", 14 - "properties": { 15 - "updates": { 16 - "type": "object", 17 - "description": "Fields to update by dot path (e.g., 'state.hp.max', 'identity.classes.0.level')", 18 - }, 19 - }, 20 - "required": ["updates"], 21 - }, 22 - }, 23 - { 24 - "name": "create_character", 25 - "input_schema": { 26 - "type": "object", 27 - "properties": { 28 - "name": {"type": "string"}, 29 - "race": {"type": "string"}, 30 - "char_class": {"type": "string"}, 31 - "level": {"type": "integer"}, 32 - "abilities": {"type": "object"}, 33 - "hp_max": {"type": "integer"}, 34 - "ac": {"type": "integer"}, 35 - "background": {"type": "string"}, 36 - "speed": {"type": "integer"}, 37 - "subclass": {"type": "string"}, 38 - "purse": { 39 - "type": "object", 40 - "properties": { 41 - "cp": {"type": "integer"}, "sp": {"type": "integer"}, 42 - "ep": {"type": "integer"}, "gp": {"type": "integer"}, 43 - "pp": {"type": "integer"}, 44 - }, 45 - }, 46 - "backstory": {"type": "string"}, 47 - }, 48 - "required": ["name", "race", "char_class", "level", "abilities", "hp_max", "ac"], 49 - }, 50 - }, 51 - { 52 - "name": "damage", 53 - "input_schema": { 54 - "type": "object", 55 - "properties": { 56 - "amount": {"type": "integer", "description": "Damage amount (non-negative)"}, 57 - "type": {"type": "string", "description": "Damage type (fire, cold, slashing, etc.)"}, 58 - }, 59 - "required": ["amount"], 60 - }, 61 - }, 62 - { 63 - "name": "heal", 64 - "input_schema": { 65 - "type": "object", 66 - "properties": { 67 - "amount": {"type": "integer", "description": "HP to restore"}, 68 - }, 69 - "required": ["amount"], 70 - }, 71 - }, 72 - { 73 - "name": "adjust_coins", 74 - "input_schema": { 75 - "type": "object", 76 - "properties": { 77 - "deltas": { 78 - "type": "object", 79 - "description": "Coin deltas: negative=spend, positive=gain", 80 - "properties": { 81 - "cp": {"type": "integer"}, "sp": {"type": "integer"}, 82 - "ep": {"type": "integer"}, "gp": {"type": "integer"}, 83 - "pp": {"type": "integer"}, 84 - }, 85 - }, 86 - }, 87 - "required": ["deltas"], 88 - }, 89 - }, 90 - { 91 - "name": "add_effect", 92 - "input_schema": { 93 - "type": "object", 94 - "properties": { 95 - "source": {"type": "string", "description": "Where the effect comes from"}, 96 - "description": {"type": "string", "description": "What the effect does"}, 97 - "expires": {"type": "string", "description": "Optional game time anchor when the effect ends"}, 98 - }, 99 - "required": ["source", "description"], 100 - }, 101 - }, 102 - { 103 - "name": "remove_effect", 104 - "input_schema": { 105 - "type": "object", 106 - "properties": { 107 - "source": {"type": "string", "description": "Effect source name (substring match)"}, 108 - }, 109 - "required": ["source"], 110 - }, 111 - }, 112 - { 113 - "name": "add_condition", 114 - "input_schema": { 115 - "type": "object", 116 - "properties": { 117 - "name": {"type": "string", "description": "Condition name"}, 118 - }, 119 - "required": ["name"], 120 - }, 121 - }, 122 - { 123 - "name": "remove_condition", 124 - "input_schema": { 125 - "type": "object", 126 - "properties": { 127 - "name": {"type": "string", "description": "Condition name"}, 128 - }, 129 - "required": ["name"], 130 - }, 131 - }, 132 - { 133 - "name": "add_item", 134 - "input_schema": { 135 - "type": "object", 136 - "properties": { 137 - "item": {"type": "string", "description": "Item description"}, 138 - "location": {"type": "string", "description": "Optional location subsection"}, 139 - }, 140 - "required": ["item"], 141 - }, 142 - }, 143 - { 144 - "name": "remove_item", 145 - "input_schema": { 146 - "type": "object", 147 - "properties": { 148 - "item": {"type": "string", "description": "Item name (substring match)"}, 149 - }, 150 - "required": ["item"], 151 - }, 152 - }, 153 - { 154 - "name": "set_item_status", 155 - "input_schema": { 156 - "type": "object", 157 - "properties": { 158 - "item": {"type": "string", "description": "Magic item entity name"}, 159 - "status": { 160 - "type": "string", 161 - "enum": ["attuned", "equipped", "carried"], 162 - "description": "New status for the item", 163 - }, 164 - }, 165 - "required": ["item", "status"], 166 - }, 167 - }, 168 - { 169 - "name": "use_resource", 170 - "input_schema": { 171 - "type": "object", 172 - "properties": { 173 - "name": {"type": "string", "description": "Resource name (substring match)"}, 174 - "amount": {"type": "integer", "description": "How many to use (default 1)"}, 175 - }, 176 - "required": ["name"], 177 - }, 178 - }, 179 - { 180 - "name": "restore_resource", 181 - "input_schema": { 182 - "type": "object", 183 - "properties": { 184 - "name": {"type": "string", "description": "Resource name"}, 185 - "amount": {"type": "integer", "description": "How many to restore"}, 186 - }, 187 - "required": ["name", "amount"], 188 - }, 189 - }, 190 - { 191 - "name": "rest", 192 - "input_schema": { 193 - "type": "object", 194 - "properties": { 195 - "type": { 196 - "type": "string", 197 - "enum": ["short", "long"], 198 - "description": "Rest type", 199 - }, 200 - }, 201 - "required": ["type"], 202 - }, 203 - }, 204 - { 205 - "name": "add_note", 206 - "input_schema": { 207 - "type": "object", 208 - "properties": { 209 - "text": {"type": "string", "description": "Note text"}, 210 - }, 211 - "required": ["text"], 212 - }, 213 - }, 214 - ]
+181
src/storied/tools/combat.py
··· 1 + """FastMCP combat tool surface — initiative tools that flip the visibility 2 + of `combat`-tagged tools on the composed top-level server. 3 + 4 + Damage and heal live in tools/character.py — they're routed by name to the 5 + right place there. The tools here only modify combat order and conditions. 6 + """ 7 + 8 + from __future__ import annotations 9 + 10 + from typing import TYPE_CHECKING, Literal 11 + 12 + from fastmcp import FastMCP 13 + from pydantic import BaseModel, Field 14 + 15 + from storied.initiative import Combatant, InitiativeTracker 16 + from storied.tools._context import Combat 17 + 18 + if TYPE_CHECKING: 19 + from fastmcp import FastMCP as _FastMCP 20 + 21 + mcp = FastMCP("combat") 22 + 23 + 24 + class CombatantInput(BaseModel): 25 + """Schema for one combatant entry passed to enter_initiative. 26 + 27 + FastMCP introspects this model and exposes the field shape to the LLM, 28 + so the DM doesn't have to guess which keys are required. 29 + """ 30 + 31 + name: str = Field(description="Combatant display name") 32 + initiative: int = Field(description="Initiative roll total (used for turn order)") 33 + hp: int = Field(description="Current hit points") 34 + hp_max: int = Field(description="Maximum hit points") 35 + ac: int = Field(description="Armor class") 36 + is_player: bool = Field( 37 + default=False, 38 + description="True for the player character; false for NPCs and monsters", 39 + ) 40 + 41 + # The composed top-level server and the set of combat tool keys to hide 42 + # when leaving combat — both registered at start_server() time so the 43 + # enter/end_initiative tools can flip combat tag visibility on the parent. 44 + _root: "_FastMCP | None" = None 45 + _combat_keys_to_hide: set[str] = set() 46 + 47 + 48 + def set_root(root: "_FastMCP", combat_keys_to_hide: set[str]) -> None: 49 + """Register the composed top-level FastMCP server. 50 + 51 + Called from mcp_server.start_server() after composition so that 52 + enter_initiative / end_initiative can call _root.disable/enable on 53 + the parent server (where claude actually sees tools). 54 + 55 + `combat_keys_to_hide` is the precomputed set of keys for tools tagged 56 + `combat` but not `combat_control` — i.e. the tools that should appear 57 + only while initiative is active. 58 + """ 59 + global _root, _combat_keys_to_hide 60 + _root = root 61 + _combat_keys_to_hide = combat_keys_to_hide 62 + 63 + 64 + def _flip_into_combat() -> None: 65 + """Hide narrative_only tools, show combat tools.""" 66 + if _root is None: 67 + return 68 + _root.disable(tags={"narrative_only"}) 69 + if _combat_keys_to_hide: 70 + _root.enable(keys=_combat_keys_to_hide) 71 + 72 + 73 + def _flip_out_of_combat() -> None: 74 + """Show narrative_only tools, hide combat tools (except combat_control).""" 75 + if _root is None: 76 + return 77 + _root.enable(tags={"narrative_only"}) 78 + if _combat_keys_to_hide: 79 + _root.disable(keys=_combat_keys_to_hide) 80 + 81 + 82 + @mcp.tool(tags={"dm", "combat", "combat_control"}) 83 + def enter_initiative( 84 + combatants: list[CombatantInput], 85 + combat: InitiativeTracker = Combat(), 86 + ) -> str: 87 + """Enter initiative mode for combat or any turn-based encounter. 88 + 89 + Provide all participants in their desired turn order (you handle 90 + tie-breaking). Roll initiative for everyone first, then call this. 91 + Each combatant needs name, initiative, hp, hp_max, ac, and optionally 92 + is_player. Initiative tools become available on the next turn. 93 + """ 94 + if combat.active: 95 + return "Initiative is already active. Call end_initiative first." 96 + 97 + parsed = [ 98 + Combatant( 99 + name=c.name, 100 + initiative=c.initiative, 101 + hp=c.hp, 102 + hp_max=c.hp_max, 103 + ac=c.ac, 104 + is_player=c.is_player, 105 + ) 106 + for c in combatants 107 + ] 108 + result = combat.begin(parsed) 109 + _flip_into_combat() 110 + return result 111 + 112 + 113 + @mcp.tool(tags={"dm", "combat"}) 114 + def next_turn(combat: InitiativeTracker = Combat()) -> str: 115 + """Advance to the next combatant's turn. Skips defeated. Call after resolving actions.""" 116 + return combat.next_turn() 117 + 118 + 119 + @mcp.tool(tags={"dm", "combat"}) 120 + def add_combatant( 121 + name: str, 122 + initiative: int, 123 + hp: int, 124 + hp_max: int, 125 + ac: int, 126 + is_player: bool = False, 127 + combat: InitiativeTracker = Combat(), 128 + ) -> str: 129 + """Add a combatant (reinforcements, surprised creatures waking up).""" 130 + c = Combatant( 131 + name=name, initiative=initiative, hp=hp, 132 + hp_max=hp_max, ac=ac, is_player=is_player, 133 + ) 134 + return combat.add_combatant(c) 135 + 136 + 137 + @mcp.tool(tags={"dm", "combat"}) 138 + def remove_combatant( 139 + name: str, 140 + combat: InitiativeTracker = Combat(), 141 + ) -> str: 142 + """Remove a combatant who fled, was banished, or is otherwise out.""" 143 + return combat.remove_combatant(name) 144 + 145 + 146 + @mcp.tool(tags={"dm", "combat"}) 147 + def condition( 148 + target: str, 149 + condition: str, 150 + action: Literal["add", "remove"] = "add", 151 + duration: int = -1, 152 + ends_on: Literal["start", "end"] = "start", 153 + source: str = "", 154 + combat: InitiativeTracker = Combat(), 155 + ) -> str: 156 + """Add or remove a condition on a combatant. 157 + 158 + Duration counts down on the source's turn. Duration -1 = until manually removed. 159 + 160 + Args: 161 + target: Combatant name 162 + condition: Condition name (Prone, Stunned, Frightened, etc.) 163 + action: "add" to apply the condition, "remove" to clear it 164 + duration: Rounds until expiry; -1 means until manually removed 165 + ends_on: Whether duration ticks down at "start" or "end" of source's turn 166 + source: Who caused the condition 167 + """ 168 + if action == "remove": 169 + return combat.remove_condition(target, condition) 170 + return combat.add_condition( 171 + target=target, condition=condition, 172 + duration=duration, ends_on=ends_on, source=source, 173 + ) 174 + 175 + 176 + @mcp.tool(tags={"dm", "combat", "combat_control"}) 177 + def end_initiative(combat: InitiativeTracker = Combat()) -> str: 178 + """End initiative and return to narrative. Returns summary with rounds, defeated, and survivor HP.""" 179 + result = combat.end() 180 + _flip_out_of_combat() 181 + return result
+215 -227
src/storied/tools/entities.py
··· 2 2 3 3 import re 4 4 from pathlib import Path 5 + from typing import Literal 5 6 6 7 import yaml 8 + from fastmcp import FastMCP 7 9 10 + from storied.log import CampaignLog 11 + from storied.search import VectorIndex 8 12 from storied.session import name_to_slug 9 - from storied.tools._context import EntityIndex, ToolContext, _get_file_lock 10 - 11 - 12 - def establish( 13 - entity_type: str, 14 - name: str, 15 - ctx: ToolContext, 16 - description: str | None = None, 17 - location: str | None = None, 18 - knows: list[str] | None = None, 19 - wants: list[str] | None = None, 20 - will: list[str] | None = None, 21 - ) -> str: 22 - """Establish or update an entity in the world. 23 - 24 - Use to create NPCs, locations, items, factions, or threads with their inner 25 - state. Everything has Knows/Wants/Will: 26 - - **Knows** = secrets, hidden truths, what isn't obvious 27 - - **Wants** = nature, tendencies, inclinations (even non-sentient things can "want") 28 - - **Will** = conditional triggers, what happens if... 29 - 30 - This isn't literal consciousness - it's narrative tendency. A bridge can "want" 31 - to collapse. Cursed gold "wants" to be spent. Frame it this way and the world 32 - feels alive. 33 - 34 - Partial updates: omit fields to preserve existing content when updating. 35 - 36 - Args: 37 - entity_type: Type of entity: npcs, locations, items, factions, threads, lore 38 - name: Display name (e.g., "Vera Blackwater", "The Rusty Anchor") 39 - This becomes the filename directly (no slugification). 40 - description: Prose description for the ## Is section. Include appearance, 41 - background, current state, relationships via [[wikilinks]]. 42 - location: Where this entity is right now. Can be a simple wikilink like 43 - "[[The Rusty Anchor]]" or a verbal description like "In the basement 44 - of [[The Rusty Anchor]]" or "Wandering the docks of [[Greyhaven]]". 45 - knows: List of secrets and hidden truths. Things that aren't obvious. 46 - wants: List of desires, tendencies, inclinations. The entity's nature. 47 - will: List of conditional behaviors: "If X -> Y" format. 48 - 49 - Returns: 50 - Confirmation with the file path 51 - """ 52 - world_dir = ctx.base_path / "worlds" / ctx.world_id / entity_type 53 - world_dir.mkdir(parents=True, exist_ok=True) 54 - file_path = world_dir / f"{name}.md" 13 + from storied.tools._context import ( 14 + Entities, 15 + EntityIndex, 16 + Lore, 17 + Player, 18 + StorageRoot, 19 + Timekeeper, 20 + World, 21 + _get_file_lock, 22 + ) 55 23 56 - lock = _get_file_lock(file_path) 57 - with lock: 58 - existing = _load_entity(file_path, ctx.entity_index) 24 + # Entity-type enums exposed to the LLM via JSON Schema. Each tool's set is 25 + # slightly different — only the kinds that make sense for that operation. 26 + EstablishType = Literal["npcs", "locations", "items", "factions", "threads", "lore"] 27 + MarkType = Literal["npcs", "locations", "items", "factions", "threads"] 28 + DiscoveryType = Literal["npcs", "locations", "factions", "lore"] 59 29 60 - if description is None: 61 - description = existing.get("description", "") 62 - if location is None: 63 - location = existing.get("location", "") 64 - if knows is None: 65 - knows = existing.get("knows", []) 66 - if wants is None: 67 - wants = existing.get("wants", []) 68 - if will is None: 69 - will = existing.get("will", []) 70 - was = existing.get("was", []) 30 + mcp = FastMCP("entities") 71 31 72 - data = { 73 - "description": description, "location": location, 74 - "knows": knows, "wants": wants, "will": will, "was": was, 75 - } 76 - _write_entity(file_path, name, entity_type, data, ctx) 77 32 78 - action = "Updated" if existing else "Established" 79 - return f"{action} {entity_type.rstrip('s')} '{name}'" 33 + # --- Internal bookkeeping helpers (no FastMCP, no Dependency) --------------- 80 34 81 35 82 36 def _load_entity(file_path: Path, entity_index: EntityIndex) -> dict: ··· 192 146 name: str, 193 147 entity_type: str, 194 148 data: dict, 195 - ctx: ToolContext, 149 + entity_index: EntityIndex, 150 + lore: VectorIndex, 196 151 ) -> None: 197 152 """Write an entity to disk and update all indexes.""" 198 153 file_content = _format_entity( ··· 200 155 data["knows"], data["wants"], data["will"], data["was"], 201 156 ) 202 157 file_path.write_text(file_content) 203 - ctx.entity_index.register(name, file_path) 204 - ctx.entity_index.cache_put(file_path, data) 205 - ctx.vector_index.upsert( 158 + entity_index.register(name, file_path) 159 + entity_index.cache_put(file_path, data) 160 + lore.upsert( 206 161 f"world:{entity_type}/{name}.md:0", 207 162 file_content, 208 163 {"source": "world", "content_type": entity_type, ··· 210 165 ) 211 166 212 167 168 + def _do_establish( 169 + entity_type: str, 170 + name: str, 171 + description: str | None, 172 + location: str | None, 173 + knows: list[str] | None, 174 + wants: list[str] | None, 175 + will: list[str] | None, 176 + base_path: Path, 177 + world_id: str, 178 + entity_index: EntityIndex, 179 + lore: VectorIndex, 180 + ) -> str: 181 + """Plain bookkeeping form of establish; called by both the FastMCP wrapper 182 + and any internal caller (e.g. world seeding).""" 183 + world_dir = base_path / "worlds" / world_id / entity_type 184 + world_dir.mkdir(parents=True, exist_ok=True) 185 + file_path = world_dir / f"{name}.md" 186 + 187 + lock = _get_file_lock(file_path) 188 + with lock: 189 + existing = _load_entity(file_path, entity_index) 190 + 191 + if description is None: 192 + description = existing.get("description", "") 193 + if location is None: 194 + location = existing.get("location", "") 195 + if knows is None: 196 + knows = existing.get("knows", []) 197 + if wants is None: 198 + wants = existing.get("wants", []) 199 + if will is None: 200 + will = existing.get("will", []) 201 + was = existing.get("was", []) 202 + 203 + data = { 204 + "description": description, "location": location, 205 + "knows": knows, "wants": wants, "will": will, "was": was, 206 + } 207 + _write_entity(file_path, name, entity_type, data, entity_index, lore) 208 + 209 + action = "Updated" if existing else "Established" 210 + return f"{action} {entity_type.rstrip('s')} '{name}'" 211 + 212 + 213 + def _do_mark( 214 + entity_type: str, 215 + name: str, 216 + event: str, 217 + resolves: list[str] | None, 218 + base_path: Path, 219 + world_id: str, 220 + entity_index: EntityIndex, 221 + lore: VectorIndex, 222 + timekeeper: CampaignLog, 223 + ) -> str: 224 + """Plain bookkeeping form of mark.""" 225 + file_path = entity_index.resolve(name) 226 + if file_path is None: 227 + file_path = base_path / "worlds" / world_id / entity_type / f"{name}.md" 228 + 229 + if not file_path.exists(): 230 + return f"Error: Entity '{name}' not found in {entity_type}" 231 + 232 + timestamp = timekeeper.get_current_time().to_anchor() 233 + 234 + lock = _get_file_lock(file_path) 235 + with lock: 236 + existing = _load_entity(file_path, entity_index) 237 + 238 + was = existing.get("was", []) 239 + was.append(f"{timestamp} | {event}") 240 + 241 + will = existing.get("will", []) 242 + resolved = [] 243 + for trigger in resolves or []: 244 + if trigger in will: 245 + will.remove(trigger) 246 + resolved.append(trigger) 247 + 248 + data = { 249 + "description": existing.get("description", ""), 250 + "location": existing.get("location", ""), 251 + "knows": existing.get("knows", []), 252 + "wants": existing.get("wants", []), 253 + "will": will, 254 + "was": was, 255 + } 256 + _write_entity(file_path, name, entity_type, data, entity_index, lore) 257 + 258 + result = f"Marked: {event}" 259 + if resolved: 260 + if len(resolved) == 1: 261 + result += f" (resolved: {resolved[0]})" 262 + else: 263 + result += f" (resolved {len(resolved)} triggers)" 264 + return result 265 + 266 + 213 267 def _auto_mark_present( 214 - present: list[str], event: str, ctx: ToolContext, 268 + present: list[str], 269 + event: str, 270 + base_path: Path, 271 + world_id: str, 272 + entity_index: EntityIndex, 273 + lore: VectorIndex, 274 + timekeeper: CampaignLog, 215 275 ) -> list[str]: 216 - """Auto-mark present entities with the current event.""" 276 + """Auto-mark present entities with the current event. 277 + 278 + Called by set_scene; uses _do_mark directly so it doesn't have to go 279 + through dependency resolution again. 280 + """ 217 281 marked: list[str] = [] 218 282 for ref in present: 219 283 link_match = re.search(r"\[\[([^\]]+)\]\]", ref) ··· 221 285 continue 222 286 name = link_match.group(1) 223 287 224 - file_path = ctx.entity_index.resolve(name) 288 + file_path = entity_index.resolve(name) 225 289 if file_path is None: 226 290 for etype in ("npcs", "locations", "items", "factions"): 227 - candidate = ctx.base_path / "worlds" / ctx.world_id / etype / f"{name}.md" 291 + candidate = base_path / "worlds" / world_id / etype / f"{name}.md" 228 292 if candidate.exists(): 229 293 file_path = candidate 230 294 break 231 295 232 296 if file_path and file_path.exists(): 233 297 entity_type = file_path.parent.name 234 - mark(entity_type=entity_type, name=name, event=event, ctx=ctx) 298 + _do_mark( 299 + entity_type, name, event, None, 300 + base_path, world_id, entity_index, lore, timekeeper, 301 + ) 235 302 marked.append(name) 236 303 237 304 return marked 238 305 239 306 307 + # --- FastMCP tool wrappers -------------------------------------------------- 308 + 309 + 310 + @mcp.tool(tags={"dm", "planner", "seeder"}) 311 + def establish( 312 + entity_type: EstablishType, 313 + name: str, 314 + description: str | None = None, 315 + location: str | None = None, 316 + knows: list[str] | None = None, 317 + wants: list[str] | None = None, 318 + will: list[str] | None = None, 319 + root: Path = StorageRoot(), 320 + world: str = World(), 321 + entities: EntityIndex = Entities(), 322 + lore: VectorIndex = Lore(), 323 + ) -> str: 324 + """Establish or update an entity in the world. 325 + 326 + Use to create NPCs, locations, items, factions, or threads with their inner 327 + state. Everything has Knows/Wants/Will: 328 + - **Knows** = secrets, hidden truths, what isn't obvious 329 + - **Wants** = nature, tendencies, inclinations (even non-sentient things can "want") 330 + - **Will** = conditional triggers, what happens if... 331 + 332 + This isn't literal consciousness - it's narrative tendency. A bridge can "want" 333 + to collapse. Cursed gold "wants" to be spent. Frame it this way and the world 334 + feels alive. 335 + 336 + Partial updates: omit fields to preserve existing content when updating. 337 + 338 + Args: 339 + entity_type: Type of entity: npcs, locations, items, factions, threads, lore 340 + name: Display name (e.g., "Vera Blackwater", "The Rusty Anchor") 341 + This becomes the filename directly (no slugification). 342 + description: Prose description for the ## Is section. Include appearance, 343 + background, current state, relationships via [[wikilinks]]. 344 + location: Where this entity is right now. Can be a simple wikilink like 345 + "[[The Rusty Anchor]]" or a verbal description like "In the basement 346 + of [[The Rusty Anchor]]" or "Wandering the docks of [[Greyhaven]]". 347 + knows: List of secrets and hidden truths. Things that aren't obvious. 348 + wants: List of desires, tendencies, inclinations. The entity's nature. 349 + will: List of conditional behaviors: "If X -> Y" format. 350 + 351 + Returns: 352 + Confirmation with the file path 353 + """ 354 + return _do_establish( 355 + entity_type, name, description, location, knows, wants, will, 356 + root, world, entities, lore, 357 + ) 358 + 359 + 360 + @mcp.tool(tags={"dm", "planner"}) 240 361 def mark( 241 - entity_type: str, 362 + entity_type: MarkType, 242 363 name: str, 243 364 event: str, 244 - ctx: ToolContext, 245 365 resolves: list[str] | None = None, 366 + root: Path = StorageRoot(), 367 + world: str = World(), 368 + entities: EntityIndex = Entities(), 369 + lore: VectorIndex = Lore(), 370 + timekeeper: CampaignLog = Timekeeper(), 246 371 ) -> str: 247 372 """Record an event in an entity's history (## Was section). 248 373 ··· 262 387 Returns: 263 388 Confirmation message 264 389 """ 265 - file_path = ctx.entity_index.resolve(name) 266 - if file_path is None: 267 - file_path = ctx.base_path / "worlds" / ctx.world_id / entity_type / f"{name}.md" 268 - 269 - if not file_path.exists(): 270 - return f"Error: Entity '{name}' not found in {entity_type}" 271 - 272 - timestamp = ctx.campaign_log.get_current_time().to_anchor() 273 - 274 - lock = _get_file_lock(file_path) 275 - with lock: 276 - existing = _load_entity(file_path, ctx.entity_index) 390 + return _do_mark( 391 + entity_type, name, event, resolves, 392 + root, world, entities, lore, timekeeper, 393 + ) 277 394 278 - was = existing.get("was", []) 279 - was.append(f"{timestamp} | {event}") 280 395 281 - will = existing.get("will", []) 282 - resolved = [] 283 - for trigger in resolves or []: 284 - if trigger in will: 285 - will.remove(trigger) 286 - resolved.append(trigger) 287 - 288 - data = { 289 - "description": existing.get("description", ""), 290 - "location": existing.get("location", ""), 291 - "knows": existing.get("knows", []), 292 - "wants": existing.get("wants", []), 293 - "will": will, 294 - "was": was, 295 - } 296 - _write_entity(file_path, name, entity_type, data, ctx) 297 - 298 - result = f"Marked: {event}" 299 - if resolved: 300 - if len(resolved) == 1: 301 - result += f" (resolved: {resolved[0]})" 302 - else: 303 - result += f" (resolved {len(resolved)} triggers)" 304 - return result 305 - 306 - 396 + @mcp.tool(tags={"dm"}) 307 397 def note_discovery( 308 398 entity: str, 309 399 content: str, 310 - ctx: ToolContext, 311 - content_type: str = "lore", 400 + content_type: DiscoveryType = "lore", 312 401 tags: list[str] | None = None, 402 + root: Path = StorageRoot(), 403 + world: str = World(), 404 + player: str = Player(), 405 + lore: VectorIndex = Lore(), 313 406 ) -> str: 314 407 """Record what the player has learned about something. 315 408 ··· 332 425 """ 333 426 slug = name_to_slug(entity) 334 427 335 - knowledge_dir = ( 336 - ctx.base_path / "players" / ctx.player_id / "worlds" 337 - / ctx.world_id / content_type 338 - ) 428 + knowledge_dir = root / "players" / player / "worlds" / world / content_type 339 429 knowledge_dir.mkdir(parents=True, exist_ok=True) 340 430 file_path = knowledge_dir / f"{slug}.md" 341 431 ··· 354 444 355 445 file_path.write_text(file_content) 356 446 357 - ctx.vector_index.upsert( 447 + lore.upsert( 358 448 f"player:{content_type}/{slug}.md:0", 359 449 file_content, 360 450 {"source": "player", "content_type": content_type, ··· 362 452 ) 363 453 364 454 return f"Noted: player learned about '{entity}'" 365 - 366 - 367 - DEFINITIONS: list[dict] = [ 368 - { 369 - "name": "establish", 370 - "description": establish.__doc__, 371 - "input_schema": { 372 - "type": "object", 373 - "properties": { 374 - "entity_type": { 375 - "type": "string", 376 - "description": "Type of entity", 377 - "enum": ["npcs", "locations", "items", "factions", "threads", "lore"], 378 - }, 379 - "name": { 380 - "type": "string", 381 - "description": "Display name (exact filename, e.g., 'Vera Blackwater')", 382 - }, 383 - "description": { 384 - "type": "string", 385 - "description": "Prose description with [[wikilinks]] for relationships", 386 - }, 387 - "location": { 388 - "type": "string", 389 - "description": "Current location (e.g., '[[The Rusty Anchor]]' or 'In the basement of [[The Rusty Anchor]]')", 390 - }, 391 - "knows": { 392 - "type": "array", 393 - "items": {"type": "string"}, 394 - "description": "Secrets, hidden truths - what isn't obvious", 395 - }, 396 - "wants": { 397 - "type": "array", 398 - "items": {"type": "string"}, 399 - "description": "Nature, tendencies, inclinations - even non-sentient things", 400 - }, 401 - "will": { 402 - "type": "array", 403 - "items": {"type": "string"}, 404 - "description": "Conditional behaviors in 'If X -> Y' format", 405 - }, 406 - }, 407 - "required": ["entity_type", "name"], 408 - }, 409 - }, 410 - { 411 - "name": "mark", 412 - "description": mark.__doc__, 413 - "input_schema": { 414 - "type": "object", 415 - "properties": { 416 - "entity_type": { 417 - "type": "string", 418 - "description": "Type of entity", 419 - "enum": ["npcs", "locations", "items", "factions", "threads"], 420 - }, 421 - "name": { 422 - "type": "string", 423 - "description": "Entity name (exact filename match)", 424 - }, 425 - "event": { 426 - "type": "string", 427 - "description": "What happened - brief description", 428 - }, 429 - "resolves": { 430 - "type": "array", 431 - "items": {"type": "string"}, 432 - "description": "Optional: Will items to remove if this event fired triggers", 433 - }, 434 - }, 435 - "required": ["entity_type", "name", "event"], 436 - }, 437 - }, 438 - { 439 - "name": "note_discovery", 440 - "description": note_discovery.__doc__, 441 - "input_schema": { 442 - "type": "object", 443 - "properties": { 444 - "entity": { 445 - "type": "string", 446 - "description": "Name of what they learned about (e.g., 'Vera Blackwater')", 447 - }, 448 - "content": { 449 - "type": "string", 450 - "description": "What the player learned or observed", 451 - }, 452 - "content_type": { 453 - "type": "string", 454 - "description": "Type of content", 455 - "enum": ["npcs", "locations", "factions", "lore"], 456 - }, 457 - "tags": { 458 - "type": "array", 459 - "items": {"type": "string"}, 460 - "description": "Tags for categorization", 461 - }, 462 - }, 463 - "required": ["entity", "content"], 464 - }, 465 - }, 466 - ]
+35 -86
src/storied/tools/mechanics.py
··· 1 - """Dice, rules lookup, and code execution tools.""" 1 + """Dice and rules-lookup tools.""" 2 2 3 3 from pathlib import Path 4 + from typing import Literal 5 + 6 + from fastmcp import FastMCP 4 7 5 8 from storied.dice import roll as dice_roll 6 - from storied.tools._context import ToolContext 9 + from storied.log import CampaignLog 10 + from storied.search import VectorIndex 11 + from storied.tools._context import Lore, Timekeeper 7 12 13 + mcp = FastMCP("mechanics") 8 14 9 - def roll(notation: str, reason: str | None = None) -> dict: 15 + 16 + @mcp.tool(tags={"dm"}) 17 + def roll(notation: str, reason: str) -> str: 10 18 """Roll dice using standard notation like '1d20', '2d6+3', '4d6kh3'. 11 19 12 20 Use for attack rolls, skill checks, saving throws, and damage rolls. ··· 18 26 "Attack with longsword", "Wisdom save", "Fireball damage") 19 27 20 28 Returns: 21 - Dict with rolls, kept dice, modifier, and total 29 + Formatted roll result string 22 30 """ 23 - result = dice_roll(notation) 24 - return result.to_dict() 31 + result = dice_roll(notation).to_dict() 32 + rolls_str = ", ".join(str(r) for r in result["rolls"]) 33 + if result["kept"] != result["rolls"]: 34 + kept_str = ", ".join(str(r) for r in result["kept"]) 35 + return ( 36 + f"Rolled {result['notation']}: [{rolls_str}] → " 37 + f"kept [{kept_str}] + {result['modifier']} = {result['total']}" 38 + ) 39 + elif result["modifier"]: 40 + return ( 41 + f"Rolled {result['notation']}: [{rolls_str}] + " 42 + f"{result['modifier']} = {result['total']}" 43 + ) 44 + else: 45 + return f"Rolled {result['notation']}: [{rolls_str}] = {result['total']}" 25 46 26 47 48 + @mcp.tool(tags={"dm", "planner", "advancement"}) 27 49 def recall( 28 50 query: str, 29 - ctx: ToolContext, 30 - scope: str = "all", 51 + scope: Literal["rules", "world", "all"] = "all", 31 52 content_type: str | None = None, 53 + lore: VectorIndex = Lore(), 54 + timekeeper: CampaignLog = Timekeeper(), 32 55 ) -> str: 33 56 """Look up rules, world content, or both. 34 57 ··· 39 62 40 63 Args: 41 64 query: What to look up (e.g., "fireball", "captain vex", "merchant guild") 42 - scope: Where to search - "rules", "world", or "all" (default) 65 + scope: Which corpus to search — "rules" (SRD), "world" (established 66 + content), or "all" (both, default) 43 67 content_type: Optional type to limit search (e.g., "spells", "npcs") 44 68 45 69 Returns: ··· 49 73 if scope == "rules": 50 74 source_filter = "srd" 51 75 52 - current_day = ctx.campaign_log.get_current_time().day 53 - hits = ctx.vector_index.search( 76 + current_day = timekeeper.get_current_time().day 77 + hits = lore.search( 54 78 query, limit=5, source_filter=source_filter, 55 79 exclude_source="srd" if scope == "world" else None, 56 80 decay_ref=current_day, ··· 66 90 return "\n".join(lines) 67 91 68 92 return f"Nothing found matching '{query}'" 69 - 70 - 71 - DEFINITIONS: list[dict] = [ 72 - { 73 - "name": "roll", 74 - "description": roll.__doc__, 75 - "input_schema": { 76 - "type": "object", 77 - "properties": { 78 - "notation": { 79 - "type": "string", 80 - "description": "Dice notation (e.g., '1d20+5', '2d6', '4d6kh3')", 81 - }, 82 - "reason": { 83 - "type": "string", 84 - "description": "What the roll is for (e.g., 'Athletics', 'Longsword attack', 'Dex save')", 85 - }, 86 - }, 87 - "required": ["notation", "reason"], 88 - }, 89 - }, 90 - { 91 - "name": "recall", 92 - "description": recall.__doc__, 93 - "input_schema": { 94 - "type": "object", 95 - "properties": { 96 - "query": { 97 - "type": "string", 98 - "description": "What to look up (e.g., 'fireball', 'captain vex')", 99 - }, 100 - "scope": { 101 - "type": "string", 102 - "description": "Where to search: 'rules' (SRD), 'world' (established content), or 'all' (both)", 103 - "enum": ["rules", "world", "all"], 104 - }, 105 - "content_type": { 106 - "type": "string", 107 - "description": "Type to limit search (e.g., 'spells', 'npcs', 'monsters')", 108 - }, 109 - }, 110 - "required": ["query"], 111 - }, 112 - }, 113 - { 114 - "name": "run_code", 115 - "description": ( 116 - "Run Python code in a secure sandbox. Use for calculations, random " 117 - "generation, data formatting, or any computation the narrative needs.\n\n" 118 - "All your DM tools are callable as functions (see signatures below). " 119 - "Most return a str with the result. The exception is roll(), which " 120 - "returns a dict with keys: notation, rolls, kept, modifier, total — " 121 - "use roll('2d6+3')['total'] for math, or index into rolls/kept for " 122 - "individual dice. Use roll() for all randomness (no random module).\n\n" 123 - "Language: variables, functions, loops, conditionals, comprehensions, " 124 - "f-strings. Stdlib: re, json, datetime, math. No classes, no other " 125 - "imports, no file/network access. Errors return as text.\n\n" 126 - "Available functions:\n{tool_signatures}" 127 - ), 128 - "input_schema": { 129 - "type": "object", 130 - "properties": { 131 - "description": { 132 - "type": "string", 133 - "description": "What this code does in game terms (e.g., 'Designing cave system', 'Splitting treasure', 'Generating NPC schedule')", 134 - }, 135 - "code": { 136 - "type": "string", 137 - "description": "Python code to execute", 138 - }, 139 - }, 140 - "required": ["description", "code"], 141 - }, 142 - }, 143 - ]
+34
src/storied/tools/run_code.py
··· 1 + """Sandboxed Python execution exposed as an MCP tool.""" 2 + 3 + from fastmcp import FastMCP 4 + 5 + mcp = FastMCP("run_code") 6 + 7 + 8 + @mcp.tool(tags={"dm"}) 9 + def run_code(description: str, code: str) -> str: 10 + """Run Python code in a secure sandbox. 11 + 12 + Use for calculations, random generation, data formatting, or any 13 + computation the narrative needs. 14 + 15 + All your DM tools are callable as functions (see signatures below). 16 + Most return a str with the result. The exception is roll(), which 17 + returns a dict with keys: notation, rolls, kept, modifier, total — 18 + use roll('2d6+3')['total'] for math, or index into rolls/kept for 19 + individual dice. Use roll() for all randomness (no random module). 20 + 21 + Language: variables, functions, loops, conditionals, comprehensions, 22 + f-strings. Stdlib: re, json, datetime, math. No classes, no other 23 + imports, no file/network access. Errors return as text. 24 + 25 + Available functions: 26 + {tool_signatures} 27 + 28 + Args: 29 + description: What this code does in game terms (e.g. 'Designing 30 + cave system', 'Splitting treasure') 31 + code: Python code to execute 32 + """ 33 + from storied.sandbox import execute as sandbox_execute 34 + return sandbox_execute(code)
+51 -105
src/storied/tools/scene.py
··· 1 1 """Scene management, session, style tuning, and DM notification tools.""" 2 2 3 - import re 3 + from pathlib import Path 4 + 5 + from fastmcp import FastMCP 4 6 5 7 from storied import notifications 8 + from storied.log import CampaignLog 9 + from storied.search import VectorIndex 6 10 from storied.session import update_session as session_update 7 - from storied.tools._context import ToolContext 11 + from storied.tools._context import ( 12 + Entities, 13 + EntityIndex, 14 + Lore, 15 + Player, 16 + StorageRoot, 17 + Timekeeper, 18 + World, 19 + ) 8 20 from storied.tools.entities import _auto_mark_present 21 + 22 + mcp = FastMCP("scene") 9 23 10 24 25 + @mcp.tool(tags={"dm", "seeder"}) 11 26 def set_scene( 12 - ctx: ToolContext, 13 27 event: str | None = None, 14 28 duration: str | None = None, 15 29 situation: str | None = None, ··· 17 31 present: list[str] | None = None, 18 32 threads: list[str] | None = None, 19 33 tags: list[str] | None = None, 34 + timekeeper: CampaignLog = Timekeeper(), 35 + player: str = Player(), 36 + world: str = World(), 37 + root: Path = StorageRoot(), 38 + entities: EntityIndex = Entities(), 39 + lore: VectorIndex = Lore(), 20 40 ) -> str: 21 41 """Call this after every response. Logs what happened, advances the clock, 22 42 and updates the scene state. ··· 42 62 parts = [] 43 63 44 64 if event and duration: 45 - anchor = ctx.campaign_log.append_entry(event, duration, tags=tags) 46 - current = ctx.campaign_log.get_current_time() 65 + anchor = timekeeper.append_entry(event, duration, tags=tags) 66 + current = timekeeper.get_current_time() 47 67 parts.append( 48 68 f"Logged: {anchor} | {event} | {duration} → " 49 69 f"Now: {current} ({current.period_of_day()}, {current.atmosphere()})" ··· 60 80 updates["threads"] = threads 61 81 62 82 if updates: 63 - result = session_update(ctx.player_id, updates, ctx.base_path) 83 + result = session_update(player, updates, root) 64 84 parts.append(result) 65 85 66 86 if event and present: 67 - marked = _auto_mark_present(present, event, ctx) 87 + marked = _auto_mark_present( 88 + present, event, root, world, entities, lore, timekeeper, 89 + ) 68 90 if marked: 69 91 parts.append(f"Auto-marked: {', '.join(marked)}") 70 92 71 93 return "; ".join(parts) if parts else "No updates" 72 94 73 95 74 - def tune(tuning: str, ctx: ToolContext) -> str: 96 + @mcp.tool(tags={"dm"}) 97 + def tune( 98 + tuning: str, 99 + world: str = World(), 100 + root: Path = StorageRoot(), 101 + ) -> str: 75 102 """Update your storytelling style based on player feedback. 76 103 77 104 Write the complete updated style as markdown prose. This replaces the 78 105 entire current style. Incorporate existing preferences where they still 79 106 apply — don't discard preferences the player hasn't contradicted. 80 107 """ 81 - path = ctx.base_path / "worlds" / ctx.world_id / "style.md" 108 + path = root / "worlds" / world / "style.md" 82 109 path.write_text(f"# Style\n\n{tuning}\n") 83 110 return "Style updated." 84 111 85 112 86 - def end_session(situation: str, ctx: ToolContext, threads: list[str] | None = None) -> str: 113 + @mcp.tool(tags={"dm"}) 114 + def end_session( 115 + situation: str, 116 + threads: list[str] | None = None, 117 + player: str = Player(), 118 + root: Path = StorageRoot(), 119 + ) -> str: 87 120 """End the current session, saving the game state for next time. 88 121 89 122 Call this when the player indicates they want to stop playing. This saves ··· 103 136 if threads is not None: 104 137 updates["threads"] = threads 105 138 106 - session_update(ctx.player_id, updates, ctx.base_path) 139 + session_update(player, updates, root) 107 140 return "SESSION_ENDED" 108 141 109 142 110 - def notify_dm(message: str, ctx: ToolContext) -> str: 143 + @mcp.tool(tags={"dm", "planner", "advancement"}) 144 + def notify_dm( 145 + message: str, 146 + world: str = World(), 147 + root: Path = StorageRoot(), 148 + ) -> str: 111 149 """Send a notification that the DM will see at the start of the next turn. 112 150 113 151 Use this to communicate important background changes to the DM, ··· 119 157 Returns: 120 158 Confirmation that the notification was queued 121 159 """ 122 - notifications.append(ctx.world_id, ctx.base_path, message) 160 + notifications.append(world, root, message) 123 161 return f"Notification queued: {message}" 124 - 125 - 126 - DEFINITIONS: list[dict] = [ 127 - { 128 - "name": "set_scene", 129 - "description": set_scene.__doc__, 130 - "input_schema": { 131 - "type": "object", 132 - "properties": { 133 - "event": { 134 - "type": "string", 135 - "description": "What happened this turn (logged to campaign journal)", 136 - }, 137 - "duration": { 138 - "type": "string", 139 - "description": "How long it took (e.g., '10 min', '1 hour', '3 rounds')", 140 - }, 141 - "situation": { 142 - "type": "string", 143 - "description": "Updated situation summary in present tense", 144 - }, 145 - "location": { 146 - "type": "string", 147 - "description": "New location when the player moves", 148 - }, 149 - "present": { 150 - "type": "array", 151 - "items": {"type": "string"}, 152 - "description": "Entities present, using [[Name]] format", 153 - }, 154 - "threads": { 155 - "type": "array", 156 - "items": {"type": "string"}, 157 - "description": "Open plot threads or objectives", 158 - }, 159 - "tags": { 160 - "type": "array", 161 - "items": {"type": "string"}, 162 - "description": "Optional: 'combat', 'rest:short', 'rest:long', 'travel', 'level'", 163 - }, 164 - }, 165 - "required": ["event", "duration"], 166 - }, 167 - }, 168 - { 169 - "name": "tune", 170 - "description": tune.__doc__, 171 - "input_schema": { 172 - "type": "object", 173 - "properties": { 174 - "tuning": { 175 - "type": "string", 176 - "description": "Complete updated style as markdown prose. Replaces the current style entirely.", 177 - }, 178 - }, 179 - "required": ["tuning"], 180 - }, 181 - }, 182 - { 183 - "name": "end_session", 184 - "description": end_session.__doc__, 185 - "input_schema": { 186 - "type": "object", 187 - "properties": { 188 - "situation": { 189 - "type": "string", 190 - "description": "Summary of current state for the next session", 191 - }, 192 - "threads": { 193 - "type": "array", 194 - "items": {"type": "string"}, 195 - "description": "Open plot threads or objectives to carry forward", 196 - }, 197 - }, 198 - "required": ["situation"], 199 - }, 200 - }, 201 - { 202 - "name": "notify_dm", 203 - "description": notify_dm.__doc__, 204 - "input_schema": { 205 - "type": "object", 206 - "properties": { 207 - "message": { 208 - "type": "string", 209 - "description": "The notification message for the DM", 210 - }, 211 - }, 212 - "required": ["message"], 213 - }, 214 - }, 215 - ]
+31 -4
tests/conftest.py
··· 1 1 """Shared test fixtures.""" 2 2 3 + import asyncio 3 4 import hashlib 5 + from collections.abc import Callable, Iterator 4 6 from pathlib import Path 7 + from typing import Any 5 8 6 9 import pytest 10 + from uncalled_for import resolved_dependencies 7 11 8 12 from storied.log import CampaignLog 9 13 from storied.search import VectorIndex 10 - from storied.tools import EntityIndex, ToolContext 14 + from storied.tools import EntityIndex, ToolContext, init_ctx, reset_ctx 15 + 16 + 17 + def call_tool(fn: Callable[..., Any], **kwargs: Any) -> Any: 18 + """Invoke a FastMCP-decorated tool synchronously, resolving its 19 + `Dependency` parameters from the process-global ToolContext. 20 + 21 + Use this in tests instead of calling the tool wrapper directly — 22 + direct calls leave the Dependency instances as parameter defaults 23 + rather than resolving them. 24 + """ 25 + async def _run() -> Any: 26 + async with resolved_dependencies(fn, kwargs) as deps: 27 + return fn(**{**kwargs, **deps}) 28 + return asyncio.run(_run()) 11 29 12 30 EMBED_DIM = 384 13 31 ··· 31 49 32 50 33 51 @pytest.fixture 34 - def ctx(tmp_path: Path) -> ToolContext: 35 - """ToolContext with fake embedder for tests that need tool infrastructure.""" 52 + def ctx(tmp_path: Path) -> Iterator[ToolContext]: 53 + """Process-global ToolContext with a fake embedder. 54 + 55 + Tools resolve their Dependency parameters from this context. Each test 56 + gets a fresh tempdir-rooted ctx; teardown clears the global to prevent 57 + cross-test leakage. 58 + """ 36 59 world_dir = tmp_path / "worlds" / "test-world" 37 60 world_dir.mkdir(parents=True) 38 61 39 62 vi = VectorIndex(tmp_path / "search.db") 40 63 vi._embed_fn = _fake_embed 41 64 42 - return ToolContext( 65 + context = init_ctx( 43 66 world_id="test-world", 44 67 player_id="default", 45 68 base_path=tmp_path, ··· 47 70 entity_index=EntityIndex(world_dir), 48 71 vector_index=vi, 49 72 ) 73 + try: 74 + yield context 75 + finally: 76 + reset_ctx()
+55 -1
tests/test_advancement.py
··· 16 16 ) 17 17 from storied.session import save_session 18 18 from storied.tools import ToolContext 19 - from storied.tools.scene import notify_dm 19 + from storied.tools.scene import notify_dm as _notify_dm 20 + 21 + from tests.conftest import call_tool 22 + 23 + 24 + def notify_dm(message: str, ctx: ToolContext) -> str: 25 + """Test shim: drop legacy `ctx` arg and resolve Dependency params.""" 26 + return call_tool(_notify_dm, message=message) 20 27 21 28 22 29 # --- Fixtures --- ··· 364 371 if adv._thread: 365 372 adv._thread.join(timeout=2) 366 373 assert adv._turn_count == 0 374 + 375 + def test_pop_result_returns_none_when_no_thread(self): 376 + adv = BackgroundAdvancement( 377 + world_id="test", player_id="default", base_path=Path("/tmp/fake"), 378 + ) 379 + assert adv.pop_result() is None 380 + 381 + def test_pop_result_returns_and_clears_after_completion(self): 382 + adv = BackgroundAdvancement( 383 + world_id="test", 384 + player_id="default", 385 + base_path=Path("/tmp/fake"), 386 + interval=1, 387 + ) 388 + with patch("storied.advancement.evaluate_advancement") as mock_eval: 389 + mock_eval.return_value = AdvancementResult(evaluated=True) 390 + adv.on_turn() 391 + if adv._thread: 392 + adv._thread.join(timeout=2) 393 + 394 + first = adv.pop_result() 395 + assert first is not None 396 + assert first.evaluated is True 397 + # Second pop returns None — result was consumed 398 + assert adv.pop_result() is None 399 + 400 + def test_maybe_evaluate_skips_when_already_running(self): 401 + adv = BackgroundAdvancement( 402 + world_id="test", player_id="default", base_path=Path("/tmp/fake"), 403 + ) 404 + # Stub a fake "still running" thread on the instance 405 + from unittest.mock import MagicMock 406 + fake_thread = MagicMock() 407 + fake_thread.is_alive.return_value = True 408 + adv._thread = fake_thread 409 + # Should early-return without spawning a new thread 410 + adv._maybe_evaluate() 411 + # The fake thread is still the only one 412 + assert adv._thread is fake_thread 413 + 414 + def test_evaluate_advancement_default_base_path(self, tmp_path: Path, monkeypatch): 415 + """The fall-through `base_path = Path.cwd()` branch.""" 416 + from storied.advancement import evaluate_advancement 417 + 418 + monkeypatch.chdir(tmp_path) # cwd has no character → returns early 419 + result = evaluate_advancement(world_id="test", player_id="default") 420 + assert result.evaluated is False
+280
tests/test_character.py
··· 195 195 assert "no character" in result.lower() 196 196 197 197 198 + class TestSchemaValidation: 199 + """update_character must reject schema-violating writes with a DM-readable 200 + error and leave the on-disk character unchanged.""" 201 + 202 + def test_resources_as_list_is_rejected(self, mira: dict, player_dir: Path): 203 + before = load_character("test-player", player_dir) 204 + result = update_character( 205 + "test-player", 206 + {"resources": [{"name": "Channel Divinity", "current": 1, "max": 1}]}, 207 + base_path=player_dir, 208 + ) 209 + assert "rejected" in result.lower() 210 + assert "resources" in result 211 + assert "dict" in result.lower() 212 + # On-disk character is unchanged 213 + after = load_character("test-player", player_dir) 214 + assert after["resources"] == before["resources"] 215 + 216 + def test_equipment_as_list_is_rejected(self, mira: dict, player_dir: Path): 217 + result = update_character( 218 + "test-player", 219 + {"equipment": ["Longsword", "Shield"]}, 220 + base_path=player_dir, 221 + ) 222 + assert "rejected" in result.lower() 223 + assert "equipment" in result 224 + 225 + def test_state_hp_must_be_a_dict(self, mira: dict, player_dir: Path): 226 + result = update_character( 227 + "test-player", 228 + {"state.hp": 24}, # missing required fields 229 + base_path=player_dir, 230 + ) 231 + assert "rejected" in result.lower() 232 + # Original HP block is preserved 233 + data = load_character("test-player", player_dir) 234 + assert isinstance(data["state"]["hp"], dict) 235 + assert data["state"]["hp"]["max"] == 24 236 + 237 + def test_valid_resources_update_succeeds(self, mira: dict, player_dir: Path): 238 + result = update_character( 239 + "test-player", 240 + {"resources.channel_divinity": { 241 + "current": 1, "max": 1, "refresh": "short_rest", 242 + "notes": "Channel Divinity", 243 + }}, 244 + base_path=player_dir, 245 + ) 246 + assert "rejected" not in result.lower() 247 + data = load_character("test-player", player_dir) 248 + assert data["resources"]["channel_divinity"]["current"] == 1 249 + 250 + def test_error_message_contains_an_example(self, mira: dict, player_dir: Path): 251 + """The DM should be able to fix the call from the error message alone.""" 252 + result = update_character( 253 + "test-player", 254 + {"resources": [{"name": "x"}]}, 255 + base_path=player_dir, 256 + ) 257 + # Concrete example helps the LLM correct itself 258 + assert "channel_divinity" in result or "{" in result 259 + 260 + 261 + class TestSchemaCoercion: 262 + """load_character heals known mis-shapes from older sessions so existing 263 + characters keep working without manual repair.""" 264 + 265 + def test_load_coerces_resources_list_to_dict(self, player_dir: Path): 266 + import yaml 267 + # Hand-write a character with resources as a list (the bad shape) 268 + path = player_dir / "players" / "test-player" / "character.yaml" 269 + path.write_text(yaml.dump({ 270 + "identity": {"name": "Damaged", "classes": [{"class": "Cleric", "level": 3}]}, 271 + "abilities": {"strength": 10, "dexterity": 10, "constitution": 10, 272 + "intelligence": 10, "wisdom": 14, "charisma": 10}, 273 + "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, 274 + "resources": [ 275 + {"name": "Channel Divinity", "current": 1, "max": 1, 276 + "refresh": "short_rest"}, 277 + {"name": "Lay on Hands", "current": 15, "max": 15, 278 + "refresh": "long_rest"}, 279 + ], 280 + })) 281 + data = load_character("test-player", player_dir) 282 + assert isinstance(data["resources"], dict) 283 + assert "channel_divinity" in data["resources"] 284 + assert data["resources"]["channel_divinity"]["current"] == 1 285 + assert data["resources"]["lay_on_hands"]["max"] == 15 286 + 287 + def test_coerced_character_can_use_resource(self, player_dir: Path): 288 + """End-to-end: a character with bad-shape resources on disk should 289 + be usable via use_resource after load coercion.""" 290 + import yaml 291 + path = player_dir / "players" / "test-player" / "character.yaml" 292 + path.write_text(yaml.dump({ 293 + "identity": {"name": "Damaged"}, 294 + "abilities": {"strength": 10, "dexterity": 10, "constitution": 10, 295 + "intelligence": 10, "wisdom": 10, "charisma": 10}, 296 + "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, 297 + "resources": [ 298 + {"name": "Channel Divinity", "current": 1, "max": 1, 299 + "refresh": "short_rest", "notes": "Channel Divinity"}, 300 + ], 301 + })) 302 + result = use_resource("test-player", "channel", base_path=player_dir) 303 + assert "Used 1" in result 304 + 305 + def test_load_coerces_equipment_list_to_dict(self, player_dir: Path): 306 + import yaml 307 + path = player_dir / "players" / "test-player" / "character.yaml" 308 + path.write_text(yaml.dump({ 309 + "identity": {"name": "Damaged"}, 310 + "abilities": {"strength": 10, "dexterity": 10, "constitution": 10, 311 + "intelligence": 10, "wisdom": 10, "charisma": 10}, 312 + "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, 313 + "equipment": ["Longsword", "Shield"], 314 + })) 315 + data = load_character("test-player", player_dir) 316 + assert isinstance(data["equipment"], dict) 317 + assert data["equipment"]["on_person"] == ["Longsword", "Shield"] 318 + 319 + 198 320 # --- Computation tests --- 199 321 200 322 ··· 342 464 result = format_character_context(data) 343 465 assert "Advancement Ready" in result 344 466 assert "Level 4" in result 467 + 468 + def test_format_sheet_tolerates_wrong_shaped_resources(self, mira: dict): 469 + """If the LLM writes the wrong shape (a list instead of a dict-of-pools), 470 + the renderer must skip the section instead of crashing the turn.""" 471 + mira["resources"] = ["hit_dice_d8", "bracer_unseen_step"] 472 + result = format_sheet(mira) 473 + assert "Resources" not in result # section omitted, no crash 474 + 475 + def test_format_sheet_tolerates_wrong_shaped_magic_items(self, mira: dict): 476 + mira["magic_items"] = ["[[Bracer]]"] # should be a dict 477 + result = format_sheet(mira) 478 + assert "Magic Items" not in result # section omitted, no crash 479 + 480 + def test_format_sheet_tolerates_wrong_shaped_equipment(self, mira: dict): 481 + mira["equipment"] = ["sword", "shield"] # should be a dict-of-locations 482 + result = format_sheet(mira) 483 + assert "Equipment" not in result # section omitted, no crash 484 + 485 + def test_format_sheet_renders_active_effects(self, mira: dict): 486 + mira["effects"] = [ 487 + {"source": "Bless", "description": "+1d4 attacks/saves"}, 488 + {"source": "Heroism", "description": "+10 temp HP", "expires": "d2-1430"}, 489 + ] 490 + result = format_sheet(mira) 491 + assert "Active Effects" in result 492 + assert "Bless" in result 493 + assert "Heroism" in result 494 + assert "until d2-1430" in result 495 + 496 + def test_format_sheet_renders_resources_with_die(self, mira: dict): 497 + mira["resources"] = { 498 + "bardic_inspiration": { 499 + "current": 3, "max": 3, "refresh": "long_rest", 500 + "notes": "Bardic Inspiration", "die": "d8", 501 + }, 502 + } 503 + result = format_sheet(mira) 504 + assert "Bardic Inspiration: 3/3" in result 505 + assert "d8" in result 506 + 507 + def test_format_sheet_renders_magic_items_carried(self, mira: dict): 508 + mira["magic_items"] = { 509 + "attuned": ["[[Bracer]]"], 510 + "equipped": ["[[Boots]]"], 511 + "carried": ["[[Cloak]]", "[[Ring]]"], 512 + } 513 + result = format_sheet(mira) 514 + assert "Magic Items" in result 515 + assert "Attuned: [[Bracer]]" in result 516 + assert "Equipped: [[Boots]]" in result 517 + assert "Carried: [[Cloak]], [[Ring]]" in result 518 + 519 + def test_format_sheet_renders_features(self, mira: dict): 520 + mira["features"] = [ 521 + {"name": "Sneak Attack", "text": "+2d6 damage", "source": "Rogue Lv1"}, 522 + {"name": "Cunning Action", "text": "Bonus action: Dash/Disengage/Hide"}, 523 + ] 524 + result = format_sheet(mira) 525 + assert "Features" in result 526 + assert "Sneak Attack" in result 527 + assert "Rogue Lv1" in result 528 + assert "Cunning Action" in result 529 + 530 + def test_format_sheet_renders_conditions(self, mira: dict): 531 + mira["conditions"] = ["Poisoned", "Prone"] 532 + result = format_sheet(mira) 533 + assert "Conditions:" in result 534 + assert "Poisoned" in result 535 + assert "Prone" in result 536 + 537 + def test_format_sheet_renders_defenses(self, mira: dict): 538 + mira["defenses"] = { 539 + "resistances": [{"damage": "fire", "source": "racial"}], 540 + "vulnerabilities": [{"damage": "cold", "source": "curse"}], 541 + "immunities": { 542 + "damage": ["psychic"], 543 + "conditions": ["charmed"], 544 + }, 545 + } 546 + result = format_sheet(mira) 547 + assert "Resistances:" in result 548 + assert "fire" in result 549 + assert "Vulnerabilities:" in result 550 + assert "cold" in result 551 + assert "Damage Immunities:" in result 552 + assert "psychic" in result 553 + assert "Condition Immunities:" in result 554 + assert "charmed" in result 555 + 556 + def test_format_sheet_renders_temp_hp_in_vital_line(self, mira: dict): 557 + mira["state"]["hp"]["temp"] = 5 558 + result = format_sheet(mira) 559 + assert "+5 temp" in result 560 + 561 + def test_format_sheet_renders_exhaustion_in_vital_line(self, mira: dict): 562 + mira["state"]["exhaustion"] = 2 563 + result = format_sheet(mira) 564 + assert "Exhaustion 2" in result 565 + 566 + def test_format_status_omits_empty_purse(self, player_dir: Path): 567 + create_character( 568 + player_id="test-player", 569 + name="Broke", 570 + race="Human", 571 + char_class="Fighter", 572 + level=1, 573 + abilities={"strength": 10, "dexterity": 10, "constitution": 10, 574 + "intelligence": 10, "wisdom": 10, "charisma": 10}, 575 + hp_max=10, 576 + ac=10, 577 + base_path=player_dir, 578 + ) 579 + data = load_character("test-player", player_dir) 580 + result = format_status(data) 581 + assert "Purse" not in result 582 + 583 + def test_format_status_truncates_equipment_over_eight_items( 584 + self, mira: dict, player_dir: Path, 585 + ): 586 + # Add lots of items so the truncation branch fires 587 + for i in range(12): 588 + update_character( 589 + "test-player", 590 + {f"equipment.on_person": [f"item_{i}" for i in range(12)]}, 591 + base_path=player_dir, 592 + ) 593 + data = load_character("test-player", player_dir) 594 + result = format_status(data) 595 + assert "and 4 more" in result # 12 items - 8 shown = 4 more 596 + 597 + def test_format_character_display_respects_base_path( 598 + self, mira: dict, player_dir: Path, tmp_path: Path, 599 + ): 600 + """The /me slash command must read from the passed base_path so 601 + sandbox sessions don't load the cwd's real character.""" 602 + from storied.cli import _format_character_display 603 + 604 + # mira lives at player_dir/players/test-player; an unrelated other_path 605 + # has no character at all. Asking for the other_path must return None, 606 + # not silently fall back to mira via cwd. 607 + other_path = tmp_path / "other" 608 + (other_path / "players" / "test-player").mkdir(parents=True) 609 + 610 + result = _format_character_display( 611 + "test-player", full=True, base_path=other_path, 612 + ) 613 + assert result is None, ( 614 + "_format_character_display must use the passed base_path; " 615 + "loading from cwd by default is what caused /me to show the wrong " 616 + "character in sandbox sessions." 617 + ) 618 + 619 + # And it should return real content when given the right base_path 620 + result = _format_character_display( 621 + "test-player", full=True, base_path=player_dir, 622 + ) 623 + assert result is not None 624 + assert "Mira" in result 345 625 346 626 347 627 # --- Operations tests ---
+219
tests/test_engine.py
··· 138 138 result = engine._parse_knowledge_file(f) 139 139 assert result["name"] == "Vera" 140 140 assert result["body"] == "Tavern owner." 141 + 142 + def test_parse_knowledge_file_malformed_frontmatter( 143 + self, engine, tmp_path: Path, 144 + ): 145 + f = tmp_path / "broken.md" 146 + f.write_text("---\nnot: [valid yaml\n---\n\nBody.") 147 + result = engine._parse_knowledge_file(f) 148 + # Falls back to body-only when frontmatter is unparseable 149 + assert "Body." in result["body"] 150 + 151 + def test_parse_knowledge_file_open_frontmatter(self, engine, tmp_path: Path): 152 + f = tmp_path / "open.md" 153 + f.write_text("---\nnever closed") 154 + result = engine._parse_knowledge_file(f) 155 + # Falls back to whole-file body when there's no closing --- 156 + assert "never closed" in result["body"] 157 + 158 + def test_log_transcript_writes_file(self, tmp_path: Path): 159 + from unittest.mock import patch 160 + 161 + from storied.engine import DMEngine 162 + from storied.initiative import InitiativeTracker 163 + from storied.tools import EntityIndex 164 + 165 + (tmp_path / "worlds" / "test").mkdir(parents=True) 166 + (tmp_path / "prompts").mkdir() 167 + (tmp_path / "prompts" / "dm-system.md").write_text("DM.") 168 + 169 + transcript_path = tmp_path / "transcripts" / "session.jsonl" 170 + with patch("storied.engine.start_mcp_server") as mock_mcp: 171 + mock_mcp.return_value = type("Handle", (), { 172 + "url": "http://localhost:0/sse", 173 + "ctx": type("Ctx", (), { 174 + "entity_index": EntityIndex(tmp_path / "worlds" / "test"), 175 + "vector_index": None, 176 + "initiative": InitiativeTracker(), 177 + })(), 178 + })() 179 + engine = DMEngine( 180 + world_id="test", 181 + player_id="default", 182 + base_path=tmp_path, 183 + prompt_name="dm-system", 184 + transcript_path=transcript_path, 185 + ) 186 + 187 + engine._log_transcript("test_event", {"foo": "bar"}) 188 + assert transcript_path.exists() 189 + content = transcript_path.read_text() 190 + assert "test_event" in content 191 + assert "bar" in content 192 + 193 + def test_log_transcript_no_path_is_noop(self, engine): 194 + # Engine constructed without a transcript_path → method returns early 195 + engine._log_transcript("noop", {"x": 1}) 196 + # No assertion needed; just verifying it doesn't crash 197 + 198 + def test_get_context_stats_returns_breakdown(self, engine): 199 + engine._build_context() 200 + stats = engine.get_context_stats() 201 + assert "model_limit" in stats 202 + assert "system_prompt" in stats 203 + assert "context_parts" in stats 204 + assert isinstance(stats["context_parts"], dict) 205 + assert stats["context_total"] > 0 206 + 207 + def test_get_current_time_returns_string(self, engine): 208 + result = engine.get_current_time() 209 + assert isinstance(result, str) 210 + assert len(result) > 0 211 + 212 + def test_reset_clears_session(self, engine): 213 + engine._session_id = "abc-123" 214 + engine.reset() 215 + assert engine._session_id is None 216 + 217 + def test_build_context_with_character(self, engine): 218 + # Drop a character file in place; _build_context should pick it up 219 + from storied.character import create_character 220 + (engine.base_path / "players" / "default").mkdir(parents=True) 221 + create_character( 222 + player_id="default", 223 + name="Mira", 224 + race="Human", 225 + char_class="Rogue", 226 + level=3, 227 + abilities={"strength": 10, "dexterity": 18, "constitution": 14, 228 + "intelligence": 14, "wisdom": 12, "charisma": 16}, 229 + hp_max=24, 230 + ac=16, 231 + base_path=engine.base_path, 232 + ) 233 + engine._build_context() 234 + assert "Character" in engine._context_parts 235 + assert "Mira" in engine._context_parts["Character"] 236 + 237 + def test_build_context_with_session(self, engine): 238 + from storied.session import save_session 239 + (engine.base_path / "players" / "default").mkdir(parents=True) 240 + save_session("default", { 241 + "location": "The Tavern", 242 + "body": "## Present\n- [[Vera]]", 243 + "situation": "Resting", 244 + }, engine.base_path) 245 + engine._build_context() 246 + assert "Session" in engine._context_parts 247 + 248 + def test_build_context_loads_present_entities(self, engine): 249 + from storied.session import save_session 250 + from storied.tools.entities import _do_establish 251 + 252 + (engine.base_path / "players" / "default").mkdir(parents=True) 253 + 254 + # Establish an NPC and put them in the session's present list 255 + _do_establish( 256 + "npcs", "Vera", "Tavern owner.", "[[The Tavern]]", 257 + None, None, None, 258 + engine.base_path, "test", 259 + engine._mcp.ctx.entity_index, 260 + type("FakeIdx", (), {"upsert": lambda *a, **k: None})(), 261 + ) 262 + save_session("default", { 263 + "location": "The Tavern", 264 + "body": "## Present\n- [[Vera]]", 265 + }, engine.base_path) 266 + 267 + engine._build_context() 268 + # The Vera entity should have been loaded into the DM context 269 + assert any("Vera" in v for v in engine._context_parts.values()) 270 + 271 + def test_load_player_knowledge_returns_none_without_dir(self, engine): 272 + # No knowledge dir created → returns None 273 + result = engine._load_player_knowledge() 274 + assert result is None 275 + 276 + def test_load_player_knowledge_aggregates_files(self, engine): 277 + knowledge = ( 278 + engine.base_path / "players" / "default" / "worlds" / "test" / "npcs" 279 + ) 280 + knowledge.mkdir(parents=True) 281 + (knowledge / "vera.md").write_text( 282 + "---\nname: Vera Blackwater\n---\n\nA tavern owner." 283 + ) 284 + result = engine._load_player_knowledge() 285 + assert result is not None 286 + assert "Vera Blackwater" in result 287 + assert "tavern owner" in result 288 + 289 + def test_find_entity_via_index(self, engine): 290 + # Drop an entity file directly and rebuild the index so the lookup hits 291 + npc_path = engine.base_path / "worlds" / "test" / "npcs" / "Vera.md" 292 + npc_path.parent.mkdir(parents=True, exist_ok=True) 293 + npc_path.write_text("# Vera\n\nA tavern owner.") 294 + engine._mcp.ctx.entity_index.register("Vera", npc_path) 295 + 296 + result = engine._find_entity("Vera") 297 + assert result is not None 298 + assert result["name"] == "Vera" 299 + assert "tavern owner" in result["body"] 300 + assert result["entity_type"] == "npcs" 301 + 302 + def test_find_entity_returns_none_when_missing(self, engine): 303 + assert engine._find_entity("Nobody") is None 304 + 305 + def test_build_context_loads_location_and_one_hop_linked(self, engine): 306 + """When the session points at a location, _build_context should load 307 + the location, then one-hop into entities the location wikilinks.""" 308 + from storied.session import save_session 309 + 310 + (engine.base_path / "players" / "default").mkdir(parents=True) 311 + 312 + # Location wikilinks to a related NPC 313 + loc_path = engine.base_path / "worlds" / "test" / "locations" / "Tavern.md" 314 + loc_path.parent.mkdir(parents=True, exist_ok=True) 315 + loc_path.write_text( 316 + "# Tavern\n\nA cozy spot where [[Vera]] holds court." 317 + ) 318 + engine._mcp.ctx.entity_index.register("Tavern", loc_path) 319 + 320 + # The linked NPC 321 + npc_path = engine.base_path / "worlds" / "test" / "npcs" / "Vera.md" 322 + npc_path.parent.mkdir(parents=True, exist_ok=True) 323 + npc_path.write_text("# Vera\n\nTavern owner.") 324 + engine._mcp.ctx.entity_index.register("Vera", npc_path) 325 + 326 + save_session("default", { 327 + "location": "Tavern", 328 + "body": "Player just walked in.", 329 + }, engine.base_path) 330 + 331 + engine._build_context() 332 + # Location should be loaded 333 + assert "Location" in engine._context_parts 334 + # One-hop linked entity should be picked up via Linked: prefix 335 + linked_keys = [ 336 + k for k in engine._context_parts if k.startswith("Linked:") 337 + ] 338 + assert any("Vera" in k for k in linked_keys) 339 + 340 + def test_build_context_includes_notifications(self, engine): 341 + from storied import notifications 342 + 343 + notifications.append( 344 + engine.world_id, engine.base_path, 345 + "World tick: Vera left the tavern", 346 + ) 347 + engine._build_context() 348 + assert "Notifications" in engine._context_parts 349 + assert "Vera left the tavern" in engine._context_parts["Notifications"] 350 + 351 + def test_build_context_injects_initiative_when_active(self, engine): 352 + from storied.initiative import Combatant 353 + 354 + engine._mcp.ctx.initiative.begin([ 355 + Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 356 + ]) 357 + engine._build_context() 358 + assert "Initiative" in engine._context_parts 359 + assert "Goblin" in engine._context_parts["Initiative"]
+17 -1
tests/test_entities.py
··· 9 9 load_entity_content, 10 10 resolve_wiki_link, 11 11 ) 12 - from storied.tools import EntityIndex, ToolContext, establish, mark 12 + from storied.tools import EntityIndex, ToolContext 13 + from storied.tools.entities import establish as _establish 14 + from storied.tools.entities import mark as _mark 15 + 16 + from tests.conftest import call_tool 17 + 18 + 19 + def establish(**kwargs): 20 + """Test shim: drop legacy `ctx` kwarg and resolve Dependency params.""" 21 + kwargs.pop("ctx", None) 22 + return call_tool(_establish, **kwargs) 23 + 24 + 25 + def mark(**kwargs): 26 + """Test shim: drop legacy `ctx` kwarg and resolve Dependency params.""" 27 + kwargs.pop("ctx", None) 28 + return call_tool(_mark, **kwargs) 13 29 14 30 15 31 class TestEstablish:
+510 -203
tests/test_execute_tool.py
··· 1 - """Tests for execute_tool dispatch and uncovered tool functions.""" 1 + """Tests for tool dispatch via the FastMCP in-memory client. 2 + 3 + These tests exercise the same call path the production server uses 4 + (claude → MCP → tool function), but in-process via fastmcp.Client. 5 + """ 6 + 7 + import asyncio 8 + from typing import Any 9 + 10 + import pytest 11 + from fastmcp import Client 2 12 13 + from storied.character import load_character 3 14 from storied.initiative import Combatant 4 - from storied.tools import ( 5 - ToolContext, 6 - _auto_mark_present, 7 - end_session, 8 - establish, 9 - execute_tool, 10 - note_discovery, 11 - planner_execute_tool, 12 - recall, 13 - seeder_execute_tool, 14 - ) 15 + from storied.mcp_server import _compose_server 16 + from storied.tools import ToolContext 17 + from storied.tools.combat import _flip_into_combat, _flip_out_of_combat 18 + from storied.tools.entities import note_discovery as _note_discovery 19 + from storied.tools.scene import end_session as _end_session 20 + 21 + from tests.conftest import call_tool 22 + 15 23 24 + # --- Helpers ---------------------------------------------------------------- 16 25 17 - class TestExecuteToolDispatch: 18 - """Tests that execute_tool routes to the correct tool.""" 19 26 20 - def test_roll_with_modifier(self, ctx: ToolContext): 21 - result = execute_tool("roll", {"notation": "1d20+5", "reason": "attack"}, ctx) 27 + def call(tool_name: str, args: dict[str, Any] | None = None) -> str: 28 + """Compose a DM server, call a tool through the in-memory client, return text.""" 29 + async def _run() -> str: 30 + server = await _compose_server("dm") 31 + async with Client(server) as client: 32 + result = await client.call_tool(tool_name, args or {}) 33 + return result.data if result.data is not None else "" 22 34 35 + return asyncio.run(_run()) 36 + 37 + 38 + def call_in_combat( 39 + tool_name: str, 40 + args: dict[str, Any], 41 + combatants: list[Combatant], 42 + ) -> str: 43 + """Variant that begins initiative on the process-global tracker first. 44 + 45 + The composed server starts with combat tools hidden; we flip them on so 46 + in-combat tools (next_turn, condition, etc.) become callable. 47 + """ 48 + from storied.tools._context import _require 49 + _require().initiative.begin(combatants) 50 + 51 + async def _run() -> str: 52 + server = await _compose_server("dm") 53 + _flip_into_combat() 54 + try: 55 + async with Client(server) as client: 56 + result = await client.call_tool(tool_name, args) 57 + return result.data if result.data is not None else "" 58 + finally: 59 + _flip_out_of_combat() 60 + 61 + return asyncio.run(_run()) 62 + 63 + 64 + # --- Dispatch through the in-memory client ---------------------------------- 65 + 66 + 67 + class TestToolDispatch: 68 + def test_roll_with_modifier(self, ctx: ToolContext): 69 + result = call("roll", {"notation": "1d20+5", "reason": "attack"}) 23 70 assert "Rolled" in result 24 71 assert "1d20+5" in result 25 72 26 73 def test_roll_no_modifier(self, ctx: ToolContext): 27 - result = execute_tool("roll", {"notation": "1d6", "reason": "damage"}, ctx) 28 - 74 + result = call("roll", {"notation": "1d6", "reason": "damage"}) 29 75 assert "Rolled" in result 30 76 31 - def test_recall(self, ctx: ToolContext): 32 - result = execute_tool("recall", {"query": "nonexistent"}, ctx) 33 - 77 + def test_recall_finds_nothing(self, ctx: ToolContext): 78 + result = call("recall", {"query": "nonexistent"}) 34 79 assert "Nothing found" in result 35 80 36 - def test_update_character(self, ctx: ToolContext): 37 - # Create character first 38 - execute_tool("create_character", { 39 - "name": "Test", "race": "Human", "char_class": "Fighter", 40 - "level": 1, "abilities": { 41 - "strength": 16, "dexterity": 12, "constitution": 14, 42 - "intelligence": 10, "wisdom": 13, "charisma": 8, 43 - }, 44 - "hp_max": 12, "ac": 16, 45 - }, ctx) 46 - 47 - result = execute_tool( 48 - "update_character", {"updates": {"state.hp.current": 8}}, ctx, 49 - ) 50 - assert "updated" in result.lower() 51 - 52 - # Verify the value actually landed in the right place in the new schema 53 - from storied.character import load_character 54 - data = load_character(ctx.player_id, ctx.base_path) 55 - assert data["state"]["hp"]["current"] == 8, ( 56 - "update_character should write to state.hp.current with the new schema, " 57 - f"but state.hp.current is {data['state']['hp']['current']}" 58 - ) 59 - 60 81 def test_set_scene(self, ctx: ToolContext): 61 - result = execute_tool("set_scene", { 62 - "event": "Spoke with guards", "duration": "10 min", 63 - }, ctx) 64 - 82 + result = call("set_scene", {"event": "Spoke with guards", "duration": "10 min"}) 65 83 assert result 66 84 67 85 def test_establish(self, ctx: ToolContext): 68 - result = execute_tool("establish", { 69 - "entity_type": "npcs", "name": "Test NPC", 70 - }, ctx) 71 - 86 + result = call("establish", {"entity_type": "npcs", "name": "Test NPC"}) 72 87 assert "Established" in result 73 88 74 89 def test_mark(self, ctx: ToolContext): 75 - execute_tool("establish", { 76 - "entity_type": "npcs", "name": "Vera", 77 - }, ctx) 78 - result = execute_tool("mark", { 90 + call("establish", {"entity_type": "npcs", "name": "Vera"}) 91 + result = call("mark", { 79 92 "entity_type": "npcs", "name": "Vera", 80 93 "event": "Revealed her secret", 81 - }, ctx) 82 - 94 + }) 83 95 assert "Marked" in result 84 96 85 97 def test_note_discovery(self, ctx: ToolContext): 86 - result = execute_tool("note_discovery", { 98 + result = call("note_discovery", { 87 99 "entity": "Vera Blackwater", 88 100 "content": "She used to be a smuggler", 89 - }, ctx) 90 - 101 + }) 91 102 assert "Noted" in result 92 103 93 104 def test_end_session(self, ctx: ToolContext): 94 - result = execute_tool("end_session", { 95 - "situation": "In the tavern", 96 - }, ctx) 97 - 105 + result = call("end_session", {"situation": "In the tavern"}) 98 106 assert result == "SESSION_ENDED" 99 107 100 - def test_unknown_tool(self, ctx: ToolContext): 101 - result = execute_tool("nonexistent", {}, ctx) 108 + def test_unknown_tool_raises(self, ctx: ToolContext): 109 + with pytest.raises(Exception): 110 + call("nonexistent", {}) 102 111 103 - assert "Unknown tool" in result 104 112 113 + class TestUpdateCharacter: 114 + def test_landed_in_state_hp_current(self, ctx: ToolContext): 115 + call("create_character", { 116 + "name": "Test", "race": "Human", "char_class": "Fighter", 117 + "level": 1, "abilities": { 118 + "strength": 16, "dexterity": 12, "constitution": 14, 119 + "intelligence": 10, "wisdom": 13, "charisma": 8, 120 + }, 121 + "hp_max": 12, "ac": 16, 122 + }) 123 + result = call("update_character", {"updates": {"state.hp.current": 8}}) 124 + assert "updated" in result.lower() 105 125 106 - class TestNoteDiscovery: 107 - """Tests for the note_discovery tool.""" 126 + data = load_character(ctx.player_id, ctx.base_path) 127 + assert data["state"]["hp"]["current"] == 8, ( 128 + "update_character should write to state.hp.current with the new schema, " 129 + f"but state.hp.current is {data['state']['hp']['current']}" 130 + ) 108 131 109 - def test_creates_knowledge_file(self, ctx: ToolContext): 110 - note_discovery("The Rusty Anchor", "A seedy tavern on the docks", ctx) 111 132 133 + # --- Sub-tool helper coverage ----------------------------------------------- 134 + 135 + 136 + class TestNoteDiscoveryDirect: 137 + """Direct (non-MCP) calls to note_discovery exercise the wrapper itself.""" 138 + 139 + def test_creates_knowledge_file(self, ctx: ToolContext): 140 + call_tool(_note_discovery, entity="The Rusty Anchor", 141 + content="A seedy tavern on the docks") 112 142 knowledge_dir = ( 113 143 ctx.base_path / "players" / ctx.player_id / "worlds" 114 144 / ctx.world_id / "lore" ··· 116 146 assert any(knowledge_dir.iterdir()) 117 147 118 148 def test_with_content_type(self, ctx: ToolContext): 119 - note_discovery( 120 - "Vera", "Tavern owner", ctx, content_type="npcs", 121 - ) 122 - 149 + call_tool(_note_discovery, entity="Vera", content="Tavern owner", 150 + content_type="npcs") 123 151 knowledge_dir = ( 124 152 ctx.base_path / "players" / ctx.player_id / "worlds" 125 153 / ctx.world_id / "npcs" ··· 127 155 assert any(knowledge_dir.iterdir()) 128 156 129 157 def test_with_tags(self, ctx: ToolContext): 130 - note_discovery( 131 - "Old Map", "Shows a hidden passage", ctx, tags=["quest"], 132 - ) 133 - 158 + call_tool(_note_discovery, entity="Old Map", 159 + content="Shows a hidden passage", tags=["quest"]) 134 160 knowledge_dir = ( 135 161 ctx.base_path / "players" / ctx.player_id / "worlds" 136 162 / ctx.world_id / "lore" ··· 139 165 assert "quest" in content 140 166 141 167 142 - class TestEndSession: 143 - """Tests for the end_session tool.""" 144 - 168 + class TestEndSessionDirect: 145 169 def test_returns_session_ended(self, ctx: ToolContext): 146 - result = end_session("In the tavern", ctx) 147 - 170 + result = call_tool(_end_session, situation="In the tavern") 148 171 assert result == "SESSION_ENDED" 149 172 150 173 def test_with_threads(self, ctx: ToolContext): 151 - result = end_session( 152 - "In the tavern", ctx, 174 + result = call_tool( 175 + _end_session, 176 + situation="In the tavern", 153 177 threads=["Find the merchant", "Investigate the warehouse"], 154 178 ) 155 - 156 179 assert result == "SESSION_ENDED" 157 180 158 181 159 - class TestPlannerExecuteTool: 160 - """Tests for planner tool restriction.""" 161 - 162 - def test_allows_recall(self, ctx: ToolContext): 163 - result = planner_execute_tool("recall", {"query": "test"}, ctx) 164 - 165 - assert "Nothing found" in result 166 - 167 - def test_rejects_set_scene(self, ctx: ToolContext): 168 - result = planner_execute_tool("set_scene", { 169 - "event": "test", "duration": "1 min", 170 - }, ctx) 171 - 172 - assert "not available" in result 182 + # --- Recall with indexed content -------------------------------------------- 173 183 174 184 175 185 class TestRecall: 176 - """Tests for the recall tool with indexed content.""" 177 - 178 186 def test_recall_finds_indexed_entity(self, ctx: ToolContext): 179 187 entity_dir = ctx.base_path / "worlds" / ctx.world_id / "npcs" 180 188 entity_dir.mkdir(parents=True, exist_ok=True) ··· 188 196 "path": str(entity_file), "title": "Vera Blackwater"}, 189 197 ) 190 198 191 - result = recall("Vera Blackwater", ctx) 192 - 199 + result = call("recall", {"query": "Vera Blackwater"}) 193 200 assert "Vera Blackwater" in result 194 201 195 202 def test_recall_rules_scope(self, ctx: ToolContext): 196 - result = recall("fireball", ctx, scope="rules") 197 - 203 + result = call("recall", {"query": "fireball", "scope": "rules"}) 198 204 assert "Nothing found" in result 199 205 200 206 def test_recall_world_scope(self, ctx: ToolContext): 201 - result = recall("something", ctx, scope="world") 202 - 207 + result = call("recall", {"query": "something", "scope": "world"}) 203 208 assert "Nothing found" in result 204 209 205 210 def test_recall_multiple_hits(self, ctx: ToolContext): ··· 211 216 "path": f"/fake/npc{i}.md", "title": f"NPC {i}"}, 212 217 ) 213 218 214 - result = recall("docks NPC", ctx) 215 - 219 + result = call("recall", {"query": "docks NPC"}) 216 220 assert "Found" in result or "Nothing found" in result 217 221 218 222 219 - class TestAutoMarkPresent: 220 - """Tests for _auto_mark_present helper.""" 223 + # --- Combat path: damage routes through the initiative tracker -------------- 221 224 222 - def test_marks_present_entities(self, ctx: ToolContext): 223 - establish(entity_type="npcs", name="Vera", ctx=ctx, 224 - description="Tavern owner.") 225 225 226 - marked = _auto_mark_present( 227 - ["[[Vera]]"], "Witnessed the fight", ctx, 226 + class TestDamageHealCombat: 227 + def test_damage_combatant_in_initiative(self, ctx: ToolContext): 228 + result = call_in_combat( 229 + "damage", 230 + {"target": "Goblin", "amount": 3}, 231 + [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], 228 232 ) 229 - 230 - assert "Vera" in marked 231 - content = (ctx.base_path / "worlds" / ctx.world_id / "npcs/Vera.md").read_text() 232 - assert "Witnessed the fight" in content 233 - 234 - def test_skips_non_wikilinks(self, ctx: ToolContext): 235 - marked = _auto_mark_present(["plain text"], "event", ctx) 236 - 237 - assert marked == [] 238 - 239 - def test_skips_unknown_entities(self, ctx: ToolContext): 240 - marked = _auto_mark_present(["[[Nobody]]"], "event", ctx) 241 - 242 - assert marked == [] 243 - 244 - 245 - class TestInitiativeViaExecuteTool: 246 - """Tests that initiative tools route through execute_tool.""" 247 - 248 - def test_enter_initiative(self, ctx: ToolContext): 249 - result = execute_tool("enter_initiative", { 250 - "combatants": [ 251 - {"name": "Kira", "initiative": 18, "hp": 25, "hp_max": 25, "ac": 16, "is_player": True}, 252 - {"name": "Goblin", "initiative": 10, "hp": 7, "hp_max": 7, "ac": 15}, 253 - ], 254 - }, ctx) 255 - 256 - assert "Initiative started" in result 257 - assert ctx.initiative.active 258 - 259 - def test_damage_via_execute_tool(self, ctx: ToolContext): 260 - ctx.initiative.begin([ 261 - Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 262 - ]) 263 - 264 - result = execute_tool("damage", {"target": "Goblin", "amount": 3}, ctx) 265 - 266 233 assert "3" in result 267 234 assert ctx.initiative._find("Goblin").hp == 4 268 235 269 236 def test_damage_syncs_player_hp(self, ctx: ToolContext): 270 - execute_tool("create_character", { 237 + call("create_character", { 271 238 "name": "Kira", "race": "Human", "char_class": "Fighter", 272 239 "level": 1, "abilities": { 273 240 "strength": 16, "dexterity": 12, "constitution": 14, 274 241 "intelligence": 10, "wisdom": 13, "charisma": 8, 275 242 }, 276 243 "hp_max": 25, "ac": 16, 277 - }, ctx) 278 - ctx.initiative.begin([ 279 - Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16, is_player=True), 280 - ]) 244 + }) 281 245 282 - result = execute_tool("damage", {"target": "Kira", "amount": 7}, ctx) 246 + result = call_in_combat( 247 + "damage", 248 + {"target": "Kira", "amount": 7}, 249 + [Combatant(name="Kira", initiative=18, hp=25, hp_max=25, 250 + ac=16, is_player=True)], 251 + ) 283 252 284 253 assert "synced" in result 285 - from storied.character import load_character 286 254 char = load_character(ctx.player_id, ctx.base_path) 287 - # Must land in the new nested schema location, not flat hp.current 288 255 assert char["state"]["hp"]["current"] == 18, ( 289 256 "_sync_player_hp must write to state.hp.current with the new schema" 290 257 ) 291 258 292 259 def test_heal_syncs_player_hp(self, ctx: ToolContext): 293 - execute_tool("create_character", { 260 + call("create_character", { 294 261 "name": "Kira", "race": "Human", "char_class": "Fighter", 295 262 "level": 1, "abilities": { 296 263 "strength": 16, "dexterity": 12, "constitution": 14, 297 264 "intelligence": 10, "wisdom": 13, "charisma": 8, 298 265 }, 299 266 "hp_max": 25, "ac": 16, 300 - }, ctx) 301 - # Set the character's HP to 20 first (matching the combatant) 302 - execute_tool( 303 - "update_character", {"updates": {"state.hp.current": 20}}, ctx, 267 + }) 268 + call("update_character", {"updates": {"state.hp.current": 20}}) 269 + 270 + result = call_in_combat( 271 + "heal", 272 + {"target": "Kira", "amount": 3}, 273 + [Combatant(name="Kira", initiative=18, hp=20, hp_max=25, 274 + ac=16, is_player=True)], 304 275 ) 305 - ctx.initiative.begin([ 306 - Combatant(name="Kira", initiative=18, hp=20, hp_max=25, ac=16, is_player=True), 307 - ]) 308 - 309 - result = execute_tool("heal", {"target": "Kira", "amount": 3}, ctx) 310 276 311 277 assert "synced" in result 312 - from storied.character import load_character 313 278 char = load_character(ctx.player_id, ctx.base_path) 314 - # Must land in the new nested schema location 315 279 assert char["state"]["hp"]["current"] == 23, ( 316 280 "_sync_player_hp must write to state.hp.current with the new schema" 317 281 ) 318 282 319 283 def test_damage_no_sync_for_non_player(self, ctx: ToolContext): 284 + result = call_in_combat( 285 + "damage", 286 + {"target": "Goblin", "amount": 3}, 287 + [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], 288 + ) 289 + assert "synced" not in result 290 + 291 + def test_unknown_target_returns_error(self, ctx: ToolContext): 292 + result = call("damage", {"target": "Nobody", "amount": 5}) 293 + assert "No such target" in result 294 + 295 + 296 + # --- Combat tool wrappers (exercised via the in-memory client) -------------- 297 + 298 + 299 + class TestCombatTools: 300 + """The combat FastMCP wrappers route through the active InitiativeTracker. 301 + 302 + These tests start initiative on the process-global ctx via enter_initiative, 303 + flip combat tools visible, then drive each wrapper via the in-memory client 304 + so the wrapper bodies (not just the underlying tracker) get exercised. 305 + """ 306 + 307 + def test_enter_initiative_starts_combat(self, ctx: ToolContext): 308 + result = call_in_combat( 309 + "next_turn", 310 + {}, 311 + [ 312 + Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16, 313 + is_player=True), 314 + Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 315 + ], 316 + ) 317 + assert ctx.initiative.active 318 + # next_turn from Kira should advance to Goblin 319 + assert "Goblin" in result 320 + 321 + def test_enter_initiative_via_client(self, ctx: ToolContext): 322 + """Drive enter_initiative through the in-memory client so the 323 + parsing + tracker.begin + flip path runs end-to-end.""" 324 + async def _run() -> str: 325 + from fastmcp import Client 326 + server = await _compose_server("dm") 327 + try: 328 + async with Client(server) as client: 329 + r = await client.call_tool("enter_initiative", { 330 + "combatants": [ 331 + {"name": "Kira", "initiative": 18, "hp": 25, 332 + "hp_max": 25, "ac": 16, "is_player": True}, 333 + {"name": "Goblin", "initiative": 10, "hp": 7, 334 + "hp_max": 7, "ac": 15}, 335 + ], 336 + }) 337 + return r.data 338 + finally: 339 + _flip_out_of_combat() 340 + 341 + result = asyncio.run(_run()) 342 + assert "Initiative started" in result or "Round 1" in result 343 + assert ctx.initiative.active 344 + 345 + def test_enter_initiative_when_already_active_errors(self, ctx: ToolContext): 346 + # Start combat manually so the wrapper hits the early-return guard 320 347 ctx.initiative.begin([ 321 348 Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 322 349 ]) 323 350 324 - result = execute_tool("damage", {"target": "Goblin", "amount": 3}, ctx) 351 + async def _run() -> str: 352 + from fastmcp import Client 353 + server = await _compose_server("dm") 354 + _flip_into_combat() 355 + try: 356 + async with Client(server) as client: 357 + r = await client.call_tool("enter_initiative", { 358 + "combatants": [{ 359 + "name": "X", "initiative": 1, "hp": 1, "hp_max": 1, 360 + "ac": 10, 361 + }], 362 + }) 363 + return r.data 364 + finally: 365 + _flip_out_of_combat() 366 + 367 + result = asyncio.run(_run()) 368 + assert "already active" in result.lower() 369 + 370 + def test_add_combatant_inserts_into_initiative(self, ctx: ToolContext): 371 + result = call_in_combat( 372 + "add_combatant", 373 + {"name": "Reinforcement", "initiative": 12, "hp": 5, "hp_max": 5, 374 + "ac": 14}, 375 + [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], 376 + ) 377 + assert "Reinforcement" in result 378 + assert ctx.initiative._find("Reinforcement") is not None 325 379 326 - assert "synced" not in result 380 + def test_remove_combatant_removes_from_initiative(self, ctx: ToolContext): 381 + result = call_in_combat( 382 + "remove_combatant", 383 + {"name": "Goblin"}, 384 + [ 385 + Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16), 386 + Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 387 + ], 388 + ) 389 + assert "removed" in result.lower() 390 + assert ctx.initiative._find("Goblin") is None 327 391 328 - def test_end_initiative_via_execute_tool(self, ctx: ToolContext): 392 + def test_condition_add(self, ctx: ToolContext): 393 + result = call_in_combat( 394 + "condition", 395 + {"target": "Goblin", "condition": "Poisoned"}, 396 + [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], 397 + ) 398 + assert "Poisoned" in result 399 + # The combatant should now have the condition tracked 400 + goblin = ctx.initiative._find("Goblin") 401 + assert any(c.name == "Poisoned" for c in goblin.conditions) 402 + 403 + def test_condition_remove(self, ctx: ToolContext): 329 404 ctx.initiative.begin([ 330 405 Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 331 406 ]) 407 + ctx.initiative.add_condition(target="Goblin", condition="Stunned") 332 408 333 - result = execute_tool("end_initiative", {}, ctx) 409 + async def _run() -> str: 410 + from fastmcp import Client 411 + server = await _compose_server("dm") 412 + _flip_into_combat() 413 + try: 414 + async with Client(server) as client: 415 + r = await client.call_tool("condition", { 416 + "target": "Goblin", 417 + "condition": "Stunned", 418 + "action": "remove", 419 + }) 420 + return r.data 421 + finally: 422 + _flip_out_of_combat() 334 423 424 + result = asyncio.run(_run()) 425 + assert "removed" in result.lower() or "Stunned" in result 426 + goblin = ctx.initiative._find("Goblin") 427 + assert not any(c.name == "Stunned" for c in goblin.conditions) 428 + 429 + def test_end_initiative_clears_combat(self, ctx: ToolContext): 430 + ctx.initiative.begin([ 431 + Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 432 + ]) 433 + 434 + async def _run() -> str: 435 + from fastmcp import Client 436 + server = await _compose_server("dm") 437 + _flip_into_combat() 438 + try: 439 + async with Client(server) as client: 440 + r = await client.call_tool("end_initiative", {}) 441 + return r.data 442 + finally: 443 + _flip_out_of_combat() 444 + 445 + result = asyncio.run(_run()) 335 446 assert "ended" in result.lower() 336 447 assert not ctx.initiative.active 337 448 338 - def test_initiative_tools_not_in_planner(self, ctx: ToolContext): 339 - result = planner_execute_tool("enter_initiative", { 340 - "combatants": [], 341 - }, ctx) 449 + 450 + # --- Character wrapper bodies ---------------------------------------------- 342 451 343 - assert "not available" in result 344 452 345 - def test_initiative_tools_not_in_seeder(self, ctx: ToolContext): 346 - result = seeder_execute_tool("damage", { 347 - "target": "Goblin", "amount": 5, 348 - }, ctx) 453 + @pytest.fixture 454 + def kira(ctx: ToolContext) -> ToolContext: 455 + """A minimum-viable character used by the wrapper-coverage tests.""" 456 + call("create_character", { 457 + "name": "Kira", "race": "Human", "char_class": "Fighter", 458 + "level": 1, "abilities": { 459 + "strength": 16, "dexterity": 12, "constitution": 14, 460 + "intelligence": 10, "wisdom": 13, "charisma": 8, 461 + }, 462 + "hp_max": 12, "ac": 16, 463 + }) 464 + return ctx 349 465 350 - assert "not available" in result 351 466 467 + class TestCharacterToolWrappers: 468 + """Each character.py @mcp.tool wrapper is a one-liner that delegates to 469 + a char_* function. These tests exercise the wrappers through the in-memory 470 + client so the wrapper bodies (and their Dependency-resolved arguments) 471 + are actually executed.""" 352 472 353 - class TestSeederExecuteTool: 354 - """Tests for seeder tool restriction.""" 473 + def test_damage_player_by_name(self, kira: ToolContext): 474 + result = call("damage", {"target": "Kira", "amount": 3}) 475 + assert "3" in result 476 + char = load_character("default", kira.base_path) 477 + assert char["state"]["hp"]["current"] == 9 355 478 356 - def test_allows_establish(self, ctx: ToolContext): 357 - result = seeder_execute_tool("establish", { 358 - "entity_type": "npcs", "name": "Test", 359 - }, ctx) 479 + def test_damage_with_type(self, kira: ToolContext): 480 + result = call("damage", {"target": "Kira", "amount": 2, "type": "fire"}) 481 + assert "2" in result 360 482 361 - assert "Established" in result 483 + def test_heal_player_by_name(self, kira: ToolContext): 484 + call("damage", {"target": "Kira", "amount": 5}) 485 + result = call("heal", {"target": "Kira", "amount": 3}) 486 + assert result 487 + char = load_character("default", kira.base_path) 488 + assert char["state"]["hp"]["current"] == 10 362 489 363 - def test_rejects_mark(self, ctx: ToolContext): 364 - result = seeder_execute_tool("mark", { 365 - "entity_type": "npcs", "name": "Test", "event": "test", 366 - }, ctx) 490 + def test_adjust_coins(self, kira: ToolContext): 491 + result = call("adjust_coins", {"deltas": {"gp": 10, "sp": 5}}) 492 + assert "10" in result 493 + char = load_character("default", kira.base_path) 494 + assert char["state"]["purse"]["gp"] == 10 495 + assert char["state"]["purse"]["sp"] == 5 367 496 368 - assert "not available" in result 497 + def test_adjust_coins_drops_zero_deltas(self, kira: ToolContext): 498 + # Spending only gp; the zero-delta filter exercises the comprehension branch 499 + call("adjust_coins", {"deltas": {"gp": 5}}) 500 + result = call("adjust_coins", {"deltas": {"gp": -3, "sp": 0}}) 501 + char = load_character("default", kira.base_path) 502 + assert char["state"]["purse"]["gp"] == 2 503 + assert "silver" not in result.lower() 504 + 505 + def test_add_effect(self, kira: ToolContext): 506 + result = call("add_effect", { 507 + "source": "Bless", "description": "+1d4 attacks/saves", 508 + }) 509 + assert result 510 + 511 + def test_add_effect_with_expires(self, kira: ToolContext): 512 + result = call("add_effect", { 513 + "source": "Heroism", "description": "+10 temp HP", 514 + "expires": "d1-1430", 515 + }) 516 + assert result 517 + 518 + def test_remove_effect(self, kira: ToolContext): 519 + call("add_effect", {"source": "Bless", "description": "+1d4"}) 520 + result = call("remove_effect", {"source": "Bless"}) 521 + assert result 522 + 523 + def test_add_and_remove_condition(self, kira: ToolContext): 524 + result = call("add_condition", {"name": "Poisoned"}) 525 + assert result 526 + result = call("remove_condition", {"name": "Poisoned"}) 527 + assert result 528 + 529 + def test_add_item_default_location(self, kira: ToolContext): 530 + result = call("add_item", {"item": "Lockpicks"}) 531 + assert result 532 + 533 + def test_add_item_with_location(self, kira: ToolContext): 534 + result = call("add_item", { 535 + "item": "Spare cloak", "location": "stashed_at_inn", 536 + }) 537 + assert result 538 + 539 + def test_remove_item(self, kira: ToolContext): 540 + call("add_item", {"item": "Boot knife"}) 541 + result = call("remove_item", {"item": "Boot knife"}) 542 + assert result 543 + 544 + def test_set_item_status(self, kira: ToolContext): 545 + # The item must already be a known magic item entity for status tracking 546 + call("establish", { 547 + "entity_type": "items", "name": "Bracer of Defense", 548 + "description": "A leather bracer with a faint silver sheen.", 549 + }) 550 + result = call("set_item_status", { 551 + "item": "Bracer of Defense", "status": "attuned", 552 + }) 553 + assert result 554 + 555 + def test_use_and_restore_resource(self, kira: ToolContext): 556 + # Add a resource via update_character first 557 + call("update_character", { 558 + "updates": { 559 + "resources.hit_dice_d10": { 560 + "current": 1, "max": 1, "refresh": "long_rest", 561 + "notes": "Hit Dice (d10)", 562 + }, 563 + }, 564 + }) 565 + result = call("use_resource", {"name": "hit_dice", "amount": 1}) 566 + assert "Used" in result 567 + result = call("restore_resource", {"name": "hit_dice", "amount": 1}) 568 + assert "Restored" in result 569 + 570 + def test_rest_short(self, kira: ToolContext): 571 + result = call("rest", {"type": "short"}) 572 + assert result 573 + 574 + def test_rest_long(self, kira: ToolContext): 575 + result = call("rest", {"type": "long"}) 576 + assert result 577 + 578 + def test_add_note(self, kira: ToolContext): 579 + result = call("add_note", {"text": "Found a hidden passage"}) 580 + assert result 581 + 582 + 583 + # --- Scene/world wrappers -------------------------------------------------- 584 + 585 + 586 + class TestSceneToolWrappers: 587 + """Cover the scene.py wrapper bodies — set_scene's optional-field branches, 588 + tune's file write, end_session's threads branch, notify_dm's append.""" 589 + 590 + def test_set_scene_event_only(self, ctx: ToolContext): 591 + result = call("set_scene", { 592 + "event": "Walked into the tavern", 593 + "duration": "5 min", 594 + }) 595 + assert "Logged" in result 596 + 597 + def test_set_scene_with_situation_and_location(self, ctx: ToolContext): 598 + result = call("set_scene", { 599 + "event": "Arrived at the inn", 600 + "duration": "1 hour", 601 + "situation": "Resting by the fire", 602 + "location": "The Rusty Anchor", 603 + }) 604 + assert "Logged" in result 605 + assert "updated" in result.lower() 606 + 607 + def test_set_scene_with_present_auto_marks(self, ctx: ToolContext): 608 + # Establish an entity first so auto-mark has something to find 609 + call("establish", { 610 + "entity_type": "npcs", "name": "Vera", 611 + "description": "Tavern owner.", 612 + }) 613 + result = call("set_scene", { 614 + "event": "Spoke with Vera", 615 + "duration": "10 min", 616 + "present": ["[[Vera]]"], 617 + }) 618 + assert "Auto-marked: Vera" in result 619 + 620 + def test_set_scene_with_threads(self, ctx: ToolContext): 621 + result = call("set_scene", { 622 + "event": "Got a lead", 623 + "duration": "5 min", 624 + "threads": ["Find the missing merchant"], 625 + }) 626 + assert result 627 + 628 + def test_set_scene_no_args_returns_no_updates(self, ctx: ToolContext): 629 + # Both event and duration omitted, no other fields → "No updates" 630 + result = call("set_scene", {}) 631 + assert result == "No updates" 632 + 633 + def test_tune_writes_style_file(self, ctx: ToolContext): 634 + result = call("tune", {"tuning": "Lean into intrigue and slow pacing."}) 635 + assert "updated" in result.lower() 636 + style_path = ctx.base_path / "worlds" / ctx.world_id / "style.md" 637 + assert style_path.exists() 638 + assert "intrigue" in style_path.read_text() 639 + 640 + def test_end_session_no_threads(self, ctx: ToolContext): 641 + result = call("end_session", {"situation": "In the tavern"}) 642 + assert result == "SESSION_ENDED" 643 + 644 + def test_end_session_with_threads(self, ctx: ToolContext): 645 + result = call("end_session", { 646 + "situation": "In the tavern", 647 + "threads": ["Investigate the warehouse", "Find the merchant"], 648 + }) 649 + assert result == "SESSION_ENDED" 650 + 651 + def test_notify_dm_appends_to_queue(self, ctx: ToolContext): 652 + result = call("notify_dm", {"message": "Background world has shifted"}) 653 + assert "queued" in result.lower() 654 + path = ctx.base_path / "worlds" / ctx.world_id / "dm_notifications.md" 655 + assert path.exists() 656 + assert "Background world has shifted" in path.read_text() 657 + 658 + 659 + # --- run_code wrapper ------------------------------------------------------- 660 + 661 + 662 + class TestRunCodeWrapper: 663 + def test_run_code_simple(self, ctx: ToolContext): 664 + result = call("run_code", { 665 + "description": "Two plus two", 666 + "code": "2 + 2", 667 + }) 668 + assert "4" in result 669 + 670 + def test_run_code_with_print(self, ctx: ToolContext): 671 + result = call("run_code", { 672 + "description": "Print test", 673 + "code": 'print("hello sandbox")', 674 + }) 675 + assert "hello sandbox" in result
+9 -74
tests/test_initiative.py
··· 1 - """Tests for initiative tracking system.""" 1 + """Tests for the initiative tracking state machine. 2 + 3 + The FastMCP combat tool surface is tested separately in test_mcp_server.py 4 + and via the in-memory client in test_execute_tool.py. This file covers 5 + just the InitiativeTracker dataclass behavior. 6 + """ 2 7 3 8 import pytest 4 9 5 10 from storied.initiative import ( 6 - ALL_INITIATIVE_TOOL_NAMES, 7 - COMBAT_TOOL_DEFINITIONS, 8 - ENTER_INITIATIVE_DEFINITION, 9 - INITIATIVE_KEEP_NARRATIVE, 10 11 Combatant, 11 12 InitiativeTracker, 12 13 TrackedCondition, 13 - execute_initiative_tool, 14 14 ) 15 15 16 16 ··· 437 437 assert "Round" in context 438 438 439 439 440 - class TestDispatch: 441 - def test_all_tool_names_present(self): 442 - expected = { 443 - "enter_initiative", "next_turn", "add_combatant", 444 - "remove_combatant", "damage", "heal", "condition", 445 - "end_initiative", 446 - } 447 - assert ALL_INITIATIVE_TOOL_NAMES == expected 448 - 449 - def test_enter_initiative_definition_exists(self): 450 - assert ENTER_INITIATIVE_DEFINITION["name"] == "enter_initiative" 451 - 452 - def test_combat_definitions_exclude_enter(self): 453 - names = {d["name"] for d in COMBAT_TOOL_DEFINITIONS} 454 - assert "enter_initiative" not in names 455 - assert "next_turn" in names 456 - 457 - def test_keep_narrative_tools(self): 458 - assert INITIATIVE_KEEP_NARRATIVE == frozenset({"roll", "recall", "update_character"}) 459 - 460 - def test_dispatch_routes_enter(self, tracker: InitiativeTracker): 461 - result = execute_initiative_tool( 462 - "enter_initiative", 463 - {"combatants": [ 464 - {"name": "Kira", "initiative": 18, "hp": 25, "hp_max": 25, "ac": 16, "is_player": True}, 465 - {"name": "Goblin", "initiative": 10, "hp": 7, "hp_max": 7, "ac": 15}, 466 - ]}, 467 - tracker, 468 - ) 469 - 470 - assert tracker.active 471 - assert "Kira" in result 472 - 473 - def test_dispatch_routes_damage(self, tracker: InitiativeTracker): 474 - tracker.begin([ 475 - Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 476 - ]) 477 - 478 - result = execute_initiative_tool( 479 - "damage", {"target": "Goblin", "amount": 3}, tracker, 480 - ) 481 - 482 - assert "3" in result 483 - 484 - def test_dispatch_unknown_tool(self, tracker: InitiativeTracker): 485 - result = execute_initiative_tool("fake_tool", {}, tracker) 486 - 487 - assert result is None 488 - 489 - def test_guard_when_inactive(self, tracker: InitiativeTracker): 490 - result = execute_initiative_tool( 491 - "next_turn", {}, tracker, 492 - ) 493 - 494 - assert "not active" in result.lower() 495 - 496 - def test_enter_errors_when_active(self, tracker: InitiativeTracker): 497 - tracker.begin([ 498 - Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 499 - ]) 500 - 501 - result = execute_initiative_tool( 502 - "enter_initiative", 503 - {"combatants": [{"name": "X", "initiative": 1, "hp": 1, "hp_max": 1, "ac": 10}]}, 504 - tracker, 505 - ) 506 - 507 - assert "already active" in result.lower() 440 + # Dispatcher tests removed — the FastMCP combat tools live in 441 + # storied.tools.combat now and are exercised end-to-end via 442 + # tests/test_mcp_server.py and tests/test_execute_tool.py.
+261 -84
tests/test_mcp_server.py
··· 1 - """Tests for the MCP server tool dispatch.""" 1 + """Tests for the FastMCP server composition. 2 + 3 + The orchestrator in storied.mcp_server builds a per-role top-level FastMCP 4 + server by mounting the tools/*.py module-level FastMCP instances and 5 + applying tag-based visibility filters. These tests verify the per-role 6 + tool visibility plus the dynamic combat-tag flip when initiative starts 7 + and ends. 8 + """ 9 + 10 + import asyncio 2 11 3 12 import pytest 4 13 5 - from storied.initiative import ( 6 - COMBAT_TOOL_DEFINITIONS, 7 - ENTER_INITIATIVE_DEFINITION, 8 - INITIATIVE_KEEP_NARRATIVE, 9 - Combatant, 10 - ) 11 - from storied.mcp_server import _dm_tool_definitions, _to_mcp_tool 12 - from storied.tools import TOOL_DEFINITIONS, ToolContext 14 + from storied.initiative import Combatant 15 + from storied.mcp_server import _compose_server 16 + from storied.tools import ToolContext, _context 17 + from storied.tools.combat import _flip_into_combat, _flip_out_of_combat 18 + 19 + 20 + def _names(role: str) -> set[str]: 21 + async def _gather() -> set[str]: 22 + server = await _compose_server(role) 23 + return {t.name for t in await server.list_tools()} 24 + return asyncio.run(_gather()) 25 + 26 + 27 + class TestPerRoleComposition: 28 + """Each role sees only the tools tagged for it.""" 29 + 30 + def test_dm_includes_core_narrative_tools(self): 31 + names = _names("dm") 32 + assert "set_scene" in names 33 + assert "establish" in names 34 + assert "mark" in names 35 + assert "end_session" in names 36 + assert "recall" in names 37 + assert "roll" in names 38 + assert "run_code" in names 39 + 40 + def test_dm_includes_character_tools(self): 41 + names = _names("dm") 42 + for tool_name in ( 43 + "damage", "heal", "adjust_coins", "add_effect", "remove_effect", 44 + "add_condition", "remove_condition", "add_item", "remove_item", 45 + "set_item_status", "use_resource", "restore_resource", "rest", 46 + "add_note", "update_character", "create_character", 47 + ): 48 + assert tool_name in names, f"missing {tool_name}" 49 + 50 + def test_dm_initial_excludes_combat_tools(self): 51 + """In DM mode, combat tools are hidden until enter_initiative runs.""" 52 + names = _names("dm") 53 + assert "next_turn" not in names 54 + assert "add_combatant" not in names 55 + assert "remove_combatant" not in names 56 + assert "condition" not in names 57 + 58 + def test_dm_initial_keeps_combat_control(self): 59 + """enter_initiative and end_initiative stay visible so combat can begin.""" 60 + names = _names("dm") 61 + assert "enter_initiative" in names 62 + assert "end_initiative" in names 63 + 64 + def test_planner_only_has_its_tools(self): 65 + assert _names("planner") == {"establish", "mark", "notify_dm", "recall"} 66 + 67 + def test_seeder_only_has_its_tools(self): 68 + assert _names("seeder") == {"establish", "set_scene"} 69 + 70 + def test_advancement_only_has_its_tools(self): 71 + assert _names("advancement") == {"notify_dm", "recall", "update_character"} 72 + 73 + 74 + class TestToolSchemas: 75 + """Verify tool input schemas expose nested field shapes to the LLM. 76 + 77 + These tests guard against regression to bare `dict` / `list` parameter 78 + types, which leave the LLM with no guidance about which keys are required. 79 + """ 80 + 81 + def _schema(self, tool_name: str) -> dict: 82 + async def _gather() -> dict: 83 + server = await _compose_server("dm") 84 + for t in await server.list_tools(): 85 + if t.name == tool_name: 86 + return t.parameters 87 + raise AssertionError(f"tool {tool_name!r} not found") 88 + return asyncio.run(_gather()) 13 89 90 + def test_enter_initiative_documents_combatant_shape(self): 91 + schema = self._schema("enter_initiative") 92 + item_schema = schema["properties"]["combatants"]["items"] 93 + required = set(item_schema["required"]) 94 + assert {"name", "initiative", "hp", "hp_max", "ac"} <= required, ( 95 + f"enter_initiative must require all combatant fields, got {required}" 96 + ) 97 + # is_player is optional but documented 98 + assert "is_player" in item_schema["properties"] 14 99 15 - class TestToMcpTool: 16 - def test_converts_name(self): 17 - defn = TOOL_DEFINITIONS[0] # roll 18 - tool = _to_mcp_tool(defn) 19 - assert tool.name == "roll" 100 + def test_create_character_documents_ability_keys(self): 101 + schema = self._schema("create_character") 102 + ability_props = schema["properties"]["abilities"]["properties"] 103 + for ability in ("strength", "dexterity", "constitution", 104 + "intelligence", "wisdom", "charisma"): 105 + assert ability in ability_props, ( 106 + f"create_character must document the {ability} ability score" 107 + ) 20 108 21 - def test_converts_description(self): 22 - defn = TOOL_DEFINITIONS[0] 23 - tool = _to_mcp_tool(defn) 24 - assert tool.description is not None 25 - assert len(tool.description) > 0 109 + def test_adjust_coins_documents_denominations(self): 110 + schema = self._schema("adjust_coins") 111 + delta_props = schema["properties"]["deltas"]["properties"] 112 + for denom in ("cp", "sp", "ep", "gp", "pp"): 113 + assert denom in delta_props, ( 114 + f"adjust_coins must document the {denom} denomination" 115 + ) 26 116 27 - def test_converts_input_schema(self): 28 - defn = TOOL_DEFINITIONS[0] 29 - tool = _to_mcp_tool(defn) 30 - assert tool.inputSchema["type"] == "object" 31 - assert "notation" in tool.inputSchema["properties"] 117 + def test_create_character_purse_documents_denominations(self): 118 + schema = self._schema("create_character") 119 + purse = schema["properties"]["purse"] 120 + # Purse is wrapped in anyOf for the | None 121 + purse_props = next( 122 + opt for opt in purse["anyOf"] if opt.get("type") == "object" 123 + )["properties"] 124 + for denom in ("cp", "sp", "ep", "gp", "pp"): 125 + assert denom in purse_props 32 126 33 127 @pytest.mark.parametrize( 34 - "defn", TOOL_DEFINITIONS, ids=[d["name"] for d in TOOL_DEFINITIONS] 128 + "tool_name,param,expected_values", 129 + [ 130 + ("rest", "type", {"short", "long"}), 131 + ("set_item_status", "status", {"attuned", "equipped", "carried"}), 132 + ("recall", "scope", {"rules", "world", "all"}), 133 + ("establish", "entity_type", 134 + {"npcs", "locations", "items", "factions", "threads", "lore"}), 135 + ("mark", "entity_type", 136 + {"npcs", "locations", "items", "factions", "threads"}), 137 + ("note_discovery", "content_type", 138 + {"npcs", "locations", "factions", "lore"}), 139 + ], 35 140 ) 36 - def test_all_tools_convert(self, defn: dict): 37 - tool = _to_mcp_tool(defn) 38 - assert tool.name == defn["name"] 39 - assert tool.inputSchema is not None 141 + def test_enum_parameters_expose_valid_values( 142 + self, tool_name: str, param: str, expected_values: set[str], 143 + ): 144 + """Each conceptually-enum parameter must surface as a JSON Schema 145 + enum, not a free-form string. Guards against regression to bare `str`.""" 146 + schema = self._schema(tool_name) 147 + prop = schema["properties"][param] 148 + # Default values wrap the enum in anyOf for `Type | None`; unwrap if needed 149 + enum_values = prop.get("enum") 150 + if enum_values is None and "anyOf" in prop: 151 + for opt in prop["anyOf"]: 152 + if "enum" in opt: 153 + enum_values = opt["enum"] 154 + break 155 + assert enum_values is not None, ( 156 + f"{tool_name}.{param} should expose an enum, got {prop}" 157 + ) 158 + assert set(enum_values) == expected_values 40 159 160 + def test_combat_condition_enum_parameters(self): 161 + """The combat `condition` tool is hidden in the default DM compose 162 + (combat-only), so check it directly on the combat module.""" 163 + from storied.tools.combat import mcp as combat_mcp 41 164 42 - class TestDynamicDmTools: 43 - """Tests that the DM tool list changes based on initiative state.""" 165 + async def _gather() -> dict: 166 + for t in await combat_mcp.list_tools(): 167 + if t.name == "condition": 168 + return t.parameters 169 + raise AssertionError("condition tool not found") 44 170 45 - def test_narrative_mode_includes_enter_initiative(self, ctx: ToolContext): 46 - defs = _dm_tool_definitions(ctx) 47 - names = {d["name"] for d in defs} 171 + params = asyncio.run(_gather()) 172 + assert set(params["properties"]["action"]["enum"]) == {"add", "remove"} 173 + assert set(params["properties"]["ends_on"]["enum"]) == {"start", "end"} 48 174 49 - assert "enter_initiative" in names 50 175 51 - def test_narrative_mode_includes_all_narrative_tools(self, ctx: ToolContext): 52 - defs = _dm_tool_definitions(ctx) 53 - names = {d["name"] for d in defs} 176 + class TestCombatTagFlip: 177 + """Entering and ending initiative toggles combat-tag visibility on the 178 + composed top-level server.""" 54 179 55 - assert "set_scene" in names 56 - assert "establish" in names 57 - assert "end_session" in names 180 + def test_flip_into_combat_shows_combat_tools(self, ctx: ToolContext): 181 + async def _run() -> set[str]: 182 + server = await _compose_server("dm") 183 + _flip_into_combat() 184 + return {t.name for t in await server.list_tools()} 58 185 59 - def test_narrative_mode_excludes_combat_tools(self, ctx: ToolContext): 60 - defs = _dm_tool_definitions(ctx) 61 - names = {d["name"] for d in defs} 186 + names = asyncio.run(_run()) 187 + assert "next_turn" in names 188 + assert "add_combatant" in names 189 + assert "condition" in names 190 + # Cleanup 191 + _flip_out_of_combat() 62 192 193 + def test_flip_out_of_combat_hides_combat_tools(self, ctx: ToolContext): 194 + async def _run() -> set[str]: 195 + server = await _compose_server("dm") 196 + _flip_into_combat() 197 + _flip_out_of_combat() 198 + return {t.name for t in await server.list_tools()} 199 + 200 + names = asyncio.run(_run()) 63 201 assert "next_turn" not in names 64 - assert "end_initiative" not in names 65 - # Note: damage/heal exist as character tools out of combat (no target), 66 - # and as initiative tools in combat (with target). They share names. 67 - assert "damage" in names # the character version 202 + assert "add_combatant" not in names 68 203 69 - def test_narrative_mode_count(self, ctx: ToolContext): 70 - defs = _dm_tool_definitions(ctx) 204 + def test_combat_control_stays_visible_through_cycle(self, ctx: ToolContext): 205 + """enter_initiative / end_initiative are tagged combat_control and 206 + must stay visible whether initiative is active or not.""" 207 + async def _gather_combat_control() -> tuple[set[str], set[str], set[str]]: 208 + server = await _compose_server("dm") 209 + initial = {t.name for t in await server.list_tools()} 210 + _flip_into_combat() 211 + during = {t.name for t in await server.list_tools()} 212 + _flip_out_of_combat() 213 + after = {t.name for t in await server.list_tools()} 214 + return initial, during, after 71 215 72 - assert len(defs) == len(TOOL_DEFINITIONS) + 1 # +1 for enter_initiative 216 + initial, during, after = asyncio.run(_gather_combat_control()) 217 + for state in (initial, during, after): 218 + assert "enter_initiative" in state 219 + assert "end_initiative" in state 73 220 74 - def test_initiative_mode_includes_combat_tools(self, ctx: ToolContext): 75 - ctx.initiative.begin([ 76 - Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16), 77 - ]) 78 221 79 - defs = _dm_tool_definitions(ctx) 80 - names = {d["name"] for d in defs} 222 + class TestPopulateIndex: 223 + """Cover the SRD-seeding helper without launching a real server.""" 81 224 82 - assert "next_turn" in names 83 - assert "damage" in names 84 - assert "end_initiative" in names 225 + def test_no_srd_no_world_dir(self, tmp_path): 226 + from unittest.mock import MagicMock 85 227 86 - def test_initiative_mode_keeps_narrative_subset(self, ctx: ToolContext): 87 - ctx.initiative.begin([ 88 - Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16), 89 - ]) 228 + from storied.mcp_server import _populate_index 90 229 91 - defs = _dm_tool_definitions(ctx) 92 - names = {d["name"] for d in defs} 230 + vi = MagicMock() 231 + _populate_index(tmp_path, tmp_path / "worlds" / "missing", vi) 232 + # No SRD seed, no SRD sections, no world dir → nothing should be called 233 + vi.reseed.assert_not_called() 234 + vi.reindex_directory.assert_not_called() 93 235 94 - for tool_name in INITIATIVE_KEEP_NARRATIVE: 95 - assert tool_name in names 236 + def test_world_dir_only(self, tmp_path): 237 + from unittest.mock import MagicMock 96 238 97 - def test_initiative_mode_excludes_narrative_only_tools(self, ctx: ToolContext): 98 - ctx.initiative.begin([ 99 - Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16), 100 - ]) 239 + from storied.mcp_server import _populate_index 101 240 102 - defs = _dm_tool_definitions(ctx) 103 - names = {d["name"] for d in defs} 241 + world_dir = tmp_path / "worlds" / "test" 242 + world_dir.mkdir(parents=True) 243 + vi = MagicMock() 244 + _populate_index(tmp_path, world_dir, vi) 245 + vi.reindex_directory.assert_called_once_with(world_dir, source="world") 104 246 105 - assert "set_scene" not in names 106 - assert "establish" not in names 107 - assert "enter_initiative" not in names 247 + def test_srd_sections_dir(self, tmp_path): 248 + from unittest.mock import MagicMock 108 249 109 - def test_initiative_mode_count(self, ctx: ToolContext): 110 - ctx.initiative.begin([ 111 - Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16), 112 - ]) 250 + from storied.mcp_server import _populate_index 113 251 114 - defs = _dm_tool_definitions(ctx) 115 - expected = len(INITIATIVE_KEEP_NARRATIVE) + len(COMBAT_TOOL_DEFINITIONS) 252 + srd_dir = tmp_path / "rules" / "srd-5.2.1" / "sections" 253 + srd_dir.mkdir(parents=True) 254 + world_dir = tmp_path / "worlds" / "test" 255 + vi = MagicMock() 256 + _populate_index(tmp_path, world_dir, vi) 257 + # SRD sections present → reindex SRD; no world dir → no second call 258 + assert vi.reindex_directory.call_count == 1 259 + vi.reindex_directory.assert_called_with(srd_dir, source="srd") 116 260 117 - assert len(defs) == expected 261 + def test_flip_helpers_no_op_when_root_unset(self): 262 + """The combat-tag flip helpers must not crash when no top-level 263 + server has been registered (e.g. when the combat module is imported 264 + in isolation by a test before any compose_server call).""" 265 + import storied.tools.combat as combat_mod 266 + 267 + # Snapshot and clear _root for the duration of the test 268 + saved_root = combat_mod._root 269 + saved_keys = combat_mod._combat_keys_to_hide 270 + combat_mod._root = None 271 + combat_mod._combat_keys_to_hide = set() 272 + try: 273 + combat_mod._flip_into_combat() # should silently no-op 274 + combat_mod._flip_out_of_combat() 275 + finally: 276 + combat_mod._root = saved_root 277 + combat_mod._combat_keys_to_hide = saved_keys 278 + 279 + def test_srd_seed_db_takes_priority(self, tmp_path): 280 + from unittest.mock import MagicMock 281 + 282 + from storied.mcp_server import _populate_index 283 + 284 + srd_seed = tmp_path / "rules" / "srd-5.2.1" / "search.db" 285 + srd_seed.parent.mkdir(parents=True) 286 + srd_seed.write_bytes(b"sqlite stub") 287 + # Also create the sections dir to verify the seed wins 288 + (tmp_path / "rules" / "srd-5.2.1" / "sections").mkdir() 289 + world_dir = tmp_path / "worlds" / "test" 290 + vi = MagicMock() 291 + _populate_index(tmp_path, world_dir, vi) 292 + vi.reseed.assert_called_once_with(srd_seed) 293 + # When the seed exists, we don't also reindex SRD sections 294 + vi.reindex_directory.assert_not_called()
+257
tests/test_notification_formatters.py
··· 1 + """Tests for the deferred-tool notification formatters. 2 + 3 + These are pure functions that turn accumulated tool input JSON into a 4 + human-readable label for the streaming TUI. Each formatter is exercised 5 + through its real DEFERRED_FORMATTERS lookup, so a missing entry in the 6 + dispatch table would surface here too. 7 + """ 8 + 9 + import pytest 10 + 11 + from storied.notification_formatters import ( 12 + DEFERRED_FORMATTERS, 13 + TOOL_LABELS, 14 + _extract_json_field, 15 + _extract_roll_reason, 16 + _parse_tool_args, 17 + ) 18 + 19 + 20 + # --- Low-level helpers ------------------------------------------------------ 21 + 22 + 23 + class TestParseToolArgs: 24 + def test_valid_json(self): 25 + assert _parse_tool_args('{"a": 1}') == {"a": 1} 26 + 27 + def test_empty_string(self): 28 + assert _parse_tool_args("") == {} 29 + 30 + def test_malformed_json(self): 31 + assert _parse_tool_args("{not valid}") == {} 32 + 33 + def test_null_json_returns_empty(self): 34 + # json.loads("null") returns None, the helper coerces to {} 35 + assert _parse_tool_args("null") == {} 36 + 37 + 38 + class TestExtractJsonField: 39 + def test_field_present(self): 40 + assert _extract_json_field('{"reason": "attack"}', "reason") == "attack" 41 + 42 + def test_field_missing(self): 43 + assert _extract_json_field('{"other": 1}', "reason") is None 44 + 45 + def test_malformed_returns_none(self): 46 + assert _extract_json_field("{bad}", "reason") is None 47 + 48 + def test_extract_roll_reason(self): 49 + assert _extract_roll_reason('{"reason": "Stealth"}') == "Stealth" 50 + 51 + def test_extract_roll_reason_missing(self): 52 + assert _extract_roll_reason('{"notation": "1d20"}') is None 53 + 54 + 55 + # --- Per-tool formatters ---------------------------------------------------- 56 + 57 + 58 + def _format(tool: str, tool_json: str) -> str: 59 + """Look up the formatter via DEFERRED_FORMATTERS and call it.""" 60 + return DEFERRED_FORMATTERS[tool](tool_json) 61 + 62 + 63 + class TestCoinNotification: 64 + def test_spend_one_denomination(self): 65 + result = _format("adjust_coins", '{"deltas": {"gp": -5}}') 66 + assert result == "Spending 5 gold" 67 + 68 + def test_receive_one_denomination(self): 69 + result = _format("adjust_coins", '{"deltas": {"gp": 10}}') 70 + assert result == "Receiving 10 gold" 71 + 72 + def test_spend_and_receive(self): 73 + result = _format("adjust_coins", '{"deltas": {"gp": -5, "sp": 3}}') 74 + assert "Spending 5 gold" in result 75 + assert "receiving 3 silver" in result 76 + 77 + def test_multiple_denominations_in_order(self): 78 + result = _format("adjust_coins", '{"deltas": {"pp": 1, "gp": 2, "cp": 7}}') 79 + # Output orders pp → gp → ep → sp → cp 80 + assert result == "Receiving 1 platinum, 2 gold, 7 copper" 81 + 82 + def test_zero_delta_omitted(self): 83 + result = _format("adjust_coins", '{"deltas": {"gp": -5, "sp": 0}}') 84 + assert "silver" not in result 85 + assert "Spending 5 gold" in result 86 + 87 + def test_no_deltas_at_all(self): 88 + result = _format("adjust_coins", '{"deltas": {}}') 89 + assert result == "Adjusting coins" 90 + 91 + def test_unknown_denomination_falls_back_to_code(self): 92 + # Defensive: unknown denom uses the raw key 93 + from storied.notification_formatters import _format_coin_notification 94 + result = _format_coin_notification('{"deltas": {"xx": 5}}') 95 + # _DENOM_NAMES doesn't include 'xx', so it falls through the loop 96 + # without matching any of the known denoms — output is generic 97 + assert result == "Adjusting coins" 98 + 99 + 100 + class TestDamageNotification: 101 + def test_with_target(self): 102 + assert _format("damage", '{"target": "Goblin", "amount": 7}') == "Goblin takes 7 damage" 103 + 104 + def test_with_type(self): 105 + assert _format("damage", '{"amount": 5, "type": "fire"}') == "Taking 5 fire damage" 106 + 107 + def test_plain(self): 108 + assert _format("damage", '{"amount": 3}') == "Taking 3 damage" 109 + 110 + def test_target_and_type_target_wins(self): 111 + result = _format("damage", '{"target": "Mira", "amount": 5, "type": "cold"}') 112 + assert "Mira takes 5 damage" == result 113 + 114 + def test_missing_amount_shows_question_mark(self): 115 + assert _format("damage", "{}") == "Taking ? damage" 116 + 117 + 118 + class TestHealNotification: 119 + def test_with_target(self): 120 + assert _format("heal", '{"target": "Mira", "amount": 5}') == "Healing Mira for 5" 121 + 122 + def test_plain(self): 123 + assert _format("heal", '{"amount": 8}') == "Healing 8 HP" 124 + 125 + 126 + class TestEffectNotification: 127 + def test_simple(self): 128 + result = _format("add_effect", '{"source": "Bless", "description": "+1d4"}') 129 + assert result == "Adding effect: Bless" 130 + 131 + def test_with_expires(self): 132 + result = _format( 133 + "add_effect", 134 + '{"source": "Heroism", "description": "+10 temp HP", "expires": "d28-1430"}', 135 + ) 136 + assert "Heroism" in result 137 + assert "until d28-1430" in result 138 + 139 + def test_remove_effect(self): 140 + assert _format("remove_effect", '{"source": "Bless"}') == "Removing effect: Bless" 141 + 142 + 143 + class TestConditionNotification: 144 + def test_add_condition(self): 145 + assert _format("add_condition", '{"name": "Poisoned"}') == "Becoming Poisoned" 146 + 147 + def test_remove_condition(self): 148 + assert _format("remove_condition", '{"name": "Frightened"}') == "Recovering from Frightened" 149 + 150 + 151 + class TestItemNotification: 152 + def test_add_item_no_location(self): 153 + assert _format("add_item", '{"item": "Lockpicks"}') == "Picking up 'Lockpicks'" 154 + 155 + def test_add_item_with_location(self): 156 + result = _format("add_item", '{"item": "Coin pouch", "location": "on_person"}') 157 + assert result == "Adding 'Coin pouch' to on_person" 158 + 159 + def test_remove_item(self): 160 + assert _format("remove_item", '{"item": "Boot knife"}') == "Removing 'Boot knife'" 161 + 162 + @pytest.mark.parametrize( 163 + "status,verb", 164 + [ 165 + ("attuned", "Attuning to"), 166 + ("equipped", "Equipping"), 167 + ("carried", "Stowing"), 168 + ("unknown_status", "Setting status of"), 169 + ], 170 + ) 171 + def test_set_item_status(self, status: str, verb: str): 172 + result = _format( 173 + "set_item_status", 174 + f'{{"item": "Bracer", "status": "{status}"}}', 175 + ) 176 + assert result == f"{verb} Bracer" 177 + 178 + 179 + class TestResourceNotification: 180 + def test_use_one(self): 181 + assert _format("use_resource", '{"name": "rage"}') == "Using rage" 182 + 183 + def test_use_multiple(self): 184 + assert _format("use_resource", '{"name": "ki", "amount": 3}') == "Using 3 of ki" 185 + 186 + def test_restore(self): 187 + assert _format("restore_resource", '{"name": "ki", "amount": 2}') == "Restoring 2 of ki" 188 + 189 + 190 + class TestRestNotification: 191 + def test_short(self): 192 + assert _format("rest", '{"type": "short"}') == "Taking a short rest" 193 + 194 + def test_long(self): 195 + assert _format("rest", '{"type": "long"}') == "Taking a long rest" 196 + 197 + def test_default_short(self): 198 + assert _format("rest", "{}") == "Taking a short rest" 199 + 200 + 201 + class TestNoteNotification: 202 + def test_short_text(self): 203 + assert _format("add_note", '{"text": "Found a key"}') == "Noting: Found a key" 204 + 205 + def test_long_text_truncated(self): 206 + text = "A" * 80 207 + result = _format("add_note", f'{{"text": "{text}"}}') 208 + assert result.endswith("...") 209 + assert len(result) < len(text) + 20 210 + 211 + 212 + class TestUpdateCharacterNotification: 213 + def test_no_updates(self): 214 + assert _format("update_character", '{"updates": {}}') == "Updating character" 215 + 216 + def test_one_key(self): 217 + result = _format("update_character", '{"updates": {"state.ac": 17}}') 218 + assert result == "Updating: state.ac" 219 + 220 + def test_three_keys(self): 221 + result = _format( 222 + "update_character", 223 + '{"updates": {"state.ac": 17, "state.speed": 30, "level": 4}}', 224 + ) 225 + assert "state.ac" in result 226 + assert "state.speed" in result 227 + assert "level" in result 228 + assert "..." not in result 229 + 230 + def test_more_than_three_keys_truncates(self): 231 + result = _format( 232 + "update_character", 233 + '{"updates": {"a": 1, "b": 2, "c": 3, "d": 4}}', 234 + ) 235 + assert result.endswith("...") 236 + 237 + 238 + # --- Dispatch table sanity -------------------------------------------------- 239 + 240 + 241 + class TestFormatterDispatchTable: 242 + def test_all_formatters_handle_empty_input(self): 243 + """Every registered formatter must produce a non-empty string for 244 + empty / malformed input rather than crashing.""" 245 + for tool_name, formatter in DEFERRED_FORMATTERS.items(): 246 + result = formatter("") 247 + assert isinstance(result, str) 248 + assert result, f"{tool_name} produced empty label for empty input" 249 + 250 + def test_every_deferred_tool_has_a_label(self): 251 + """TOOL_LABELS is the fallback when there's no formatter; every 252 + formatter-keyed tool should also have a label entry so the renderer 253 + can fall back gracefully.""" 254 + for tool_name in DEFERRED_FORMATTERS: 255 + assert tool_name in TOOL_LABELS, ( 256 + f"deferred tool {tool_name} has no entry in TOOL_LABELS" 257 + )
+12
tests/test_notifications.py
··· 56 56 57 57 messages = notifications.drain("new-world", tmp_path) 58 58 assert messages == ["Hello"] 59 + 60 + def test_drain_empty_existing_file_returns_empty( 61 + self, world: tuple[str, Path], 62 + ): 63 + """An existing notifications file with only whitespace drains 64 + as empty and is removed.""" 65 + world_id, base_path = world 66 + path = base_path / "worlds" / "test-world" / "dm_notifications.md" 67 + path.write_text(" \n \n") 68 + 69 + assert notifications.drain(world_id, base_path) == [] 70 + assert not path.exists()
+173 -24
tests/test_planner.py
··· 1 1 """Tests for the world planner — entity richness scoring, discovery, and context.""" 2 2 3 3 import json 4 + from pathlib import Path 4 5 from unittest.mock import MagicMock, patch 5 6 6 7 import pytest 7 8 8 9 from storied.planner import ( 10 + BackgroundTicker, 9 11 PlanResult, 12 + _find_entities_with_will, 10 13 build_planning_context, 14 + build_tick_context, 11 15 entity_richness, 12 16 find_nearby_entities, 13 17 plan_world, 14 18 ) 15 19 from storied.session import save_session 16 - from storied.tools import ToolContext, establish, mark 20 + from storied.tools import ToolContext 21 + from storied.tools.entities import establish, mark 22 + 23 + from tests.conftest import call_tool 17 24 18 25 19 26 @pytest.fixture 20 27 def populated_world(ctx: ToolContext) -> ToolContext: 21 28 """Create a world with some entities for testing.""" 22 - establish( 29 + call_tool( 30 + establish, 23 31 entity_type="locations", 24 32 name="Town Square", 25 - ctx=ctx, 26 33 description="The center of [[Millford]]. A fountain stands here, surrounded by market stalls.", 27 34 location="Central [[Millford]]", 28 35 knows=["The fountain was built by [[Old Gregor]]"], 29 36 wants=["To be a gathering place"], 30 37 will=["If market day → attract crowds"], 31 38 ) 32 - establish( 39 + call_tool( 40 + establish, 33 41 entity_type="npcs", 34 42 name="Old Gregor", 35 - ctx=ctx, 36 43 description="An elderly stonemason.", 37 44 location="[[Town Square]]", 38 45 ) 39 - establish( 46 + call_tool( 47 + establish, 40 48 entity_type="locations", 41 49 name="Millford", 42 - ctx=ctx, 43 50 description="A small riverside town.", 44 51 ) 45 - establish( 52 + call_tool( 53 + establish, 46 54 entity_type="npcs", 47 55 name="Thin NPC", 48 - ctx=ctx, 49 56 description="Someone with no inner life.", 50 57 ) 51 58 return ctx ··· 55 62 """Tests for richness scoring.""" 56 63 57 64 def test_empty_entity_scores_zero(self, ctx: ToolContext): 58 - establish( 59 - entity_type="npcs", 60 - name="Empty", 61 - ctx=ctx, 62 - ) 65 + call_tool(establish, entity_type="npcs", name="Empty") 63 66 path = ctx.base_path / "worlds/test-world/npcs/Empty.md" 64 67 assert entity_richness(path) == 0.0 65 68 66 69 def test_description_only(self, ctx: ToolContext): 67 - establish( 70 + call_tool( 71 + establish, 68 72 entity_type="npcs", 69 73 name="Described", 70 - ctx=ctx, 71 74 description="A tall warrior.", 72 75 ) 73 76 path = ctx.base_path / "worlds/test-world/npcs/Described.md" 74 77 assert entity_richness(path) == pytest.approx(0.2) 75 78 76 79 def test_fully_rich_entity(self, ctx: ToolContext): 77 - establish( 80 + call_tool( 81 + establish, 78 82 entity_type="npcs", 79 83 name="Rich NPC", 80 - ctx=ctx, 81 84 description="A fully fleshed out character in [[Town Square]].", 82 85 knows=["Secret one", "Secret two"], 83 86 wants=["Goal one"], 84 87 will=["If X → do Y"], 85 88 ) 86 - mark( 89 + call_tool( 90 + mark, 87 91 entity_type="npcs", 88 92 name="Rich NPC", 89 93 event="Something happened", 90 - ctx=ctx, 91 94 ) 92 95 path = ctx.base_path / "worlds/test-world/npcs/Rich NPC.md" 93 96 score = entity_richness(path) 94 97 assert score == pytest.approx(1.0) 95 98 96 99 def test_partial_richness(self, ctx: ToolContext): 97 - establish( 100 + call_tool( 101 + establish, 98 102 entity_type="npcs", 99 103 name="Partial", 100 - ctx=ctx, 101 104 description="Has description and knows.", 102 105 knows=["A secret"], 103 106 ) ··· 107 110 assert score == pytest.approx(0.4) 108 111 109 112 def test_wikilinks_contribute(self, ctx: ToolContext): 110 - establish( 113 + call_tool( 114 + establish, 111 115 entity_type="npcs", 112 116 name="Linked", 113 - ctx=ctx, 114 117 description="Hangs out at [[The Tavern]] with [[Bob]].", 115 118 ) 116 119 path = ctx.base_path / "worlds/test-world/npcs/Linked.md" ··· 419 422 ) 420 423 421 424 assert result.tool_calls == 1 425 + 426 + 427 + # --- World tick helpers (subprocess-bound tick_world is pragma'd out) ------ 428 + 429 + 430 + class TestFindEntitiesWithWill: 431 + def test_returns_empty_when_world_missing(self, ctx: ToolContext, tmp_path: Path): 432 + results = _find_entities_with_will("nonexistent", tmp_path) 433 + assert results == [] 434 + 435 + def test_finds_entities_with_will_triggers(self, populated_world: ToolContext): 436 + # Establish one with will, one without 437 + call_tool( 438 + establish, 439 + entity_type="npcs", 440 + name="Triggered", 441 + description="Has triggers.", 442 + will=["If approached → flee"], 443 + ) 444 + call_tool( 445 + establish, 446 + entity_type="npcs", 447 + name="Idle", 448 + description="No triggers.", 449 + ) 450 + results = _find_entities_with_will( 451 + populated_world.world_id, populated_world.base_path, 452 + ) 453 + names = {name for name, _ in results} 454 + assert "Triggered" in names 455 + assert "Idle" not in names 456 + 457 + def test_skips_missing_type_directories( 458 + self, populated_world: ToolContext, 459 + ): 460 + # The fixture creates npcs and locations but not items/factions/threads. 461 + # The function should iterate without crashing. 462 + results = _find_entities_with_will( 463 + populated_world.world_id, populated_world.base_path, 464 + ) 465 + assert isinstance(results, list) 466 + 467 + 468 + class TestBuildTickContext: 469 + def test_includes_current_time(self, populated_world: ToolContext): 470 + ctx_str = build_tick_context( 471 + populated_world.world_id, populated_world.player_id, 472 + populated_world.base_path, entities=[], 473 + ) 474 + assert "Current Game Time" in ctx_str 475 + 476 + def test_includes_session_location(self, populated_world: ToolContext): 477 + save_session("default", { 478 + "location": "Town Square", 479 + "body": "## Present\n- [[Old Gregor]]", 480 + }, populated_world.base_path) 481 + 482 + ctx_str = build_tick_context( 483 + populated_world.world_id, populated_world.player_id, 484 + populated_world.base_path, entities=[], 485 + ) 486 + assert "Town Square" in ctx_str 487 + assert "Old Gregor" in ctx_str 488 + 489 + def test_includes_recent_events(self, populated_world: ToolContext): 490 + populated_world.campaign_log.append_entry( 491 + "Met the merchant", "10 min", 492 + ) 493 + populated_world.campaign_log.append_entry( 494 + "Found the secret door", "5 min", 495 + ) 496 + ctx_str = build_tick_context( 497 + populated_world.world_id, populated_world.player_id, 498 + populated_world.base_path, entities=[], 499 + ) 500 + assert "Recent Events" in ctx_str 501 + assert "Met the merchant" in ctx_str 502 + 503 + def test_includes_entity_with_triggers(self, populated_world: ToolContext): 504 + call_tool( 505 + establish, 506 + entity_type="npcs", 507 + name="Lurker", 508 + description="Hides in shadows.", 509 + will=["If alone → emerge"], 510 + ) 511 + triggers = _find_entities_with_will( 512 + populated_world.world_id, populated_world.base_path, 513 + ) 514 + ctx_str = build_tick_context( 515 + populated_world.world_id, populated_world.player_id, 516 + populated_world.base_path, entities=triggers, 517 + ) 518 + assert "Active Triggers" in ctx_str 519 + assert "Lurker" in ctx_str 520 + assert "If alone → emerge" in ctx_str 521 + 522 + 523 + class TestBackgroundTicker: 524 + """Cover the non-subprocess paths of BackgroundTicker. 525 + 526 + The threaded `_run` method is pragma'd because it spawns a real claude 527 + subprocess. Everything around it (init, day-tracking, no-op fast paths) 528 + is testable here. 529 + """ 530 + 531 + def test_init_stores_state(self, tmp_path: Path): 532 + ticker = BackgroundTicker( 533 + world_id="test", player_id="default", base_path=tmp_path, 534 + ) 535 + assert ticker._world_id == "test" 536 + assert ticker._last_tick_day == 0 537 + assert ticker._thread is None 538 + 539 + def test_maybe_tick_skips_when_day_unchanged( 540 + self, populated_world: ToolContext, 541 + ): 542 + ticker = BackgroundTicker( 543 + world_id=populated_world.world_id, 544 + player_id=populated_world.player_id, 545 + base_path=populated_world.base_path, 546 + ) 547 + ticker._last_tick_day = populated_world.campaign_log.current_day 548 + ticker.maybe_tick(populated_world.campaign_log) 549 + # No thread should have been spawned 550 + assert ticker._thread is None 551 + 552 + def test_maybe_tick_skips_when_no_triggers(self, ctx: ToolContext): 553 + # ctx has a fresh empty world with no entities → no triggers, 554 + # so maybe_tick should mark the day done without spawning anything. 555 + ticker = BackgroundTicker( 556 + world_id=ctx.world_id, 557 + player_id=ctx.player_id, 558 + base_path=ctx.base_path, 559 + ) 560 + # Advance the day so the day-changed guard passes 561 + ctx.campaign_log.append_entry("Travel", "18 hours") 562 + ticker.maybe_tick(ctx.campaign_log) 563 + assert ticker._thread is None 564 + assert ticker._last_tick_day == ctx.campaign_log.current_day 565 + 566 + def test_pop_result_returns_none_when_no_thread(self, tmp_path: Path): 567 + ticker = BackgroundTicker( 568 + world_id="test", player_id="default", base_path=tmp_path, 569 + ) 570 + assert ticker.pop_result() is None
+15 -12
tests/test_sandbox.py
··· 120 120 121 121 122 122 class TestToolBridge: 123 - """DM tools exposed as direct host functions.""" 123 + """DM tools exposed as direct host functions. 124 + 125 + The sandbox resolves Dependency parameters from the process-global 126 + ToolContext (set up by the `ctx` fixture), so callers don't have to 127 + pass `ctx` explicitly anymore. 128 + """ 124 129 125 130 def test_recall(self, ctx: ToolContext): 126 - result = execute("recall(query='nonexistent')", ctx=ctx) 131 + result = execute("recall(query='nonexistent')") 127 132 128 133 assert "Nothing found" in result 129 134 130 135 def test_establish(self, ctx: ToolContext): 131 136 result = execute( 132 137 "establish(entity_type='npc', name='Bob the Baker', " 133 - "description='A friendly baker')", 134 - ctx=ctx, 138 + "description='A friendly baker')" 135 139 ) 136 140 137 141 assert "Bob" in result 138 142 139 - def test_tools_not_available_without_ctx(self): 140 - result = execute("recall(query='test')") 141 - 142 - assert "Error" in result 143 - 144 143 145 144 class TestToolSignatures: 146 145 """Dynamic signature builder for the run_code tool description.""" ··· 152 151 "enter_initiative", "end_initiative", "next_turn"]: 153 152 assert f"{name}(" in sigs 154 153 155 - def test_excludes_internal_params(self): 154 + def test_excludes_dependency_params(self): 155 + """Dependency-default params are wrapper bookkeeping; the LLM-facing 156 + signature should drop them all.""" 156 157 sigs = build_tool_signatures() 157 158 158 - assert "ctx:" not in sigs 159 - assert "tracker:" not in sigs 159 + # None of the Dependency-class instances should appear as defaults 160 + for marker in ("Combat()", "Lore()", "StorageRoot()", "Player()", 161 + "Timekeeper()", "Entities()", "World()"): 162 + assert marker not in sigs
+15 -39
tests/test_seeder.py
··· 1 1 """Tests for world seeding — empty world detection and initial worldbuilding.""" 2 2 3 + import asyncio 3 4 import json 4 5 from pathlib import Path 5 6 from unittest.mock import MagicMock, patch ··· 7 8 import pytest 8 9 9 10 from storied.character import create_character 11 + from storied.mcp_server import _compose_server 10 12 from storied.planner import SeedResult, seed_world 11 - from storied.tools import ( 12 - SEEDER_TOOL_DEFINITIONS, 13 - SEEDER_TOOLS, 14 - ToolContext, 15 - seeder_execute_tool, 16 - ) 13 + from storied.tools import ToolContext 17 14 18 15 19 16 class TestSeederTools: 20 - """Tests for seeder tool filtering.""" 17 + """Tests for seeder tool filtering via tag-based composition.""" 21 18 22 - def test_seeder_tools_set(self): 23 - assert SEEDER_TOOLS == {"establish", "set_scene"} 24 - 25 - def test_seeder_tool_definitions_filtered(self): 26 - names = {t["name"] for t in SEEDER_TOOL_DEFINITIONS} 27 - assert names == SEEDER_TOOLS 28 - 29 - def test_seeder_rejects_disallowed_tools(self, ctx: ToolContext): 30 - for tool_name in ["roll", "recall", "mark", "mark_time", "note_discovery", "end_session"]: 31 - result = seeder_execute_tool(tool_name, {}, ctx) 32 - assert "not available to seeder" in result 19 + def _seeder_names(self) -> set[str]: 20 + async def _gather() -> set[str]: 21 + server = await _compose_server("seeder") 22 + return {t.name for t in await server.list_tools()} 23 + return asyncio.run(_gather()) 33 24 34 - def test_seeder_allows_establish(self, ctx: ToolContext): 35 - (ctx.base_path / "worlds" / ctx.world_id / "npcs").mkdir(parents=True, exist_ok=True) 36 - result = seeder_execute_tool( 37 - "establish", 38 - {"entity_type": "npcs", "name": "Test NPC", "description": "A test."}, 39 - ctx, 40 - ) 41 - assert "Established" in result 25 + def test_seeder_only_has_establish_and_set_scene(self): 26 + assert self._seeder_names() == {"establish", "set_scene"} 42 27 43 - def test_seeder_allows_set_scene(self, ctx: ToolContext): 44 - (ctx.base_path / "players" / ctx.player_id).mkdir(parents=True) 45 - result = seeder_execute_tool( 46 - "set_scene", 47 - { 48 - "event": "Dawn breaks", 49 - "duration": "0 min", 50 - "situation": "Walking through the forest.", 51 - }, 52 - ctx, 53 - ) 54 - assert "Logged" in result 55 - assert "Session updated" in result 28 + def test_seeder_excludes_disallowed_tools(self): 29 + names = self._seeder_names() 30 + for forbidden in ("roll", "recall", "mark", "note_discovery", "end_session"): 31 + assert forbidden not in names 56 32 57 33 58 34 @pytest.fixture
+14 -18
tests/test_tune.py
··· 1 1 """Tests for the DM style tuning system.""" 2 2 3 - from storied.tools import ToolContext, execute_tool, tune 3 + from storied.tools import ToolContext 4 + from storied.tools.scene import tune as _tune 5 + 6 + from tests.conftest import call_tool 7 + 8 + 9 + def tune(tuning: str) -> str: 10 + return call_tool(_tune, tuning=tuning) 4 11 5 12 6 13 class TestTune: 7 14 """Tests for the tune tool.""" 8 15 9 16 def test_tune_creates_style_file(self, ctx: ToolContext): 10 - tune("Lean into intrigue and social encounters.", ctx) 17 + tune("Lean into intrigue and social encounters.") 11 18 12 19 style_path = ctx.base_path / "worlds" / ctx.world_id / "style.md" 13 20 assert style_path.exists() 14 21 15 22 def test_tune_writes_content(self, ctx: ToolContext): 16 - tune("More exploration, less combat.", ctx) 23 + tune("More exploration, less combat.") 17 24 18 25 content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() 19 26 assert "More exploration, less combat." in content 20 27 21 28 def test_tune_has_heading(self, ctx: ToolContext): 22 - tune("Keep pacing slow.", ctx) 29 + tune("Keep pacing slow.") 23 30 24 31 content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() 25 32 assert content.startswith("# Style\n") 26 33 27 34 def test_tune_replaces_existing(self, ctx: ToolContext): 28 - tune("Lots of combat.", ctx) 29 - tune("Actually, less combat.", ctx) 35 + tune("Lots of combat.") 36 + tune("Actually, less combat.") 30 37 31 38 content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() 32 39 assert "Actually, less combat." in content 33 40 assert "Lots of combat." not in content 34 41 35 42 def test_tune_returns_confirmation(self, ctx: ToolContext): 36 - result = tune("Dark and atmospheric tone.", ctx) 37 - 38 - assert "updated" in result.lower() 39 - 40 - 41 - class TestExecuteToolTune: 42 - """Tests for tune dispatch through execute_tool.""" 43 - 44 - def test_execute_tune(self, ctx: ToolContext): 45 - result = execute_tool("tune", {"tuning": "More social encounters."}, ctx) 43 + result = tune("Dark and atmospheric tone.") 46 44 47 45 assert "updated" in result.lower() 48 - content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() 49 - assert "More social encounters." in content
+535 -2
uv.lock
··· 8 8 ] 9 9 10 10 [[package]] 11 + name = "aiofile" 12 + version = "3.9.0" 13 + source = { registry = "https://pypi.org/simple" } 14 + dependencies = [ 15 + { name = "caio" }, 16 + ] 17 + sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } 18 + wheels = [ 19 + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, 20 + ] 21 + 22 + [[package]] 11 23 name = "annotated-doc" 12 24 version = "0.0.4" 13 25 source = { registry = "https://pypi.org/simple" } ··· 54 66 sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } 55 67 wheels = [ 56 68 { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, 69 + ] 70 + 71 + [[package]] 72 + name = "authlib" 73 + version = "1.6.9" 74 + source = { registry = "https://pypi.org/simple" } 75 + dependencies = [ 76 + { name = "cryptography" }, 77 + ] 78 + sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } 79 + wheels = [ 80 + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, 81 + ] 82 + 83 + [[package]] 84 + name = "beartype" 85 + version = "0.22.9" 86 + source = { registry = "https://pypi.org/simple" } 87 + sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } 88 + wheels = [ 89 + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, 90 + ] 91 + 92 + [[package]] 93 + name = "cachetools" 94 + version = "7.0.5" 95 + source = { registry = "https://pypi.org/simple" } 96 + sdist = { url = "https://files.pythonhosted.org/packages/af/dd/57fe3fdb6e65b25a5987fd2cdc7e22db0aef508b91634d2e57d22928d41b/cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990", size = 37367, upload-time = "2026-03-09T20:51:29.451Z" } 97 + wheels = [ 98 + { url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", size = 13918, upload-time = "2026-03-09T20:51:27.33Z" }, 99 + ] 100 + 101 + [[package]] 102 + name = "caio" 103 + version = "0.9.25" 104 + source = { registry = "https://pypi.org/simple" } 105 + sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } 106 + wheels = [ 107 + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, 108 + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, 109 + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, 110 + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, 111 + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, 112 + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, 113 + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, 114 + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, 115 + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, 116 + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, 117 + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, 118 + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, 119 + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, 57 120 ] 58 121 59 122 [[package]] ··· 344 407 ] 345 408 346 409 [[package]] 410 + name = "cyclopts" 411 + version = "4.10.2" 412 + source = { registry = "https://pypi.org/simple" } 413 + dependencies = [ 414 + { name = "attrs" }, 415 + { name = "docstring-parser" }, 416 + { name = "rich" }, 417 + { name = "rich-rst" }, 418 + ] 419 + sdist = { url = "https://files.pythonhosted.org/packages/66/2c/fced34890f6e5a93a4b7afb2c71e8eee2a0719fb26193a0abf159ecb714d/cyclopts-4.10.2.tar.gz", hash = "sha256:d7b950457ef2563596d56331f80cbbbf86a2772535fb8b315c4f03bc7e6127f1", size = 166664, upload-time = "2026-04-08T23:57:45.805Z" } 420 + wheels = [ 421 + { url = "https://files.pythonhosted.org/packages/b4/bd/05055d8360cef0757d79367157f3b15c0a0715e81e08f86a04018ec045f0/cyclopts-4.10.2-py3-none-any.whl", hash = "sha256:a1f2d6f8f7afac9456b48f75a40b36658778ddc9c6d406b520d017ae32c990fe", size = 204314, upload-time = "2026-04-08T23:57:46.969Z" }, 422 + ] 423 + 424 + [[package]] 425 + name = "dnspython" 426 + version = "2.8.0" 427 + source = { registry = "https://pypi.org/simple" } 428 + sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } 429 + wheels = [ 430 + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, 431 + ] 432 + 433 + [[package]] 434 + name = "docstring-parser" 435 + version = "0.17.0" 436 + source = { registry = "https://pypi.org/simple" } 437 + sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } 438 + wheels = [ 439 + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, 440 + ] 441 + 442 + [[package]] 443 + name = "docutils" 444 + version = "0.22.4" 445 + source = { registry = "https://pypi.org/simple" } 446 + sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } 447 + wheels = [ 448 + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, 449 + ] 450 + 451 + [[package]] 452 + name = "email-validator" 453 + version = "2.3.0" 454 + source = { registry = "https://pypi.org/simple" } 455 + dependencies = [ 456 + { name = "dnspython" }, 457 + { name = "idna" }, 458 + ] 459 + sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } 460 + wheels = [ 461 + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, 462 + ] 463 + 464 + [[package]] 465 + name = "exceptiongroup" 466 + version = "1.3.1" 467 + source = { registry = "https://pypi.org/simple" } 468 + dependencies = [ 469 + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 470 + ] 471 + sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } 472 + wheels = [ 473 + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, 474 + ] 475 + 476 + [[package]] 347 477 name = "fastembed" 348 478 version = "0.8.0" 349 479 source = { registry = "https://pypi.org/simple" } ··· 365 495 ] 366 496 367 497 [[package]] 498 + name = "fastmcp" 499 + version = "3.2.3" 500 + source = { registry = "https://pypi.org/simple" } 501 + dependencies = [ 502 + { name = "authlib" }, 503 + { name = "cyclopts" }, 504 + { name = "exceptiongroup" }, 505 + { name = "httpx" }, 506 + { name = "jsonref" }, 507 + { name = "jsonschema-path" }, 508 + { name = "mcp" }, 509 + { name = "openapi-pydantic" }, 510 + { name = "opentelemetry-api" }, 511 + { name = "packaging" }, 512 + { name = "platformdirs" }, 513 + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, 514 + { name = "pydantic", extra = ["email"] }, 515 + { name = "pyperclip" }, 516 + { name = "python-dotenv" }, 517 + { name = "pyyaml" }, 518 + { name = "rich" }, 519 + { name = "uncalled-for" }, 520 + { name = "uvicorn" }, 521 + { name = "watchfiles" }, 522 + { name = "websockets" }, 523 + ] 524 + sdist = { url = "https://files.pythonhosted.org/packages/b9/42/7eed0a38e3b7a386805fecacf8a5a9353a2b3040395ef9e30e585d8549ac/fastmcp-3.2.3.tar.gz", hash = "sha256:4f02ae8b00227285a0cf6544dea1db29b022c8cdd8d3dfdec7118540210ae60a", size = 26328743, upload-time = "2026-04-09T22:05:03.402Z" } 525 + wheels = [ 526 + { url = "https://files.pythonhosted.org/packages/f5/48/84b6dcba793178a44b9d99b4def6cd62f870dcfc5bb7b9153ac390135812/fastmcp-3.2.3-py3-none-any.whl", hash = "sha256:cc50af6eed1f62ed8b6ebf4987286d8d1d006f08d5bec739d5c7fb76160e0911", size = 707260, upload-time = "2026-04-09T22:05:01.225Z" }, 527 + ] 528 + 529 + [[package]] 368 530 name = "filelock" 369 531 version = "3.25.2" 370 532 source = { registry = "https://pypi.org/simple" } ··· 498 660 ] 499 661 500 662 [[package]] 663 + name = "importlib-metadata" 664 + version = "8.7.1" 665 + source = { registry = "https://pypi.org/simple" } 666 + dependencies = [ 667 + { name = "zipp" }, 668 + ] 669 + sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } 670 + wheels = [ 671 + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, 672 + ] 673 + 674 + [[package]] 501 675 name = "iniconfig" 502 676 version = "2.3.0" 503 677 source = { registry = "https://pypi.org/simple" } ··· 507 681 ] 508 682 509 683 [[package]] 684 + name = "jaraco-classes" 685 + version = "3.4.0" 686 + source = { registry = "https://pypi.org/simple" } 687 + dependencies = [ 688 + { name = "more-itertools" }, 689 + ] 690 + sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } 691 + wheels = [ 692 + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, 693 + ] 694 + 695 + [[package]] 696 + name = "jaraco-context" 697 + version = "6.1.2" 698 + source = { registry = "https://pypi.org/simple" } 699 + sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } 700 + wheels = [ 701 + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, 702 + ] 703 + 704 + [[package]] 705 + name = "jaraco-functools" 706 + version = "4.4.0" 707 + source = { registry = "https://pypi.org/simple" } 708 + dependencies = [ 709 + { name = "more-itertools" }, 710 + ] 711 + sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } 712 + wheels = [ 713 + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, 714 + ] 715 + 716 + [[package]] 717 + name = "jeepney" 718 + version = "0.9.0" 719 + source = { registry = "https://pypi.org/simple" } 720 + sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } 721 + wheels = [ 722 + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, 723 + ] 724 + 725 + [[package]] 726 + name = "jsonref" 727 + version = "1.1.0" 728 + source = { registry = "https://pypi.org/simple" } 729 + sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } 730 + wheels = [ 731 + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, 732 + ] 733 + 734 + [[package]] 510 735 name = "jsonschema" 511 736 version = "4.26.0" 512 737 source = { registry = "https://pypi.org/simple" } ··· 522 747 ] 523 748 524 749 [[package]] 750 + name = "jsonschema-path" 751 + version = "0.4.5" 752 + source = { registry = "https://pypi.org/simple" } 753 + dependencies = [ 754 + { name = "pathable" }, 755 + { name = "pyyaml" }, 756 + { name = "referencing" }, 757 + ] 758 + sdist = { url = "https://files.pythonhosted.org/packages/5b/8a/7e6102f2b8bdc6705a9eb5294f8f6f9ccd3a8420e8e8e19671d1dd773251/jsonschema_path-0.4.5.tar.gz", hash = "sha256:c6cd7d577ae290c7defd4f4029e86fdb248ca1bd41a07557795b3c95e5144918", size = 15113, upload-time = "2026-03-03T09:56:46.87Z" } 759 + wheels = [ 760 + { url = "https://files.pythonhosted.org/packages/04/d5/4e96c44f6c1ea3d812cf5391d81a4f5abaa540abf8d04ecd7f66e0ed11df/jsonschema_path-0.4.5-py3-none-any.whl", hash = "sha256:7d77a2c3f3ec569a40efe5c5f942c44c1af2a6f96fe0866794c9ef5b8f87fd65", size = 19368, upload-time = "2026-03-03T09:56:45.39Z" }, 761 + ] 762 + 763 + [[package]] 525 764 name = "jsonschema-specifications" 526 765 version = "2025.9.1" 527 766 source = { registry = "https://pypi.org/simple" } ··· 531 770 sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } 532 771 wheels = [ 533 772 { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, 773 + ] 774 + 775 + [[package]] 776 + name = "keyring" 777 + version = "25.7.0" 778 + source = { registry = "https://pypi.org/simple" } 779 + dependencies = [ 780 + { name = "jaraco-classes" }, 781 + { name = "jaraco-context" }, 782 + { name = "jaraco-functools" }, 783 + { name = "jeepney", marker = "sys_platform == 'linux'" }, 784 + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, 785 + { name = "secretstorage", marker = "sys_platform == 'linux'" }, 786 + ] 787 + sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } 788 + wheels = [ 789 + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, 534 790 ] 535 791 536 792 [[package]] ··· 727 983 ] 728 984 729 985 [[package]] 986 + name = "more-itertools" 987 + version = "11.0.2" 988 + source = { registry = "https://pypi.org/simple" } 989 + sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" } 990 + wheels = [ 991 + { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, 992 + ] 993 + 994 + [[package]] 730 995 name = "mpmath" 731 996 version = "1.3.0" 732 997 source = { registry = "https://pypi.org/simple" } ··· 872 1137 ] 873 1138 874 1139 [[package]] 1140 + name = "openapi-pydantic" 1141 + version = "0.5.1" 1142 + source = { registry = "https://pypi.org/simple" } 1143 + dependencies = [ 1144 + { name = "pydantic" }, 1145 + ] 1146 + sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } 1147 + wheels = [ 1148 + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, 1149 + ] 1150 + 1151 + [[package]] 1152 + name = "opentelemetry-api" 1153 + version = "1.41.0" 1154 + source = { registry = "https://pypi.org/simple" } 1155 + dependencies = [ 1156 + { name = "importlib-metadata" }, 1157 + { name = "typing-extensions" }, 1158 + ] 1159 + sdist = { url = "https://files.pythonhosted.org/packages/47/8e/3778a7e87801d994869a9396b9fc2a289e5f9be91ff54a27d41eace494b0/opentelemetry_api-1.41.0.tar.gz", hash = "sha256:9421d911326ec12dee8bc933f7839090cad7a3f13fcfb0f9e82f8174dc003c09", size = 71416, upload-time = "2026-04-09T14:38:34.544Z" } 1160 + wheels = [ 1161 + { url = "https://files.pythonhosted.org/packages/58/ee/99ab786653b3bda9c37ade7e24a7b607a1b1f696063172768417539d876d/opentelemetry_api-1.41.0-py3-none-any.whl", hash = "sha256:0e77c806e6a89c9e4f8d372034622f3e1418a11bdbe1c80a50b3d3397ad0fa4f", size = 69007, upload-time = "2026-04-09T14:38:11.833Z" }, 1162 + ] 1163 + 1164 + [[package]] 875 1165 name = "packaging" 876 1166 version = "25.0" 877 1167 source = { registry = "https://pypi.org/simple" } 878 1168 sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } 879 1169 wheels = [ 880 1170 { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, 1171 + ] 1172 + 1173 + [[package]] 1174 + name = "pathable" 1175 + version = "0.5.0" 1176 + source = { registry = "https://pypi.org/simple" } 1177 + sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" } 1178 + wheels = [ 1179 + { url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" }, 881 1180 ] 882 1181 883 1182 [[package]] ··· 959 1258 ] 960 1259 961 1260 [[package]] 1261 + name = "platformdirs" 1262 + version = "4.9.6" 1263 + source = { registry = "https://pypi.org/simple" } 1264 + sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } 1265 + wheels = [ 1266 + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, 1267 + ] 1268 + 1269 + [[package]] 962 1270 name = "pluggy" 963 1271 version = "1.6.0" 964 1272 source = { registry = "https://pypi.org/simple" } ··· 980 1288 { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, 981 1289 { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, 982 1290 { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, 1291 + ] 1292 + 1293 + [[package]] 1294 + name = "py-key-value-aio" 1295 + version = "0.4.4" 1296 + source = { registry = "https://pypi.org/simple" } 1297 + dependencies = [ 1298 + { name = "beartype" }, 1299 + { name = "typing-extensions" }, 1300 + ] 1301 + sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" } 1302 + wheels = [ 1303 + { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" }, 1304 + ] 1305 + 1306 + [package.optional-dependencies] 1307 + filetree = [ 1308 + { name = "aiofile" }, 1309 + { name = "anyio" }, 1310 + ] 1311 + keyring = [ 1312 + { name = "keyring" }, 1313 + ] 1314 + memory = [ 1315 + { name = "cachetools" }, 983 1316 ] 984 1317 985 1318 [[package]] ··· 1034 1367 { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, 1035 1368 ] 1036 1369 1370 + [package.optional-dependencies] 1371 + email = [ 1372 + { name = "email-validator" }, 1373 + ] 1374 + 1037 1375 [[package]] 1038 1376 name = "pydantic-core" 1039 1377 version = "2.41.5" ··· 1214 1552 ] 1215 1553 1216 1554 [[package]] 1555 + name = "pyperclip" 1556 + version = "1.11.0" 1557 + source = { registry = "https://pypi.org/simple" } 1558 + sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } 1559 + wheels = [ 1560 + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, 1561 + ] 1562 + 1563 + [[package]] 1217 1564 name = "pysqlite3-binary" 1218 1565 version = "0.5.4.post2" 1219 1566 source = { registry = "https://pypi.org/simple" } ··· 1288 1635 ] 1289 1636 1290 1637 [[package]] 1638 + name = "pywin32-ctypes" 1639 + version = "0.2.3" 1640 + source = { registry = "https://pypi.org/simple" } 1641 + sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } 1642 + wheels = [ 1643 + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, 1644 + ] 1645 + 1646 + [[package]] 1291 1647 name = "pyyaml" 1292 1648 version = "6.0.3" 1293 1649 source = { registry = "https://pypi.org/simple" } ··· 1373 1729 sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } 1374 1730 wheels = [ 1375 1731 { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, 1732 + ] 1733 + 1734 + [[package]] 1735 + name = "rich-rst" 1736 + version = "1.3.2" 1737 + source = { registry = "https://pypi.org/simple" } 1738 + dependencies = [ 1739 + { name = "docutils" }, 1740 + { name = "rich" }, 1741 + ] 1742 + sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } 1743 + wheels = [ 1744 + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, 1376 1745 ] 1377 1746 1378 1747 [[package]] ··· 1483 1852 ] 1484 1853 1485 1854 [[package]] 1855 + name = "secretstorage" 1856 + version = "3.5.0" 1857 + source = { registry = "https://pypi.org/simple" } 1858 + dependencies = [ 1859 + { name = "cryptography" }, 1860 + { name = "jeepney" }, 1861 + ] 1862 + sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } 1863 + wheels = [ 1864 + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, 1865 + ] 1866 + 1867 + [[package]] 1486 1868 name = "shellingham" 1487 1869 version = "1.5.4" 1488 1870 source = { registry = "https://pypi.org/simple" } ··· 1536 1918 dependencies = [ 1537 1919 { name = "argcomplete" }, 1538 1920 { name = "fastembed" }, 1921 + { name = "fastmcp" }, 1539 1922 { name = "httpx" }, 1540 - { name = "mcp" }, 1541 1923 { name = "pydantic-monty" }, 1542 1924 { name = "pymupdf" }, 1543 1925 { name = "pymupdf4llm" }, ··· 1545 1927 { name = "pyyaml" }, 1546 1928 { name = "rich" }, 1547 1929 { name = "sqlite-vec" }, 1930 + { name = "uncalled-for" }, 1548 1931 ] 1549 1932 1550 1933 [package.optional-dependencies] ··· 1555 1938 { name = "ruff" }, 1556 1939 ] 1557 1940 1941 + [package.dev-dependencies] 1942 + dev = [ 1943 + { name = "mypy" }, 1944 + { name = "pytest" }, 1945 + { name = "pytest-cov" }, 1946 + { name = "ruff" }, 1947 + ] 1948 + 1558 1949 [package.metadata] 1559 1950 requires-dist = [ 1560 1951 { name = "argcomplete", specifier = ">=3.0" }, 1561 1952 { name = "fastembed", specifier = ">=0.4" }, 1953 + { name = "fastmcp", specifier = ">=3.2" }, 1562 1954 { name = "httpx", specifier = ">=0.27" }, 1563 - { name = "mcp", specifier = ">=1.9" }, 1564 1955 { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0" }, 1565 1956 { name = "pydantic-monty", specifier = ">=0.0.9" }, 1566 1957 { name = "pymupdf", specifier = ">=1.24" }, ··· 1572 1963 { name = "rich", specifier = ">=13.0" }, 1573 1964 { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1" }, 1574 1965 { name = "sqlite-vec", specifier = ">=0.1" }, 1966 + { name = "uncalled-for", specifier = ">=0.1" }, 1575 1967 ] 1576 1968 provides-extras = ["dev"] 1577 1969 1970 + [package.metadata.requires-dev] 1971 + dev = [ 1972 + { name = "mypy", specifier = ">=1.19.1" }, 1973 + { name = "pytest", specifier = ">=9.0.2" }, 1974 + { name = "pytest-cov", specifier = ">=7.0.0" }, 1975 + { name = "ruff", specifier = ">=0.14.10" }, 1976 + ] 1977 + 1578 1978 [[package]] 1579 1979 name = "sympy" 1580 1980 version = "1.14.0" ··· 1671 2071 ] 1672 2072 1673 2073 [[package]] 2074 + name = "uncalled-for" 2075 + version = "0.3.1" 2076 + source = { registry = "https://pypi.org/simple" } 2077 + sdist = { url = "https://files.pythonhosted.org/packages/e1/68/35c1d87e608940badbcfeb630347aa0509897284684f61fab6423d02b253/uncalled_for-0.3.1.tar.gz", hash = "sha256:5e412ac6708f04b56bef5867b5dcf6690ebce4eb7316058d9c50787492bb4bca", size = 49693, upload-time = "2026-04-07T13:05:06.462Z" } 2078 + wheels = [ 2079 + { url = "https://files.pythonhosted.org/packages/11/e1/7ec67882ad8fc9f86384bef6421fa252c9cbe5744f8df6ce77afc9eca1f5/uncalled_for-0.3.1-py3-none-any.whl", hash = "sha256:074cdc92da8356278f93d0ded6f2a66dd883dbecaf9bc89437646ee2289cc200", size = 11361, upload-time = "2026-04-07T13:05:05.341Z" }, 2080 + ] 2081 + 2082 + [[package]] 1674 2083 name = "urllib3" 1675 2084 version = "2.6.3" 1676 2085 source = { registry = "https://pypi.org/simple" } ··· 1693 2102 ] 1694 2103 1695 2104 [[package]] 2105 + name = "watchfiles" 2106 + version = "1.1.1" 2107 + source = { registry = "https://pypi.org/simple" } 2108 + dependencies = [ 2109 + { name = "anyio" }, 2110 + ] 2111 + sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } 2112 + wheels = [ 2113 + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, 2114 + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, 2115 + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, 2116 + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, 2117 + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, 2118 + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, 2119 + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, 2120 + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, 2121 + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, 2122 + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, 2123 + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, 2124 + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, 2125 + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, 2126 + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, 2127 + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, 2128 + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, 2129 + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, 2130 + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, 2131 + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, 2132 + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, 2133 + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, 2134 + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, 2135 + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, 2136 + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, 2137 + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, 2138 + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, 2139 + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, 2140 + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, 2141 + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, 2142 + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, 2143 + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, 2144 + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, 2145 + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, 2146 + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, 2147 + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, 2148 + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, 2149 + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, 2150 + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, 2151 + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, 2152 + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, 2153 + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, 2154 + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, 2155 + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, 2156 + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, 2157 + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, 2158 + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, 2159 + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, 2160 + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, 2161 + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, 2162 + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, 2163 + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, 2164 + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, 2165 + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, 2166 + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, 2167 + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, 2168 + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, 2169 + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, 2170 + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, 2171 + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, 2172 + ] 2173 + 2174 + [[package]] 2175 + name = "websockets" 2176 + version = "16.0" 2177 + source = { registry = "https://pypi.org/simple" } 2178 + sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } 2179 + wheels = [ 2180 + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, 2181 + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, 2182 + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, 2183 + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, 2184 + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, 2185 + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, 2186 + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, 2187 + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, 2188 + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, 2189 + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, 2190 + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, 2191 + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, 2192 + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, 2193 + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, 2194 + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, 2195 + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, 2196 + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, 2197 + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, 2198 + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, 2199 + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, 2200 + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, 2201 + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, 2202 + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, 2203 + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, 2204 + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, 2205 + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, 2206 + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, 2207 + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, 2208 + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, 2209 + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, 2210 + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, 2211 + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, 2212 + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, 2213 + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, 2214 + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, 2215 + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, 2216 + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, 2217 + ] 2218 + 2219 + [[package]] 1696 2220 name = "win32-setctime" 1697 2221 version = "1.2.0" 1698 2222 source = { registry = "https://pypi.org/simple" } ··· 1700 2224 wheels = [ 1701 2225 { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, 1702 2226 ] 2227 + 2228 + [[package]] 2229 + name = "zipp" 2230 + version = "3.23.0" 2231 + source = { registry = "https://pypi.org/simple" } 2232 + sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } 2233 + wheels = [ 2234 + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, 2235 + ]