diff --git a/pyproject.toml b/pyproject.toml --- a/pyproject.toml +++ b/pyproject.toml @@ -12,8 +12,8 @@ dependencies = [ "argcomplete>=3.0", "fastembed>=0.4", + "fastmcp>=3.2", "httpx>=0.27", - "mcp>=1.9", "pydantic-monty>=0.0.9", "pymupdf>=1.24", "pymupdf4llm>=0.0.17", @@ -21,6 +21,7 @@ "pysqlite3-binary>=0.5", "pyyaml>=6.0", "rich>=13.0", "sqlite-vec>=0.1", + "uncalled-for>=0.1", ] [project.scripts] @@ -70,4 +71,12 @@ branch = true omit = ["src/storied/cli.py", "src/storied/srd/*"] [tool.coverage.report] -fail_under = 85 +fail_under = 95 + +[dependency-groups] +dev = [ + "mypy>=1.19.1", + "pytest>=9.0.2", + "pytest-cov>=7.0.0", + "ruff>=0.14.10", +] diff --git a/src/storied/advancement.py b/src/storied/advancement.py --- a/src/storied/advancement.py +++ b/src/storied/advancement.py @@ -182,7 +182,9 @@ self._result = None self._thread = Thread(target=self._run, daemon=True) self._thread.start() - def _run(self) -> None: + def _run(self) -> None: # pragma: no cover + # Threaded entry point that drives evaluate_advancement (which spawns + # a claude subprocess via run_with_tools). Excluded from coverage. self._result = evaluate_advancement( world_id=self._world_id, player_id=self._player_id, diff --git a/src/storied/character/data.py b/src/storied/character/data.py --- a/src/storied/character/data.py +++ b/src/storied/character/data.py @@ -5,6 +5,8 @@ from pathlib import Path import yaml +from storied.character.schema import coerce_character, validate_for_write + # Default character schema — used when creating a new character DEFAULT_SCHEMA: dict = { @@ -74,14 +76,17 @@ """Load a character's structured data from character.yaml. Returns None if no character exists. Returns the parsed YAML dict, with missing fields filled in from DEFAULT_SCHEMA so callers can rely on the - full structure being present. + full structure being present. Known mis-shapes from older sessions + (e.g. resources stored as a list) are coerced into the canonical shape + so the existing character keeps working. """ yaml_path = _character_yaml_path(player_id, base_path) if not yaml_path.exists(): return None raw = yaml.safe_load(yaml_path.read_text()) or {} - return _merge_defaults(raw) + coerced = coerce_character(raw) + return _merge_defaults(coerced) def load_character_prose(player_id: str, base_path: Path | None = None) -> str: @@ -143,6 +148,11 @@ ) -> str: """Update fields in character.yaml using dot notation. Example: {"state.hp.current": 5, "identity.classes.0.level": 4} + + The result is validated against the Character schema before being + written. If validation fails, the update is rejected (the file on disk + is unchanged) and the returned message tells the DM what's wrong so + they can retry with the correct shape. """ data = load_character(player_id, base_path) if data is None: @@ -169,6 +179,13 @@ for denom in ("cp", "sp", "ep", "gp", "pp"): if denom in purse and purse[denom] < 0: purse[denom] = 0 changes.append(f"({denom} clamped to 0)") + + error = validate_for_write(data) + if error: + return ( + f"Update rejected — {error}. " + f"Character on disk is unchanged." + ) save_character(player_id, data, base_path) return "Character updated: " + ", ".join(changes) diff --git a/src/storied/character/display.py b/src/storied/character/display.py --- a/src/storied/character/display.py +++ b/src/storied/character/display.py @@ -84,11 +84,15 @@ return lines def _format_resources(char: dict) -> list[str]: - resources = char.get("resources", {}) or {} - if not resources: + resources = char.get("resources") or {} + # Tolerate the LLM writing the wrong shape (e.g. a list instead of a + # dict-of-pools). Render nothing rather than crashing the whole turn. + if not isinstance(resources, dict) or not resources: return [] lines = ["**Resources:**"] for name, pool in resources.items(): + if not isinstance(pool, dict): + continue current = pool.get("current", 0) maximum = pool.get("max", 0) notes = pool.get("notes", name) @@ -99,7 +103,9 @@ return lines def _format_magic_items(char: dict) -> list[str]: - mi = char.get("magic_items", {}) or {} + mi = char.get("magic_items") or {} + if not isinstance(mi, dict): + return [] if not (mi.get("attuned") or mi.get("equipped") or mi.get("carried")): return [] lines = ["**Magic Items:**"] @@ -127,8 +133,8 @@ return lines def _format_equipment(char: dict) -> list[str]: - equipment = char.get("equipment", {}) or {} - if not equipment: + equipment = char.get("equipment") or {} + if not isinstance(equipment, dict) or not equipment: return [] lines = ["**Equipment:**"] for location, items in equipment.items(): diff --git a/src/storied/character/schema.py b/src/storied/character/schema.py new file mode 100644 --- /dev/null +++ b/src/storied/character/schema.py @@ -0,0 +1,215 @@ +"""Pydantic schema for character.yaml. + +This is the canonical shape the DM's `update_character` writes must conform +to. Validation lives at the write boundary so the LLM gets a clear, actionable +error if it tries to use the wrong shape (e.g. a list of pools where a dict +is expected). On read, `coerce_character` heals known mis-shapes from older +sessions so existing characters keep working. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator + + +# --- Sub-models ------------------------------------------------------------- + + +class Abilities(BaseModel): + """The six 5e ability scores. All required so the LLM can't omit one.""" + + model_config = ConfigDict(extra="forbid") + strength: int + dexterity: int + constitution: int + intelligence: int + wisdom: int + charisma: int + + +class HP(BaseModel): + model_config = ConfigDict(extra="allow") + max: int + current: int + temp: int = 0 + + +class Purse(BaseModel): + """Starting coin counts for the player's purse. All denominations default + to zero so the LLM can omit any it doesn't want to set.""" + + model_config = ConfigDict(extra="allow") + cp: int = 0 + sp: int = 0 + ep: int = 0 + gp: int = 0 + pp: int = 0 + + +class CoinDelta(BaseModel): + """Signed deltas applied via adjust_coins. Use negative values to spend, + positive to gain. Coins are clamped to zero on the underlying purse.""" + + model_config = ConfigDict(extra="forbid") + cp: int = 0 + sp: int = 0 + ep: int = 0 + gp: int = 0 + pp: int = 0 + + +class DeathSaves(BaseModel): + model_config = ConfigDict(extra="allow") + successes: int = 0 + failures: int = 0 + + +class State(BaseModel): + model_config = ConfigDict(extra="allow") + hp: HP + ac: int = 10 + speed: int = 30 + movement_modes: dict[str, int] = Field(default_factory=dict) + senses: dict[str, int] = Field(default_factory=dict) + purse: Purse = Field(default_factory=Purse) + inspiration: bool = False + exhaustion: int = 0 + death_saves: DeathSaves = Field(default_factory=DeathSaves) + + +class ResourcePool(BaseModel): + """A single named resource pool (hit dice, channel divinity, ki, etc).""" + + model_config = ConfigDict(extra="allow") + current: int = 0 + max: int = 0 + refresh: str = "" + notes: str = "" + die: str | None = None + + +class MagicItems(BaseModel): + model_config = ConfigDict(extra="allow") + attuned: list[str] = Field(default_factory=list) + equipped: list[str] = Field(default_factory=list) + carried: list[str] = Field(default_factory=list) + + +# --- Top-level character model ---------------------------------------------- + + +class Character(BaseModel): + """The canonical character.yaml shape. + + `extra="allow"` lets the DM add custom fields the schema doesn't know + about (e.g. campaign-specific tags), but the named fields are checked + strictly. Validation runs in `update_character` before save and again + after coercion in `load_character`. + """ + + model_config = ConfigDict(extra="allow") + + identity: dict[str, Any] = Field(default_factory=dict) + abilities: dict[str, int] = Field(default_factory=dict) + proficiencies: dict[str, Any] = Field(default_factory=dict) + state: State + defenses: dict[str, Any] = Field(default_factory=dict) + conditions: list[str] = Field(default_factory=list) + features: list[dict[str, Any]] = Field(default_factory=list) + resources: dict[str, ResourcePool] = Field(default_factory=dict) + equipment: dict[str, list[str]] = Field(default_factory=dict) + magic_items: MagicItems = Field(default_factory=MagicItems) + effects: list[dict[str, Any]] = Field(default_factory=list) + spellcasting: dict[str, Any] | None = None + + @field_validator("resources", mode="before") + @classmethod + def _resources_must_be_dict(cls, v: Any) -> Any: + if isinstance(v, list): + raise ValueError( + "must be a dict of pools, not a list. Example: " + "{'channel_divinity': {'current': 1, 'max': 1, " + "'refresh': 'short_rest', 'notes': 'Channel Divinity'}}" + ) + return v + + @field_validator("equipment", mode="before") + @classmethod + def _equipment_must_be_dict(cls, v: Any) -> Any: + if isinstance(v, list): + raise ValueError( + "must be a dict keyed by location, not a list. Example: " + "{'on_person': ['Longsword', 'Lockpicks'], " + "'stashed_at_inn': ['Spare cloak']}" + ) + return v + + +# --- Helpers --------------------------------------------------------------- + + +def validate_for_write(data: dict[str, Any]) -> str | None: + """Validate `data` against the Character schema. + + Returns None if valid. Returns a DM-readable error message if invalid. + """ + try: + Character.model_validate(data) + except ValidationError as exc: + return _format_error(exc) + return None + + +def _format_error(exc: ValidationError) -> str: + """Format a Pydantic ValidationError as a single-line DM-readable message.""" + parts = [] + for err in exc.errors(): + loc = ".".join(str(x) for x in err["loc"]) or "(root)" + msg = err["msg"] + # Pydantic prefixes "Value error, " on raised ValueErrors — strip it + if msg.startswith("Value error, "): + msg = msg[len("Value error, "):] + parts.append(f"{loc}: {msg}") + return "; ".join(parts) + + +def coerce_character(data: dict[str, Any]) -> dict[str, Any]: + """Heal known mis-shapes in `data` so it conforms to the schema. + + Used by `load_character` to keep existing sessions playable when the DM + has previously written the wrong shape (e.g. resources as a list-of-pools + instead of a dict-of-pools). Returns the coerced dict in place. + + Conservative: only fixes shapes we recognize. Anything else falls through + untouched and will surface via the next write-time validation. + """ + # resources: list-of-pools → dict-of-pools, keyed by name (or index) + resources = data.get("resources") + if isinstance(resources, list): + coerced: dict[str, dict[str, Any]] = {} + for i, pool in enumerate(resources): + if not isinstance(pool, dict): + continue + key = pool.get("name") or pool.get("notes") or f"pool_{i}" + # normalize the key (lowercase, underscores) so future lookups work + key = str(key).lower().replace(" ", "_") + coerced[key] = {k: v for k, v in pool.items() if k != "name"} + data["resources"] = coerced + + # equipment: list of items → {"on_person": [items]} + equipment = data.get("equipment") + if isinstance(equipment, list): + data["equipment"] = {"on_person": [str(x) for x in equipment]} + + # magic_items: list → {"carried": list} + magic_items = data.get("magic_items") + if isinstance(magic_items, list): + data["magic_items"] = { + "attuned": [], + "equipped": [], + "carried": [str(x) for x in magic_items], + } + + return data diff --git a/src/storied/claude.py b/src/storied/claude.py --- a/src/storied/claude.py +++ b/src/storied/claude.py @@ -192,7 +192,7 @@ # -- Public entrypoints ------------------------------------------------------- -def stream_with_tools( +def stream_with_tools( # pragma: no cover system_prompt: str, user_message: str, mcp_url: str, @@ -209,6 +209,11 @@ ToolStop, Result) as they arrive. The caller handles the agentic loop presentation; Claude Code handles tool execution via MCP. Used by the DM engine for real-time gameplay. + + Not unit-tested: this is a thin wrapper around `subprocess.Popen` that + drives the real `claude` CLI. The arg-building and event-parsing helpers + it composes are covered separately by their own tests; mocking the full + subprocess streaming pipeline here would test the mock, not the code. """ mcp_config = _build_mcp_config(mcp_url) args = _build_tool_args( @@ -325,7 +330,7 @@ return result -def run_prompt( +def run_prompt( # pragma: no cover system_prompt: str, user_message: str, *, @@ -336,6 +341,9 @@ """Run a simple prompt through claude -p and return the text response. Plain text in, plain text out. No MCP tools, no streaming, no session. Used for utility formatting (e.g., /status, /me). + + Not unit-tested: see stream_with_tools — this is another thin + `subprocess.run` wrapper around the real `claude` CLI. """ try: claude_path = _find_claude() diff --git a/src/storied/cli.py b/src/storied/cli.py --- a/src/storied/cli.py +++ b/src/storied/cli.py @@ -19,11 +19,19 @@ "/dm": "Say something out-of-character to the DM (e.g. /dm less combat please)", "/note": "Add a note to your character sheet (e.g. /note remember the prayer words)", } -def _format_character_display(player_id: str, full: bool) -> str | None: - """Format character data for /status or /me display.""" +def _format_character_display( + player_id: str, + full: bool, + base_path: Path | None = None, +) -> str | None: + """Format character data for /status or /me display. + + `base_path` must be threaded through from cmd_play so sandbox sessions + read the sandbox character.yaml instead of the cwd default. + """ from storied.character import format_sheet, format_status, load_character - data = load_character(player_id) + data = load_character(player_id, base_path=base_path) if data is None: return None return format_sheet(data) if full else format_status(data) @@ -237,6 +245,11 @@ if sandbox: sandbox_dir = Path(tempfile.mkdtemp(prefix="storied-sandbox-")) (sandbox_dir / "worlds" / world_id).mkdir(parents=True) (sandbox_dir / "players" / player_id).mkdir(parents=True) + # Symlink rules/ from cwd so recall(scope="rules") works in the + # sandbox without rebuilding the SRD index from scratch. + rules_src = Path.cwd() / "rules" + if rules_src.exists(): + (sandbox_dir / "rules").symlink_to(rules_src.resolve()) base_path = sandbox_dir if sandbox else None creation_mode = False @@ -481,7 +494,9 @@ # Handle /status and /me commands if action.strip().lower() in ("/status", "/me"): is_full = action.strip().lower() == "/me" - formatted = _format_character_display(player_id, full=is_full) + formatted = _format_character_display( + player_id, full=is_full, base_path=base_path, + ) if formatted: console.print() console.print(Markdown(formatted)) diff --git a/src/storied/engine.py b/src/storied/engine.py --- a/src/storied/engine.py +++ b/src/storied/engine.py @@ -373,16 +373,22 @@ "total_input": self._total_input_tokens, "total_output": self._total_output_tokens, } - def process_action(self, player_input: str) -> str: + def process_action(self, player_input: str) -> str: # pragma: no cover """Process player input and return DM narrative (non-streaming).""" chunks = list(self.stream_action(player_input)) return "".join(chunks) - def stream_action(self, player_input: str) -> Iterator[str]: + def stream_action(self, player_input: str) -> Iterator[str]: # pragma: no cover """Stream DM response for real-time output. Uses stream_with_tools() to drive a claude -p subprocess. Yields text chunks and tool notifications as they arrive. + + Not unit-tested: this is the agentic loop that drives the real + `claude` subprocess via stream_with_tools(). The deferred-notification + formatters and tool-name parsing it composes are covered separately + in tests/test_notification_formatters.py; mocking the full streaming + loop here would test the mock, not the code. """ self._log_transcript("player_input", {"content": player_input}) diff --git a/src/storied/initiative.py b/src/storied/initiative.py --- a/src/storied/initiative.py +++ b/src/storied/initiative.py @@ -1,4 +1,11 @@ -"""Initiative tracking for structured combat and turn-based encounters.""" +"""Initiative tracking for structured combat and turn-based encounters. + +This module owns the InitiativeTracker state machine and the related +dataclasses. The FastMCP combat tool surface lives in +storied.tools.combat (which depends on both this module and +storied.tools._context, hence the split — keeping it here would create +a circular import). +""" from dataclasses import dataclass, field @@ -349,226 +356,3 @@ parts = [] for c in self.combatants: parts.append(f" {c.initiative}: {c.name} ({c.hp}/{c.hp_max} HP, AC {c.ac})") return "\n".join(parts) - - -# --- Tool functions (thin wrappers around tracker methods) --- - - -def enter_initiative( - combatants_raw: list[dict], tracker: InitiativeTracker, -) -> str: - """Enter initiative mode for combat or any turn-based encounter. - - Provide all participants in their desired turn order (you handle - tie-breaking). Roll initiative for everyone first, then call this. - Initiative tools become available on the next turn. - """ - if tracker.active: - return "Initiative is already active. Call end_initiative first." - - combatants = [ - Combatant( - name=c["name"], - initiative=c["initiative"], - hp=c["hp"], - hp_max=c["hp_max"], - ac=c["ac"], - is_player=c.get("is_player", False), - ) - for c in combatants_raw - ] - return tracker.begin(combatants) - - -def next_turn(tracker: InitiativeTracker) -> str: - """Advance to the next combatant's turn. Skips defeated. Call after resolving actions.""" - return tracker.next_turn() - - -def add_combatant( - tracker: InitiativeTracker, - name: str, - initiative: int, - hp: int, - hp_max: int, - ac: int, - is_player: bool = False, -) -> str: - """Add a combatant (reinforcements, surprised creatures waking up).""" - c = Combatant( - name=name, initiative=initiative, hp=hp, - hp_max=hp_max, ac=ac, is_player=is_player, - ) - return tracker.add_combatant(c) - - -def remove_combatant(tracker: InitiativeTracker, name: str) -> str: - """Remove a combatant who fled, was banished, or is otherwise out.""" - return tracker.remove_combatant(name) - - -def damage(tracker: InitiativeTracker, target: str, amount: int) -> str: - """Deal damage. Tracks defeat at 0 HP, reports Bloodied at half. Auto-syncs player character sheet.""" - return tracker.apply_damage(target, amount) - - -def heal(tracker: InitiativeTracker, target: str, amount: int) -> str: - """Heal a combatant. Clamped to max HP. Revives defeated. Auto-syncs player character sheet.""" - return tracker.apply_heal(target, amount) - - -def condition( - tracker: InitiativeTracker, - target: str, - condition_name: str, - action: str = "add", - duration: int = -1, - ends_on: str = "start", - source: str = "", -) -> str: - """Add or remove a condition. Duration counts down on the source's turn. Duration -1 = until manually removed.""" - if action == "remove": - return tracker.remove_condition(target, condition_name) - return tracker.add_condition( - target=target, condition=condition_name, - duration=duration, ends_on=ends_on, source=source, - ) - - -def end_initiative(tracker: InitiativeTracker) -> str: - """End initiative and return to narrative. Returns summary with rounds, defeated, and survivor HP.""" - return tracker.end() - - -def execute_initiative_tool( - tool_name: str, tool_input: dict, tracker: InitiativeTracker, -) -> str | None: - """Dispatch an initiative tool call. Returns None for unknown tools.""" - if tool_name not in ALL_INITIATIVE_TOOL_NAMES: - return None - - if tool_name == "enter_initiative": - return enter_initiative(tool_input["combatants"], tracker) - - if not tracker.active: - return "Initiative is not active. Call enter_initiative first." - - if tool_name == "next_turn": - return next_turn(tracker) - elif tool_name == "add_combatant": - return add_combatant( - tracker, tool_input["name"], tool_input["initiative"], - tool_input["hp"], tool_input["hp_max"], tool_input["ac"], - tool_input.get("is_player", False), - ) - elif tool_name == "remove_combatant": - return remove_combatant(tracker, tool_input["name"]) - elif tool_name == "damage": - return damage(tracker, tool_input["target"], tool_input["amount"]) - elif tool_name == "heal": - return heal(tracker, tool_input["target"], tool_input["amount"]) - elif tool_name == "condition": - return condition( - tracker, tool_input["target"], tool_input["condition"], - action=tool_input.get("action", "add"), - duration=tool_input.get("duration", -1), - ends_on=tool_input.get("ends_on", "start"), - source=tool_input.get("source", ""), - ) - elif tool_name == "end_initiative": - return end_initiative(tracker) - - return None - - -# --- Tool definitions --- - -_COMBATANT_PROPS: dict = { - "name": {"type": "string", "description": "Combatant name"}, - "initiative": {"type": "integer", "description": "Initiative roll total"}, - "hp": {"type": "integer", "description": "Current hit points"}, - "hp_max": {"type": "integer", "description": "Maximum hit points"}, - "ac": {"type": "integer", "description": "Armor class"}, - "is_player": {"type": "boolean", "description": "True for the player character"}, -} -_COMBATANT_REQUIRED = ["name", "initiative", "hp", "hp_max", "ac"] - -_TARGET_AMOUNT_SCHEMA: dict = { - "type": "object", - "properties": { - "target": {"type": "string", "description": "Combatant name"}, - "amount": {"type": "integer", "description": "Amount"}, - }, - "required": ["target", "amount"], -} - -_NO_INPUT: dict = {"type": "object", "properties": {}} - -ENTER_INITIATIVE_DEFINITION: dict = { - "name": "enter_initiative", - "description": enter_initiative.__doc__, - "input_schema": { - "type": "object", - "properties": { - "combatants": { - "type": "array", - "description": "Participants in initiative order (first acts first)", - "items": { - "type": "object", - "properties": _COMBATANT_PROPS, - "required": _COMBATANT_REQUIRED, - }, - }, - }, - "required": ["combatants"], - }, -} - -COMBAT_TOOL_DEFINITIONS: list[dict] = [ - {"name": "next_turn", - "description": next_turn.__doc__, - "input_schema": _NO_INPUT}, - {"name": "add_combatant", - "description": add_combatant.__doc__, - "input_schema": { - "type": "object", "properties": _COMBATANT_PROPS, - "required": _COMBATANT_REQUIRED}}, - {"name": "remove_combatant", - "description": remove_combatant.__doc__, - "input_schema": { - "type": "object", - "properties": {"name": {"type": "string", "description": "Combatant name"}}, - "required": ["name"]}}, - {"name": "damage", - "description": damage.__doc__, - "input_schema": _TARGET_AMOUNT_SCHEMA}, - {"name": "heal", - "description": heal.__doc__, - "input_schema": _TARGET_AMOUNT_SCHEMA}, - {"name": "condition", - "description": condition.__doc__, - "input_schema": { - "type": "object", - "properties": { - "target": {"type": "string", "description": "Combatant name"}, - "condition": {"type": "string", "description": "Condition name (Prone, Stunned, etc)"}, - "action": {"type": "string", "enum": ["add", "remove"]}, - "duration": {"type": "integer", "description": "Rounds until expiry (-1 = until removed)"}, - "ends_on": {"type": "string", "enum": ["start", "end"], - "description": "Expires at start or end of source's turn"}, - "source": {"type": "string", "description": "Who caused the condition"}, - }, - "required": ["target", "condition"]}}, - {"name": "end_initiative", - "description": end_initiative.__doc__, - "input_schema": _NO_INPUT}, -] - -ALL_INITIATIVE_TOOL_NAMES: set[str] = ( - {ENTER_INITIATIVE_DEFINITION["name"]} - | {d["name"] for d in COMBAT_TOOL_DEFINITIONS} -) - -INITIATIVE_KEEP_NARRATIVE: frozenset[str] = frozenset({ - "roll", "recall", "update_character", -}) diff --git a/src/storied/mcp_server.py b/src/storied/mcp_server.py --- a/src/storied/mcp_server.py +++ b/src/storied/mcp_server.py @@ -1,80 +1,33 @@ -"""In-process MCP server exposing storied game tools to Claude Code. +"""In-process FastMCP server exposing storied game tools to Claude Code. -start_server() launches an HTTP MCP server on localhost in a background -thread. The engine passes its URL to claude -p via --mcp-config. Tools -share state (CampaignLog, etc.) with the engine process. +start_server() launches a FastMCP server (SSE transport) on a free localhost +port in a background thread. Each call composes a per-role top-level server +by mounting the tools/*.py module-level FastMCP instances and applying +tag-based visibility filters. ToolContext is process-global and accessed by +tools via the Dependency subclasses in storied.tools._context. """ +import asyncio import socket import threading import time from pathlib import Path -import anyio - import uvicorn -from mcp.server.lowlevel import Server -from mcp.server.sse import SseServerTransport -from mcp.types import TextContent, Tool +from fastmcp import FastMCP from storied.log import CampaignLog from storied.search import VectorIndex -from storied.initiative import ( - COMBAT_TOOL_DEFINITIONS, - ENTER_INITIATIVE_DEFINITION, - INITIATIVE_KEEP_NARRATIVE, -) -from storied.sandbox import build_tool_signatures -from storied.tools import ( - ADVANCEMENT_TOOL_DEFINITIONS, +from storied.tools import character, combat, entities, mechanics, run_code, scene +from storied.tools._context import ( EntityIndex, - PLANNER_TOOL_DEFINITIONS, - SEEDER_TOOL_DEFINITIONS, - TOOL_DEFINITIONS, ToolContext, - advancement_execute_tool, - execute_tool, - planner_execute_tool, - seeder_execute_tool, + init_ctx, ) -_tool_signatures: str | None = None - -TOOL_SETS: dict[str, list[dict]] = { - "planner": PLANNER_TOOL_DEFINITIONS, - "seeder": SEEDER_TOOL_DEFINITIONS, - "advancement": ADVANCEMENT_TOOL_DEFINITIONS, -} - -EXECUTORS = { - "dm": execute_tool, - "planner": planner_execute_tool, - "seeder": seeder_execute_tool, - "advancement": advancement_execute_tool, -} +ALL_ROLES = {"dm", "planner", "seeder", "advancement"} - -def _dm_tool_definitions(ctx: ToolContext) -> list[dict]: - """Return DM tools based on whether initiative is active.""" - if ctx.initiative.active: - kept = [d for d in TOOL_DEFINITIONS if d["name"] in INITIATIVE_KEEP_NARRATIVE] - return kept + COMBAT_TOOL_DEFINITIONS - return TOOL_DEFINITIONS + [ENTER_INITIATIVE_DEFINITION] - - -def _to_mcp_tool(defn: dict) -> Tool: - """Convert an Anthropic-format tool definition to an MCP Tool.""" - global _tool_signatures - desc = defn.get("description", "") - if "{tool_signatures}" in desc: - if _tool_signatures is None: - _tool_signatures = build_tool_signatures() - desc = desc.replace("{tool_signatures}", _tool_signatures) - return Tool( - name=defn["name"], - description=desc, - inputSchema=defn["input_schema"], - ) +_tool_signatures: str | None = None def _find_free_port() -> int: @@ -85,107 +38,139 @@ return s.getsockname()[1] class MCPServerHandle: - """Handle to a running in-process MCP server.""" + """Handle to a running in-process FastMCP server.""" - def __init__(self, port: int, thread: threading.Thread, ctx: ToolContext): + def __init__( + self, + port: int, + thread: threading.Thread, + ctx: ToolContext, + server: FastMCP, + ): self.port = port self.url = f"http://127.0.0.1:{port}/sse" self.ctx = ctx + self.server = server self._thread = thread - def stop(self) -> None: - """Stop the server (best-effort).""" + def stop(self) -> None: # pragma: no cover + """Stop the server (best-effort). + + Not unit-tested: tied to the live uvicorn thread spawned by + start_server, which is itself excluded from coverage. + """ if self._thread.is_alive(): self._thread.join(timeout=2) -def start_server( +def _populate_index(base_path: Path, world_dir: Path, vi: VectorIndex) -> None: + """Auto-populate an empty index from SRD seed + world content.""" + srd_seed = base_path / "rules" / "srd-5.2.1" / "search.db" + if srd_seed.exists(): + vi.reseed(srd_seed) + else: + srd_dir = base_path / "rules" / "srd-5.2.1" / "sections" + if srd_dir.exists(): + vi.reindex_directory(srd_dir, source="srd") + if world_dir.exists(): + vi.reindex_directory(world_dir, source="world") + + +async def _compose_server(role: str) -> FastMCP: + """Build a fresh top-level FastMCP server for the given role. + + Mounts every tools/*.py module unconditionally, then applies tag + filtering: hide tools whose role tags don't include `role`. For DM + mode, additionally hides combat tools (except combat_control) at + startup so they only appear during initiative. Substitutes the + {tool_signatures} placeholder in tool descriptions while we're here. + """ + server: FastMCP = FastMCP("storied") + server.mount(mechanics.mcp) + server.mount(character.mcp) + server.mount(scene.mcp) + server.mount(entities.mcp) + server.mount(combat.mcp) + server.mount(run_code.mcp) + + # Hide tools tagged for other roles, then re-enable tools that also + # carry our role tag (e.g. update_character is in both `dm` and + # `advancement` — disabling `advancement` would hide it without the + # second pass). + other_roles = ALL_ROLES - {role} + server.disable(tags=other_roles) + server.enable(tags={role}) + + if role == "dm": + combat_keys: set[str] = set() + for tool in await server.list_tools(): + if "combat" in tool.tags and "combat_control" not in tool.tags: + combat_keys.add(tool.key) + if combat_keys: + server.disable(keys=combat_keys) + combat.set_root(server, combat_keys) + + # Substitute {tool_signatures} placeholder in tool descriptions + global _tool_signatures + if _tool_signatures is None: + from storied.sandbox import build_tool_signatures + _tool_signatures = build_tool_signatures() + for tool in await server.list_tools(): + if tool.description and "{tool_signatures}" in tool.description: + tool.description = tool.description.replace( + "{tool_signatures}", _tool_signatures or "" + ) + + return server + + +def start_server( # pragma: no cover world_id: str, player_id: str, base_path: Path, tool_set: str = "dm", campaign_log: CampaignLog | None = None, ) -> MCPServerHandle: - """Start an in-process MCP HTTP server on a free localhost port. + """Start an in-process FastMCP HTTP server on a free localhost port. Returns an MCPServerHandle with the URL to pass to --mcp-config. - The server runs in a daemon thread and shares state with the caller. + The server runs in a daemon thread and shares the process-global + ToolContext (set via init_ctx) with the caller. + + Not unit-tested: this is the live launcher that spins up uvicorn in + a daemon thread and waits for the port to open. The pure compose path + (`_compose_server`) and tool registry it builds are tested separately + in tests/test_mcp_server.py; mocking out uvicorn here would test the + mock, not the launcher. """ if campaign_log is None: campaign_log = CampaignLog(world_id, base_path) world_dir = base_path / "worlds" / world_id - def _populate_index(vi: VectorIndex) -> None: - """Auto-populate an empty index from SRD seed + world content.""" - srd_seed = base_path / "rules" / "srd-5.2.1" / "search.db" - if srd_seed.exists(): - vi.reseed(srd_seed) - else: - srd_dir = base_path / "rules" / "srd-5.2.1" / "sections" - if srd_dir.exists(): - vi.reindex_directory(srd_dir, source="srd") - if world_dir.exists(): - vi.reindex_directory(world_dir, source="world") + vector_index = VectorIndex( + world_dir / "search.db", + on_empty=lambda vi: _populate_index(base_path, world_dir, vi), + ) - ctx = ToolContext( + ctx = init_ctx( world_id=world_id, player_id=player_id, base_path=base_path, campaign_log=campaign_log, entity_index=EntityIndex(world_dir), - vector_index=VectorIndex(world_dir / "search.db", on_empty=_populate_index), + vector_index=vector_index, ) - static_definitions = TOOL_SETS.get(tool_set) - executor = EXECUTORS.get(tool_set, execute_tool) - - mcp = Server("storied") - - @mcp.list_tools() - async def list_tools() -> list[Tool]: - if tool_set == "dm": - return [_to_mcp_tool(d) for d in _dm_tool_definitions(ctx)] - return [_to_mcp_tool(d) for d in (static_definitions or TOOL_DEFINITIONS)] + server = asyncio.run(_compose_server(tool_set)) - @mcp.call_tool() - async def call_tool(name: str, arguments: dict) -> list[TextContent]: - if tool_set == "dm": - allowed = {d["name"] for d in _dm_tool_definitions(ctx)} - if name not in allowed: - return [TextContent( - type="text", - text=f"Tool '{name}' not available in current mode.", - )] - result = executor(name, arguments, ctx) - return [TextContent(type="text", text=str(result))] - - sse = SseServerTransport("/messages/") - - async def app(scope, receive, send): - if scope["type"] != "http": - return - path = scope.get("path", "") - if path == "/sse": - try: - async with sse.connect_sse(scope, receive, send) as streams: - await mcp.run( - streams[0], - streams[1], - mcp.create_initialization_options(), - ) - except (anyio.ClosedResourceError, anyio.BrokenResourceError): - pass - elif path.startswith("/messages/"): - await sse.handle_post_message(scope, receive, send) + app = server.http_app(transport="sse", path="/sse") port = _find_free_port() - config = uvicorn.Config( - app, host="127.0.0.1", port=port, log_level="warning", - ) - server = uvicorn.Server(config) + config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning") + uv_server = uvicorn.Server(config) - thread = threading.Thread(target=server.run, daemon=True) + thread = threading.Thread(target=uv_server.run, daemon=True) thread.start() for _ in range(50): @@ -195,4 +180,4 @@ break except OSError: time.sleep(0.1) - return MCPServerHandle(port, thread, ctx) + return MCPServerHandle(port, thread, ctx, server) diff --git a/src/storied/planner.py b/src/storied/planner.py --- a/src/storied/planner.py +++ b/src/storied/planner.py @@ -401,14 +401,20 @@ return "\n\n".join(parts) -def tick_world( +def tick_world( # pragma: no cover world_id: str = "default", player_id: str = "default", base_path: Path | None = None, model: str = "claude-opus-4-6", on_progress: Callable[[str], None] | None = None, ) -> TickResult: - """Advance the world by evaluating Will triggers and adding small changes.""" + """Advance the world by evaluating Will triggers and adding small changes. + + Not unit-tested: this is the world-tick orchestrator that drives the + real `claude` subprocess via run_with_tools(). The data-side helpers it + composes (`_find_entities_with_will`, `build_tick_context`) are covered + separately. + """ if base_path is None: base_path = Path.cwd() @@ -502,7 +508,9 @@ self._result = None self._thread = Thread(target=self._run, daemon=True) self._thread.start() - def _run(self) -> None: + def _run(self) -> None: # pragma: no cover + # Threaded entry point that drives tick_world (which is itself + # subprocess-bound). Excluded from coverage along with tick_world. self._result = tick_world( world_id=self._world_id, player_id=self._player_id, diff --git a/src/storied/sandbox.py b/src/storied/sandbox.py --- a/src/storied/sandbox.py +++ b/src/storied/sandbox.py @@ -2,126 +2,124 @@ """Sandboxed Python execution via Pydantic Monty. Provides an `execute` function that runs arbitrary Python code in a secure Rust-based sandbox with no filesystem, network, or environment access. -All DM tools are available as host functions when a ToolContext is provided. +All DM tools are available as host functions; their dependency parameters +resolve from the process-global ToolContext via uncalled-for. """ from __future__ import annotations +import asyncio import inspect from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import Any import pydantic_monty +from uncalled_for import Dependency, get_dependency_parameters, resolved_dependencies from storied.dice import roll as dice_roll +from storied.tools import character, combat, entities, mechanics, scene -if TYPE_CHECKING: - from storied.tools import ToolContext +# All module-level FastMCP servers whose tools should be exposed in the sandbox. +# run_code itself is excluded (no recursion). +_DM_SERVERS = [mechanics.mcp, character.mcp, scene.mcp, entities.mcp, combat.mcp] _LIMITS = pydantic_monty.ResourceLimits( max_duration_secs=5.0, max_memory=10 * 1_024 * 1_024, # 10 MB ) -# Functions whose signatures we can read with inspect -_TOOL_FUNCTIONS: dict[str, str] = {} +def _roll_host(notation: str, reason: str | None = None) -> dict[str, Any]: + """Host function: roll dice and return the result dict. -def _roll_host(notation: str, reason: str | None = None) -> dict[str, Any]: - """Host function: roll dice and return the result dict.""" + Sandbox callers want a dict so they can index into rolls/kept/total + for math; the MCP `roll` tool returns a formatted string instead. + """ return dice_roll(notation).to_dict() -def _build_host_functions( - ctx: ToolContext | None, - extra: dict[str, Callable[..., Any]] | None, -) -> dict[str, Callable[..., Any]]: - """Build the full set of host functions for the sandbox.""" - fns: dict[str, Callable[..., Any]] = {"roll": _roll_host} +def _adapt(fn: Callable[..., Any]) -> Callable[..., Any]: + """Wrap a FastMCP tool function so the sandbox can call it synchronously. - if ctx is not None: - from storied.tools import TOOL_DEFINITIONS, execute_tool - from storied.initiative import ( - COMBAT_TOOL_DEFINITIONS, - ENTER_INITIATIVE_DEFINITION, - ) + The wrapped tool may have parameters with `Dependency` defaults; we + resolve them via uncalled-for's `resolved_dependencies` and splat the + resolved values back into the call. The sandbox passes its own kwargs + for the LLM-visible params. + """ + def wrapper(**kwargs: Any) -> Any: + async def _run() -> Any: + async with resolved_dependencies(fn, kwargs) as deps: + return fn(**{**kwargs, **deps}) + return asyncio.run(_run()) + wrapper.__name__ = fn.__name__ + wrapper.__doc__ = fn.__doc__ + return wrapper - all_defs = TOOL_DEFINITIONS + COMBAT_TOOL_DEFINITIONS + [ENTER_INITIATIVE_DEFINITION] - for defn in all_defs: - name = defn["name"] - if name in ("roll", "run_code"): - continue - def _make(tool_name: str) -> Callable[..., str]: - def fn(**kwargs: Any) -> str: - return execute_tool(tool_name, kwargs, ctx) - return fn +def _enumerate_tools() -> list[tuple[str, Callable[..., Any]]]: + """Walk every DM-side FastMCP server and collect (name, raw_fn) pairs. - fns[name] = _make(name) + Reads the LocalProvider._components dict directly so this works inside + or outside an async context. The tool registry doesn't change after + decoration time, so a sync read is safe. + """ + pairs: list[tuple[str, Callable[..., Any]]] = [] + seen: set[str] = set() + for srv in _DM_SERVERS: + for tool in srv.local_provider._components.values(): + name = tool.name + if name in ("roll", "run_code") or name in seen: + continue + if not hasattr(tool, "fn"): + continue # skip non-function components (resources, prompts) + seen.add(name) + pairs.append((name, tool.fn)) + return pairs + +def _build_host_functions( + extra: dict[str, Callable[..., Any]] | None = None, +) -> dict[str, Callable[..., Any]]: + """Build the full set of host functions for the sandbox.""" + fns: dict[str, Callable[..., Any]] = {"roll": _roll_host} + for name, fn in _enumerate_tools(): + fns[name] = _adapt(fn) if extra: fns.update(extra) - return fns -def _sig(fn: Callable[..., Any], exclude: set[str], ret: str) -> str: - """Format a function signature with return type, excluding internal params.""" +def _format_signature(name: str, fn: Callable[..., Any], ret: str) -> str: + """Format a function signature for tool_signatures, dropping Dependency params.""" sig = inspect.signature(fn, eval_str=True) - params = [p for p in sig.parameters.values() if p.name not in exclude] + dep_params = set(get_dependency_parameters(fn).keys()) + params = [p for p in sig.parameters.values() if p.name not in dep_params] param_str = ", ".join(str(p) for p in params) - return f"{fn.__name__}({param_str}) -> {ret}" + return f"{name}({param_str}) -> {ret}" def build_tool_signatures() -> str: """Build human-readable function signatures for all DM tools. - Signatures reflect what's actually callable in the sandbox: - - roll() comes from _roll_host (returns dict) - - all other tools go through execute_tool (return str) + Walks every DM-side FastMCP server and formats each tool's signature + with its `Depends`-style parameters elided. The roll() entry comes + from _roll_host (returns a dict). """ - from storied.tools import ( - add_condition, add_effect, add_item, add_note, - adjust_coins, create_character, damage, heal, - recall, establish, mark, note_discovery, - remove_condition, remove_effect, remove_item, - rest, restore_resource, set_item_status, set_scene, - tune, end_session, update_character, use_resource, - ) - from storied.initiative import ( - enter_initiative, next_turn, add_combatant, remove_combatant, - condition, end_initiative, - ) - - ctx_params = {"ctx", "tracker"} - str_fns = [ - # Character/state operations - damage, heal, adjust_coins, update_character, create_character, - add_effect, remove_effect, add_condition, remove_condition, - add_item, remove_item, set_item_status, - use_resource, restore_resource, rest, add_note, - # World operations - recall, establish, mark, note_discovery, set_scene, tune, end_session, - # Initiative - enter_initiative, next_turn, add_combatant, remove_combatant, - condition, end_initiative, + lines: list[str] = [ + _format_signature("roll", _roll_host, "dict"), ] - - lines: list[str] = [_sig(_roll_host, set(), "dict").replace("_roll_host", "roll")] - for fn in str_fns: - lines.append(_sig(fn, ctx_params, "str")) - + for name, fn in _enumerate_tools(): + lines.append(_format_signature(name, fn, "str")) return "\n".join(lines) def execute( code: str, - ctx: ToolContext | None = None, host_functions: dict[str, Callable[..., Any]] | None = None, ) -> str: """Run Python code in a sandboxed environment and return the output. - When ctx is provided, every DM tool is available as a host function: + Every DM tool is available as a host function: recall(query="fireball", scope="rules") establish(entity_type="npc", name="Bob", description="A baker") roll("2d6+3") # returns dict with total, rolls, etc. @@ -132,7 +130,7 @@ """ if not code.strip(): return "" - fns = _build_host_functions(ctx, host_functions) + fns = _build_host_functions(host_functions) output_lines: list[str] = [] diff --git a/src/storied/tools/__init__.py b/src/storied/tools/__init__.py --- a/src/storied/tools/__init__.py +++ b/src/storied/tools/__init__.py @@ -1,262 +1,42 @@ """DM tools for Claude to use during gameplay. -Submodules group related tools by domain. This __init__ aggregates -definitions and re-exports public names so existing imports continue -to work unchanged. +Each submodule owns a module-level FastMCP server registered with @mcp.tool +decorators. The orchestrator in storied.mcp_server composes a per-role +top-level server by mounting the right submodules and applying tag-based +visibility filters. ToolContext is process-global and injected into tools +via class-based Dependency subclasses from storied.tools._context. """ -from storied.initiative import ( - ALL_INITIATIVE_TOOL_NAMES, - execute_initiative_tool, -) from storied.tools._context import ( + Combat, + Entities, EntityIndex, + Lore, + Player, + StorageRoot, + Timekeeper, ToolContext, + World, _get_file_lock, _sync_player_hp, -) -from storied.tools.character import ( - add_condition, - add_effect, - add_item, - add_note, - adjust_coins, - create_character, - damage, - heal, - remove_condition, - remove_effect, - remove_item, - rest, - restore_resource, - set_item_status, - update_character, - use_resource, -) -from storied.tools.entities import ( - _auto_mark_present, - _load_entity, - establish, - mark, - note_discovery, + init_ctx, + reset_ctx, ) -from storied.tools.mechanics import ( - recall, - roll, -) -from storied.tools.scene import ( - end_session, - notify_dm, - set_scene, - tune, -) - -# Merge per-module DEFINITIONS lists into the canonical flat list -from storied.tools.character import DEFINITIONS as _CHAR_DEFS -from storied.tools.entities import DEFINITIONS as _ENTITY_DEFS -from storied.tools.mechanics import DEFINITIONS as _MECH_DEFS -from storied.tools.scene import DEFINITIONS as _SCENE_DEFS - -TOOL_DEFINITIONS: list[dict] = ( - _MECH_DEFS + _CHAR_DEFS + _SCENE_DEFS + _ENTITY_DEFS -) - - -def execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: - """Execute a tool by name with the given input.""" - # Initiative-only tools (enter_initiative, next_turn, end_initiative, - # add/remove_combatant, condition) always route to the initiative module. - # damage/heal route to initiative ONLY when called with a target — without - # one they apply to the player character via the new operations module. - if tool_name in ALL_INITIATIVE_TOOL_NAMES: - is_player_op = tool_name in ("damage", "heal") and "target" not in tool_input - if not is_player_op: - result = execute_initiative_tool(tool_name, tool_input, ctx.initiative) - if result is not None: - if tool_name in ("damage", "heal"): - result = _sync_player_hp(tool_input["target"], ctx, result) - return result - - if tool_name == "roll": - result = roll(tool_input["notation"]) - rolls_str = ", ".join(str(r) for r in result["rolls"]) - if result["kept"] != result["rolls"]: - kept_str = ", ".join(str(r) for r in result["kept"]) - return f"Rolled {result['notation']}: [{rolls_str}] → kept [{kept_str}] + {result['modifier']} = {result['total']}" - elif result["modifier"]: - return f"Rolled {result['notation']}: [{rolls_str}] + {result['modifier']} = {result['total']}" - else: - return f"Rolled {result['notation']}: [{rolls_str}] = {result['total']}" - - elif tool_name == "recall": - return recall( - tool_input["query"], ctx, - scope=tool_input.get("scope", "all"), - content_type=tool_input.get("content_type"), - ) - - elif tool_name == "update_character": - return update_character(tool_input["updates"], ctx) - - elif tool_name == "adjust_coins": - return adjust_coins(tool_input["deltas"], ctx) - - elif tool_name == "create_character": - return create_character( - name=tool_input["name"], - race=tool_input["race"], - char_class=tool_input["char_class"], - level=tool_input["level"], - abilities=tool_input["abilities"], - hp_max=tool_input["hp_max"], - ac=tool_input["ac"], - ctx=ctx, - background=tool_input.get("background"), - speed=tool_input.get("speed", 30), - purse=tool_input.get("purse"), - subclass=tool_input.get("subclass"), - backstory=tool_input.get("backstory"), - ) - - elif tool_name == "damage": - return damage(tool_input["amount"], ctx, type=tool_input.get("type")) - - elif tool_name == "heal": - return heal(tool_input["amount"], ctx) - - elif tool_name == "add_effect": - return add_effect( - source=tool_input["source"], - description=tool_input["description"], - ctx=ctx, - expires=tool_input.get("expires"), - ) - - elif tool_name == "remove_effect": - return remove_effect(tool_input["source"], ctx) - - elif tool_name == "add_condition": - return add_condition(tool_input["name"], ctx) - - elif tool_name == "remove_condition": - return remove_condition(tool_input["name"], ctx) - - elif tool_name == "add_item": - return add_item(tool_input["item"], ctx, location=tool_input.get("location")) - - elif tool_name == "remove_item": - return remove_item(tool_input["item"], ctx) - - elif tool_name == "set_item_status": - return set_item_status(tool_input["item"], tool_input["status"], ctx) - - elif tool_name == "use_resource": - return use_resource(tool_input["name"], ctx, amount=tool_input.get("amount", 1)) - - elif tool_name == "restore_resource": - return restore_resource(tool_input["name"], tool_input["amount"], ctx) - - elif tool_name == "rest": - return rest(tool_input["type"], ctx) - - elif tool_name == "add_note": - return add_note(tool_input["text"], ctx) - - elif tool_name == "set_scene": - return set_scene( - ctx=ctx, - event=tool_input.get("event"), - duration=tool_input.get("duration"), - situation=tool_input.get("situation"), - location=tool_input.get("location"), - present=tool_input.get("present"), - threads=tool_input.get("threads"), - tags=tool_input.get("tags"), - ) - - elif tool_name == "establish": - return establish( - entity_type=tool_input["entity_type"], - name=tool_input["name"], - ctx=ctx, - description=tool_input.get("description"), - location=tool_input.get("location"), - knows=tool_input.get("knows"), - wants=tool_input.get("wants"), - will=tool_input.get("will"), - ) - - elif tool_name == "mark": - return mark( - entity_type=tool_input["entity_type"], - name=tool_input["name"], - event=tool_input["event"], - ctx=ctx, - resolves=tool_input.get("resolves"), - ) - - elif tool_name == "note_discovery": - return note_discovery( - entity=tool_input["entity"], - content=tool_input["content"], - ctx=ctx, - content_type=tool_input.get("content_type", "lore"), - tags=tool_input.get("tags"), - ) - - elif tool_name == "tune": - return tune(tuning=tool_input["tuning"], ctx=ctx) - - elif tool_name == "end_session": - return end_session( - situation=tool_input["situation"], - ctx=ctx, - threads=tool_input.get("threads"), - ) - - elif tool_name == "run_code": - from storied.sandbox import execute as sandbox_execute - return sandbox_execute(tool_input["code"], ctx=ctx) - - elif tool_name == "notify_dm": - return notify_dm(message=tool_input["message"], ctx=ctx) - - else: - return f"Unknown tool: {tool_name}" - - -# --- Tool sets for specialized agents --- - -PLANNER_TOOLS = {"recall", "establish", "mark", "notify_dm"} -PLANNER_TOOL_DEFINITIONS = [t for t in TOOL_DEFINITIONS if t["name"] in PLANNER_TOOLS] - - -def planner_execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: - """Execute a planner-allowed tool.""" - if tool_name not in PLANNER_TOOLS: - return f"Tool not available to planner: {tool_name}" - return execute_tool(tool_name, tool_input, ctx) - - -SEEDER_TOOLS = {"establish", "set_scene"} -SEEDER_TOOL_DEFINITIONS = [t for t in TOOL_DEFINITIONS if t["name"] in SEEDER_TOOLS] +from storied.tools.entities import _load_entity - -def seeder_execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: - """Execute a seeder-allowed tool.""" - if tool_name not in SEEDER_TOOLS: - return f"Tool not available to seeder: {tool_name}" - return execute_tool(tool_name, tool_input, ctx) - - -ADVANCEMENT_TOOLS = {"recall", "update_character", "notify_dm"} -ADVANCEMENT_TOOL_DEFINITIONS = [ - t for t in TOOL_DEFINITIONS if t["name"] in ADVANCEMENT_TOOLS +__all__ = [ + "Combat", + "Entities", + "EntityIndex", + "Lore", + "Player", + "StorageRoot", + "Timekeeper", + "ToolContext", + "World", + "_get_file_lock", + "_load_entity", + "_sync_player_hp", + "init_ctx", + "reset_ctx", ] - - -def advancement_execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: - """Execute an advancement-evaluator-allowed tool.""" - if tool_name not in ADVANCEMENT_TOOLS: - return f"Tool not available to advancement evaluator: {tool_name}" - return execute_tool(tool_name, tool_input, ctx) diff --git a/src/storied/tools/_context.py b/src/storied/tools/_context.py --- a/src/storied/tools/_context.py +++ b/src/storied/tools/_context.py @@ -4,6 +4,8 @@ import threading from dataclasses import dataclass, field from pathlib import Path +from uncalled_for import Dependency + from storied.character import update_character as char_update from storied.initiative import InitiativeTracker from storied.log import CampaignLog @@ -60,8 +62,9 @@ @dataclass class ToolContext: """Shared infrastructure for all tool calls. - Created once per MCP server and passed to every tool invocation. - All fields are required — no defensive None checks in tool code. + Created once per process via init_ctx() and exposed to tools through + the Dependency subclasses below. Tools should never reach for the + full ToolContext — they ask for the specific slices they need. """ world_id: str @@ -73,10 +76,127 @@ vector_index: VectorIndex initiative: InitiativeTracker = field(default_factory=InitiativeTracker) -def _sync_player_hp(target: str, ctx: ToolContext, result: str) -> str: +# --- Process-global ToolContext --------------------------------------------- + +_ctx: ToolContext | None = None + + +def init_ctx( + world_id: str, + player_id: str, + base_path: Path, + campaign_log: CampaignLog, + entity_index: EntityIndex, + vector_index: VectorIndex, +) -> ToolContext: + """Initialize the process-global ToolContext. + + Idempotent — last writer wins. Tests use this to reset state between cases. + """ + global _ctx + _ctx = ToolContext( + world_id=world_id, + player_id=player_id, + base_path=base_path, + campaign_log=campaign_log, + entity_index=entity_index, + vector_index=vector_index, + ) + return _ctx + + +def reset_ctx() -> None: + """Clear the process-global ToolContext (for test teardown).""" + global _ctx + _ctx = None + + +def _require() -> ToolContext: + if _ctx is None: + raise RuntimeError("ToolContext not initialized; call init_ctx() first") + return _ctx + + +# --- Class-based dependencies for each ToolContext slice -------------------- +# +# Each Dependency subclass exposes one piece of process state at the parameter +# site. Tools declare only the slices they touch, e.g.: +# +# def recall(query: str, lore: VectorIndex = Lore()) -> str: ... +# +# FastMCP's schema introspection skips parameters whose default is a +# Dependency instance, so the LLM never sees them. + + +class World(Dependency[str]): + """The current world ID.""" + single = True + + async def __aenter__(self) -> str: + return _require().world_id + + +class Player(Dependency[str]): + """The current player ID.""" + single = True + + async def __aenter__(self) -> str: + return _require().player_id + + +class StorageRoot(Dependency[Path]): + """The filesystem root for worlds, players, and rules.""" + single = True + + async def __aenter__(self) -> Path: + return _require().base_path + + +class Timekeeper(Dependency[CampaignLog]): + """The campaign log: game time, world events, recent history.""" + single = True + + async def __aenter__(self) -> CampaignLog: + return _require().campaign_log + + +class Entities(Dependency[EntityIndex]): + """The entity name→path index with parsed-entity cache.""" + single = True + + async def __aenter__(self) -> EntityIndex: + return _require().entity_index + + +class Lore(Dependency[VectorIndex]): + """The semantic search index over rules + world content.""" + single = True + + async def __aenter__(self) -> VectorIndex: + return _require().vector_index + + +class Combat(Dependency[InitiativeTracker]): + """The active initiative tracker.""" + single = True + + async def __aenter__(self) -> InitiativeTracker: + return _require().initiative + + +# --- Helpers --------------------------------------------------------------- + + +def _sync_player_hp( + target: str, + initiative: InitiativeTracker, + player_id: str, + base_path: Path, + result: str, +) -> str: """Auto-sync player character sheet when damage/heal targets a player.""" - combatant = ctx.initiative._find(target) + combatant = initiative._find(target) if combatant and combatant.is_player: - char_update(ctx.player_id, {"state.hp.current": combatant.hp}, ctx.base_path) + char_update(player_id, {"state.hp.current": combatant.hp}, base_path) result += f" (character sheet synced to {combatant.hp} HP)" return result diff --git a/src/storied/tools/character.py b/src/storied/tools/character.py --- a/src/storied/tools/character.py +++ b/src/storied/tools/character.py @@ -1,9 +1,15 @@ """Character management tools — bookkeeping primitives for the DM.""" +from pathlib import Path +from typing import Literal + +from fastmcp import FastMCP +from pydantic import JsonValue + +from storied.character.schema import Abilities, CoinDelta, Purse from storied.character import ( add_condition as char_add_condition, ) -from storied.tools.character_schemas import SCHEMAS from storied.character import ( add_effect as char_add_effect, ) @@ -26,6 +32,9 @@ from storied.character import ( heal as char_heal, ) from storied.character import ( + load_character, +) +from storied.character import ( remove_condition as char_remove_condition, ) from storied.character import ( @@ -49,49 +58,72 @@ ) from storied.character import ( use_resource as char_use_resource, ) -from storied.tools._context import ToolContext +from storied.initiative import InitiativeTracker +from storied.log import CampaignLog +from storied.tools._context import ( + Combat, + Player, + StorageRoot, + Timekeeper, + _sync_player_hp, +) + +mcp = FastMCP("character") # --- Universal field setter --- -def update_character(updates: dict, ctx: ToolContext) -> str: +@mcp.tool(tags={"dm", "advancement", "character"}) +def update_character( + updates: dict[str, JsonValue], + player: str = Player(), + root: Path = StorageRoot(), +) -> str: """Update arbitrary fields on the character sheet via dot notation. Use this for any change that doesn't have a more specific tool. For HP, coins, items, effects, etc., prefer the dedicated tools (damage, heal, adjust_coins, add_item, add_effect, ...) — they prevent arithmetic errors. + Keys are dot-paths into character.yaml, e.g.: + - {"identity.classes.0.level": 4} — level up + - {"state.hp.max": 30} — increase max HP + - {"state.exhaustion": 1} — set exhaustion level + - {"state.death_saves.successes": 2} — record a death save + - {"proficiencies.skills.persuasion": "proficient"} + - {"advancement_ready": null} — clear advancement flag + + The result is validated against the character schema before being saved. + If a write would corrupt the schema (e.g. replacing the resources dict + with a list), the update is rejected and the file on disk is unchanged. + Args: - updates: Fields to update by dot path. Examples: - - {"identity.classes.0.level": 4} — level up - - {"state.hp.max": 30} — increase max HP - - {"state.exhaustion": 1} — set exhaustion level - - {"state.death_saves.successes": 2} — record a death save - - {"proficiencies.skills.persuasion": "proficient"} — add proficiency - - {"advancement_ready": null} — clear advancement flag - - {"features": [...]} — replace features list + updates: Map of dot-path → new value. Values can be any JSON-compatible + type (string, number, boolean, null, list, or nested object). Returns: - Confirmation of what was updated + Confirmation of what was updated, or a rejection error. """ - return char_update(ctx.player_id, updates, ctx.base_path) + return char_update(player, updates, root) +@mcp.tool(tags={"dm", "character"}) def create_character( name: str, race: str, char_class: str, level: int, - abilities: dict[str, int], + abilities: Abilities, hp_max: int, ac: int, - ctx: ToolContext, background: str | None = None, speed: int = 30, - purse: dict[str, int] | None = None, + purse: Purse | None = None, subclass: str | None = None, backstory: str | None = None, + player: str = Player(), + root: Path = StorageRoot(), ) -> str: """Create a new player character with the new structured schema. @@ -102,12 +134,13 @@ name: Character name race: Race (e.g., "Human", "High Elf") char_class: Class (e.g., "Fighter", "Wizard") level: Starting level (usually 1) - abilities: All six ability scores + abilities: All six ability scores (strength/dexterity/constitution/ + intelligence/wisdom/charisma) hp_max: Maximum hit points ac: Armor class background: Background (e.g., "Soldier", "Criminal") speed: Movement speed in feet - purse: Starting coins {cp, sp, ep, gp, pp} + purse: Starting coins (cp/sp/ep/gp/pp); omit for empty purse subclass: Subclass if chosen backstory: Character backstory and personality (goes in character.md) @@ -115,77 +148,132 @@ Returns: Confirmation message """ return char_create( - player_id=ctx.player_id, + player_id=player, name=name, race=race, char_class=char_class, level=level, - abilities=abilities, + abilities=abilities.model_dump(), hp_max=hp_max, ac=ac, background=background, speed=speed, - purse=purse, + purse=purse.model_dump() if purse else None, subclass=subclass, backstory=backstory, - base_path=ctx.base_path, + base_path=root, ) -# --- HP operations --- +# --- HP operations (unified — work in or out of combat) --- -def damage(amount: int, ctx: ToolContext, type: str | None = None) -> str: - """Apply damage to the character. Temp HP absorbs first, then current HP. +@mcp.tool(tags={"dm", "character"}) +def damage( + target: str, + amount: int, + type: str | None = None, + combat: InitiativeTracker = Combat(), + player: str = Player(), + root: Path = StorageRoot(), +) -> str: + """Apply damage to a named target. + + Routes by name: if `target` matches a current combatant in initiative, + the damage hits the combatant (and syncs to the character sheet if it's + the player). Otherwise, if `target` matches the player character's name, + the damage hits the player character sheet directly. Temp HP absorbs + first, then current HP. Args: + target: Combatant or player character name amount: Damage amount (non-negative) type: Optional damage type (fire, cold, slashing, etc.) for narration Returns: Damage taken and remaining HP """ - return char_damage(ctx.player_id, amount, damage_type=type, base_path=ctx.base_path) + if combat.active and combat._find(target) is not None: + result = combat.apply_damage(target, amount) + return _sync_player_hp(target, combat, player, root, result) + char = load_character(player, root) + if char and char["identity"]["name"] == target: + return char_damage(player, amount, damage_type=type, base_path=root) -def heal(amount: int, ctx: ToolContext) -> str: - """Heal the character. Clamped to max HP. + return f"No such target: {target}" + + +@mcp.tool(tags={"dm", "character"}) +def heal( + target: str, + amount: int, + combat: InitiativeTracker = Combat(), + player: str = Player(), + root: Path = StorageRoot(), +) -> str: + """Heal a named target. + + Routes by name: if `target` matches a current combatant, heals the + combatant (and syncs to the character sheet if it's the player). + Otherwise, if `target` matches the player character, heals the + character sheet directly. Clamped to max HP. Args: + target: Combatant or player character name amount: HP to restore (non-negative) Returns: Healing applied and current HP """ - return char_heal(ctx.player_id, amount, base_path=ctx.base_path) + if combat.active and combat._find(target) is not None: + result = combat.apply_heal(target, amount) + return _sync_player_hp(target, combat, player, root, result) + + char = load_character(player, root) + if char and char["identity"]["name"] == target: + return char_heal(player, amount, base_path=root) + + return f"No such target: {target}" # --- Coin operations --- -def adjust_coins(deltas: dict[str, int], ctx: ToolContext) -> str: +@mcp.tool(tags={"dm", "character"}) +def adjust_coins( + deltas: CoinDelta, + player: str = Player(), + root: Path = StorageRoot(), +) -> str: """Adjust the player's coins by relative amounts. - Use negative values to spend, positive to gain. Coins are clamped to 0. + Use negative values to spend, positive to gain. Omit any denomination + you don't want to change. Coins are clamped to zero on the underlying purse. Args: - deltas: Coin changes by denomination (cp/sp/ep/gp/pp). - Examples: {"gp": -5}, {"gp": 10, "sp": 5} + deltas: Coin changes per denomination (cp/sp/ep/gp/pp). + Example: spend 5 gp → {"gp": -5}; gain 10 gp + 5 sp → {"gp": 10, "sp": 5} Returns: Summary of changes and new purse balance """ - return char_adjust_coins(ctx.player_id, deltas, ctx.base_path) + # Drop zero-deltas before passing to the underlying op so the result + # message only mentions denominations the DM actually touched. + nonzero = {k: v for k, v in deltas.model_dump().items() if v != 0} + return char_adjust_coins(player, nonzero, root) # --- Effect operations --- +@mcp.tool(tags={"dm", "character"}) def add_effect( source: str, description: str, - ctx: ToolContext, expires: str | None = None, + player: str = Player(), + root: Path = StorageRoot(), ) -> str: """Track a temporary effect on the character. @@ -200,12 +288,15 @@ Returns: Confirmation """ - return char_add_effect( - ctx.player_id, source, description, expires=expires, base_path=ctx.base_path - ) + return char_add_effect(player, source, description, expires=expires, base_path=root) -def remove_effect(source: str, ctx: ToolContext) -> str: +@mcp.tool(tags={"dm", "character"}) +def remove_effect( + source: str, + player: str = Player(), + root: Path = StorageRoot(), +) -> str: """Remove an effect by source name (case-insensitive substring match). Args: @@ -214,13 +305,18 @@ Returns: Confirmation of what was removed """ - return char_remove_effect(ctx.player_id, source, base_path=ctx.base_path) + return char_remove_effect(player, source, base_path=root) # --- Condition operations --- -def add_condition(name: str, ctx: ToolContext) -> str: +@mcp.tool(tags={"dm", "character"}) +def add_condition( + name: str, + player: str = Player(), + root: Path = StorageRoot(), +) -> str: """Mark a condition on the character (5e conditions or any custom name). Args: @@ -229,10 +325,15 @@ Returns: Confirmation """ - return char_add_condition(ctx.player_id, name, base_path=ctx.base_path) + return char_add_condition(player, name, base_path=root) -def remove_condition(name: str, ctx: ToolContext) -> str: +@mcp.tool(tags={"dm", "character"}) +def remove_condition( + name: str, + player: str = Player(), + root: Path = StorageRoot(), +) -> str: """Remove a condition from the character. Args: @@ -241,13 +342,19 @@ Returns: Confirmation """ - return char_remove_condition(ctx.player_id, name, base_path=ctx.base_path) + return char_remove_condition(player, name, base_path=root) # --- Inventory operations --- -def add_item(item: str, ctx: ToolContext, location: str | None = None) -> str: +@mcp.tool(tags={"dm", "character"}) +def add_item( + item: str, + location: str | None = None, + player: str = Player(), + root: Path = StorageRoot(), +) -> str: """Add a mundane item to the character's equipment. Items are organized by location subsection (e.g., "on_person", "stashed_at_inn"). @@ -264,10 +371,15 @@ Returns: Confirmation with the location it was added to """ - return char_add_item(ctx.player_id, item, location=location, base_path=ctx.base_path) + return char_add_item(player, item, location=location, base_path=root) -def remove_item(item: str, ctx: ToolContext) -> str: +@mcp.tool(tags={"dm", "character"}) +def remove_item( + item: str, + player: str = Player(), + root: Path = StorageRoot(), +) -> str: """Remove an item from the character's equipment by name. Uses case-insensitive substring matching across all equipment subsections. @@ -279,11 +391,17 @@ Returns: Confirmation with the item that was removed """ - return char_remove_item(ctx.player_id, item, base_path=ctx.base_path) + return char_remove_item(player, item, base_path=root) -def set_item_status(item: str, status: str, ctx: ToolContext) -> str: - """Set a magic item's status (attuned, equipped, or carried). +@mcp.tool(tags={"dm", "character"}) +def set_item_status( + item: str, + status: Literal["attuned", "equipped", "carried"], + player: str = Player(), + root: Path = StorageRoot(), +) -> str: + """Set a magic item's status. The item should already exist as a world entity in worlds/{world}/items/. This function manages where the wikilink lives in the character's @@ -291,20 +409,24 @@ magic_items dict. Args: item: Magic item name (matches the world entity name) - status: One of "attuned", "equipped", or "carried" + status: New status — one of attuned, equipped, or carried Returns: Confirmation """ - return char_set_item_status( - ctx.player_id, item, status, base_path=ctx.base_path - ) + return char_set_item_status(player, item, status, base_path=root) # --- Resource operations --- -def use_resource(name: str, ctx: ToolContext, amount: int = 1) -> str: +@mcp.tool(tags={"dm", "character"}) +def use_resource( + name: str, + amount: int = 1, + player: str = Player(), + root: Path = StorageRoot(), +) -> str: """Decrement a resource pool (rage uses, ki points, hit dice, magic item charges, etc.). Substring match on the resource name. Resources are clamped to 0. @@ -316,12 +438,16 @@ Returns: Confirmation with remaining count """ - return char_use_resource( - ctx.player_id, name, amount=amount, base_path=ctx.base_path - ) + return char_use_resource(player, name, amount=amount, base_path=root) -def restore_resource(name: str, amount: int, ctx: ToolContext) -> str: +@mcp.tool(tags={"dm", "character"}) +def restore_resource( + name: str, + amount: int, + player: str = Player(), + root: Path = StorageRoot(), +) -> str: """Restore points to a resource pool, clamped to max. Usually you'll use `rest` instead, which refreshes resources by their @@ -334,12 +460,15 @@ Returns: Confirmation """ - return char_restore_resource( - ctx.player_id, name, amount, base_path=ctx.base_path - ) + return char_restore_resource(player, name, amount, base_path=root) -def rest(type: str, ctx: ToolContext) -> str: +@mcp.tool(tags={"dm", "character"}) +def rest( + type: Literal["short", "long"], + player: str = Player(), + root: Path = StorageRoot(), +) -> str: """Take a short or long rest. Refreshes resources by refresh type (long rest also refreshes short_rest @@ -347,18 +476,24 @@ resources). Long rest also clears death saves, removes one exhaustion level, and restores HP to max. Args: - type: "short" or "long" + type: Rest type — "short" or "long" Returns: Summary of what was refreshed """ - return char_rest(ctx.player_id, type, base_path=ctx.base_path) + return char_rest(player, type, base_path=root) # --- Notes --- -def add_note(text: str, ctx: ToolContext) -> str: +@mcp.tool(tags={"dm", "character"}) +def add_note( + text: str, + timekeeper: CampaignLog = Timekeeper(), + player: str = Player(), + root: Path = StorageRoot(), +) -> str: """Append a note to the player's notes.md journal. Notes accumulate over time and are stamped with the current game time. @@ -370,40 +505,5 @@ Returns: Confirmation """ - time_anchor = ctx.campaign_log.get_current_time().to_anchor() - return char_add_note( - ctx.player_id, text, time_anchor=time_anchor, base_path=ctx.base_path - ) - - - -# --- Tool definitions for the API --- -# Wrapper docstrings + schemas from character_schemas.py - -_WRAPPERS = { - "update_character": update_character, - "create_character": create_character, - "damage": damage, - "heal": heal, - "adjust_coins": adjust_coins, - "add_effect": add_effect, - "remove_effect": remove_effect, - "add_condition": add_condition, - "remove_condition": remove_condition, - "add_item": add_item, - "remove_item": remove_item, - "set_item_status": set_item_status, - "use_resource": use_resource, - "restore_resource": restore_resource, - "rest": rest, - "add_note": add_note, -} - -DEFINITIONS: list[dict] = [ - { - "name": s["name"], - "description": _WRAPPERS[s["name"]].__doc__, - "input_schema": s["input_schema"], - } - for s in SCHEMAS -] + time_anchor = timekeeper.get_current_time().to_anchor() + return char_add_note(player, text, time_anchor=time_anchor, base_path=root) diff --git a/src/storied/tools/character_schemas.py b/src/storied/tools/character_schemas.py deleted file mode 100644 --- a/src/storied/tools/character_schemas.py +++ /dev/null @@ -1,214 +0,0 @@ -"""JSON schemas for character tool definitions. - -Imported by tools/character.py to construct the DEFINITIONS list. -The descriptions come from the wrapper function docstrings, so this -file holds only the input_schema portion plus the tool name. -""" - - -SCHEMAS: list[dict] = [ - { - "name": "update_character", - "input_schema": { - "type": "object", - "properties": { - "updates": { - "type": "object", - "description": "Fields to update by dot path (e.g., 'state.hp.max', 'identity.classes.0.level')", - }, - }, - "required": ["updates"], - }, - }, - { - "name": "create_character", - "input_schema": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "race": {"type": "string"}, - "char_class": {"type": "string"}, - "level": {"type": "integer"}, - "abilities": {"type": "object"}, - "hp_max": {"type": "integer"}, - "ac": {"type": "integer"}, - "background": {"type": "string"}, - "speed": {"type": "integer"}, - "subclass": {"type": "string"}, - "purse": { - "type": "object", - "properties": { - "cp": {"type": "integer"}, "sp": {"type": "integer"}, - "ep": {"type": "integer"}, "gp": {"type": "integer"}, - "pp": {"type": "integer"}, - }, - }, - "backstory": {"type": "string"}, - }, - "required": ["name", "race", "char_class", "level", "abilities", "hp_max", "ac"], - }, - }, - { - "name": "damage", - "input_schema": { - "type": "object", - "properties": { - "amount": {"type": "integer", "description": "Damage amount (non-negative)"}, - "type": {"type": "string", "description": "Damage type (fire, cold, slashing, etc.)"}, - }, - "required": ["amount"], - }, - }, - { - "name": "heal", - "input_schema": { - "type": "object", - "properties": { - "amount": {"type": "integer", "description": "HP to restore"}, - }, - "required": ["amount"], - }, - }, - { - "name": "adjust_coins", - "input_schema": { - "type": "object", - "properties": { - "deltas": { - "type": "object", - "description": "Coin deltas: negative=spend, positive=gain", - "properties": { - "cp": {"type": "integer"}, "sp": {"type": "integer"}, - "ep": {"type": "integer"}, "gp": {"type": "integer"}, - "pp": {"type": "integer"}, - }, - }, - }, - "required": ["deltas"], - }, - }, - { - "name": "add_effect", - "input_schema": { - "type": "object", - "properties": { - "source": {"type": "string", "description": "Where the effect comes from"}, - "description": {"type": "string", "description": "What the effect does"}, - "expires": {"type": "string", "description": "Optional game time anchor when the effect ends"}, - }, - "required": ["source", "description"], - }, - }, - { - "name": "remove_effect", - "input_schema": { - "type": "object", - "properties": { - "source": {"type": "string", "description": "Effect source name (substring match)"}, - }, - "required": ["source"], - }, - }, - { - "name": "add_condition", - "input_schema": { - "type": "object", - "properties": { - "name": {"type": "string", "description": "Condition name"}, - }, - "required": ["name"], - }, - }, - { - "name": "remove_condition", - "input_schema": { - "type": "object", - "properties": { - "name": {"type": "string", "description": "Condition name"}, - }, - "required": ["name"], - }, - }, - { - "name": "add_item", - "input_schema": { - "type": "object", - "properties": { - "item": {"type": "string", "description": "Item description"}, - "location": {"type": "string", "description": "Optional location subsection"}, - }, - "required": ["item"], - }, - }, - { - "name": "remove_item", - "input_schema": { - "type": "object", - "properties": { - "item": {"type": "string", "description": "Item name (substring match)"}, - }, - "required": ["item"], - }, - }, - { - "name": "set_item_status", - "input_schema": { - "type": "object", - "properties": { - "item": {"type": "string", "description": "Magic item entity name"}, - "status": { - "type": "string", - "enum": ["attuned", "equipped", "carried"], - "description": "New status for the item", - }, - }, - "required": ["item", "status"], - }, - }, - { - "name": "use_resource", - "input_schema": { - "type": "object", - "properties": { - "name": {"type": "string", "description": "Resource name (substring match)"}, - "amount": {"type": "integer", "description": "How many to use (default 1)"}, - }, - "required": ["name"], - }, - }, - { - "name": "restore_resource", - "input_schema": { - "type": "object", - "properties": { - "name": {"type": "string", "description": "Resource name"}, - "amount": {"type": "integer", "description": "How many to restore"}, - }, - "required": ["name", "amount"], - }, - }, - { - "name": "rest", - "input_schema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["short", "long"], - "description": "Rest type", - }, - }, - "required": ["type"], - }, - }, - { - "name": "add_note", - "input_schema": { - "type": "object", - "properties": { - "text": {"type": "string", "description": "Note text"}, - }, - "required": ["text"], - }, - }, -] diff --git a/src/storied/tools/combat.py b/src/storied/tools/combat.py new file mode 100644 --- /dev/null +++ b/src/storied/tools/combat.py @@ -0,0 +1,181 @@ +"""FastMCP combat tool surface — initiative tools that flip the visibility +of `combat`-tagged tools on the composed top-level server. + +Damage and heal live in tools/character.py — they're routed by name to the +right place there. The tools here only modify combat order and conditions. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +from fastmcp import FastMCP +from pydantic import BaseModel, Field + +from storied.initiative import Combatant, InitiativeTracker +from storied.tools._context import Combat + +if TYPE_CHECKING: + from fastmcp import FastMCP as _FastMCP + +mcp = FastMCP("combat") + + +class CombatantInput(BaseModel): + """Schema for one combatant entry passed to enter_initiative. + + FastMCP introspects this model and exposes the field shape to the LLM, + so the DM doesn't have to guess which keys are required. + """ + + name: str = Field(description="Combatant display name") + initiative: int = Field(description="Initiative roll total (used for turn order)") + hp: int = Field(description="Current hit points") + hp_max: int = Field(description="Maximum hit points") + ac: int = Field(description="Armor class") + is_player: bool = Field( + default=False, + description="True for the player character; false for NPCs and monsters", + ) + +# The composed top-level server and the set of combat tool keys to hide +# when leaving combat — both registered at start_server() time so the +# enter/end_initiative tools can flip combat tag visibility on the parent. +_root: "_FastMCP | None" = None +_combat_keys_to_hide: set[str] = set() + + +def set_root(root: "_FastMCP", combat_keys_to_hide: set[str]) -> None: + """Register the composed top-level FastMCP server. + + Called from mcp_server.start_server() after composition so that + enter_initiative / end_initiative can call _root.disable/enable on + the parent server (where claude actually sees tools). + + `combat_keys_to_hide` is the precomputed set of keys for tools tagged + `combat` but not `combat_control` — i.e. the tools that should appear + only while initiative is active. + """ + global _root, _combat_keys_to_hide + _root = root + _combat_keys_to_hide = combat_keys_to_hide + + +def _flip_into_combat() -> None: + """Hide narrative_only tools, show combat tools.""" + if _root is None: + return + _root.disable(tags={"narrative_only"}) + if _combat_keys_to_hide: + _root.enable(keys=_combat_keys_to_hide) + + +def _flip_out_of_combat() -> None: + """Show narrative_only tools, hide combat tools (except combat_control).""" + if _root is None: + return + _root.enable(tags={"narrative_only"}) + if _combat_keys_to_hide: + _root.disable(keys=_combat_keys_to_hide) + + +@mcp.tool(tags={"dm", "combat", "combat_control"}) +def enter_initiative( + combatants: list[CombatantInput], + combat: InitiativeTracker = Combat(), +) -> str: + """Enter initiative mode for combat or any turn-based encounter. + + Provide all participants in their desired turn order (you handle + tie-breaking). Roll initiative for everyone first, then call this. + Each combatant needs name, initiative, hp, hp_max, ac, and optionally + is_player. Initiative tools become available on the next turn. + """ + if combat.active: + return "Initiative is already active. Call end_initiative first." + + parsed = [ + Combatant( + name=c.name, + initiative=c.initiative, + hp=c.hp, + hp_max=c.hp_max, + ac=c.ac, + is_player=c.is_player, + ) + for c in combatants + ] + result = combat.begin(parsed) + _flip_into_combat() + return result + + +@mcp.tool(tags={"dm", "combat"}) +def next_turn(combat: InitiativeTracker = Combat()) -> str: + """Advance to the next combatant's turn. Skips defeated. Call after resolving actions.""" + return combat.next_turn() + + +@mcp.tool(tags={"dm", "combat"}) +def add_combatant( + name: str, + initiative: int, + hp: int, + hp_max: int, + ac: int, + is_player: bool = False, + combat: InitiativeTracker = Combat(), +) -> str: + """Add a combatant (reinforcements, surprised creatures waking up).""" + c = Combatant( + name=name, initiative=initiative, hp=hp, + hp_max=hp_max, ac=ac, is_player=is_player, + ) + return combat.add_combatant(c) + + +@mcp.tool(tags={"dm", "combat"}) +def remove_combatant( + name: str, + combat: InitiativeTracker = Combat(), +) -> str: + """Remove a combatant who fled, was banished, or is otherwise out.""" + return combat.remove_combatant(name) + + +@mcp.tool(tags={"dm", "combat"}) +def condition( + target: str, + condition: str, + action: Literal["add", "remove"] = "add", + duration: int = -1, + ends_on: Literal["start", "end"] = "start", + source: str = "", + combat: InitiativeTracker = Combat(), +) -> str: + """Add or remove a condition on a combatant. + + Duration counts down on the source's turn. Duration -1 = until manually removed. + + Args: + target: Combatant name + condition: Condition name (Prone, Stunned, Frightened, etc.) + action: "add" to apply the condition, "remove" to clear it + duration: Rounds until expiry; -1 means until manually removed + ends_on: Whether duration ticks down at "start" or "end" of source's turn + source: Who caused the condition + """ + if action == "remove": + return combat.remove_condition(target, condition) + return combat.add_condition( + target=target, condition=condition, + duration=duration, ends_on=ends_on, source=source, + ) + + +@mcp.tool(tags={"dm", "combat", "combat_control"}) +def end_initiative(combat: InitiativeTracker = Combat()) -> str: + """End initiative and return to narrative. Returns summary with rounds, defeated, and survivor HP.""" + result = combat.end() + _flip_out_of_combat() + return result diff --git a/src/storied/tools/entities.py b/src/storied/tools/entities.py --- a/src/storied/tools/entities.py +++ b/src/storied/tools/entities.py @@ -2,81 +2,35 @@ """World entity tools — establish, mark, note_discovery.""" import re from pathlib import Path +from typing import Literal import yaml +from fastmcp import FastMCP +from storied.log import CampaignLog +from storied.search import VectorIndex from storied.session import name_to_slug -from storied.tools._context import EntityIndex, ToolContext, _get_file_lock - - -def establish( - entity_type: str, - name: str, - ctx: ToolContext, - description: str | None = None, - location: str | None = None, - knows: list[str] | None = None, - wants: list[str] | None = None, - will: list[str] | None = None, -) -> str: - """Establish or update an entity in the world. - - Use to create NPCs, locations, items, factions, or threads with their inner - state. Everything has Knows/Wants/Will: - - **Knows** = secrets, hidden truths, what isn't obvious - - **Wants** = nature, tendencies, inclinations (even non-sentient things can "want") - - **Will** = conditional triggers, what happens if... - - This isn't literal consciousness - it's narrative tendency. A bridge can "want" - to collapse. Cursed gold "wants" to be spent. Frame it this way and the world - feels alive. - - Partial updates: omit fields to preserve existing content when updating. - - Args: - entity_type: Type of entity: npcs, locations, items, factions, threads, lore - name: Display name (e.g., "Vera Blackwater", "The Rusty Anchor") - This becomes the filename directly (no slugification). - description: Prose description for the ## Is section. Include appearance, - background, current state, relationships via [[wikilinks]]. - location: Where this entity is right now. Can be a simple wikilink like - "[[The Rusty Anchor]]" or a verbal description like "In the basement - of [[The Rusty Anchor]]" or "Wandering the docks of [[Greyhaven]]". - knows: List of secrets and hidden truths. Things that aren't obvious. - wants: List of desires, tendencies, inclinations. The entity's nature. - will: List of conditional behaviors: "If X -> Y" format. - - Returns: - Confirmation with the file path - """ - world_dir = ctx.base_path / "worlds" / ctx.world_id / entity_type - world_dir.mkdir(parents=True, exist_ok=True) - file_path = world_dir / f"{name}.md" +from storied.tools._context import ( + Entities, + EntityIndex, + Lore, + Player, + StorageRoot, + Timekeeper, + World, + _get_file_lock, +) - lock = _get_file_lock(file_path) - with lock: - existing = _load_entity(file_path, ctx.entity_index) +# Entity-type enums exposed to the LLM via JSON Schema. Each tool's set is +# slightly different — only the kinds that make sense for that operation. +EstablishType = Literal["npcs", "locations", "items", "factions", "threads", "lore"] +MarkType = Literal["npcs", "locations", "items", "factions", "threads"] +DiscoveryType = Literal["npcs", "locations", "factions", "lore"] - if description is None: - description = existing.get("description", "") - if location is None: - location = existing.get("location", "") - if knows is None: - knows = existing.get("knows", []) - if wants is None: - wants = existing.get("wants", []) - if will is None: - will = existing.get("will", []) - was = existing.get("was", []) +mcp = FastMCP("entities") - data = { - "description": description, "location": location, - "knows": knows, "wants": wants, "will": will, "was": was, - } - _write_entity(file_path, name, entity_type, data, ctx) - action = "Updated" if existing else "Established" - return f"{action} {entity_type.rstrip('s')} '{name}'" +# --- Internal bookkeeping helpers (no FastMCP, no Dependency) --------------- def _load_entity(file_path: Path, entity_index: EntityIndex) -> dict: @@ -192,7 +146,8 @@ file_path: Path, name: str, entity_type: str, data: dict, - ctx: ToolContext, + entity_index: EntityIndex, + lore: VectorIndex, ) -> None: """Write an entity to disk and update all indexes.""" file_content = _format_entity( @@ -200,9 +155,9 @@ name, data["description"], data["location"], data["knows"], data["wants"], data["will"], data["was"], ) file_path.write_text(file_content) - ctx.entity_index.register(name, file_path) - ctx.entity_index.cache_put(file_path, data) - ctx.vector_index.upsert( + entity_index.register(name, file_path) + entity_index.cache_put(file_path, data) + lore.upsert( f"world:{entity_type}/{name}.md:0", file_content, {"source": "world", "content_type": entity_type, @@ -210,10 +165,119 @@ "path": str(file_path), "title": name}, ) +def _do_establish( + entity_type: str, + name: str, + description: str | None, + location: str | None, + knows: list[str] | None, + wants: list[str] | None, + will: list[str] | None, + base_path: Path, + world_id: str, + entity_index: EntityIndex, + lore: VectorIndex, +) -> str: + """Plain bookkeeping form of establish; called by both the FastMCP wrapper + and any internal caller (e.g. world seeding).""" + world_dir = base_path / "worlds" / world_id / entity_type + world_dir.mkdir(parents=True, exist_ok=True) + file_path = world_dir / f"{name}.md" + + lock = _get_file_lock(file_path) + with lock: + existing = _load_entity(file_path, entity_index) + + if description is None: + description = existing.get("description", "") + if location is None: + location = existing.get("location", "") + if knows is None: + knows = existing.get("knows", []) + if wants is None: + wants = existing.get("wants", []) + if will is None: + will = existing.get("will", []) + was = existing.get("was", []) + + data = { + "description": description, "location": location, + "knows": knows, "wants": wants, "will": will, "was": was, + } + _write_entity(file_path, name, entity_type, data, entity_index, lore) + + action = "Updated" if existing else "Established" + return f"{action} {entity_type.rstrip('s')} '{name}'" + + +def _do_mark( + entity_type: str, + name: str, + event: str, + resolves: list[str] | None, + base_path: Path, + world_id: str, + entity_index: EntityIndex, + lore: VectorIndex, + timekeeper: CampaignLog, +) -> str: + """Plain bookkeeping form of mark.""" + file_path = entity_index.resolve(name) + if file_path is None: + file_path = base_path / "worlds" / world_id / entity_type / f"{name}.md" + + if not file_path.exists(): + return f"Error: Entity '{name}' not found in {entity_type}" + + timestamp = timekeeper.get_current_time().to_anchor() + + lock = _get_file_lock(file_path) + with lock: + existing = _load_entity(file_path, entity_index) + + was = existing.get("was", []) + was.append(f"{timestamp} | {event}") + + will = existing.get("will", []) + resolved = [] + for trigger in resolves or []: + if trigger in will: + will.remove(trigger) + resolved.append(trigger) + + data = { + "description": existing.get("description", ""), + "location": existing.get("location", ""), + "knows": existing.get("knows", []), + "wants": existing.get("wants", []), + "will": will, + "was": was, + } + _write_entity(file_path, name, entity_type, data, entity_index, lore) + + result = f"Marked: {event}" + if resolved: + if len(resolved) == 1: + result += f" (resolved: {resolved[0]})" + else: + result += f" (resolved {len(resolved)} triggers)" + return result + + def _auto_mark_present( - present: list[str], event: str, ctx: ToolContext, + present: list[str], + event: str, + base_path: Path, + world_id: str, + entity_index: EntityIndex, + lore: VectorIndex, + timekeeper: CampaignLog, ) -> list[str]: - """Auto-mark present entities with the current event.""" + """Auto-mark present entities with the current event. + + Called by set_scene; uses _do_mark directly so it doesn't have to go + through dependency resolution again. + """ marked: list[str] = [] for ref in present: link_match = re.search(r"\[\[([^\]]+)\]\]", ref) @@ -221,28 +285,89 @@ if not link_match: continue name = link_match.group(1) - file_path = ctx.entity_index.resolve(name) + file_path = entity_index.resolve(name) if file_path is None: for etype in ("npcs", "locations", "items", "factions"): - candidate = ctx.base_path / "worlds" / ctx.world_id / etype / f"{name}.md" + candidate = base_path / "worlds" / world_id / etype / f"{name}.md" if candidate.exists(): file_path = candidate break if file_path and file_path.exists(): entity_type = file_path.parent.name - mark(entity_type=entity_type, name=name, event=event, ctx=ctx) + _do_mark( + entity_type, name, event, None, + base_path, world_id, entity_index, lore, timekeeper, + ) marked.append(name) return marked +# --- FastMCP tool wrappers -------------------------------------------------- + + +@mcp.tool(tags={"dm", "planner", "seeder"}) +def establish( + entity_type: EstablishType, + name: str, + description: str | None = None, + location: str | None = None, + knows: list[str] | None = None, + wants: list[str] | None = None, + will: list[str] | None = None, + root: Path = StorageRoot(), + world: str = World(), + entities: EntityIndex = Entities(), + lore: VectorIndex = Lore(), +) -> str: + """Establish or update an entity in the world. + + Use to create NPCs, locations, items, factions, or threads with their inner + state. Everything has Knows/Wants/Will: + - **Knows** = secrets, hidden truths, what isn't obvious + - **Wants** = nature, tendencies, inclinations (even non-sentient things can "want") + - **Will** = conditional triggers, what happens if... + + This isn't literal consciousness - it's narrative tendency. A bridge can "want" + to collapse. Cursed gold "wants" to be spent. Frame it this way and the world + feels alive. + + Partial updates: omit fields to preserve existing content when updating. + + Args: + entity_type: Type of entity: npcs, locations, items, factions, threads, lore + name: Display name (e.g., "Vera Blackwater", "The Rusty Anchor") + This becomes the filename directly (no slugification). + description: Prose description for the ## Is section. Include appearance, + background, current state, relationships via [[wikilinks]]. + location: Where this entity is right now. Can be a simple wikilink like + "[[The Rusty Anchor]]" or a verbal description like "In the basement + of [[The Rusty Anchor]]" or "Wandering the docks of [[Greyhaven]]". + knows: List of secrets and hidden truths. Things that aren't obvious. + wants: List of desires, tendencies, inclinations. The entity's nature. + will: List of conditional behaviors: "If X -> Y" format. + + Returns: + Confirmation with the file path + """ + return _do_establish( + entity_type, name, description, location, knows, wants, will, + root, world, entities, lore, + ) + + +@mcp.tool(tags={"dm", "planner"}) def mark( - entity_type: str, + entity_type: MarkType, name: str, event: str, - ctx: ToolContext, resolves: list[str] | None = None, + root: Path = StorageRoot(), + world: str = World(), + entities: EntityIndex = Entities(), + lore: VectorIndex = Lore(), + timekeeper: CampaignLog = Timekeeper(), ) -> str: """Record an event in an entity's history (## Was section). @@ -262,54 +387,22 @@ Returns: Confirmation message """ - file_path = ctx.entity_index.resolve(name) - if file_path is None: - file_path = ctx.base_path / "worlds" / ctx.world_id / entity_type / f"{name}.md" - - if not file_path.exists(): - return f"Error: Entity '{name}' not found in {entity_type}" - - timestamp = ctx.campaign_log.get_current_time().to_anchor() - - lock = _get_file_lock(file_path) - with lock: - existing = _load_entity(file_path, ctx.entity_index) + return _do_mark( + entity_type, name, event, resolves, + root, world, entities, lore, timekeeper, + ) - was = existing.get("was", []) - was.append(f"{timestamp} | {event}") - will = existing.get("will", []) - resolved = [] - for trigger in resolves or []: - if trigger in will: - will.remove(trigger) - resolved.append(trigger) - - data = { - "description": existing.get("description", ""), - "location": existing.get("location", ""), - "knows": existing.get("knows", []), - "wants": existing.get("wants", []), - "will": will, - "was": was, - } - _write_entity(file_path, name, entity_type, data, ctx) - - result = f"Marked: {event}" - if resolved: - if len(resolved) == 1: - result += f" (resolved: {resolved[0]})" - else: - result += f" (resolved {len(resolved)} triggers)" - return result - - +@mcp.tool(tags={"dm"}) def note_discovery( entity: str, content: str, - ctx: ToolContext, - content_type: str = "lore", + content_type: DiscoveryType = "lore", tags: list[str] | None = None, + root: Path = StorageRoot(), + world: str = World(), + player: str = Player(), + lore: VectorIndex = Lore(), ) -> str: """Record what the player has learned about something. @@ -332,10 +425,7 @@ Confirmation message """ slug = name_to_slug(entity) - knowledge_dir = ( - ctx.base_path / "players" / ctx.player_id / "worlds" - / ctx.world_id / content_type - ) + knowledge_dir = root / "players" / player / "worlds" / world / content_type knowledge_dir.mkdir(parents=True, exist_ok=True) file_path = knowledge_dir / f"{slug}.md" @@ -354,7 +444,7 @@ file_content += "\n" file_path.write_text(file_content) - ctx.vector_index.upsert( + lore.upsert( f"player:{content_type}/{slug}.md:0", file_content, {"source": "player", "content_type": content_type, @@ -362,105 +452,3 @@ "path": str(file_path), "title": entity}, ) return f"Noted: player learned about '{entity}'" - - -DEFINITIONS: list[dict] = [ - { - "name": "establish", - "description": establish.__doc__, - "input_schema": { - "type": "object", - "properties": { - "entity_type": { - "type": "string", - "description": "Type of entity", - "enum": ["npcs", "locations", "items", "factions", "threads", "lore"], - }, - "name": { - "type": "string", - "description": "Display name (exact filename, e.g., 'Vera Blackwater')", - }, - "description": { - "type": "string", - "description": "Prose description with [[wikilinks]] for relationships", - }, - "location": { - "type": "string", - "description": "Current location (e.g., '[[The Rusty Anchor]]' or 'In the basement of [[The Rusty Anchor]]')", - }, - "knows": { - "type": "array", - "items": {"type": "string"}, - "description": "Secrets, hidden truths - what isn't obvious", - }, - "wants": { - "type": "array", - "items": {"type": "string"}, - "description": "Nature, tendencies, inclinations - even non-sentient things", - }, - "will": { - "type": "array", - "items": {"type": "string"}, - "description": "Conditional behaviors in 'If X -> Y' format", - }, - }, - "required": ["entity_type", "name"], - }, - }, - { - "name": "mark", - "description": mark.__doc__, - "input_schema": { - "type": "object", - "properties": { - "entity_type": { - "type": "string", - "description": "Type of entity", - "enum": ["npcs", "locations", "items", "factions", "threads"], - }, - "name": { - "type": "string", - "description": "Entity name (exact filename match)", - }, - "event": { - "type": "string", - "description": "What happened - brief description", - }, - "resolves": { - "type": "array", - "items": {"type": "string"}, - "description": "Optional: Will items to remove if this event fired triggers", - }, - }, - "required": ["entity_type", "name", "event"], - }, - }, - { - "name": "note_discovery", - "description": note_discovery.__doc__, - "input_schema": { - "type": "object", - "properties": { - "entity": { - "type": "string", - "description": "Name of what they learned about (e.g., 'Vera Blackwater')", - }, - "content": { - "type": "string", - "description": "What the player learned or observed", - }, - "content_type": { - "type": "string", - "description": "Type of content", - "enum": ["npcs", "locations", "factions", "lore"], - }, - "tags": { - "type": "array", - "items": {"type": "string"}, - "description": "Tags for categorization", - }, - }, - "required": ["entity", "content"], - }, - }, -] diff --git a/src/storied/tools/mechanics.py b/src/storied/tools/mechanics.py --- a/src/storied/tools/mechanics.py +++ b/src/storied/tools/mechanics.py @@ -1,12 +1,20 @@ -"""Dice, rules lookup, and code execution tools.""" +"""Dice and rules-lookup tools.""" from pathlib import Path +from typing import Literal + +from fastmcp import FastMCP from storied.dice import roll as dice_roll -from storied.tools._context import ToolContext +from storied.log import CampaignLog +from storied.search import VectorIndex +from storied.tools._context import Lore, Timekeeper +mcp = FastMCP("mechanics") -def roll(notation: str, reason: str | None = None) -> dict: + +@mcp.tool(tags={"dm"}) +def roll(notation: str, reason: str) -> str: """Roll dice using standard notation like '1d20', '2d6+3', '4d6kh3'. Use for attack rolls, skill checks, saving throws, and damage rolls. @@ -18,17 +26,32 @@ reason: Brief description of what the roll is for (e.g., "Athletics", "Attack with longsword", "Wisdom save", "Fireball damage") Returns: - Dict with rolls, kept dice, modifier, and total + Formatted roll result string """ - result = dice_roll(notation) - return result.to_dict() + result = dice_roll(notation).to_dict() + rolls_str = ", ".join(str(r) for r in result["rolls"]) + if result["kept"] != result["rolls"]: + kept_str = ", ".join(str(r) for r in result["kept"]) + return ( + f"Rolled {result['notation']}: [{rolls_str}] → " + f"kept [{kept_str}] + {result['modifier']} = {result['total']}" + ) + elif result["modifier"]: + return ( + f"Rolled {result['notation']}: [{rolls_str}] + " + f"{result['modifier']} = {result['total']}" + ) + else: + return f"Rolled {result['notation']}: [{rolls_str}] = {result['total']}" +@mcp.tool(tags={"dm", "planner", "advancement"}) def recall( query: str, - ctx: ToolContext, - scope: str = "all", + scope: Literal["rules", "world", "all"] = "all", content_type: str | None = None, + lore: VectorIndex = Lore(), + timekeeper: CampaignLog = Timekeeper(), ) -> str: """Look up rules, world content, or both. @@ -39,7 +62,8 @@ - Both: search everything (default) Args: query: What to look up (e.g., "fireball", "captain vex", "merchant guild") - scope: Where to search - "rules", "world", or "all" (default) + scope: Which corpus to search — "rules" (SRD), "world" (established + content), or "all" (both, default) content_type: Optional type to limit search (e.g., "spells", "npcs") Returns: @@ -49,8 +73,8 @@ source_filter: str | None = None if scope == "rules": source_filter = "srd" - current_day = ctx.campaign_log.get_current_time().day - hits = ctx.vector_index.search( + current_day = timekeeper.get_current_time().day + hits = lore.search( query, limit=5, source_filter=source_filter, exclude_source="srd" if scope == "world" else None, decay_ref=current_day, @@ -66,78 +90,3 @@ lines.append(f" - {h.doc_id.split(':')[1]} ({h.source}): {h.snippet[:80]}") return "\n".join(lines) return f"Nothing found matching '{query}'" - - -DEFINITIONS: list[dict] = [ - { - "name": "roll", - "description": roll.__doc__, - "input_schema": { - "type": "object", - "properties": { - "notation": { - "type": "string", - "description": "Dice notation (e.g., '1d20+5', '2d6', '4d6kh3')", - }, - "reason": { - "type": "string", - "description": "What the roll is for (e.g., 'Athletics', 'Longsword attack', 'Dex save')", - }, - }, - "required": ["notation", "reason"], - }, - }, - { - "name": "recall", - "description": recall.__doc__, - "input_schema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "What to look up (e.g., 'fireball', 'captain vex')", - }, - "scope": { - "type": "string", - "description": "Where to search: 'rules' (SRD), 'world' (established content), or 'all' (both)", - "enum": ["rules", "world", "all"], - }, - "content_type": { - "type": "string", - "description": "Type to limit search (e.g., 'spells', 'npcs', 'monsters')", - }, - }, - "required": ["query"], - }, - }, - { - "name": "run_code", - "description": ( - "Run Python code in a secure sandbox. Use for calculations, random " - "generation, data formatting, or any computation the narrative needs.\n\n" - "All your DM tools are callable as functions (see signatures below). " - "Most return a str with the result. The exception is roll(), which " - "returns a dict with keys: notation, rolls, kept, modifier, total — " - "use roll('2d6+3')['total'] for math, or index into rolls/kept for " - "individual dice. Use roll() for all randomness (no random module).\n\n" - "Language: variables, functions, loops, conditionals, comprehensions, " - "f-strings. Stdlib: re, json, datetime, math. No classes, no other " - "imports, no file/network access. Errors return as text.\n\n" - "Available functions:\n{tool_signatures}" - ), - "input_schema": { - "type": "object", - "properties": { - "description": { - "type": "string", - "description": "What this code does in game terms (e.g., 'Designing cave system', 'Splitting treasure', 'Generating NPC schedule')", - }, - "code": { - "type": "string", - "description": "Python code to execute", - }, - }, - "required": ["description", "code"], - }, - }, -] diff --git a/src/storied/tools/run_code.py b/src/storied/tools/run_code.py new file mode 100644 --- /dev/null +++ b/src/storied/tools/run_code.py @@ -0,0 +1,34 @@ +"""Sandboxed Python execution exposed as an MCP tool.""" + +from fastmcp import FastMCP + +mcp = FastMCP("run_code") + + +@mcp.tool(tags={"dm"}) +def run_code(description: str, code: str) -> str: + """Run Python code in a secure sandbox. + + Use for calculations, random generation, data formatting, or any + computation the narrative needs. + + All your DM tools are callable as functions (see signatures below). + Most return a str with the result. The exception is roll(), which + returns a dict with keys: notation, rolls, kept, modifier, total — + use roll('2d6+3')['total'] for math, or index into rolls/kept for + individual dice. Use roll() for all randomness (no random module). + + Language: variables, functions, loops, conditionals, comprehensions, + f-strings. Stdlib: re, json, datetime, math. No classes, no other + imports, no file/network access. Errors return as text. + + Available functions: + {tool_signatures} + + Args: + description: What this code does in game terms (e.g. 'Designing + cave system', 'Splitting treasure') + code: Python code to execute + """ + from storied.sandbox import execute as sandbox_execute + return sandbox_execute(code) diff --git a/src/storied/tools/scene.py b/src/storied/tools/scene.py --- a/src/storied/tools/scene.py +++ b/src/storied/tools/scene.py @@ -1,15 +1,29 @@ """Scene management, session, style tuning, and DM notification tools.""" -import re +from pathlib import Path + +from fastmcp import FastMCP from storied import notifications +from storied.log import CampaignLog +from storied.search import VectorIndex from storied.session import update_session as session_update -from storied.tools._context import ToolContext +from storied.tools._context import ( + Entities, + EntityIndex, + Lore, + Player, + StorageRoot, + Timekeeper, + World, +) from storied.tools.entities import _auto_mark_present + +mcp = FastMCP("scene") +@mcp.tool(tags={"dm", "seeder"}) def set_scene( - ctx: ToolContext, event: str | None = None, duration: str | None = None, situation: str | None = None, @@ -17,6 +31,12 @@ location: str | None = None, present: list[str] | None = None, threads: list[str] | None = None, tags: list[str] | None = None, + timekeeper: CampaignLog = Timekeeper(), + player: str = Player(), + world: str = World(), + root: Path = StorageRoot(), + entities: EntityIndex = Entities(), + lore: VectorIndex = Lore(), ) -> str: """Call this after every response. Logs what happened, advances the clock, and updates the scene state. @@ -42,8 +62,8 @@ """ parts = [] if event and duration: - anchor = ctx.campaign_log.append_entry(event, duration, tags=tags) - current = ctx.campaign_log.get_current_time() + anchor = timekeeper.append_entry(event, duration, tags=tags) + current = timekeeper.get_current_time() parts.append( f"Logged: {anchor} | {event} | {duration} → " f"Now: {current} ({current.period_of_day()}, {current.atmosphere()})" @@ -60,30 +80,43 @@ if threads is not None: updates["threads"] = threads if updates: - result = session_update(ctx.player_id, updates, ctx.base_path) + result = session_update(player, updates, root) parts.append(result) if event and present: - marked = _auto_mark_present(present, event, ctx) + marked = _auto_mark_present( + present, event, root, world, entities, lore, timekeeper, + ) if marked: parts.append(f"Auto-marked: {', '.join(marked)}") return "; ".join(parts) if parts else "No updates" -def tune(tuning: str, ctx: ToolContext) -> str: +@mcp.tool(tags={"dm"}) +def tune( + tuning: str, + world: str = World(), + root: Path = StorageRoot(), +) -> str: """Update your storytelling style based on player feedback. Write the complete updated style as markdown prose. This replaces the entire current style. Incorporate existing preferences where they still apply — don't discard preferences the player hasn't contradicted. """ - path = ctx.base_path / "worlds" / ctx.world_id / "style.md" + path = root / "worlds" / world / "style.md" path.write_text(f"# Style\n\n{tuning}\n") return "Style updated." -def end_session(situation: str, ctx: ToolContext, threads: list[str] | None = None) -> str: +@mcp.tool(tags={"dm"}) +def end_session( + situation: str, + threads: list[str] | None = None, + player: str = Player(), + root: Path = StorageRoot(), +) -> str: """End the current session, saving the game state for next time. Call this when the player indicates they want to stop playing. This saves @@ -103,11 +136,16 @@ updates: dict = {"situation": situation} if threads is not None: updates["threads"] = threads - session_update(ctx.player_id, updates, ctx.base_path) + session_update(player, updates, root) return "SESSION_ENDED" -def notify_dm(message: str, ctx: ToolContext) -> str: +@mcp.tool(tags={"dm", "planner", "advancement"}) +def notify_dm( + message: str, + world: str = World(), + root: Path = StorageRoot(), +) -> str: """Send a notification that the DM will see at the start of the next turn. Use this to communicate important background changes to the DM, @@ -119,97 +157,5 @@ Returns: Confirmation that the notification was queued """ - notifications.append(ctx.world_id, ctx.base_path, message) + notifications.append(world, root, message) return f"Notification queued: {message}" - - -DEFINITIONS: list[dict] = [ - { - "name": "set_scene", - "description": set_scene.__doc__, - "input_schema": { - "type": "object", - "properties": { - "event": { - "type": "string", - "description": "What happened this turn (logged to campaign journal)", - }, - "duration": { - "type": "string", - "description": "How long it took (e.g., '10 min', '1 hour', '3 rounds')", - }, - "situation": { - "type": "string", - "description": "Updated situation summary in present tense", - }, - "location": { - "type": "string", - "description": "New location when the player moves", - }, - "present": { - "type": "array", - "items": {"type": "string"}, - "description": "Entities present, using [[Name]] format", - }, - "threads": { - "type": "array", - "items": {"type": "string"}, - "description": "Open plot threads or objectives", - }, - "tags": { - "type": "array", - "items": {"type": "string"}, - "description": "Optional: 'combat', 'rest:short', 'rest:long', 'travel', 'level'", - }, - }, - "required": ["event", "duration"], - }, - }, - { - "name": "tune", - "description": tune.__doc__, - "input_schema": { - "type": "object", - "properties": { - "tuning": { - "type": "string", - "description": "Complete updated style as markdown prose. Replaces the current style entirely.", - }, - }, - "required": ["tuning"], - }, - }, - { - "name": "end_session", - "description": end_session.__doc__, - "input_schema": { - "type": "object", - "properties": { - "situation": { - "type": "string", - "description": "Summary of current state for the next session", - }, - "threads": { - "type": "array", - "items": {"type": "string"}, - "description": "Open plot threads or objectives to carry forward", - }, - }, - "required": ["situation"], - }, - }, - { - "name": "notify_dm", - "description": notify_dm.__doc__, - "input_schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "The notification message for the DM", - }, - }, - "required": ["message"], - }, - }, -] diff --git a/tests/conftest.py b/tests/conftest.py --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,31 @@ """Shared test fixtures.""" +import asyncio import hashlib +from collections.abc import Callable, Iterator from pathlib import Path +from typing import Any import pytest +from uncalled_for import resolved_dependencies from storied.log import CampaignLog from storied.search import VectorIndex -from storied.tools import EntityIndex, ToolContext +from storied.tools import EntityIndex, ToolContext, init_ctx, reset_ctx + + +def call_tool(fn: Callable[..., Any], **kwargs: Any) -> Any: + """Invoke a FastMCP-decorated tool synchronously, resolving its + `Dependency` parameters from the process-global ToolContext. + + Use this in tests instead of calling the tool wrapper directly — + direct calls leave the Dependency instances as parameter defaults + rather than resolving them. + """ + async def _run() -> Any: + async with resolved_dependencies(fn, kwargs) as deps: + return fn(**{**kwargs, **deps}) + return asyncio.run(_run()) EMBED_DIM = 384 @@ -31,15 +49,20 @@ return results @pytest.fixture -def ctx(tmp_path: Path) -> ToolContext: - """ToolContext with fake embedder for tests that need tool infrastructure.""" +def ctx(tmp_path: Path) -> Iterator[ToolContext]: + """Process-global ToolContext with a fake embedder. + + Tools resolve their Dependency parameters from this context. Each test + gets a fresh tempdir-rooted ctx; teardown clears the global to prevent + cross-test leakage. + """ world_dir = tmp_path / "worlds" / "test-world" world_dir.mkdir(parents=True) vi = VectorIndex(tmp_path / "search.db") vi._embed_fn = _fake_embed - return ToolContext( + context = init_ctx( world_id="test-world", player_id="default", base_path=tmp_path, @@ -47,3 +70,7 @@ campaign_log=CampaignLog("test-world", tmp_path), entity_index=EntityIndex(world_dir), vector_index=vi, ) + try: + yield context + finally: + reset_ctx() diff --git a/tests/test_advancement.py b/tests/test_advancement.py --- a/tests/test_advancement.py +++ b/tests/test_advancement.py @@ -16,7 +16,14 @@ evaluate_advancement, ) from storied.session import save_session from storied.tools import ToolContext -from storied.tools.scene import notify_dm +from storied.tools.scene import notify_dm as _notify_dm + +from tests.conftest import call_tool + + +def notify_dm(message: str, ctx: ToolContext) -> str: + """Test shim: drop legacy `ctx` arg and resolve Dependency params.""" + return call_tool(_notify_dm, message=message) # --- Fixtures --- @@ -364,3 +371,50 @@ adv.on_turn() if adv._thread: adv._thread.join(timeout=2) assert adv._turn_count == 0 + + def test_pop_result_returns_none_when_no_thread(self): + adv = BackgroundAdvancement( + world_id="test", player_id="default", base_path=Path("/tmp/fake"), + ) + assert adv.pop_result() is None + + def test_pop_result_returns_and_clears_after_completion(self): + adv = BackgroundAdvancement( + world_id="test", + player_id="default", + base_path=Path("/tmp/fake"), + interval=1, + ) + with patch("storied.advancement.evaluate_advancement") as mock_eval: + mock_eval.return_value = AdvancementResult(evaluated=True) + adv.on_turn() + if adv._thread: + adv._thread.join(timeout=2) + + first = adv.pop_result() + assert first is not None + assert first.evaluated is True + # Second pop returns None — result was consumed + assert adv.pop_result() is None + + def test_maybe_evaluate_skips_when_already_running(self): + adv = BackgroundAdvancement( + world_id="test", player_id="default", base_path=Path("/tmp/fake"), + ) + # Stub a fake "still running" thread on the instance + from unittest.mock import MagicMock + fake_thread = MagicMock() + fake_thread.is_alive.return_value = True + adv._thread = fake_thread + # Should early-return without spawning a new thread + adv._maybe_evaluate() + # The fake thread is still the only one + assert adv._thread is fake_thread + + def test_evaluate_advancement_default_base_path(self, tmp_path: Path, monkeypatch): + """The fall-through `base_path = Path.cwd()` branch.""" + from storied.advancement import evaluate_advancement + + monkeypatch.chdir(tmp_path) # cwd has no character → returns early + result = evaluate_advancement(world_id="test", player_id="default") + assert result.evaluated is False diff --git a/tests/test_character.py b/tests/test_character.py --- a/tests/test_character.py +++ b/tests/test_character.py @@ -195,6 +195,128 @@ result = update_character("missing", {"foo": "bar"}, base_path=player_dir) assert "no character" in result.lower() +class TestSchemaValidation: + """update_character must reject schema-violating writes with a DM-readable + error and leave the on-disk character unchanged.""" + + def test_resources_as_list_is_rejected(self, mira: dict, player_dir: Path): + before = load_character("test-player", player_dir) + result = update_character( + "test-player", + {"resources": [{"name": "Channel Divinity", "current": 1, "max": 1}]}, + base_path=player_dir, + ) + assert "rejected" in result.lower() + assert "resources" in result + assert "dict" in result.lower() + # On-disk character is unchanged + after = load_character("test-player", player_dir) + assert after["resources"] == before["resources"] + + def test_equipment_as_list_is_rejected(self, mira: dict, player_dir: Path): + result = update_character( + "test-player", + {"equipment": ["Longsword", "Shield"]}, + base_path=player_dir, + ) + assert "rejected" in result.lower() + assert "equipment" in result + + def test_state_hp_must_be_a_dict(self, mira: dict, player_dir: Path): + result = update_character( + "test-player", + {"state.hp": 24}, # missing required fields + base_path=player_dir, + ) + assert "rejected" in result.lower() + # Original HP block is preserved + data = load_character("test-player", player_dir) + assert isinstance(data["state"]["hp"], dict) + assert data["state"]["hp"]["max"] == 24 + + def test_valid_resources_update_succeeds(self, mira: dict, player_dir: Path): + result = update_character( + "test-player", + {"resources.channel_divinity": { + "current": 1, "max": 1, "refresh": "short_rest", + "notes": "Channel Divinity", + }}, + base_path=player_dir, + ) + assert "rejected" not in result.lower() + data = load_character("test-player", player_dir) + assert data["resources"]["channel_divinity"]["current"] == 1 + + def test_error_message_contains_an_example(self, mira: dict, player_dir: Path): + """The DM should be able to fix the call from the error message alone.""" + result = update_character( + "test-player", + {"resources": [{"name": "x"}]}, + base_path=player_dir, + ) + # Concrete example helps the LLM correct itself + assert "channel_divinity" in result or "{" in result + + +class TestSchemaCoercion: + """load_character heals known mis-shapes from older sessions so existing + characters keep working without manual repair.""" + + def test_load_coerces_resources_list_to_dict(self, player_dir: Path): + import yaml + # Hand-write a character with resources as a list (the bad shape) + path = player_dir / "players" / "test-player" / "character.yaml" + path.write_text(yaml.dump({ + "identity": {"name": "Damaged", "classes": [{"class": "Cleric", "level": 3}]}, + "abilities": {"strength": 10, "dexterity": 10, "constitution": 10, + "intelligence": 10, "wisdom": 14, "charisma": 10}, + "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, + "resources": [ + {"name": "Channel Divinity", "current": 1, "max": 1, + "refresh": "short_rest"}, + {"name": "Lay on Hands", "current": 15, "max": 15, + "refresh": "long_rest"}, + ], + })) + data = load_character("test-player", player_dir) + assert isinstance(data["resources"], dict) + assert "channel_divinity" in data["resources"] + assert data["resources"]["channel_divinity"]["current"] == 1 + assert data["resources"]["lay_on_hands"]["max"] == 15 + + def test_coerced_character_can_use_resource(self, player_dir: Path): + """End-to-end: a character with bad-shape resources on disk should + be usable via use_resource after load coercion.""" + import yaml + path = player_dir / "players" / "test-player" / "character.yaml" + path.write_text(yaml.dump({ + "identity": {"name": "Damaged"}, + "abilities": {"strength": 10, "dexterity": 10, "constitution": 10, + "intelligence": 10, "wisdom": 10, "charisma": 10}, + "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, + "resources": [ + {"name": "Channel Divinity", "current": 1, "max": 1, + "refresh": "short_rest", "notes": "Channel Divinity"}, + ], + })) + result = use_resource("test-player", "channel", base_path=player_dir) + assert "Used 1" in result + + def test_load_coerces_equipment_list_to_dict(self, player_dir: Path): + import yaml + path = player_dir / "players" / "test-player" / "character.yaml" + path.write_text(yaml.dump({ + "identity": {"name": "Damaged"}, + "abilities": {"strength": 10, "dexterity": 10, "constitution": 10, + "intelligence": 10, "wisdom": 10, "charisma": 10}, + "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, + "equipment": ["Longsword", "Shield"], + })) + data = load_character("test-player", player_dir) + assert isinstance(data["equipment"], dict) + assert data["equipment"]["on_person"] == ["Longsword", "Shield"] + + # --- Computation tests --- @@ -342,6 +464,164 @@ data = load_character("test-player", player_dir) result = format_character_context(data) assert "Advancement Ready" in result assert "Level 4" in result + + def test_format_sheet_tolerates_wrong_shaped_resources(self, mira: dict): + """If the LLM writes the wrong shape (a list instead of a dict-of-pools), + the renderer must skip the section instead of crashing the turn.""" + mira["resources"] = ["hit_dice_d8", "bracer_unseen_step"] + result = format_sheet(mira) + assert "Resources" not in result # section omitted, no crash + + def test_format_sheet_tolerates_wrong_shaped_magic_items(self, mira: dict): + mira["magic_items"] = ["[[Bracer]]"] # should be a dict + result = format_sheet(mira) + assert "Magic Items" not in result # section omitted, no crash + + def test_format_sheet_tolerates_wrong_shaped_equipment(self, mira: dict): + mira["equipment"] = ["sword", "shield"] # should be a dict-of-locations + result = format_sheet(mira) + assert "Equipment" not in result # section omitted, no crash + + def test_format_sheet_renders_active_effects(self, mira: dict): + mira["effects"] = [ + {"source": "Bless", "description": "+1d4 attacks/saves"}, + {"source": "Heroism", "description": "+10 temp HP", "expires": "d2-1430"}, + ] + result = format_sheet(mira) + assert "Active Effects" in result + assert "Bless" in result + assert "Heroism" in result + assert "until d2-1430" in result + + def test_format_sheet_renders_resources_with_die(self, mira: dict): + mira["resources"] = { + "bardic_inspiration": { + "current": 3, "max": 3, "refresh": "long_rest", + "notes": "Bardic Inspiration", "die": "d8", + }, + } + result = format_sheet(mira) + assert "Bardic Inspiration: 3/3" in result + assert "d8" in result + + def test_format_sheet_renders_magic_items_carried(self, mira: dict): + mira["magic_items"] = { + "attuned": ["[[Bracer]]"], + "equipped": ["[[Boots]]"], + "carried": ["[[Cloak]]", "[[Ring]]"], + } + result = format_sheet(mira) + assert "Magic Items" in result + assert "Attuned: [[Bracer]]" in result + assert "Equipped: [[Boots]]" in result + assert "Carried: [[Cloak]], [[Ring]]" in result + + def test_format_sheet_renders_features(self, mira: dict): + mira["features"] = [ + {"name": "Sneak Attack", "text": "+2d6 damage", "source": "Rogue Lv1"}, + {"name": "Cunning Action", "text": "Bonus action: Dash/Disengage/Hide"}, + ] + result = format_sheet(mira) + assert "Features" in result + assert "Sneak Attack" in result + assert "Rogue Lv1" in result + assert "Cunning Action" in result + + def test_format_sheet_renders_conditions(self, mira: dict): + mira["conditions"] = ["Poisoned", "Prone"] + result = format_sheet(mira) + assert "Conditions:" in result + assert "Poisoned" in result + assert "Prone" in result + + def test_format_sheet_renders_defenses(self, mira: dict): + mira["defenses"] = { + "resistances": [{"damage": "fire", "source": "racial"}], + "vulnerabilities": [{"damage": "cold", "source": "curse"}], + "immunities": { + "damage": ["psychic"], + "conditions": ["charmed"], + }, + } + result = format_sheet(mira) + assert "Resistances:" in result + assert "fire" in result + assert "Vulnerabilities:" in result + assert "cold" in result + assert "Damage Immunities:" in result + assert "psychic" in result + assert "Condition Immunities:" in result + assert "charmed" in result + + def test_format_sheet_renders_temp_hp_in_vital_line(self, mira: dict): + mira["state"]["hp"]["temp"] = 5 + result = format_sheet(mira) + assert "+5 temp" in result + + def test_format_sheet_renders_exhaustion_in_vital_line(self, mira: dict): + mira["state"]["exhaustion"] = 2 + result = format_sheet(mira) + assert "Exhaustion 2" in result + + def test_format_status_omits_empty_purse(self, player_dir: Path): + create_character( + player_id="test-player", + name="Broke", + race="Human", + char_class="Fighter", + level=1, + abilities={"strength": 10, "dexterity": 10, "constitution": 10, + "intelligence": 10, "wisdom": 10, "charisma": 10}, + hp_max=10, + ac=10, + base_path=player_dir, + ) + data = load_character("test-player", player_dir) + result = format_status(data) + assert "Purse" not in result + + def test_format_status_truncates_equipment_over_eight_items( + self, mira: dict, player_dir: Path, + ): + # Add lots of items so the truncation branch fires + for i in range(12): + update_character( + "test-player", + {f"equipment.on_person": [f"item_{i}" for i in range(12)]}, + base_path=player_dir, + ) + data = load_character("test-player", player_dir) + result = format_status(data) + assert "and 4 more" in result # 12 items - 8 shown = 4 more + + def test_format_character_display_respects_base_path( + self, mira: dict, player_dir: Path, tmp_path: Path, + ): + """The /me slash command must read from the passed base_path so + sandbox sessions don't load the cwd's real character.""" + from storied.cli import _format_character_display + + # mira lives at player_dir/players/test-player; an unrelated other_path + # has no character at all. Asking for the other_path must return None, + # not silently fall back to mira via cwd. + other_path = tmp_path / "other" + (other_path / "players" / "test-player").mkdir(parents=True) + + result = _format_character_display( + "test-player", full=True, base_path=other_path, + ) + assert result is None, ( + "_format_character_display must use the passed base_path; " + "loading from cwd by default is what caused /me to show the wrong " + "character in sandbox sessions." + ) + + # And it should return real content when given the right base_path + result = _format_character_display( + "test-player", full=True, base_path=player_dir, + ) + assert result is not None + assert "Mira" in result # --- Operations tests --- diff --git a/tests/test_engine.py b/tests/test_engine.py --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -138,3 +138,222 @@ f.write_text("---\ntype: npc\nname: Vera\n---\n\nTavern owner.") result = engine._parse_knowledge_file(f) assert result["name"] == "Vera" assert result["body"] == "Tavern owner." + + def test_parse_knowledge_file_malformed_frontmatter( + self, engine, tmp_path: Path, + ): + f = tmp_path / "broken.md" + f.write_text("---\nnot: [valid yaml\n---\n\nBody.") + result = engine._parse_knowledge_file(f) + # Falls back to body-only when frontmatter is unparseable + assert "Body." in result["body"] + + def test_parse_knowledge_file_open_frontmatter(self, engine, tmp_path: Path): + f = tmp_path / "open.md" + f.write_text("---\nnever closed") + result = engine._parse_knowledge_file(f) + # Falls back to whole-file body when there's no closing --- + assert "never closed" in result["body"] + + def test_log_transcript_writes_file(self, tmp_path: Path): + from unittest.mock import patch + + from storied.engine import DMEngine + from storied.initiative import InitiativeTracker + from storied.tools import EntityIndex + + (tmp_path / "worlds" / "test").mkdir(parents=True) + (tmp_path / "prompts").mkdir() + (tmp_path / "prompts" / "dm-system.md").write_text("DM.") + + transcript_path = tmp_path / "transcripts" / "session.jsonl" + with patch("storied.engine.start_mcp_server") as mock_mcp: + mock_mcp.return_value = type("Handle", (), { + "url": "http://localhost:0/sse", + "ctx": type("Ctx", (), { + "entity_index": EntityIndex(tmp_path / "worlds" / "test"), + "vector_index": None, + "initiative": InitiativeTracker(), + })(), + })() + engine = DMEngine( + world_id="test", + player_id="default", + base_path=tmp_path, + prompt_name="dm-system", + transcript_path=transcript_path, + ) + + engine._log_transcript("test_event", {"foo": "bar"}) + assert transcript_path.exists() + content = transcript_path.read_text() + assert "test_event" in content + assert "bar" in content + + def test_log_transcript_no_path_is_noop(self, engine): + # Engine constructed without a transcript_path → method returns early + engine._log_transcript("noop", {"x": 1}) + # No assertion needed; just verifying it doesn't crash + + def test_get_context_stats_returns_breakdown(self, engine): + engine._build_context() + stats = engine.get_context_stats() + assert "model_limit" in stats + assert "system_prompt" in stats + assert "context_parts" in stats + assert isinstance(stats["context_parts"], dict) + assert stats["context_total"] > 0 + + def test_get_current_time_returns_string(self, engine): + result = engine.get_current_time() + assert isinstance(result, str) + assert len(result) > 0 + + def test_reset_clears_session(self, engine): + engine._session_id = "abc-123" + engine.reset() + assert engine._session_id is None + + def test_build_context_with_character(self, engine): + # Drop a character file in place; _build_context should pick it up + from storied.character import create_character + (engine.base_path / "players" / "default").mkdir(parents=True) + create_character( + player_id="default", + name="Mira", + race="Human", + char_class="Rogue", + level=3, + abilities={"strength": 10, "dexterity": 18, "constitution": 14, + "intelligence": 14, "wisdom": 12, "charisma": 16}, + hp_max=24, + ac=16, + base_path=engine.base_path, + ) + engine._build_context() + assert "Character" in engine._context_parts + assert "Mira" in engine._context_parts["Character"] + + def test_build_context_with_session(self, engine): + from storied.session import save_session + (engine.base_path / "players" / "default").mkdir(parents=True) + save_session("default", { + "location": "The Tavern", + "body": "## Present\n- [[Vera]]", + "situation": "Resting", + }, engine.base_path) + engine._build_context() + assert "Session" in engine._context_parts + + def test_build_context_loads_present_entities(self, engine): + from storied.session import save_session + from storied.tools.entities import _do_establish + + (engine.base_path / "players" / "default").mkdir(parents=True) + + # Establish an NPC and put them in the session's present list + _do_establish( + "npcs", "Vera", "Tavern owner.", "[[The Tavern]]", + None, None, None, + engine.base_path, "test", + engine._mcp.ctx.entity_index, + type("FakeIdx", (), {"upsert": lambda *a, **k: None})(), + ) + save_session("default", { + "location": "The Tavern", + "body": "## Present\n- [[Vera]]", + }, engine.base_path) + + engine._build_context() + # The Vera entity should have been loaded into the DM context + assert any("Vera" in v for v in engine._context_parts.values()) + + def test_load_player_knowledge_returns_none_without_dir(self, engine): + # No knowledge dir created → returns None + result = engine._load_player_knowledge() + assert result is None + + def test_load_player_knowledge_aggregates_files(self, engine): + knowledge = ( + engine.base_path / "players" / "default" / "worlds" / "test" / "npcs" + ) + knowledge.mkdir(parents=True) + (knowledge / "vera.md").write_text( + "---\nname: Vera Blackwater\n---\n\nA tavern owner." + ) + result = engine._load_player_knowledge() + assert result is not None + assert "Vera Blackwater" in result + assert "tavern owner" in result + + def test_find_entity_via_index(self, engine): + # Drop an entity file directly and rebuild the index so the lookup hits + npc_path = engine.base_path / "worlds" / "test" / "npcs" / "Vera.md" + npc_path.parent.mkdir(parents=True, exist_ok=True) + npc_path.write_text("# Vera\n\nA tavern owner.") + engine._mcp.ctx.entity_index.register("Vera", npc_path) + + result = engine._find_entity("Vera") + assert result is not None + assert result["name"] == "Vera" + assert "tavern owner" in result["body"] + assert result["entity_type"] == "npcs" + + def test_find_entity_returns_none_when_missing(self, engine): + assert engine._find_entity("Nobody") is None + + def test_build_context_loads_location_and_one_hop_linked(self, engine): + """When the session points at a location, _build_context should load + the location, then one-hop into entities the location wikilinks.""" + from storied.session import save_session + + (engine.base_path / "players" / "default").mkdir(parents=True) + + # Location wikilinks to a related NPC + loc_path = engine.base_path / "worlds" / "test" / "locations" / "Tavern.md" + loc_path.parent.mkdir(parents=True, exist_ok=True) + loc_path.write_text( + "# Tavern\n\nA cozy spot where [[Vera]] holds court." + ) + engine._mcp.ctx.entity_index.register("Tavern", loc_path) + + # The linked NPC + npc_path = engine.base_path / "worlds" / "test" / "npcs" / "Vera.md" + npc_path.parent.mkdir(parents=True, exist_ok=True) + npc_path.write_text("# Vera\n\nTavern owner.") + engine._mcp.ctx.entity_index.register("Vera", npc_path) + + save_session("default", { + "location": "Tavern", + "body": "Player just walked in.", + }, engine.base_path) + + engine._build_context() + # Location should be loaded + assert "Location" in engine._context_parts + # One-hop linked entity should be picked up via Linked: prefix + linked_keys = [ + k for k in engine._context_parts if k.startswith("Linked:") + ] + assert any("Vera" in k for k in linked_keys) + + def test_build_context_includes_notifications(self, engine): + from storied import notifications + + notifications.append( + engine.world_id, engine.base_path, + "World tick: Vera left the tavern", + ) + engine._build_context() + assert "Notifications" in engine._context_parts + assert "Vera left the tavern" in engine._context_parts["Notifications"] + + def test_build_context_injects_initiative_when_active(self, engine): + from storied.initiative import Combatant + + engine._mcp.ctx.initiative.begin([ + Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), + ]) + engine._build_context() + assert "Initiative" in engine._context_parts + assert "Goblin" in engine._context_parts["Initiative"] diff --git a/tests/test_entities.py b/tests/test_entities.py --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -9,7 +9,23 @@ extract_wiki_links, load_entity_content, resolve_wiki_link, ) -from storied.tools import EntityIndex, ToolContext, establish, mark +from storied.tools import EntityIndex, ToolContext +from storied.tools.entities import establish as _establish +from storied.tools.entities import mark as _mark + +from tests.conftest import call_tool + + +def establish(**kwargs): + """Test shim: drop legacy `ctx` kwarg and resolve Dependency params.""" + kwargs.pop("ctx", None) + return call_tool(_establish, **kwargs) + + +def mark(**kwargs): + """Test shim: drop legacy `ctx` kwarg and resolve Dependency params.""" + kwargs.pop("ctx", None) + return call_tool(_mark, **kwargs) class TestEstablish: diff --git a/tests/test_execute_tool.py b/tests/test_execute_tool.py --- a/tests/test_execute_tool.py +++ b/tests/test_execute_tool.py @@ -1,114 +1,144 @@ -"""Tests for execute_tool dispatch and uncovered tool functions.""" +"""Tests for tool dispatch via the FastMCP in-memory client. + +These tests exercise the same call path the production server uses +(claude → MCP → tool function), but in-process via fastmcp.Client. +""" + +import asyncio +from typing import Any + +import pytest +from fastmcp import Client +from storied.character import load_character from storied.initiative import Combatant -from storied.tools import ( - ToolContext, - _auto_mark_present, - end_session, - establish, - execute_tool, - note_discovery, - planner_execute_tool, - recall, - seeder_execute_tool, -) +from storied.mcp_server import _compose_server +from storied.tools import ToolContext +from storied.tools.combat import _flip_into_combat, _flip_out_of_combat +from storied.tools.entities import note_discovery as _note_discovery +from storied.tools.scene import end_session as _end_session + +from tests.conftest import call_tool + +# --- Helpers ---------------------------------------------------------------- -class TestExecuteToolDispatch: - """Tests that execute_tool routes to the correct tool.""" - def test_roll_with_modifier(self, ctx: ToolContext): - result = execute_tool("roll", {"notation": "1d20+5", "reason": "attack"}, ctx) +def call(tool_name: str, args: dict[str, Any] | None = None) -> str: + """Compose a DM server, call a tool through the in-memory client, return text.""" + async def _run() -> str: + server = await _compose_server("dm") + async with Client(server) as client: + result = await client.call_tool(tool_name, args or {}) + return result.data if result.data is not None else "" + return asyncio.run(_run()) + + +def call_in_combat( + tool_name: str, + args: dict[str, Any], + combatants: list[Combatant], +) -> str: + """Variant that begins initiative on the process-global tracker first. + + The composed server starts with combat tools hidden; we flip them on so + in-combat tools (next_turn, condition, etc.) become callable. + """ + from storied.tools._context import _require + _require().initiative.begin(combatants) + + async def _run() -> str: + server = await _compose_server("dm") + _flip_into_combat() + try: + async with Client(server) as client: + result = await client.call_tool(tool_name, args) + return result.data if result.data is not None else "" + finally: + _flip_out_of_combat() + + return asyncio.run(_run()) + + +# --- Dispatch through the in-memory client ---------------------------------- + + +class TestToolDispatch: + def test_roll_with_modifier(self, ctx: ToolContext): + result = call("roll", {"notation": "1d20+5", "reason": "attack"}) assert "Rolled" in result assert "1d20+5" in result def test_roll_no_modifier(self, ctx: ToolContext): - result = execute_tool("roll", {"notation": "1d6", "reason": "damage"}, ctx) - + result = call("roll", {"notation": "1d6", "reason": "damage"}) assert "Rolled" in result - def test_recall(self, ctx: ToolContext): - result = execute_tool("recall", {"query": "nonexistent"}, ctx) - + def test_recall_finds_nothing(self, ctx: ToolContext): + result = call("recall", {"query": "nonexistent"}) assert "Nothing found" in result - def test_update_character(self, ctx: ToolContext): - # Create character first - execute_tool("create_character", { - "name": "Test", "race": "Human", "char_class": "Fighter", - "level": 1, "abilities": { - "strength": 16, "dexterity": 12, "constitution": 14, - "intelligence": 10, "wisdom": 13, "charisma": 8, - }, - "hp_max": 12, "ac": 16, - }, ctx) - - result = execute_tool( - "update_character", {"updates": {"state.hp.current": 8}}, ctx, - ) - assert "updated" in result.lower() - - # Verify the value actually landed in the right place in the new schema - from storied.character import load_character - data = load_character(ctx.player_id, ctx.base_path) - assert data["state"]["hp"]["current"] == 8, ( - "update_character should write to state.hp.current with the new schema, " - f"but state.hp.current is {data['state']['hp']['current']}" - ) - def test_set_scene(self, ctx: ToolContext): - result = execute_tool("set_scene", { - "event": "Spoke with guards", "duration": "10 min", - }, ctx) - + result = call("set_scene", {"event": "Spoke with guards", "duration": "10 min"}) assert result def test_establish(self, ctx: ToolContext): - result = execute_tool("establish", { - "entity_type": "npcs", "name": "Test NPC", - }, ctx) - + result = call("establish", {"entity_type": "npcs", "name": "Test NPC"}) assert "Established" in result def test_mark(self, ctx: ToolContext): - execute_tool("establish", { - "entity_type": "npcs", "name": "Vera", - }, ctx) - result = execute_tool("mark", { + call("establish", {"entity_type": "npcs", "name": "Vera"}) + result = call("mark", { "entity_type": "npcs", "name": "Vera", "event": "Revealed her secret", - }, ctx) - + }) assert "Marked" in result def test_note_discovery(self, ctx: ToolContext): - result = execute_tool("note_discovery", { + result = call("note_discovery", { "entity": "Vera Blackwater", "content": "She used to be a smuggler", - }, ctx) - + }) assert "Noted" in result def test_end_session(self, ctx: ToolContext): - result = execute_tool("end_session", { - "situation": "In the tavern", - }, ctx) - + result = call("end_session", {"situation": "In the tavern"}) assert result == "SESSION_ENDED" - def test_unknown_tool(self, ctx: ToolContext): - result = execute_tool("nonexistent", {}, ctx) + def test_unknown_tool_raises(self, ctx: ToolContext): + with pytest.raises(Exception): + call("nonexistent", {}) - assert "Unknown tool" in result +class TestUpdateCharacter: + def test_landed_in_state_hp_current(self, ctx: ToolContext): + call("create_character", { + "name": "Test", "race": "Human", "char_class": "Fighter", + "level": 1, "abilities": { + "strength": 16, "dexterity": 12, "constitution": 14, + "intelligence": 10, "wisdom": 13, "charisma": 8, + }, + "hp_max": 12, "ac": 16, + }) + result = call("update_character", {"updates": {"state.hp.current": 8}}) + assert "updated" in result.lower() -class TestNoteDiscovery: - """Tests for the note_discovery tool.""" + data = load_character(ctx.player_id, ctx.base_path) + assert data["state"]["hp"]["current"] == 8, ( + "update_character should write to state.hp.current with the new schema, " + f"but state.hp.current is {data['state']['hp']['current']}" + ) - def test_creates_knowledge_file(self, ctx: ToolContext): - note_discovery("The Rusty Anchor", "A seedy tavern on the docks", ctx) +# --- Sub-tool helper coverage ----------------------------------------------- + + +class TestNoteDiscoveryDirect: + """Direct (non-MCP) calls to note_discovery exercise the wrapper itself.""" + + def test_creates_knowledge_file(self, ctx: ToolContext): + call_tool(_note_discovery, entity="The Rusty Anchor", + content="A seedy tavern on the docks") knowledge_dir = ( ctx.base_path / "players" / ctx.player_id / "worlds" / ctx.world_id / "lore" @@ -116,10 +146,8 @@ ) assert any(knowledge_dir.iterdir()) def test_with_content_type(self, ctx: ToolContext): - note_discovery( - "Vera", "Tavern owner", ctx, content_type="npcs", - ) - + call_tool(_note_discovery, entity="Vera", content="Tavern owner", + content_type="npcs") knowledge_dir = ( ctx.base_path / "players" / ctx.player_id / "worlds" / ctx.world_id / "npcs" @@ -127,10 +155,8 @@ ) assert any(knowledge_dir.iterdir()) def test_with_tags(self, ctx: ToolContext): - note_discovery( - "Old Map", "Shows a hidden passage", ctx, tags=["quest"], - ) - + call_tool(_note_discovery, entity="Old Map", + content="Shows a hidden passage", tags=["quest"]) knowledge_dir = ( ctx.base_path / "players" / ctx.player_id / "worlds" / ctx.world_id / "lore" @@ -139,42 +165,24 @@ content = next(knowledge_dir.iterdir()).read_text() assert "quest" in content -class TestEndSession: - """Tests for the end_session tool.""" - +class TestEndSessionDirect: def test_returns_session_ended(self, ctx: ToolContext): - result = end_session("In the tavern", ctx) - + result = call_tool(_end_session, situation="In the tavern") assert result == "SESSION_ENDED" def test_with_threads(self, ctx: ToolContext): - result = end_session( - "In the tavern", ctx, + result = call_tool( + _end_session, + situation="In the tavern", threads=["Find the merchant", "Investigate the warehouse"], ) - assert result == "SESSION_ENDED" -class TestPlannerExecuteTool: - """Tests for planner tool restriction.""" - - def test_allows_recall(self, ctx: ToolContext): - result = planner_execute_tool("recall", {"query": "test"}, ctx) - - assert "Nothing found" in result - - def test_rejects_set_scene(self, ctx: ToolContext): - result = planner_execute_tool("set_scene", { - "event": "test", "duration": "1 min", - }, ctx) - - assert "not available" in result +# --- Recall with indexed content -------------------------------------------- class TestRecall: - """Tests for the recall tool with indexed content.""" - def test_recall_finds_indexed_entity(self, ctx: ToolContext): entity_dir = ctx.base_path / "worlds" / ctx.world_id / "npcs" entity_dir.mkdir(parents=True, exist_ok=True) @@ -188,18 +196,15 @@ {"source": "world", "content_type": "npcs", "path": str(entity_file), "title": "Vera Blackwater"}, ) - result = recall("Vera Blackwater", ctx) - + result = call("recall", {"query": "Vera Blackwater"}) assert "Vera Blackwater" in result def test_recall_rules_scope(self, ctx: ToolContext): - result = recall("fireball", ctx, scope="rules") - + result = call("recall", {"query": "fireball", "scope": "rules"}) assert "Nothing found" in result def test_recall_world_scope(self, ctx: ToolContext): - result = recall("something", ctx, scope="world") - + result = call("recall", {"query": "something", "scope": "world"}) assert "Nothing found" in result def test_recall_multiple_hits(self, ctx: ToolContext): @@ -211,158 +216,460 @@ {"source": "world", "content_type": "npcs", "path": f"/fake/npc{i}.md", "title": f"NPC {i}"}, ) - result = recall("docks NPC", ctx) - + result = call("recall", {"query": "docks NPC"}) assert "Found" in result or "Nothing found" in result -class TestAutoMarkPresent: - """Tests for _auto_mark_present helper.""" +# --- Combat path: damage routes through the initiative tracker -------------- - def test_marks_present_entities(self, ctx: ToolContext): - establish(entity_type="npcs", name="Vera", ctx=ctx, - description="Tavern owner.") - marked = _auto_mark_present( - ["[[Vera]]"], "Witnessed the fight", ctx, +class TestDamageHealCombat: + def test_damage_combatant_in_initiative(self, ctx: ToolContext): + result = call_in_combat( + "damage", + {"target": "Goblin", "amount": 3}, + [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], ) - - assert "Vera" in marked - content = (ctx.base_path / "worlds" / ctx.world_id / "npcs/Vera.md").read_text() - assert "Witnessed the fight" in content - - def test_skips_non_wikilinks(self, ctx: ToolContext): - marked = _auto_mark_present(["plain text"], "event", ctx) - - assert marked == [] - - def test_skips_unknown_entities(self, ctx: ToolContext): - marked = _auto_mark_present(["[[Nobody]]"], "event", ctx) - - assert marked == [] - - -class TestInitiativeViaExecuteTool: - """Tests that initiative tools route through execute_tool.""" - - def test_enter_initiative(self, ctx: ToolContext): - result = execute_tool("enter_initiative", { - "combatants": [ - {"name": "Kira", "initiative": 18, "hp": 25, "hp_max": 25, "ac": 16, "is_player": True}, - {"name": "Goblin", "initiative": 10, "hp": 7, "hp_max": 7, "ac": 15}, - ], - }, ctx) - - assert "Initiative started" in result - assert ctx.initiative.active - - def test_damage_via_execute_tool(self, ctx: ToolContext): - ctx.initiative.begin([ - Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), - ]) - - result = execute_tool("damage", {"target": "Goblin", "amount": 3}, ctx) - assert "3" in result assert ctx.initiative._find("Goblin").hp == 4 def test_damage_syncs_player_hp(self, ctx: ToolContext): - execute_tool("create_character", { + call("create_character", { "name": "Kira", "race": "Human", "char_class": "Fighter", "level": 1, "abilities": { "strength": 16, "dexterity": 12, "constitution": 14, "intelligence": 10, "wisdom": 13, "charisma": 8, }, "hp_max": 25, "ac": 16, - }, ctx) - ctx.initiative.begin([ - Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16, is_player=True), - ]) + }) - result = execute_tool("damage", {"target": "Kira", "amount": 7}, ctx) + result = call_in_combat( + "damage", + {"target": "Kira", "amount": 7}, + [Combatant(name="Kira", initiative=18, hp=25, hp_max=25, + ac=16, is_player=True)], + ) assert "synced" in result - from storied.character import load_character char = load_character(ctx.player_id, ctx.base_path) - # Must land in the new nested schema location, not flat hp.current assert char["state"]["hp"]["current"] == 18, ( "_sync_player_hp must write to state.hp.current with the new schema" ) def test_heal_syncs_player_hp(self, ctx: ToolContext): - execute_tool("create_character", { + call("create_character", { "name": "Kira", "race": "Human", "char_class": "Fighter", "level": 1, "abilities": { "strength": 16, "dexterity": 12, "constitution": 14, "intelligence": 10, "wisdom": 13, "charisma": 8, }, "hp_max": 25, "ac": 16, - }, ctx) - # Set the character's HP to 20 first (matching the combatant) - execute_tool( - "update_character", {"updates": {"state.hp.current": 20}}, ctx, + }) + call("update_character", {"updates": {"state.hp.current": 20}}) + + result = call_in_combat( + "heal", + {"target": "Kira", "amount": 3}, + [Combatant(name="Kira", initiative=18, hp=20, hp_max=25, + ac=16, is_player=True)], ) - ctx.initiative.begin([ - Combatant(name="Kira", initiative=18, hp=20, hp_max=25, ac=16, is_player=True), - ]) - - result = execute_tool("heal", {"target": "Kira", "amount": 3}, ctx) assert "synced" in result - from storied.character import load_character char = load_character(ctx.player_id, ctx.base_path) - # Must land in the new nested schema location assert char["state"]["hp"]["current"] == 23, ( "_sync_player_hp must write to state.hp.current with the new schema" ) def test_damage_no_sync_for_non_player(self, ctx: ToolContext): + result = call_in_combat( + "damage", + {"target": "Goblin", "amount": 3}, + [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], + ) + assert "synced" not in result + + def test_unknown_target_returns_error(self, ctx: ToolContext): + result = call("damage", {"target": "Nobody", "amount": 5}) + assert "No such target" in result + + +# --- Combat tool wrappers (exercised via the in-memory client) -------------- + + +class TestCombatTools: + """The combat FastMCP wrappers route through the active InitiativeTracker. + + These tests start initiative on the process-global ctx via enter_initiative, + flip combat tools visible, then drive each wrapper via the in-memory client + so the wrapper bodies (not just the underlying tracker) get exercised. + """ + + def test_enter_initiative_starts_combat(self, ctx: ToolContext): + result = call_in_combat( + "next_turn", + {}, + [ + Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16, + is_player=True), + Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), + ], + ) + assert ctx.initiative.active + # next_turn from Kira should advance to Goblin + assert "Goblin" in result + + def test_enter_initiative_via_client(self, ctx: ToolContext): + """Drive enter_initiative through the in-memory client so the + parsing + tracker.begin + flip path runs end-to-end.""" + async def _run() -> str: + from fastmcp import Client + server = await _compose_server("dm") + try: + async with Client(server) as client: + r = await client.call_tool("enter_initiative", { + "combatants": [ + {"name": "Kira", "initiative": 18, "hp": 25, + "hp_max": 25, "ac": 16, "is_player": True}, + {"name": "Goblin", "initiative": 10, "hp": 7, + "hp_max": 7, "ac": 15}, + ], + }) + return r.data + finally: + _flip_out_of_combat() + + result = asyncio.run(_run()) + assert "Initiative started" in result or "Round 1" in result + assert ctx.initiative.active + + def test_enter_initiative_when_already_active_errors(self, ctx: ToolContext): + # Start combat manually so the wrapper hits the early-return guard ctx.initiative.begin([ Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), ]) - result = execute_tool("damage", {"target": "Goblin", "amount": 3}, ctx) + async def _run() -> str: + from fastmcp import Client + server = await _compose_server("dm") + _flip_into_combat() + try: + async with Client(server) as client: + r = await client.call_tool("enter_initiative", { + "combatants": [{ + "name": "X", "initiative": 1, "hp": 1, "hp_max": 1, + "ac": 10, + }], + }) + return r.data + finally: + _flip_out_of_combat() + + result = asyncio.run(_run()) + assert "already active" in result.lower() + + def test_add_combatant_inserts_into_initiative(self, ctx: ToolContext): + result = call_in_combat( + "add_combatant", + {"name": "Reinforcement", "initiative": 12, "hp": 5, "hp_max": 5, + "ac": 14}, + [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], + ) + assert "Reinforcement" in result + assert ctx.initiative._find("Reinforcement") is not None - assert "synced" not in result + def test_remove_combatant_removes_from_initiative(self, ctx: ToolContext): + result = call_in_combat( + "remove_combatant", + {"name": "Goblin"}, + [ + Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16), + Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), + ], + ) + assert "removed" in result.lower() + assert ctx.initiative._find("Goblin") is None - def test_end_initiative_via_execute_tool(self, ctx: ToolContext): + def test_condition_add(self, ctx: ToolContext): + result = call_in_combat( + "condition", + {"target": "Goblin", "condition": "Poisoned"}, + [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], + ) + assert "Poisoned" in result + # The combatant should now have the condition tracked + goblin = ctx.initiative._find("Goblin") + assert any(c.name == "Poisoned" for c in goblin.conditions) + + def test_condition_remove(self, ctx: ToolContext): ctx.initiative.begin([ Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), ]) + ctx.initiative.add_condition(target="Goblin", condition="Stunned") - result = execute_tool("end_initiative", {}, ctx) + async def _run() -> str: + from fastmcp import Client + server = await _compose_server("dm") + _flip_into_combat() + try: + async with Client(server) as client: + r = await client.call_tool("condition", { + "target": "Goblin", + "condition": "Stunned", + "action": "remove", + }) + return r.data + finally: + _flip_out_of_combat() + result = asyncio.run(_run()) + assert "removed" in result.lower() or "Stunned" in result + goblin = ctx.initiative._find("Goblin") + assert not any(c.name == "Stunned" for c in goblin.conditions) + + def test_end_initiative_clears_combat(self, ctx: ToolContext): + ctx.initiative.begin([ + Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), + ]) + + async def _run() -> str: + from fastmcp import Client + server = await _compose_server("dm") + _flip_into_combat() + try: + async with Client(server) as client: + r = await client.call_tool("end_initiative", {}) + return r.data + finally: + _flip_out_of_combat() + + result = asyncio.run(_run()) assert "ended" in result.lower() assert not ctx.initiative.active - def test_initiative_tools_not_in_planner(self, ctx: ToolContext): - result = planner_execute_tool("enter_initiative", { - "combatants": [], - }, ctx) + +# --- Character wrapper bodies ---------------------------------------------- - assert "not available" in result - def test_initiative_tools_not_in_seeder(self, ctx: ToolContext): - result = seeder_execute_tool("damage", { - "target": "Goblin", "amount": 5, - }, ctx) +@pytest.fixture +def kira(ctx: ToolContext) -> ToolContext: + """A minimum-viable character used by the wrapper-coverage tests.""" + call("create_character", { + "name": "Kira", "race": "Human", "char_class": "Fighter", + "level": 1, "abilities": { + "strength": 16, "dexterity": 12, "constitution": 14, + "intelligence": 10, "wisdom": 13, "charisma": 8, + }, + "hp_max": 12, "ac": 16, + }) + return ctx - assert "not available" in result +class TestCharacterToolWrappers: + """Each character.py @mcp.tool wrapper is a one-liner that delegates to + a char_* function. These tests exercise the wrappers through the in-memory + client so the wrapper bodies (and their Dependency-resolved arguments) + are actually executed.""" -class TestSeederExecuteTool: - """Tests for seeder tool restriction.""" + def test_damage_player_by_name(self, kira: ToolContext): + result = call("damage", {"target": "Kira", "amount": 3}) + assert "3" in result + char = load_character("default", kira.base_path) + assert char["state"]["hp"]["current"] == 9 - def test_allows_establish(self, ctx: ToolContext): - result = seeder_execute_tool("establish", { - "entity_type": "npcs", "name": "Test", - }, ctx) + def test_damage_with_type(self, kira: ToolContext): + result = call("damage", {"target": "Kira", "amount": 2, "type": "fire"}) + assert "2" in result - assert "Established" in result + def test_heal_player_by_name(self, kira: ToolContext): + call("damage", {"target": "Kira", "amount": 5}) + result = call("heal", {"target": "Kira", "amount": 3}) + assert result + char = load_character("default", kira.base_path) + assert char["state"]["hp"]["current"] == 10 - def test_rejects_mark(self, ctx: ToolContext): - result = seeder_execute_tool("mark", { - "entity_type": "npcs", "name": "Test", "event": "test", - }, ctx) + def test_adjust_coins(self, kira: ToolContext): + result = call("adjust_coins", {"deltas": {"gp": 10, "sp": 5}}) + assert "10" in result + char = load_character("default", kira.base_path) + assert char["state"]["purse"]["gp"] == 10 + assert char["state"]["purse"]["sp"] == 5 - assert "not available" in result + def test_adjust_coins_drops_zero_deltas(self, kira: ToolContext): + # Spending only gp; the zero-delta filter exercises the comprehension branch + call("adjust_coins", {"deltas": {"gp": 5}}) + result = call("adjust_coins", {"deltas": {"gp": -3, "sp": 0}}) + char = load_character("default", kira.base_path) + assert char["state"]["purse"]["gp"] == 2 + assert "silver" not in result.lower() + + def test_add_effect(self, kira: ToolContext): + result = call("add_effect", { + "source": "Bless", "description": "+1d4 attacks/saves", + }) + assert result + + def test_add_effect_with_expires(self, kira: ToolContext): + result = call("add_effect", { + "source": "Heroism", "description": "+10 temp HP", + "expires": "d1-1430", + }) + assert result + + def test_remove_effect(self, kira: ToolContext): + call("add_effect", {"source": "Bless", "description": "+1d4"}) + result = call("remove_effect", {"source": "Bless"}) + assert result + + def test_add_and_remove_condition(self, kira: ToolContext): + result = call("add_condition", {"name": "Poisoned"}) + assert result + result = call("remove_condition", {"name": "Poisoned"}) + assert result + + def test_add_item_default_location(self, kira: ToolContext): + result = call("add_item", {"item": "Lockpicks"}) + assert result + + def test_add_item_with_location(self, kira: ToolContext): + result = call("add_item", { + "item": "Spare cloak", "location": "stashed_at_inn", + }) + assert result + + def test_remove_item(self, kira: ToolContext): + call("add_item", {"item": "Boot knife"}) + result = call("remove_item", {"item": "Boot knife"}) + assert result + + def test_set_item_status(self, kira: ToolContext): + # The item must already be a known magic item entity for status tracking + call("establish", { + "entity_type": "items", "name": "Bracer of Defense", + "description": "A leather bracer with a faint silver sheen.", + }) + result = call("set_item_status", { + "item": "Bracer of Defense", "status": "attuned", + }) + assert result + + def test_use_and_restore_resource(self, kira: ToolContext): + # Add a resource via update_character first + call("update_character", { + "updates": { + "resources.hit_dice_d10": { + "current": 1, "max": 1, "refresh": "long_rest", + "notes": "Hit Dice (d10)", + }, + }, + }) + result = call("use_resource", {"name": "hit_dice", "amount": 1}) + assert "Used" in result + result = call("restore_resource", {"name": "hit_dice", "amount": 1}) + assert "Restored" in result + + def test_rest_short(self, kira: ToolContext): + result = call("rest", {"type": "short"}) + assert result + + def test_rest_long(self, kira: ToolContext): + result = call("rest", {"type": "long"}) + assert result + + def test_add_note(self, kira: ToolContext): + result = call("add_note", {"text": "Found a hidden passage"}) + assert result + + +# --- Scene/world wrappers -------------------------------------------------- + + +class TestSceneToolWrappers: + """Cover the scene.py wrapper bodies — set_scene's optional-field branches, + tune's file write, end_session's threads branch, notify_dm's append.""" + + def test_set_scene_event_only(self, ctx: ToolContext): + result = call("set_scene", { + "event": "Walked into the tavern", + "duration": "5 min", + }) + assert "Logged" in result + + def test_set_scene_with_situation_and_location(self, ctx: ToolContext): + result = call("set_scene", { + "event": "Arrived at the inn", + "duration": "1 hour", + "situation": "Resting by the fire", + "location": "The Rusty Anchor", + }) + assert "Logged" in result + assert "updated" in result.lower() + + def test_set_scene_with_present_auto_marks(self, ctx: ToolContext): + # Establish an entity first so auto-mark has something to find + call("establish", { + "entity_type": "npcs", "name": "Vera", + "description": "Tavern owner.", + }) + result = call("set_scene", { + "event": "Spoke with Vera", + "duration": "10 min", + "present": ["[[Vera]]"], + }) + assert "Auto-marked: Vera" in result + + def test_set_scene_with_threads(self, ctx: ToolContext): + result = call("set_scene", { + "event": "Got a lead", + "duration": "5 min", + "threads": ["Find the missing merchant"], + }) + assert result + + def test_set_scene_no_args_returns_no_updates(self, ctx: ToolContext): + # Both event and duration omitted, no other fields → "No updates" + result = call("set_scene", {}) + assert result == "No updates" + + def test_tune_writes_style_file(self, ctx: ToolContext): + result = call("tune", {"tuning": "Lean into intrigue and slow pacing."}) + assert "updated" in result.lower() + style_path = ctx.base_path / "worlds" / ctx.world_id / "style.md" + assert style_path.exists() + assert "intrigue" in style_path.read_text() + + def test_end_session_no_threads(self, ctx: ToolContext): + result = call("end_session", {"situation": "In the tavern"}) + assert result == "SESSION_ENDED" + + def test_end_session_with_threads(self, ctx: ToolContext): + result = call("end_session", { + "situation": "In the tavern", + "threads": ["Investigate the warehouse", "Find the merchant"], + }) + assert result == "SESSION_ENDED" + + def test_notify_dm_appends_to_queue(self, ctx: ToolContext): + result = call("notify_dm", {"message": "Background world has shifted"}) + assert "queued" in result.lower() + path = ctx.base_path / "worlds" / ctx.world_id / "dm_notifications.md" + assert path.exists() + assert "Background world has shifted" in path.read_text() + + +# --- run_code wrapper ------------------------------------------------------- + + +class TestRunCodeWrapper: + def test_run_code_simple(self, ctx: ToolContext): + result = call("run_code", { + "description": "Two plus two", + "code": "2 + 2", + }) + assert "4" in result + + def test_run_code_with_print(self, ctx: ToolContext): + result = call("run_code", { + "description": "Print test", + "code": 'print("hello sandbox")', + }) + assert "hello sandbox" in result diff --git a/tests/test_initiative.py b/tests/test_initiative.py --- a/tests/test_initiative.py +++ b/tests/test_initiative.py @@ -1,16 +1,16 @@ -"""Tests for initiative tracking system.""" +"""Tests for the initiative tracking state machine. + +The FastMCP combat tool surface is tested separately in test_mcp_server.py +and via the in-memory client in test_execute_tool.py. This file covers +just the InitiativeTracker dataclass behavior. +""" import pytest from storied.initiative import ( - ALL_INITIATIVE_TOOL_NAMES, - COMBAT_TOOL_DEFINITIONS, - ENTER_INITIATIVE_DEFINITION, - INITIATIVE_KEEP_NARRATIVE, Combatant, InitiativeTracker, TrackedCondition, - execute_initiative_tool, ) @@ -437,71 +437,6 @@ assert "Round" in context -class TestDispatch: - def test_all_tool_names_present(self): - expected = { - "enter_initiative", "next_turn", "add_combatant", - "remove_combatant", "damage", "heal", "condition", - "end_initiative", - } - assert ALL_INITIATIVE_TOOL_NAMES == expected - - def test_enter_initiative_definition_exists(self): - assert ENTER_INITIATIVE_DEFINITION["name"] == "enter_initiative" - - def test_combat_definitions_exclude_enter(self): - names = {d["name"] for d in COMBAT_TOOL_DEFINITIONS} - assert "enter_initiative" not in names - assert "next_turn" in names - - def test_keep_narrative_tools(self): - assert INITIATIVE_KEEP_NARRATIVE == frozenset({"roll", "recall", "update_character"}) - - def test_dispatch_routes_enter(self, tracker: InitiativeTracker): - result = execute_initiative_tool( - "enter_initiative", - {"combatants": [ - {"name": "Kira", "initiative": 18, "hp": 25, "hp_max": 25, "ac": 16, "is_player": True}, - {"name": "Goblin", "initiative": 10, "hp": 7, "hp_max": 7, "ac": 15}, - ]}, - tracker, - ) - - assert tracker.active - assert "Kira" in result - - def test_dispatch_routes_damage(self, tracker: InitiativeTracker): - tracker.begin([ - Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), - ]) - - result = execute_initiative_tool( - "damage", {"target": "Goblin", "amount": 3}, tracker, - ) - - assert "3" in result - - def test_dispatch_unknown_tool(self, tracker: InitiativeTracker): - result = execute_initiative_tool("fake_tool", {}, tracker) - - assert result is None - - def test_guard_when_inactive(self, tracker: InitiativeTracker): - result = execute_initiative_tool( - "next_turn", {}, tracker, - ) - - assert "not active" in result.lower() - - def test_enter_errors_when_active(self, tracker: InitiativeTracker): - tracker.begin([ - Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), - ]) - - result = execute_initiative_tool( - "enter_initiative", - {"combatants": [{"name": "X", "initiative": 1, "hp": 1, "hp_max": 1, "ac": 10}]}, - tracker, - ) - - assert "already active" in result.lower() +# Dispatcher tests removed — the FastMCP combat tools live in +# storied.tools.combat now and are exercised end-to-end via +# tests/test_mcp_server.py and tests/test_execute_tool.py. diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1,117 +1,294 @@ -"""Tests for the MCP server tool dispatch.""" +"""Tests for the FastMCP server composition. + +The orchestrator in storied.mcp_server builds a per-role top-level FastMCP +server by mounting the tools/*.py module-level FastMCP instances and +applying tag-based visibility filters. These tests verify the per-role +tool visibility plus the dynamic combat-tag flip when initiative starts +and ends. +""" + +import asyncio import pytest -from storied.initiative import ( - COMBAT_TOOL_DEFINITIONS, - ENTER_INITIATIVE_DEFINITION, - INITIATIVE_KEEP_NARRATIVE, - Combatant, -) -from storied.mcp_server import _dm_tool_definitions, _to_mcp_tool -from storied.tools import TOOL_DEFINITIONS, ToolContext +from storied.initiative import Combatant +from storied.mcp_server import _compose_server +from storied.tools import ToolContext, _context +from storied.tools.combat import _flip_into_combat, _flip_out_of_combat + + +def _names(role: str) -> set[str]: + async def _gather() -> set[str]: + server = await _compose_server(role) + return {t.name for t in await server.list_tools()} + return asyncio.run(_gather()) + + +class TestPerRoleComposition: + """Each role sees only the tools tagged for it.""" + + def test_dm_includes_core_narrative_tools(self): + names = _names("dm") + assert "set_scene" in names + assert "establish" in names + assert "mark" in names + assert "end_session" in names + assert "recall" in names + assert "roll" in names + assert "run_code" in names + + def test_dm_includes_character_tools(self): + names = _names("dm") + for tool_name in ( + "damage", "heal", "adjust_coins", "add_effect", "remove_effect", + "add_condition", "remove_condition", "add_item", "remove_item", + "set_item_status", "use_resource", "restore_resource", "rest", + "add_note", "update_character", "create_character", + ): + assert tool_name in names, f"missing {tool_name}" + + def test_dm_initial_excludes_combat_tools(self): + """In DM mode, combat tools are hidden until enter_initiative runs.""" + names = _names("dm") + assert "next_turn" not in names + assert "add_combatant" not in names + assert "remove_combatant" not in names + assert "condition" not in names + + def test_dm_initial_keeps_combat_control(self): + """enter_initiative and end_initiative stay visible so combat can begin.""" + names = _names("dm") + assert "enter_initiative" in names + assert "end_initiative" in names + + def test_planner_only_has_its_tools(self): + assert _names("planner") == {"establish", "mark", "notify_dm", "recall"} + + def test_seeder_only_has_its_tools(self): + assert _names("seeder") == {"establish", "set_scene"} + + def test_advancement_only_has_its_tools(self): + assert _names("advancement") == {"notify_dm", "recall", "update_character"} + + +class TestToolSchemas: + """Verify tool input schemas expose nested field shapes to the LLM. + + These tests guard against regression to bare `dict` / `list` parameter + types, which leave the LLM with no guidance about which keys are required. + """ + + def _schema(self, tool_name: str) -> dict: + async def _gather() -> dict: + server = await _compose_server("dm") + for t in await server.list_tools(): + if t.name == tool_name: + return t.parameters + raise AssertionError(f"tool {tool_name!r} not found") + return asyncio.run(_gather()) + def test_enter_initiative_documents_combatant_shape(self): + schema = self._schema("enter_initiative") + item_schema = schema["properties"]["combatants"]["items"] + required = set(item_schema["required"]) + assert {"name", "initiative", "hp", "hp_max", "ac"} <= required, ( + f"enter_initiative must require all combatant fields, got {required}" + ) + # is_player is optional but documented + assert "is_player" in item_schema["properties"] -class TestToMcpTool: - def test_converts_name(self): - defn = TOOL_DEFINITIONS[0] # roll - tool = _to_mcp_tool(defn) - assert tool.name == "roll" + def test_create_character_documents_ability_keys(self): + schema = self._schema("create_character") + ability_props = schema["properties"]["abilities"]["properties"] + for ability in ("strength", "dexterity", "constitution", + "intelligence", "wisdom", "charisma"): + assert ability in ability_props, ( + f"create_character must document the {ability} ability score" + ) - def test_converts_description(self): - defn = TOOL_DEFINITIONS[0] - tool = _to_mcp_tool(defn) - assert tool.description is not None - assert len(tool.description) > 0 + def test_adjust_coins_documents_denominations(self): + schema = self._schema("adjust_coins") + delta_props = schema["properties"]["deltas"]["properties"] + for denom in ("cp", "sp", "ep", "gp", "pp"): + assert denom in delta_props, ( + f"adjust_coins must document the {denom} denomination" + ) - def test_converts_input_schema(self): - defn = TOOL_DEFINITIONS[0] - tool = _to_mcp_tool(defn) - assert tool.inputSchema["type"] == "object" - assert "notation" in tool.inputSchema["properties"] + def test_create_character_purse_documents_denominations(self): + schema = self._schema("create_character") + purse = schema["properties"]["purse"] + # Purse is wrapped in anyOf for the | None + purse_props = next( + opt for opt in purse["anyOf"] if opt.get("type") == "object" + )["properties"] + for denom in ("cp", "sp", "ep", "gp", "pp"): + assert denom in purse_props @pytest.mark.parametrize( - "defn", TOOL_DEFINITIONS, ids=[d["name"] for d in TOOL_DEFINITIONS] + "tool_name,param,expected_values", + [ + ("rest", "type", {"short", "long"}), + ("set_item_status", "status", {"attuned", "equipped", "carried"}), + ("recall", "scope", {"rules", "world", "all"}), + ("establish", "entity_type", + {"npcs", "locations", "items", "factions", "threads", "lore"}), + ("mark", "entity_type", + {"npcs", "locations", "items", "factions", "threads"}), + ("note_discovery", "content_type", + {"npcs", "locations", "factions", "lore"}), + ], ) - def test_all_tools_convert(self, defn: dict): - tool = _to_mcp_tool(defn) - assert tool.name == defn["name"] - assert tool.inputSchema is not None + def test_enum_parameters_expose_valid_values( + self, tool_name: str, param: str, expected_values: set[str], + ): + """Each conceptually-enum parameter must surface as a JSON Schema + enum, not a free-form string. Guards against regression to bare `str`.""" + schema = self._schema(tool_name) + prop = schema["properties"][param] + # Default values wrap the enum in anyOf for `Type | None`; unwrap if needed + enum_values = prop.get("enum") + if enum_values is None and "anyOf" in prop: + for opt in prop["anyOf"]: + if "enum" in opt: + enum_values = opt["enum"] + break + assert enum_values is not None, ( + f"{tool_name}.{param} should expose an enum, got {prop}" + ) + assert set(enum_values) == expected_values + def test_combat_condition_enum_parameters(self): + """The combat `condition` tool is hidden in the default DM compose + (combat-only), so check it directly on the combat module.""" + from storied.tools.combat import mcp as combat_mcp -class TestDynamicDmTools: - """Tests that the DM tool list changes based on initiative state.""" + async def _gather() -> dict: + for t in await combat_mcp.list_tools(): + if t.name == "condition": + return t.parameters + raise AssertionError("condition tool not found") - def test_narrative_mode_includes_enter_initiative(self, ctx: ToolContext): - defs = _dm_tool_definitions(ctx) - names = {d["name"] for d in defs} + params = asyncio.run(_gather()) + assert set(params["properties"]["action"]["enum"]) == {"add", "remove"} + assert set(params["properties"]["ends_on"]["enum"]) == {"start", "end"} - assert "enter_initiative" in names - def test_narrative_mode_includes_all_narrative_tools(self, ctx: ToolContext): - defs = _dm_tool_definitions(ctx) - names = {d["name"] for d in defs} +class TestCombatTagFlip: + """Entering and ending initiative toggles combat-tag visibility on the + composed top-level server.""" - assert "set_scene" in names - assert "establish" in names - assert "end_session" in names + def test_flip_into_combat_shows_combat_tools(self, ctx: ToolContext): + async def _run() -> set[str]: + server = await _compose_server("dm") + _flip_into_combat() + return {t.name for t in await server.list_tools()} - def test_narrative_mode_excludes_combat_tools(self, ctx: ToolContext): - defs = _dm_tool_definitions(ctx) - names = {d["name"] for d in defs} + names = asyncio.run(_run()) + assert "next_turn" in names + assert "add_combatant" in names + assert "condition" in names + # Cleanup + _flip_out_of_combat() + def test_flip_out_of_combat_hides_combat_tools(self, ctx: ToolContext): + async def _run() -> set[str]: + server = await _compose_server("dm") + _flip_into_combat() + _flip_out_of_combat() + return {t.name for t in await server.list_tools()} + + names = asyncio.run(_run()) assert "next_turn" not in names - assert "end_initiative" not in names - # Note: damage/heal exist as character tools out of combat (no target), - # and as initiative tools in combat (with target). They share names. - assert "damage" in names # the character version + assert "add_combatant" not in names - def test_narrative_mode_count(self, ctx: ToolContext): - defs = _dm_tool_definitions(ctx) + def test_combat_control_stays_visible_through_cycle(self, ctx: ToolContext): + """enter_initiative / end_initiative are tagged combat_control and + must stay visible whether initiative is active or not.""" + async def _gather_combat_control() -> tuple[set[str], set[str], set[str]]: + server = await _compose_server("dm") + initial = {t.name for t in await server.list_tools()} + _flip_into_combat() + during = {t.name for t in await server.list_tools()} + _flip_out_of_combat() + after = {t.name for t in await server.list_tools()} + return initial, during, after - assert len(defs) == len(TOOL_DEFINITIONS) + 1 # +1 for enter_initiative + initial, during, after = asyncio.run(_gather_combat_control()) + for state in (initial, during, after): + assert "enter_initiative" in state + assert "end_initiative" in state - def test_initiative_mode_includes_combat_tools(self, ctx: ToolContext): - ctx.initiative.begin([ - Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16), - ]) - defs = _dm_tool_definitions(ctx) - names = {d["name"] for d in defs} +class TestPopulateIndex: + """Cover the SRD-seeding helper without launching a real server.""" - assert "next_turn" in names - assert "damage" in names - assert "end_initiative" in names + def test_no_srd_no_world_dir(self, tmp_path): + from unittest.mock import MagicMock - def test_initiative_mode_keeps_narrative_subset(self, ctx: ToolContext): - ctx.initiative.begin([ - Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16), - ]) + from storied.mcp_server import _populate_index - defs = _dm_tool_definitions(ctx) - names = {d["name"] for d in defs} + vi = MagicMock() + _populate_index(tmp_path, tmp_path / "worlds" / "missing", vi) + # No SRD seed, no SRD sections, no world dir → nothing should be called + vi.reseed.assert_not_called() + vi.reindex_directory.assert_not_called() - for tool_name in INITIATIVE_KEEP_NARRATIVE: - assert tool_name in names + def test_world_dir_only(self, tmp_path): + from unittest.mock import MagicMock - def test_initiative_mode_excludes_narrative_only_tools(self, ctx: ToolContext): - ctx.initiative.begin([ - Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16), - ]) + from storied.mcp_server import _populate_index - defs = _dm_tool_definitions(ctx) - names = {d["name"] for d in defs} + world_dir = tmp_path / "worlds" / "test" + world_dir.mkdir(parents=True) + vi = MagicMock() + _populate_index(tmp_path, world_dir, vi) + vi.reindex_directory.assert_called_once_with(world_dir, source="world") - assert "set_scene" not in names - assert "establish" not in names - assert "enter_initiative" not in names + def test_srd_sections_dir(self, tmp_path): + from unittest.mock import MagicMock - def test_initiative_mode_count(self, ctx: ToolContext): - ctx.initiative.begin([ - Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16), - ]) + from storied.mcp_server import _populate_index - defs = _dm_tool_definitions(ctx) - expected = len(INITIATIVE_KEEP_NARRATIVE) + len(COMBAT_TOOL_DEFINITIONS) + srd_dir = tmp_path / "rules" / "srd-5.2.1" / "sections" + srd_dir.mkdir(parents=True) + world_dir = tmp_path / "worlds" / "test" + vi = MagicMock() + _populate_index(tmp_path, world_dir, vi) + # SRD sections present → reindex SRD; no world dir → no second call + assert vi.reindex_directory.call_count == 1 + vi.reindex_directory.assert_called_with(srd_dir, source="srd") - assert len(defs) == expected + def test_flip_helpers_no_op_when_root_unset(self): + """The combat-tag flip helpers must not crash when no top-level + server has been registered (e.g. when the combat module is imported + in isolation by a test before any compose_server call).""" + import storied.tools.combat as combat_mod + + # Snapshot and clear _root for the duration of the test + saved_root = combat_mod._root + saved_keys = combat_mod._combat_keys_to_hide + combat_mod._root = None + combat_mod._combat_keys_to_hide = set() + try: + combat_mod._flip_into_combat() # should silently no-op + combat_mod._flip_out_of_combat() + finally: + combat_mod._root = saved_root + combat_mod._combat_keys_to_hide = saved_keys + + def test_srd_seed_db_takes_priority(self, tmp_path): + from unittest.mock import MagicMock + + from storied.mcp_server import _populate_index + + srd_seed = tmp_path / "rules" / "srd-5.2.1" / "search.db" + srd_seed.parent.mkdir(parents=True) + srd_seed.write_bytes(b"sqlite stub") + # Also create the sections dir to verify the seed wins + (tmp_path / "rules" / "srd-5.2.1" / "sections").mkdir() + world_dir = tmp_path / "worlds" / "test" + vi = MagicMock() + _populate_index(tmp_path, world_dir, vi) + vi.reseed.assert_called_once_with(srd_seed) + # When the seed exists, we don't also reindex SRD sections + vi.reindex_directory.assert_not_called() diff --git a/tests/test_notification_formatters.py b/tests/test_notification_formatters.py new file mode 100644 --- /dev/null +++ b/tests/test_notification_formatters.py @@ -0,0 +1,257 @@ +"""Tests for the deferred-tool notification formatters. + +These are pure functions that turn accumulated tool input JSON into a +human-readable label for the streaming TUI. Each formatter is exercised +through its real DEFERRED_FORMATTERS lookup, so a missing entry in the +dispatch table would surface here too. +""" + +import pytest + +from storied.notification_formatters import ( + DEFERRED_FORMATTERS, + TOOL_LABELS, + _extract_json_field, + _extract_roll_reason, + _parse_tool_args, +) + + +# --- Low-level helpers ------------------------------------------------------ + + +class TestParseToolArgs: + def test_valid_json(self): + assert _parse_tool_args('{"a": 1}') == {"a": 1} + + def test_empty_string(self): + assert _parse_tool_args("") == {} + + def test_malformed_json(self): + assert _parse_tool_args("{not valid}") == {} + + def test_null_json_returns_empty(self): + # json.loads("null") returns None, the helper coerces to {} + assert _parse_tool_args("null") == {} + + +class TestExtractJsonField: + def test_field_present(self): + assert _extract_json_field('{"reason": "attack"}', "reason") == "attack" + + def test_field_missing(self): + assert _extract_json_field('{"other": 1}', "reason") is None + + def test_malformed_returns_none(self): + assert _extract_json_field("{bad}", "reason") is None + + def test_extract_roll_reason(self): + assert _extract_roll_reason('{"reason": "Stealth"}') == "Stealth" + + def test_extract_roll_reason_missing(self): + assert _extract_roll_reason('{"notation": "1d20"}') is None + + +# --- Per-tool formatters ---------------------------------------------------- + + +def _format(tool: str, tool_json: str) -> str: + """Look up the formatter via DEFERRED_FORMATTERS and call it.""" + return DEFERRED_FORMATTERS[tool](tool_json) + + +class TestCoinNotification: + def test_spend_one_denomination(self): + result = _format("adjust_coins", '{"deltas": {"gp": -5}}') + assert result == "Spending 5 gold" + + def test_receive_one_denomination(self): + result = _format("adjust_coins", '{"deltas": {"gp": 10}}') + assert result == "Receiving 10 gold" + + def test_spend_and_receive(self): + result = _format("adjust_coins", '{"deltas": {"gp": -5, "sp": 3}}') + assert "Spending 5 gold" in result + assert "receiving 3 silver" in result + + def test_multiple_denominations_in_order(self): + result = _format("adjust_coins", '{"deltas": {"pp": 1, "gp": 2, "cp": 7}}') + # Output orders pp → gp → ep → sp → cp + assert result == "Receiving 1 platinum, 2 gold, 7 copper" + + def test_zero_delta_omitted(self): + result = _format("adjust_coins", '{"deltas": {"gp": -5, "sp": 0}}') + assert "silver" not in result + assert "Spending 5 gold" in result + + def test_no_deltas_at_all(self): + result = _format("adjust_coins", '{"deltas": {}}') + assert result == "Adjusting coins" + + def test_unknown_denomination_falls_back_to_code(self): + # Defensive: unknown denom uses the raw key + from storied.notification_formatters import _format_coin_notification + result = _format_coin_notification('{"deltas": {"xx": 5}}') + # _DENOM_NAMES doesn't include 'xx', so it falls through the loop + # without matching any of the known denoms — output is generic + assert result == "Adjusting coins" + + +class TestDamageNotification: + def test_with_target(self): + assert _format("damage", '{"target": "Goblin", "amount": 7}') == "Goblin takes 7 damage" + + def test_with_type(self): + assert _format("damage", '{"amount": 5, "type": "fire"}') == "Taking 5 fire damage" + + def test_plain(self): + assert _format("damage", '{"amount": 3}') == "Taking 3 damage" + + def test_target_and_type_target_wins(self): + result = _format("damage", '{"target": "Mira", "amount": 5, "type": "cold"}') + assert "Mira takes 5 damage" == result + + def test_missing_amount_shows_question_mark(self): + assert _format("damage", "{}") == "Taking ? damage" + + +class TestHealNotification: + def test_with_target(self): + assert _format("heal", '{"target": "Mira", "amount": 5}') == "Healing Mira for 5" + + def test_plain(self): + assert _format("heal", '{"amount": 8}') == "Healing 8 HP" + + +class TestEffectNotification: + def test_simple(self): + result = _format("add_effect", '{"source": "Bless", "description": "+1d4"}') + assert result == "Adding effect: Bless" + + def test_with_expires(self): + result = _format( + "add_effect", + '{"source": "Heroism", "description": "+10 temp HP", "expires": "d28-1430"}', + ) + assert "Heroism" in result + assert "until d28-1430" in result + + def test_remove_effect(self): + assert _format("remove_effect", '{"source": "Bless"}') == "Removing effect: Bless" + + +class TestConditionNotification: + def test_add_condition(self): + assert _format("add_condition", '{"name": "Poisoned"}') == "Becoming Poisoned" + + def test_remove_condition(self): + assert _format("remove_condition", '{"name": "Frightened"}') == "Recovering from Frightened" + + +class TestItemNotification: + def test_add_item_no_location(self): + assert _format("add_item", '{"item": "Lockpicks"}') == "Picking up 'Lockpicks'" + + def test_add_item_with_location(self): + result = _format("add_item", '{"item": "Coin pouch", "location": "on_person"}') + assert result == "Adding 'Coin pouch' to on_person" + + def test_remove_item(self): + assert _format("remove_item", '{"item": "Boot knife"}') == "Removing 'Boot knife'" + + @pytest.mark.parametrize( + "status,verb", + [ + ("attuned", "Attuning to"), + ("equipped", "Equipping"), + ("carried", "Stowing"), + ("unknown_status", "Setting status of"), + ], + ) + def test_set_item_status(self, status: str, verb: str): + result = _format( + "set_item_status", + f'{{"item": "Bracer", "status": "{status}"}}', + ) + assert result == f"{verb} Bracer" + + +class TestResourceNotification: + def test_use_one(self): + assert _format("use_resource", '{"name": "rage"}') == "Using rage" + + def test_use_multiple(self): + assert _format("use_resource", '{"name": "ki", "amount": 3}') == "Using 3 of ki" + + def test_restore(self): + assert _format("restore_resource", '{"name": "ki", "amount": 2}') == "Restoring 2 of ki" + + +class TestRestNotification: + def test_short(self): + assert _format("rest", '{"type": "short"}') == "Taking a short rest" + + def test_long(self): + assert _format("rest", '{"type": "long"}') == "Taking a long rest" + + def test_default_short(self): + assert _format("rest", "{}") == "Taking a short rest" + + +class TestNoteNotification: + def test_short_text(self): + assert _format("add_note", '{"text": "Found a key"}') == "Noting: Found a key" + + def test_long_text_truncated(self): + text = "A" * 80 + result = _format("add_note", f'{{"text": "{text}"}}') + assert result.endswith("...") + assert len(result) < len(text) + 20 + + +class TestUpdateCharacterNotification: + def test_no_updates(self): + assert _format("update_character", '{"updates": {}}') == "Updating character" + + def test_one_key(self): + result = _format("update_character", '{"updates": {"state.ac": 17}}') + assert result == "Updating: state.ac" + + def test_three_keys(self): + result = _format( + "update_character", + '{"updates": {"state.ac": 17, "state.speed": 30, "level": 4}}', + ) + assert "state.ac" in result + assert "state.speed" in result + assert "level" in result + assert "..." not in result + + def test_more_than_three_keys_truncates(self): + result = _format( + "update_character", + '{"updates": {"a": 1, "b": 2, "c": 3, "d": 4}}', + ) + assert result.endswith("...") + + +# --- Dispatch table sanity -------------------------------------------------- + + +class TestFormatterDispatchTable: + def test_all_formatters_handle_empty_input(self): + """Every registered formatter must produce a non-empty string for + empty / malformed input rather than crashing.""" + for tool_name, formatter in DEFERRED_FORMATTERS.items(): + result = formatter("") + assert isinstance(result, str) + assert result, f"{tool_name} produced empty label for empty input" + + def test_every_deferred_tool_has_a_label(self): + """TOOL_LABELS is the fallback when there's no formatter; every + formatter-keyed tool should also have a label entry so the renderer + can fall back gracefully.""" + for tool_name in DEFERRED_FORMATTERS: + assert tool_name in TOOL_LABELS, ( + f"deferred tool {tool_name} has no entry in TOOL_LABELS" + ) diff --git a/tests/test_notifications.py b/tests/test_notifications.py --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -56,3 +56,15 @@ notifications.append("new-world", tmp_path, "Hello") messages = notifications.drain("new-world", tmp_path) assert messages == ["Hello"] + + def test_drain_empty_existing_file_returns_empty( + self, world: tuple[str, Path], + ): + """An existing notifications file with only whitespace drains + as empty and is removed.""" + world_id, base_path = world + path = base_path / "worlds" / "test-world" / "dm_notifications.md" + path.write_text(" \n \n") + + assert notifications.drain(world_id, base_path) == [] + assert not path.exists() diff --git a/tests/test_planner.py b/tests/test_planner.py --- a/tests/test_planner.py +++ b/tests/test_planner.py @@ -1,51 +1,58 @@ """Tests for the world planner — entity richness scoring, discovery, and context.""" import json +from pathlib import Path from unittest.mock import MagicMock, patch import pytest from storied.planner import ( + BackgroundTicker, PlanResult, + _find_entities_with_will, build_planning_context, + build_tick_context, entity_richness, find_nearby_entities, plan_world, ) from storied.session import save_session -from storied.tools import ToolContext, establish, mark +from storied.tools import ToolContext +from storied.tools.entities import establish, mark + +from tests.conftest import call_tool @pytest.fixture def populated_world(ctx: ToolContext) -> ToolContext: """Create a world with some entities for testing.""" - establish( + call_tool( + establish, entity_type="locations", name="Town Square", - ctx=ctx, description="The center of [[Millford]]. A fountain stands here, surrounded by market stalls.", location="Central [[Millford]]", knows=["The fountain was built by [[Old Gregor]]"], wants=["To be a gathering place"], will=["If market day → attract crowds"], ) - establish( + call_tool( + establish, entity_type="npcs", name="Old Gregor", - ctx=ctx, description="An elderly stonemason.", location="[[Town Square]]", ) - establish( + call_tool( + establish, entity_type="locations", name="Millford", - ctx=ctx, description="A small riverside town.", ) - establish( + call_tool( + establish, entity_type="npcs", name="Thin NPC", - ctx=ctx, description="Someone with no inner life.", ) return ctx @@ -55,49 +62,45 @@ class TestEntityRichness: """Tests for richness scoring.""" def test_empty_entity_scores_zero(self, ctx: ToolContext): - establish( - entity_type="npcs", - name="Empty", - ctx=ctx, - ) + call_tool(establish, entity_type="npcs", name="Empty") path = ctx.base_path / "worlds/test-world/npcs/Empty.md" assert entity_richness(path) == 0.0 def test_description_only(self, ctx: ToolContext): - establish( + call_tool( + establish, entity_type="npcs", name="Described", - ctx=ctx, description="A tall warrior.", ) path = ctx.base_path / "worlds/test-world/npcs/Described.md" assert entity_richness(path) == pytest.approx(0.2) def test_fully_rich_entity(self, ctx: ToolContext): - establish( + call_tool( + establish, entity_type="npcs", name="Rich NPC", - ctx=ctx, description="A fully fleshed out character in [[Town Square]].", knows=["Secret one", "Secret two"], wants=["Goal one"], will=["If X → do Y"], ) - mark( + call_tool( + mark, entity_type="npcs", name="Rich NPC", event="Something happened", - ctx=ctx, ) path = ctx.base_path / "worlds/test-world/npcs/Rich NPC.md" score = entity_richness(path) assert score == pytest.approx(1.0) def test_partial_richness(self, ctx: ToolContext): - establish( + call_tool( + establish, entity_type="npcs", name="Partial", - ctx=ctx, description="Has description and knows.", knows=["A secret"], ) @@ -107,10 +110,10 @@ # description (0.2) + knows (0.2) = 0.4 assert score == pytest.approx(0.4) def test_wikilinks_contribute(self, ctx: ToolContext): - establish( + call_tool( + establish, entity_type="npcs", name="Linked", - ctx=ctx, description="Hangs out at [[The Tavern]] with [[Bob]].", ) path = ctx.base_path / "worlds/test-world/npcs/Linked.md" @@ -419,3 +422,149 @@ model="claude-opus-4-6", ) assert result.tool_calls == 1 + + +# --- World tick helpers (subprocess-bound tick_world is pragma'd out) ------ + + +class TestFindEntitiesWithWill: + def test_returns_empty_when_world_missing(self, ctx: ToolContext, tmp_path: Path): + results = _find_entities_with_will("nonexistent", tmp_path) + assert results == [] + + def test_finds_entities_with_will_triggers(self, populated_world: ToolContext): + # Establish one with will, one without + call_tool( + establish, + entity_type="npcs", + name="Triggered", + description="Has triggers.", + will=["If approached → flee"], + ) + call_tool( + establish, + entity_type="npcs", + name="Idle", + description="No triggers.", + ) + results = _find_entities_with_will( + populated_world.world_id, populated_world.base_path, + ) + names = {name for name, _ in results} + assert "Triggered" in names + assert "Idle" not in names + + def test_skips_missing_type_directories( + self, populated_world: ToolContext, + ): + # The fixture creates npcs and locations but not items/factions/threads. + # The function should iterate without crashing. + results = _find_entities_with_will( + populated_world.world_id, populated_world.base_path, + ) + assert isinstance(results, list) + + +class TestBuildTickContext: + def test_includes_current_time(self, populated_world: ToolContext): + ctx_str = build_tick_context( + populated_world.world_id, populated_world.player_id, + populated_world.base_path, entities=[], + ) + assert "Current Game Time" in ctx_str + + def test_includes_session_location(self, populated_world: ToolContext): + save_session("default", { + "location": "Town Square", + "body": "## Present\n- [[Old Gregor]]", + }, populated_world.base_path) + + ctx_str = build_tick_context( + populated_world.world_id, populated_world.player_id, + populated_world.base_path, entities=[], + ) + assert "Town Square" in ctx_str + assert "Old Gregor" in ctx_str + + def test_includes_recent_events(self, populated_world: ToolContext): + populated_world.campaign_log.append_entry( + "Met the merchant", "10 min", + ) + populated_world.campaign_log.append_entry( + "Found the secret door", "5 min", + ) + ctx_str = build_tick_context( + populated_world.world_id, populated_world.player_id, + populated_world.base_path, entities=[], + ) + assert "Recent Events" in ctx_str + assert "Met the merchant" in ctx_str + + def test_includes_entity_with_triggers(self, populated_world: ToolContext): + call_tool( + establish, + entity_type="npcs", + name="Lurker", + description="Hides in shadows.", + will=["If alone → emerge"], + ) + triggers = _find_entities_with_will( + populated_world.world_id, populated_world.base_path, + ) + ctx_str = build_tick_context( + populated_world.world_id, populated_world.player_id, + populated_world.base_path, entities=triggers, + ) + assert "Active Triggers" in ctx_str + assert "Lurker" in ctx_str + assert "If alone → emerge" in ctx_str + + +class TestBackgroundTicker: + """Cover the non-subprocess paths of BackgroundTicker. + + The threaded `_run` method is pragma'd because it spawns a real claude + subprocess. Everything around it (init, day-tracking, no-op fast paths) + is testable here. + """ + + def test_init_stores_state(self, tmp_path: Path): + ticker = BackgroundTicker( + world_id="test", player_id="default", base_path=tmp_path, + ) + assert ticker._world_id == "test" + assert ticker._last_tick_day == 0 + assert ticker._thread is None + + def test_maybe_tick_skips_when_day_unchanged( + self, populated_world: ToolContext, + ): + ticker = BackgroundTicker( + world_id=populated_world.world_id, + player_id=populated_world.player_id, + base_path=populated_world.base_path, + ) + ticker._last_tick_day = populated_world.campaign_log.current_day + ticker.maybe_tick(populated_world.campaign_log) + # No thread should have been spawned + assert ticker._thread is None + + def test_maybe_tick_skips_when_no_triggers(self, ctx: ToolContext): + # ctx has a fresh empty world with no entities → no triggers, + # so maybe_tick should mark the day done without spawning anything. + ticker = BackgroundTicker( + world_id=ctx.world_id, + player_id=ctx.player_id, + base_path=ctx.base_path, + ) + # Advance the day so the day-changed guard passes + ctx.campaign_log.append_entry("Travel", "18 hours") + ticker.maybe_tick(ctx.campaign_log) + assert ticker._thread is None + assert ticker._last_tick_day == ctx.campaign_log.current_day + + def test_pop_result_returns_none_when_no_thread(self, tmp_path: Path): + ticker = BackgroundTicker( + world_id="test", player_id="default", base_path=tmp_path, + ) + assert ticker.pop_result() is None diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -120,27 +120,26 @@ assert "NotImplementedError" in result class TestToolBridge: - """DM tools exposed as direct host functions.""" + """DM tools exposed as direct host functions. + + The sandbox resolves Dependency parameters from the process-global + ToolContext (set up by the `ctx` fixture), so callers don't have to + pass `ctx` explicitly anymore. + """ def test_recall(self, ctx: ToolContext): - result = execute("recall(query='nonexistent')", ctx=ctx) + result = execute("recall(query='nonexistent')") assert "Nothing found" in result def test_establish(self, ctx: ToolContext): result = execute( "establish(entity_type='npc', name='Bob the Baker', " - "description='A friendly baker')", - ctx=ctx, + "description='A friendly baker')" ) assert "Bob" in result - def test_tools_not_available_without_ctx(self): - result = execute("recall(query='test')") - - assert "Error" in result - class TestToolSignatures: """Dynamic signature builder for the run_code tool description.""" @@ -152,8 +151,12 @@ for name in ["roll", "recall", "establish", "mark", "damage", "heal", "enter_initiative", "end_initiative", "next_turn"]: assert f"{name}(" in sigs - def test_excludes_internal_params(self): + def test_excludes_dependency_params(self): + """Dependency-default params are wrapper bookkeeping; the LLM-facing + signature should drop them all.""" sigs = build_tool_signatures() - assert "ctx:" not in sigs - assert "tracker:" not in sigs + # None of the Dependency-class instances should appear as defaults + for marker in ("Combat()", "Lore()", "StorageRoot()", "Player()", + "Timekeeper()", "Entities()", "World()"): + assert marker not in sigs diff --git a/tests/test_seeder.py b/tests/test_seeder.py --- a/tests/test_seeder.py +++ b/tests/test_seeder.py @@ -1,5 +1,6 @@ """Tests for world seeding — empty world detection and initial worldbuilding.""" +import asyncio import json from pathlib import Path from unittest.mock import MagicMock, patch @@ -7,52 +8,27 @@ import pytest from storied.character import create_character +from storied.mcp_server import _compose_server from storied.planner import SeedResult, seed_world -from storied.tools import ( - SEEDER_TOOL_DEFINITIONS, - SEEDER_TOOLS, - ToolContext, - seeder_execute_tool, -) +from storied.tools import ToolContext class TestSeederTools: - """Tests for seeder tool filtering.""" + """Tests for seeder tool filtering via tag-based composition.""" - def test_seeder_tools_set(self): - assert SEEDER_TOOLS == {"establish", "set_scene"} - - def test_seeder_tool_definitions_filtered(self): - names = {t["name"] for t in SEEDER_TOOL_DEFINITIONS} - assert names == SEEDER_TOOLS - - def test_seeder_rejects_disallowed_tools(self, ctx: ToolContext): - for tool_name in ["roll", "recall", "mark", "mark_time", "note_discovery", "end_session"]: - result = seeder_execute_tool(tool_name, {}, ctx) - assert "not available to seeder" in result + def _seeder_names(self) -> set[str]: + async def _gather() -> set[str]: + server = await _compose_server("seeder") + return {t.name for t in await server.list_tools()} + return asyncio.run(_gather()) - def test_seeder_allows_establish(self, ctx: ToolContext): - (ctx.base_path / "worlds" / ctx.world_id / "npcs").mkdir(parents=True, exist_ok=True) - result = seeder_execute_tool( - "establish", - {"entity_type": "npcs", "name": "Test NPC", "description": "A test."}, - ctx, - ) - assert "Established" in result + def test_seeder_only_has_establish_and_set_scene(self): + assert self._seeder_names() == {"establish", "set_scene"} - def test_seeder_allows_set_scene(self, ctx: ToolContext): - (ctx.base_path / "players" / ctx.player_id).mkdir(parents=True) - result = seeder_execute_tool( - "set_scene", - { - "event": "Dawn breaks", - "duration": "0 min", - "situation": "Walking through the forest.", - }, - ctx, - ) - assert "Logged" in result - assert "Session updated" in result + def test_seeder_excludes_disallowed_tools(self): + names = self._seeder_names() + for forbidden in ("roll", "recall", "mark", "note_discovery", "end_session"): + assert forbidden not in names @pytest.fixture diff --git a/tests/test_tune.py b/tests/test_tune.py --- a/tests/test_tune.py +++ b/tests/test_tune.py @@ -1,49 +1,45 @@ """Tests for the DM style tuning system.""" -from storied.tools import ToolContext, execute_tool, tune +from storied.tools import ToolContext +from storied.tools.scene import tune as _tune + +from tests.conftest import call_tool + + +def tune(tuning: str) -> str: + return call_tool(_tune, tuning=tuning) class TestTune: """Tests for the tune tool.""" def test_tune_creates_style_file(self, ctx: ToolContext): - tune("Lean into intrigue and social encounters.", ctx) + tune("Lean into intrigue and social encounters.") style_path = ctx.base_path / "worlds" / ctx.world_id / "style.md" assert style_path.exists() def test_tune_writes_content(self, ctx: ToolContext): - tune("More exploration, less combat.", ctx) + tune("More exploration, less combat.") content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() assert "More exploration, less combat." in content def test_tune_has_heading(self, ctx: ToolContext): - tune("Keep pacing slow.", ctx) + tune("Keep pacing slow.") content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() assert content.startswith("# Style\n") def test_tune_replaces_existing(self, ctx: ToolContext): - tune("Lots of combat.", ctx) - tune("Actually, less combat.", ctx) + tune("Lots of combat.") + tune("Actually, less combat.") content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() assert "Actually, less combat." in content assert "Lots of combat." not in content def test_tune_returns_confirmation(self, ctx: ToolContext): - result = tune("Dark and atmospheric tone.", ctx) - - assert "updated" in result.lower() - - -class TestExecuteToolTune: - """Tests for tune dispatch through execute_tool.""" - - def test_execute_tune(self, ctx: ToolContext): - result = execute_tool("tune", {"tuning": "More social encounters."}, ctx) + result = tune("Dark and atmospheric tone.") assert "updated" in result.lower() - content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() - assert "More social encounters." in content diff --git a/uv.lock b/uv.lock --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,18 @@ "python_full_version < '3.13'", ] [[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] name = "annotated-doc" version = "0.0.4" source = { registry = "https://pypi.org/simple" } @@ -54,6 +66,57 @@ source = { registry = "https://pypi.org/simple" } 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" } wheels = [ { 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" }, +] + +[[package]] +name = "authlib" +version = "1.6.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "cachetools" +version = "7.0.5" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, ] [[package]] @@ -344,6 +407,73 @@ { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, ] [[package]] +name = "cyclopts" +version = "4.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] name = "fastembed" version = "0.8.0" source = { registry = "https://pypi.org/simple" } @@ -365,6 +495,38 @@ { url = "https://files.pythonhosted.org/packages/2a/e8/26b7d78bb8972498c467ca34cb12ee2e60d26ba5eae6d8443189a1af37a5/fastembed-0.8.0-py3-none-any.whl", hash = "sha256:40bee672657574a1009e35ec50030a55f2b426842cb011845379817641bbbbd0", size = 116572, upload-time = "2026-03-23T16:34:40.69Z" }, ] [[package]] +name = "fastmcp" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "pydantic", extra = ["email"] }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "uncalled-for" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] name = "filelock" version = "3.25.2" source = { registry = "https://pypi.org/simple" } @@ -498,6 +660,18 @@ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } @@ -507,6 +681,57 @@ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] name = "jsonschema" version = "4.26.0" source = { registry = "https://pypi.org/simple" } @@ -522,6 +747,20 @@ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] [[package]] +name = "jsonschema-path" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] name = "jsonschema-specifications" version = "2025.9.1" source = { registry = "https://pypi.org/simple" } @@ -531,6 +770,23 @@ ] 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" } wheels = [ { 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" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +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" } +wheels = [ + { 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" }, ] [[package]] @@ -727,6 +983,15 @@ { url = "https://files.pythonhosted.org/packages/a0/0f/59204bf136d1201f8d7884cfbaf7498c5b4674e87a4c693f9bde63741ce1/mmh3-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b", size = 40391, upload-time = "2026-03-05T15:55:56.697Z" }, ] [[package]] +name = "more-itertools" +version = "11.0.2" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] name = "mpmath" version = "1.3.0" source = { registry = "https://pypi.org/simple" } @@ -872,12 +1137,46 @@ { url = "https://files.pythonhosted.org/packages/6c/1d/1666dc64e78d8587d168fec4e3b7922b92eb286a2ddeebcf6acb55c7dc82/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731", size = 17247021, upload-time = "2026-03-17T22:04:52.377Z" }, ] [[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } 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" } wheels = [ { 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" }, +] + +[[package]] +name = "pathable" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, ] [[package]] @@ -959,6 +1258,15 @@ { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, ] [[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } @@ -980,6 +1288,31 @@ { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, { 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" }, { 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" }, { 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" }, +] + +[[package]] +name = "py-key-value-aio" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +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" } +wheels = [ + { 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" }, +] + +[package.optional-dependencies] +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, ] [[package]] @@ -1034,6 +1367,11 @@ wheels = [ { 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" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.41.5" @@ -1214,6 +1552,15 @@ { url = "https://files.pythonhosted.org/packages/c0/1e/5fae75a5dc478e376ab95253c2f611665a4d9e2249667387a975e00fbbcb/pymupdf4llm-0.2.7-py3-none-any.whl", hash = "sha256:3ac6b0344c8bade2c97c3d7ea5eb354c71383a8d1ca177fafc3519dd564273b7", size = 66905, upload-time = "2025-12-07T20:43:12.447Z" }, ] [[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] name = "pysqlite3-binary" version = "0.5.4.post2" source = { registry = "https://pypi.org/simple" } @@ -1288,6 +1635,15 @@ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] [[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } @@ -1373,6 +1729,19 @@ ] 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" } wheels = [ { 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" }, +] + +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +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" } +wheels = [ + { 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" }, ] [[package]] @@ -1483,6 +1852,19 @@ { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, ] [[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] name = "shellingham" version = "1.5.4" source = { registry = "https://pypi.org/simple" } @@ -1536,8 +1918,8 @@ source = { editable = "." } dependencies = [ { name = "argcomplete" }, { name = "fastembed" }, + { name = "fastmcp" }, { name = "httpx" }, - { name = "mcp" }, { name = "pydantic-monty" }, { name = "pymupdf" }, { name = "pymupdf4llm" }, @@ -1545,6 +1927,7 @@ { name = "pysqlite3-binary" }, { name = "pyyaml" }, { name = "rich" }, { name = "sqlite-vec" }, + { name = "uncalled-for" }, ] [package.optional-dependencies] @@ -1555,12 +1938,20 @@ { name = "pytest-cov" }, { name = "ruff" }, ] +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "argcomplete", specifier = ">=3.0" }, { name = "fastembed", specifier = ">=0.4" }, + { name = "fastmcp", specifier = ">=3.2" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", specifier = ">=1.9" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0" }, { name = "pydantic-monty", specifier = ">=0.0.9" }, { name = "pymupdf", specifier = ">=1.24" }, @@ -1572,9 +1963,18 @@ { name = "pyyaml", specifier = ">=6.0" }, { name = "rich", specifier = ">=13.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1" }, { name = "sqlite-vec", specifier = ">=0.1" }, + { name = "uncalled-for", specifier = ">=0.1" }, ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.19.1" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.14.10" }, +] + [[package]] name = "sympy" version = "1.14.0" @@ -1671,6 +2071,15 @@ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] +name = "uncalled-for" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] name = "urllib3" version = "2.6.3" source = { registry = "https://pypi.org/simple" } @@ -1693,6 +2102,121 @@ { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] [[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +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" } +wheels = [ + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, +] + +[[package]] name = "win32-setctime" version = "1.2.0" source = { registry = "https://pypi.org/simple" } @@ -1700,3 +2224,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } wheels = [ { 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" }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +]