A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Add sandboxed code execution for the DM via Pydantic Monty

The DM had no way to compute anything — treasure splits, random generation,
travel time calculations all happened in its head. Now there's a `run_code`
tool backed by Pydantic's Monty sandbox (Rust-based, no filesystem/network
access) that lets the DM write and execute Python on the fly.

The interesting part: all of the DM's existing tools are exposed as host
functions inside the sandbox. Code can `recall("fireball")`, `establish()`
NPCs, `roll("2d6+3")` for math, etc. The tool description includes
dynamically-generated Python function signatures (via `inspect.signature`)
so the DM knows exactly what's callable, built the same way docketeer does
it with runtime-formatted docstrings.

The DM provides a `description` field ("Designing cave system", "Splitting
treasure") that shows in the terminal notification instead of a generic
"Running code" label.

Also extracted initiative tools into real Python functions with typed
signatures — they were inlined in a dispatcher before, which meant
`inspect.signature` couldn't reach them. Now `next_turn`, `damage`, `heal`,
`condition`, etc. are all standalone functions with docstrings wired to the
MCP tool definitions.

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

+479 -45
+1
prompts/dm-system.md
··· 22 22 | `tune` | Update your style/personality tuning based on player feedback | 23 23 | `end_session` | Gracefully end the session | 24 24 | `enter_initiative` | Enter initiative mode for combat or turn-based encounters | 25 + | `run_code` | Run Python in a sandbox — all your tools available as functions | 25 26 26 27 ### Initiative Mode (during initiative) 27 28 | Tool | Purpose |
+1
pyproject.toml
··· 14 14 "fastembed>=0.4", 15 15 "httpx>=0.27", 16 16 "mcp>=1.9", 17 + "pydantic-monty>=0.0.9", 17 18 "pymupdf>=1.24", 18 19 "pymupdf4llm>=0.0.17", 19 20 "pysqlite3-binary>=0.5",
+14 -4
src/storied/engine.py
··· 36 36 return path.read_text() 37 37 38 38 39 - def _extract_roll_reason(tool_json: str) -> str | None: 40 - """Extract the reason field from accumulated roll tool JSON.""" 39 + def _extract_json_field(tool_json: str, field: str) -> str | None: 40 + """Extract a named field from accumulated tool JSON.""" 41 41 try: 42 42 args = json.loads(tool_json) 43 - return args.get("reason") 43 + return args.get(field) 44 44 except (json.JSONDecodeError, AttributeError): 45 45 return None 46 + 47 + 48 + def _extract_roll_reason(tool_json: str) -> str | None: 49 + """Extract the reason field from accumulated roll tool JSON.""" 50 + return _extract_json_field(tool_json, "reason") 46 51 47 52 48 53 def _tool_notification(name: str) -> str: ··· 71 76 "heal": "Healing", 72 77 "condition": "Updating condition", 73 78 "end_initiative": "Ending initiative", 79 + "run_code": "Running code", 74 80 } 75 81 label = labels.get(short, short) 76 82 return f"[{label}...]" ··· 432 438 if short == "create_character": 433 439 self.character_created = True 434 440 435 - if short == "roll" and not self.debug: 441 + if short in ("roll", "run_code") and not self.debug: 436 442 deferred_notification = True 437 443 elif self.debug: 438 444 yield f"[→ {short}(...)]" ··· 448 454 if deferred_notification and current_tool_name == "roll": 449 455 reason = _extract_roll_reason(current_tool_json) 450 456 label = f"Rolling {reason}" if reason else "Rolling" 457 + yield f"[{label}...]" 458 + elif deferred_notification and current_tool_name == "run_code": 459 + desc = _extract_json_field(current_tool_json, "description") 460 + label = desc if desc else "Running code" 451 461 yield f"[{label}...]" 452 462 453 463 if self.debug and current_tool_json:
+90 -40
src/storied/initiative.py
··· 353 353 354 354 # --- Tool functions (thin wrappers around tracker methods) --- 355 355 356 - def enter_initiative(combatants_raw: list[dict], tracker: InitiativeTracker) -> str: 357 - """Enter initiative mode with pre-sorted combatants.""" 356 + 357 + def enter_initiative( 358 + combatants_raw: list[dict], tracker: InitiativeTracker, 359 + ) -> str: 360 + """Enter initiative mode for combat or any turn-based encounter. 361 + 362 + Provide all participants in their desired turn order (you handle 363 + tie-breaking). Roll initiative for everyone first, then call this. 364 + Initiative tools become available on the next turn. 365 + """ 358 366 if tracker.active: 359 367 return "Initiative is already active. Call end_initiative first." 360 368 ··· 372 380 return tracker.begin(combatants) 373 381 374 382 383 + def next_turn(tracker: InitiativeTracker) -> str: 384 + """Advance to the next combatant's turn. Skips defeated. Call after resolving actions.""" 385 + return tracker.next_turn() 386 + 387 + 388 + def add_combatant( 389 + tracker: InitiativeTracker, 390 + name: str, 391 + initiative: int, 392 + hp: int, 393 + hp_max: int, 394 + ac: int, 395 + is_player: bool = False, 396 + ) -> str: 397 + """Add a combatant (reinforcements, surprised creatures waking up).""" 398 + c = Combatant( 399 + name=name, initiative=initiative, hp=hp, 400 + hp_max=hp_max, ac=ac, is_player=is_player, 401 + ) 402 + return tracker.add_combatant(c) 403 + 404 + 405 + def remove_combatant(tracker: InitiativeTracker, name: str) -> str: 406 + """Remove a combatant who fled, was banished, or is otherwise out.""" 407 + return tracker.remove_combatant(name) 408 + 409 + 410 + def damage(tracker: InitiativeTracker, target: str, amount: int) -> str: 411 + """Deal damage. Tracks defeat at 0 HP, reports Bloodied at half. Auto-syncs player character sheet.""" 412 + return tracker.apply_damage(target, amount) 413 + 414 + 415 + def heal(tracker: InitiativeTracker, target: str, amount: int) -> str: 416 + """Heal a combatant. Clamped to max HP. Revives defeated. Auto-syncs player character sheet.""" 417 + return tracker.apply_heal(target, amount) 418 + 419 + 420 + def condition( 421 + tracker: InitiativeTracker, 422 + target: str, 423 + condition_name: str, 424 + action: str = "add", 425 + duration: int = -1, 426 + ends_on: str = "start", 427 + source: str = "", 428 + ) -> str: 429 + """Add or remove a condition. Duration counts down on the source's turn. Duration -1 = until manually removed.""" 430 + if action == "remove": 431 + return tracker.remove_condition(target, condition_name) 432 + return tracker.add_condition( 433 + target=target, condition=condition_name, 434 + duration=duration, ends_on=ends_on, source=source, 435 + ) 436 + 437 + 438 + def end_initiative(tracker: InitiativeTracker) -> str: 439 + """End initiative and return to narrative. Returns summary with rounds, defeated, and survivor HP.""" 440 + return tracker.end() 441 + 442 + 375 443 def execute_initiative_tool( 376 444 tool_name: str, tool_input: dict, tracker: InitiativeTracker, 377 445 ) -> str | None: ··· 386 454 return "Initiative is not active. Call enter_initiative first." 387 455 388 456 if tool_name == "next_turn": 389 - return tracker.next_turn() 457 + return next_turn(tracker) 390 458 elif tool_name == "add_combatant": 391 - c = Combatant( 392 - name=tool_input["name"], 393 - initiative=tool_input["initiative"], 394 - hp=tool_input["hp"], 395 - hp_max=tool_input["hp_max"], 396 - ac=tool_input["ac"], 397 - is_player=tool_input.get("is_player", False), 459 + return add_combatant( 460 + tracker, tool_input["name"], tool_input["initiative"], 461 + tool_input["hp"], tool_input["hp_max"], tool_input["ac"], 462 + tool_input.get("is_player", False), 398 463 ) 399 - return tracker.add_combatant(c) 400 464 elif tool_name == "remove_combatant": 401 - return tracker.remove_combatant(tool_input["name"]) 465 + return remove_combatant(tracker, tool_input["name"]) 402 466 elif tool_name == "damage": 403 - return tracker.apply_damage(tool_input["target"], tool_input["amount"]) 467 + return damage(tracker, tool_input["target"], tool_input["amount"]) 404 468 elif tool_name == "heal": 405 - return tracker.apply_heal(tool_input["target"], tool_input["amount"]) 469 + return heal(tracker, tool_input["target"], tool_input["amount"]) 406 470 elif tool_name == "condition": 407 - action = tool_input.get("action", "add") 408 - if action == "remove": 409 - return tracker.remove_condition( 410 - tool_input["target"], tool_input["condition"], 411 - ) 412 - return tracker.add_condition( 413 - target=tool_input["target"], 414 - condition=tool_input["condition"], 471 + return condition( 472 + tracker, tool_input["target"], tool_input["condition"], 473 + action=tool_input.get("action", "add"), 415 474 duration=tool_input.get("duration", -1), 416 475 ends_on=tool_input.get("ends_on", "start"), 417 476 source=tool_input.get("source", ""), 418 477 ) 419 478 elif tool_name == "end_initiative": 420 - return tracker.end() 479 + return end_initiative(tracker) 421 480 422 481 return None 423 482 ··· 447 506 448 507 ENTER_INITIATIVE_DEFINITION: dict = { 449 508 "name": "enter_initiative", 450 - "description": ( 451 - "Enter initiative mode for combat or any turn-based encounter. " 452 - "Provide all participants in their desired turn order (you handle " 453 - "tie-breaking). Roll initiative for everyone first, then call this. " 454 - "Initiative tools become available on the next turn." 455 - ), 509 + "description": enter_initiative.__doc__, 456 510 "input_schema": { 457 511 "type": "object", 458 512 "properties": { ··· 472 526 473 527 COMBAT_TOOL_DEFINITIONS: list[dict] = [ 474 528 {"name": "next_turn", 475 - "description": "Advance to the next combatant's turn. Skips defeated. Call after resolving actions.", 529 + "description": next_turn.__doc__, 476 530 "input_schema": _NO_INPUT}, 477 531 {"name": "add_combatant", 478 - "description": "Add a combatant (reinforcements, surprised creatures waking up).", 532 + "description": add_combatant.__doc__, 479 533 "input_schema": { 480 534 "type": "object", "properties": _COMBATANT_PROPS, 481 535 "required": _COMBATANT_REQUIRED}}, 482 536 {"name": "remove_combatant", 483 - "description": "Remove a combatant who fled, was banished, or is otherwise out.", 537 + "description": remove_combatant.__doc__, 484 538 "input_schema": { 485 539 "type": "object", 486 540 "properties": {"name": {"type": "string", "description": "Combatant name"}}, 487 541 "required": ["name"]}}, 488 542 {"name": "damage", 489 - "description": "Deal damage. Tracks defeat at 0 HP, reports Bloodied at half. Auto-syncs player character sheet.", 543 + "description": damage.__doc__, 490 544 "input_schema": _TARGET_AMOUNT_SCHEMA}, 491 545 {"name": "heal", 492 - "description": "Heal a combatant. Clamped to max HP. Revives defeated. Auto-syncs player character sheet.", 546 + "description": heal.__doc__, 493 547 "input_schema": _TARGET_AMOUNT_SCHEMA}, 494 548 {"name": "condition", 495 - "description": ( 496 - "Add or remove a condition. For timed effects, duration counts down " 497 - "on the source's turn. Duration -1 = until manually removed."), 549 + "description": condition.__doc__, 498 550 "input_schema": { 499 551 "type": "object", 500 552 "properties": { ··· 508 560 }, 509 561 "required": ["target", "condition"]}}, 510 562 {"name": "end_initiative", 511 - "description": ( 512 - "End initiative and return to narrative. Returns summary with rounds, " 513 - "defeated, and survivor HP. Use the duration in your set_scene call."), 563 + "description": end_initiative.__doc__, 514 564 "input_schema": _NO_INPUT}, 515 565 ] 516 566
+10 -1
src/storied/mcp_server.py
··· 24 24 ENTER_INITIATIVE_DEFINITION, 25 25 INITIATIVE_KEEP_NARRATIVE, 26 26 ) 27 + from storied.sandbox import build_tool_signatures 27 28 from storied.tools import ( 28 29 EntityIndex, 29 30 PLANNER_TOOL_DEFINITIONS, ··· 34 35 planner_execute_tool, 35 36 seeder_execute_tool, 36 37 ) 38 + 39 + _tool_signatures: str | None = None 37 40 38 41 TOOL_SETS: dict[str, list[dict]] = { 39 42 "planner": PLANNER_TOOL_DEFINITIONS, ··· 57 60 58 61 def _to_mcp_tool(defn: dict) -> Tool: 59 62 """Convert an Anthropic-format tool definition to an MCP Tool.""" 63 + global _tool_signatures 64 + desc = defn.get("description", "") 65 + if "{tool_signatures}" in desc: 66 + if _tool_signatures is None: 67 + _tool_signatures = build_tool_signatures() 68 + desc = desc.replace("{tool_signatures}", _tool_signatures) 60 69 return Tool( 61 70 name=defn["name"], 62 - description=defn.get("description", ""), 71 + description=desc, 63 72 inputSchema=defn["input_schema"], 64 73 ) 65 74
+136
src/storied/sandbox.py
··· 1 + """Sandboxed Python execution via Pydantic Monty. 2 + 3 + Provides an `execute` function that runs arbitrary Python code in a secure 4 + Rust-based sandbox with no filesystem, network, or environment access. 5 + All DM tools are available as host functions when a ToolContext is provided. 6 + """ 7 + 8 + from __future__ import annotations 9 + 10 + import inspect 11 + from collections.abc import Callable 12 + from typing import TYPE_CHECKING, Any 13 + 14 + import pydantic_monty 15 + 16 + from storied.dice import roll as dice_roll 17 + 18 + if TYPE_CHECKING: 19 + from storied.tools import ToolContext 20 + 21 + _LIMITS = pydantic_monty.ResourceLimits( 22 + max_duration_secs=5.0, 23 + max_memory=10 * 1_024 * 1_024, # 10 MB 24 + ) 25 + 26 + # Functions whose signatures we can read with inspect 27 + _TOOL_FUNCTIONS: dict[str, str] = {} 28 + 29 + 30 + def _roll_host(notation: str) -> dict[str, Any]: 31 + """Host function: roll dice and return the result dict.""" 32 + return dice_roll(notation).to_dict() 33 + 34 + 35 + def _build_host_functions( 36 + ctx: ToolContext | None, 37 + extra: dict[str, Callable[..., Any]] | None, 38 + ) -> dict[str, Callable[..., Any]]: 39 + """Build the full set of host functions for the sandbox.""" 40 + fns: dict[str, Callable[..., Any]] = {"roll": _roll_host} 41 + 42 + if ctx is not None: 43 + from storied.tools import TOOL_DEFINITIONS, execute_tool 44 + from storied.initiative import ( 45 + COMBAT_TOOL_DEFINITIONS, 46 + ENTER_INITIATIVE_DEFINITION, 47 + ) 48 + 49 + all_defs = TOOL_DEFINITIONS + COMBAT_TOOL_DEFINITIONS + [ENTER_INITIATIVE_DEFINITION] 50 + for defn in all_defs: 51 + name = defn["name"] 52 + if name in ("roll", "run_code"): 53 + continue 54 + 55 + def _make(tool_name: str) -> Callable[..., str]: 56 + def fn(**kwargs: Any) -> str: 57 + return execute_tool(tool_name, kwargs, ctx) 58 + return fn 59 + 60 + fns[name] = _make(name) 61 + 62 + if extra: 63 + fns.update(extra) 64 + 65 + return fns 66 + 67 + 68 + def build_tool_signatures() -> str: 69 + """Build human-readable function signatures for all DM tools.""" 70 + from storied.tools import ( 71 + roll, recall, establish, mark, note_discovery, 72 + set_scene, update_character, create_character, tune, end_session, 73 + ) 74 + from storied.initiative import ( 75 + enter_initiative, next_turn, add_combatant, remove_combatant, 76 + damage, heal, condition, end_initiative, 77 + ) 78 + 79 + all_fns = [ 80 + roll, recall, establish, mark, note_discovery, set_scene, 81 + update_character, create_character, tune, end_session, 82 + enter_initiative, next_turn, add_combatant, remove_combatant, 83 + damage, heal, condition, end_initiative, 84 + ] 85 + 86 + lines: list[str] = [] 87 + for fn in all_fns: 88 + sig = inspect.signature(fn) 89 + params = [p for p in sig.parameters.values() if p.name not in ("ctx", "tracker")] 90 + param_str = ", ".join(str(p) for p in params) 91 + lines.append(f"{fn.__name__}({param_str})") 92 + 93 + return "\n".join(lines) 94 + 95 + 96 + def execute( 97 + code: str, 98 + ctx: ToolContext | None = None, 99 + host_functions: dict[str, Callable[..., Any]] | None = None, 100 + ) -> str: 101 + """Run Python code in a sandboxed environment and return the output. 102 + 103 + When ctx is provided, every DM tool is available as a host function: 104 + recall(query="fireball", scope="rules") 105 + establish(entity_type="npc", name="Bob", description="A baker") 106 + roll("2d6+3") # returns dict with total, rolls, etc. 107 + 108 + Returns a string combining any printed output and the final expression 109 + value. Errors are returned as formatted strings, never raised. 110 + """ 111 + if not code.strip(): 112 + return "" 113 + 114 + fns = _build_host_functions(ctx, host_functions) 115 + 116 + output_lines: list[str] = [] 117 + 118 + def _capture(_stream: str, text: str) -> None: 119 + output_lines.append(text) 120 + 121 + try: 122 + m = pydantic_monty.Monty(code) 123 + result = m.run( 124 + external_functions=fns, 125 + limits=_LIMITS, 126 + print_callback=_capture, 127 + ) 128 + except (pydantic_monty.MontyRuntimeError, pydantic_monty.MontySyntaxError) as e: 129 + return str(e) 130 + 131 + printed = "".join(output_lines).rstrip("\n") 132 + if result is not None: 133 + if printed: 134 + return f"{printed}\n{result}" 135 + return str(result) 136 + return printed
+32
src/storied/tools.py
··· 989 989 "required": ["situation"], 990 990 }, 991 991 }, 992 + { 993 + "name": "run_code", 994 + "description": ( 995 + "Run Python code in a secure sandbox. Use for calculations, random " 996 + "generation, data formatting, or any computation the narrative needs. " 997 + "All your tools are available as functions: recall(query=...), " 998 + "establish(entity_type=..., name=...), roll('2d6+3'), etc. " 999 + "No file/network access. Supported: variables, functions, loops, " 1000 + "conditionals, comprehensions, f-strings, plus re, json, datetime, " 1001 + "math, random from the standard library. No classes or imports beyond " 1002 + "builtins. Errors are returned as text — recover gracefully.\n\n" 1003 + "Available functions:\n{tool_signatures}" 1004 + ), 1005 + "input_schema": { 1006 + "type": "object", 1007 + "properties": { 1008 + "description": { 1009 + "type": "string", 1010 + "description": "What this code does in game terms (e.g., 'Designing cave system', 'Splitting treasure', 'Generating NPC schedule')", 1011 + }, 1012 + "code": { 1013 + "type": "string", 1014 + "description": "Python code to execute", 1015 + }, 1016 + }, 1017 + "required": ["description", "code"], 1018 + }, 1019 + }, 992 1020 ] 993 1021 994 1022 ··· 1092 1120 ctx=ctx, 1093 1121 threads=tool_input.get("threads"), 1094 1122 ) 1123 + 1124 + elif tool_name == "run_code": 1125 + from storied.sandbox import execute as sandbox_execute 1126 + return sandbox_execute(tool_input["code"], ctx=ctx) 1095 1127 1096 1128 else: 1097 1129 return f"Unknown tool: {tool_name}"
+149
tests/test_sandbox.py
··· 1 + """Tests for sandboxed code execution via Monty.""" 2 + 3 + from storied.sandbox import build_tool_signatures, execute 4 + from storied.tools import ToolContext 5 + 6 + 7 + class TestBasicExecution: 8 + """Core execution: expressions, statements, print output.""" 9 + 10 + def test_simple_expression(self): 11 + result = execute("1 + 2") 12 + 13 + assert "3" in result 14 + 15 + def test_multi_line_script(self): 16 + result = execute("x = 10\ny = 20\nx + y") 17 + 18 + assert "30" in result 19 + 20 + def test_print_captured(self): 21 + result = execute('print("hello")\nprint("world")') 22 + 23 + assert "hello" in result 24 + assert "world" in result 25 + 26 + def test_print_and_expression(self): 27 + result = execute('print("debug")\n42') 28 + 29 + assert "debug" in result 30 + assert "42" in result 31 + 32 + def test_no_return_value(self): 33 + result = execute("x = 5") 34 + 35 + assert isinstance(result, str) 36 + 37 + def test_empty_code(self): 38 + result = execute("") 39 + 40 + assert isinstance(result, str) 41 + 42 + 43 + class TestHostFunctions: 44 + """Host functions exposed to sandboxed code.""" 45 + 46 + def test_roll_available(self): 47 + result = execute('r = roll("1d6")\nr["total"]') 48 + 49 + assert result # should contain a number 1-6 50 + 51 + def test_roll_returns_dict(self): 52 + result = execute("r = roll('2d6+3')\nprint('total=' + str(r['total']))") 53 + 54 + assert "total=" in result 55 + 56 + def test_custom_host_function(self): 57 + result = execute( 58 + "double(5)", 59 + host_functions={"double": lambda x: x * 2}, 60 + ) 61 + 62 + assert "10" in result 63 + 64 + 65 + class TestErrorHandling: 66 + """Errors returned as strings, not raised.""" 67 + 68 + def test_division_by_zero(self): 69 + result = execute("1 / 0") 70 + 71 + assert "ZeroDivisionError" in result 72 + 73 + def test_name_error(self): 74 + result = execute("undefined_variable") 75 + 76 + assert "NameError" in result 77 + 78 + def test_syntax_error(self): 79 + result = execute("if True") 80 + 81 + assert result # Monty returns a parse error message 82 + 83 + def test_type_error(self): 84 + result = execute('"hello" + 5') 85 + 86 + assert "TypeError" in result 87 + 88 + 89 + class TestResourceLimits: 90 + """Sandbox enforces time and memory limits.""" 91 + 92 + def test_infinite_loop_times_out(self): 93 + result = execute("while True: pass") 94 + 95 + assert "time" in result.lower() or "timeout" in result.lower() 96 + 97 + 98 + class TestSandboxSecurity: 99 + """Dangerous operations are blocked.""" 100 + 101 + def test_no_file_access(self): 102 + result = execute('open("/etc/passwd").read()') 103 + 104 + assert "Error" in result 105 + 106 + def test_no_os_access(self): 107 + result = execute("import os\nos.environ") 108 + 109 + assert "NotImplementedError" in result 110 + 111 + 112 + class TestToolBridge: 113 + """DM tools exposed as direct host functions.""" 114 + 115 + def test_recall(self, ctx: ToolContext): 116 + result = execute("recall(query='nonexistent')", ctx=ctx) 117 + 118 + assert "Nothing found" in result 119 + 120 + def test_establish(self, ctx: ToolContext): 121 + result = execute( 122 + "establish(entity_type='npc', name='Bob the Baker', " 123 + "description='A friendly baker')", 124 + ctx=ctx, 125 + ) 126 + 127 + assert "Bob" in result 128 + 129 + def test_tools_not_available_without_ctx(self): 130 + result = execute("recall(query='test')") 131 + 132 + assert "Error" in result 133 + 134 + 135 + class TestToolSignatures: 136 + """Dynamic signature builder for the run_code tool description.""" 137 + 138 + def test_includes_all_tools(self): 139 + sigs = build_tool_signatures() 140 + 141 + for name in ["roll", "recall", "establish", "mark", "damage", "heal", 142 + "enter_initiative", "end_initiative", "next_turn"]: 143 + assert f"{name}(" in sigs 144 + 145 + def test_excludes_internal_params(self): 146 + sigs = build_tool_signatures() 147 + 148 + assert "ctx:" not in sigs 149 + assert "tracker:" not in sigs
+46
uv.lock
··· 1106 1106 ] 1107 1107 1108 1108 [[package]] 1109 + name = "pydantic-monty" 1110 + version = "0.0.9" 1111 + source = { registry = "https://pypi.org/simple" } 1112 + sdist = { url = "https://files.pythonhosted.org/packages/b1/11/d4024f543e15107c2e8081a294fb09d5ef57a32ca6fa12823d5887a3eaf1/pydantic_monty-0.0.9.tar.gz", hash = "sha256:9d3a85ce2e861b795b39d392410ac3620eb12261fdef6e1bc73edd6121d3c844", size = 888855, upload-time = "2026-03-28T13:18:27.598Z" } 1113 + wheels = [ 1114 + { url = "https://files.pythonhosted.org/packages/82/2b/4aaf85c79df3b47767d4c065a08b60c59f76b7611cbe29c7024e641a6dc1/pydantic_monty-0.0.9-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2c7b92ce83ba9f7d433f445da525243ce267b362a9fc8ecc15b322da91a595e2", size = 6918436, upload-time = "2026-03-28T13:18:58.99Z" }, 1115 + { url = "https://files.pythonhosted.org/packages/b2/35/a0a947eebb2e2a0db1775e374bc11dab549f46d6505d7d691368be984dcf/pydantic_monty-0.0.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b3d8e128f1822af6572bf6626ba9c2a2be53a8b291216809fb89e719272ea66", size = 6951931, upload-time = "2026-03-28T13:19:08.429Z" }, 1116 + { url = "https://files.pythonhosted.org/packages/8e/e9/cf75febeafde9f6a3acddc6423bef3aed55525105bb1e2f32ee63e5c92a5/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a066a552ec3b358af5da11bf02ed8f3b2c0ae7b566a9bd38545176a6578d0bae", size = 6722630, upload-time = "2026-03-28T13:18:15.083Z" }, 1117 + { url = "https://files.pythonhosted.org/packages/f1/af/47b15bca41854ae41f0a83ea57a1aa0fad0805add7ada8b03f7f3c361e0f/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b51a3f809ae84dbd85bfcb307e98e759707a6c8097178806613c565fa6155ba4", size = 7010504, upload-time = "2026-03-28T13:18:17.094Z" }, 1118 + { url = "https://files.pythonhosted.org/packages/a1/eb/969453fe63080c9cb1461cf4e13821058bbffb7da83bda88bc02cd9948ee/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:83b01931866e73caaaec308aadfbe2655006fc456e28c36845306d699359e11b", size = 7545150, upload-time = "2026-03-28T13:18:41.873Z" }, 1119 + { url = "https://files.pythonhosted.org/packages/45/86/802d7a6a47c97ea5bae93cff711a45a7be1223da279f21a79d3cba66b85b/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fa858e49bde281fa48e27961813740f0cc47e6ccf4f3a438e5e513c9e328978", size = 7750797, upload-time = "2026-03-28T13:18:51.355Z" }, 1120 + { url = "https://files.pythonhosted.org/packages/a0/1e/e13202123771aaeb4ec0b86b85236923d57ea4fb661a6c8c01ebb84f7ce6/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8525c9f7baff77097786da6e9b51ad21f09242b5ff0d590f007c0d7f350fb3af", size = 7481146, upload-time = "2026-03-28T13:17:45.45Z" }, 1121 + { url = "https://files.pythonhosted.org/packages/3d/af/c205a267c799862faf5a186e7612a744af1e1deeaa9c6f40888d6b08e800/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59fcfd27dc7e65a9ac909f964c9ab5aec083cd6d3781ac0ff5afb06f481adf34", size = 7454819, upload-time = "2026-03-28T13:17:47.425Z" }, 1122 + { url = "https://files.pythonhosted.org/packages/c1/a6/47066efd5c0e25cd6c4ea15ee91fd1291be2bd07744edaeab66c2d0e659f/pydantic_monty-0.0.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c30748cd5c0d9d2028d372372cd0bd9476b8508632372ec285d97f2ee2e9b40e", size = 6901304, upload-time = "2026-03-28T13:19:14.133Z" }, 1123 + { url = "https://files.pythonhosted.org/packages/bb/f0/ee0edcd279d5ff6c74ec31db27777809d03d9960f32ba50e9b43765e173c/pydantic_monty-0.0.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:987e5b07ed56431bc94a586a7aff777c9928675bb938e8c15906dc8da49ac012", size = 7342554, upload-time = "2026-03-28T13:18:02.594Z" }, 1124 + { url = "https://files.pythonhosted.org/packages/31/8f/a59673fc3d543916a7866e028d29ee2ee2c1493c4d804454be57ad2e081a/pydantic_monty-0.0.9-cp312-cp312-win32.whl", hash = "sha256:02d9d08eb0affc79491eebae0caed5559d5370cf6b806a9e1d67056bf3700c6d", size = 6816176, upload-time = "2026-03-28T13:17:41.153Z" }, 1125 + { url = "https://files.pythonhosted.org/packages/2d/1c/ff4618ec34f67eade81ca2405d7da529ff3cb94a7c23034aa86a31bf3998/pydantic_monty-0.0.9-cp312-cp312-win_amd64.whl", hash = "sha256:7b705f76bb0b5c8307da564db06d550c24cd42fb6ece205e89ff8f2c24b9b8cf", size = 7571602, upload-time = "2026-03-28T13:17:36.824Z" }, 1126 + { url = "https://files.pythonhosted.org/packages/89/f7/8b84b390527dad31c3a011a2242452a21e8199823d3bfc9190f4830c7837/pydantic_monty-0.0.9-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:c3211eff69511cbf3a3b4422c59851bfb7121cd8757da74d2e10244795d2d178", size = 6917197, upload-time = "2026-03-28T13:19:10.375Z" }, 1127 + { url = "https://files.pythonhosted.org/packages/74/60/815b882e31683fd8e0b8fc19d7aa772df92e5fff68d02b39b4b5b7bd83e7/pydantic_monty-0.0.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:586d68268ba74b76be7e88c5e7bfec6710466144b53a336bcc840752ed3efabc", size = 6951689, upload-time = "2026-03-28T13:17:30.929Z" }, 1128 + { url = "https://files.pythonhosted.org/packages/51/4b/2c7e4549251e300633da6afc8a1f40fc6e196d220e3b5773b9b13e5086f7/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9ecb857f814075f40cce780cdff4e661cd8f9e4139f5423d2e7db87be2f12ab", size = 6722305, upload-time = "2026-03-28T13:18:12.397Z" }, 1129 + { url = "https://files.pythonhosted.org/packages/27/c4/c3eb404eaf8f4f3cdb870795649c156ff4de8ed18c8a6ee28b4a9de26392/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:36c70b783b54891b33652852a8b97196550c1a2208a20ed527a11322c026c0b6", size = 7010210, upload-time = "2026-03-28T13:18:47.805Z" }, 1130 + { url = "https://files.pythonhosted.org/packages/a4/02/b3cb448bec463f5ade5415bd8360e9e1abe9ae5922fa7279f81fb71fede3/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:83ecdae13224b54e0fe1081249c7ba903603fc1f91e9104463365221dfedb6a7", size = 7543621, upload-time = "2026-03-28T13:18:21.69Z" }, 1131 + { url = "https://files.pythonhosted.org/packages/a8/22/d5bad5a2c26b3a9f3e410b19c580b558a29c86cc4432acb4699357e6a1f2/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d758730fc2c52916a787a3b98187166df57ae5410df83a1232b03f28a1648c68", size = 7750752, upload-time = "2026-03-28T13:18:37.48Z" }, 1132 + { url = "https://files.pythonhosted.org/packages/09/4e/b5fdb4d407351405172c7ec1ae3cca391178edbfc963ad7c2f72c60f6219/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30167ac5da02f8c787abf8fd5b9078d278f1e0d7d34228ab533f6895a6cfaeae", size = 7480766, upload-time = "2026-03-28T13:18:31.434Z" }, 1133 + { url = "https://files.pythonhosted.org/packages/df/50/ee98ba9a03024a69a4a959e0876e9ac41d87128e2ce38035eb071bd599d7/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a3f9be27c3d919273785128975f3ef77ff9f4acd0241f087670385ca1035661", size = 7453527, upload-time = "2026-03-28T13:18:24.052Z" }, 1134 + { url = "https://files.pythonhosted.org/packages/06/8f/1f724eb0e6bd617c4dc406d9602658546bab311d9cf8e6b1b3a18ab99cfb/pydantic_monty-0.0.9-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6c95adfa982a1f5e54661af16d85cb9a3eb063d1b5ad573d4725f1f7f3e58f99", size = 6900638, upload-time = "2026-03-28T13:18:54.918Z" }, 1135 + { url = "https://files.pythonhosted.org/packages/7e/9e/ded78efb6646a772710413ff8531c57a2c53a5080c25e0d05cd7741157ed/pydantic_monty-0.0.9-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d682cce29303913991464f5c874e28ff3f9a10b1d6feb0bd0a8aa1d0344ee8d3", size = 7342105, upload-time = "2026-03-28T13:17:32.895Z" }, 1136 + { url = "https://files.pythonhosted.org/packages/ec/d5/06070569726af1ff0f6ae4a4d48eadbcd3df2be3d52430f813176f56e249/pydantic_monty-0.0.9-cp313-cp313-win32.whl", hash = "sha256:cf409b859bb23fbe95051dbf5ffd093a3324cd610547783f6a47062ab2db11dc", size = 6815315, upload-time = "2026-03-28T13:18:08.672Z" }, 1137 + { url = "https://files.pythonhosted.org/packages/e8/34/bfe47278242f84be0cd3687cb8a6165df22335ba3b3940596bf4681bfeba/pydantic_monty-0.0.9-cp313-cp313-win_amd64.whl", hash = "sha256:565ef2628b5fd840c178b6c6c223d74e9fd3c28dd83a405ae0b87f864fa37c94", size = 7571256, upload-time = "2026-03-28T13:17:49.859Z" }, 1138 + { url = "https://files.pythonhosted.org/packages/32/fe/1ab7a8fd72456f5cad945260961aeeb217d910f0a44edc0f8fa79af26857/pydantic_monty-0.0.9-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:eef159f7a1496806d787e38abe271b8e39ea777e60f7bab06eaa60ee7224f619", size = 6919711, upload-time = "2026-03-28T13:19:21.869Z" }, 1139 + { url = "https://files.pythonhosted.org/packages/93/55/e9d476b643107a07a9bb540cc9313d0db614479b38817a0ba2df5d6c03ba/pydantic_monty-0.0.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1e91005ea5c532c03a35a4fc589c2a73f647edf38f3a02d8438782f184726e2", size = 6970765, upload-time = "2026-03-28T13:19:17.923Z" }, 1140 + { url = "https://files.pythonhosted.org/packages/d2/24/b177895c9799b2a790eb055149774120c11144551dd6d97232242b4cb1d3/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d547c5ffc96dcb0574de83551dd4ed184df47e66f39600059230a17ac100185", size = 6723034, upload-time = "2026-03-28T13:18:33.47Z" }, 1141 + { url = "https://files.pythonhosted.org/packages/aa/9b/ff8c9e948d8a28e9f03feb93813844b3095f943b63455f4f52aedf8c2582/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5611e32c3b4ca080b14b61be351c76f81e9e3cbc6c622663ffde964c06ee87c2", size = 7011075, upload-time = "2026-03-28T13:19:02.952Z" }, 1142 + { url = "https://files.pythonhosted.org/packages/b5/52/165067cfb23ba7c31eb6ec3aa335900f58b9e4a7d831ef7bf6e07f50804e/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b25cf37c2e680b4ed1e290cbd17f607f621caad340ffc05733101c735d2a868", size = 7541685, upload-time = "2026-03-28T13:19:19.918Z" }, 1143 + { url = "https://files.pythonhosted.org/packages/f2/a0/ee47760be7a78634699ba1170a468d03dbcb51eaaae732eb579cc94382e1/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ccb49df19577e97c55dd9853ec1e57936c352ef9e34321c71fde61020a751525", size = 7751220, upload-time = "2026-03-28T13:17:38.858Z" }, 1144 + { url = "https://files.pythonhosted.org/packages/93/9d/d5a6f0eba4cd3b626d2d71f93f1dbd4f7e7cf83e68dd88231ee92681d15b/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da129cfe11b084972b3d889e48db80084efd355a1f9ab0efc4fdcc0eda8d25ff", size = 7502146, upload-time = "2026-03-28T13:17:53.523Z" }, 1145 + { url = "https://files.pythonhosted.org/packages/16/79/7f257f08357bf6e4854f65900046a14c008eaabc31e604daa533d5efd7bf/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6a6142fbe432e2c9b58383320f489fccca141d442f3746c93e2aca995a9302a6", size = 7455712, upload-time = "2026-03-28T13:18:43.891Z" }, 1146 + { url = "https://files.pythonhosted.org/packages/9c/bf/09840855d51f9a22022932b0a2c82cab73810b07de44b122427e83edffe0/pydantic_monty-0.0.9-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:829cc76d7b77ee250b0a5d34ee29b18bf5374ad54102c4a229143bd054dd5c5c", size = 6902487, upload-time = "2026-03-28T13:18:49.542Z" }, 1147 + { url = "https://files.pythonhosted.org/packages/6c/a4/c158c9040b7c19b0f1cab9c67260d9e1068bef5dc48e7f07f8335eb1e6ee/pydantic_monty-0.0.9-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:66f8cd4139a932cd5b4772927656dcfbf71b16633e08872030768970db12f3a2", size = 7343760, upload-time = "2026-03-28T13:18:25.865Z" }, 1148 + { url = "https://files.pythonhosted.org/packages/77/95/5f293008d3b52ed87e4614a215a71163f4a04aa942cd009906008ae397b9/pydantic_monty-0.0.9-cp314-cp314-win32.whl", hash = "sha256:c12223734c1a20ab0fdec1a5ac187ebe7000a7425addd0a0d348d765f03d3bed", size = 6816935, upload-time = "2026-03-28T13:17:24.154Z" }, 1149 + { url = "https://files.pythonhosted.org/packages/5d/0f/11457bfa549750da0278f7c4f7dbc793bb6754aa3fab21a92fbfc3bdf8af/pydantic_monty-0.0.9-cp314-cp314-win_amd64.whl", hash = "sha256:a21c26fb13c776118000f3d6989dcc3b1d8171250ba79f2b5dc4e912b53be658", size = 7592268, upload-time = "2026-03-28T13:19:04.742Z" }, 1150 + ] 1151 + 1152 + [[package]] 1109 1153 name = "pydantic-settings" 1110 1154 version = "2.13.1" 1111 1155 source = { registry = "https://pypi.org/simple" } ··· 1494 1538 { name = "fastembed" }, 1495 1539 { name = "httpx" }, 1496 1540 { name = "mcp" }, 1541 + { name = "pydantic-monty" }, 1497 1542 { name = "pymupdf" }, 1498 1543 { name = "pymupdf4llm" }, 1499 1544 { name = "pysqlite3-binary" }, ··· 1517 1562 { name = "httpx", specifier = ">=0.27" }, 1518 1563 { name = "mcp", specifier = ">=1.9" }, 1519 1564 { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0" }, 1565 + { name = "pydantic-monty", specifier = ">=0.0.9" }, 1520 1566 { name = "pymupdf", specifier = ">=1.24" }, 1521 1567 { name = "pymupdf4llm", specifier = ">=0.0.17" }, 1522 1568 { name = "pysqlite3-binary", specifier = ">=0.5" },