caudate-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. api/__init__.py +5 -0
  2. api/anthropic_compat.py +1518 -0
  3. api/artifact_viewer.py +366 -0
  4. api/caudate_middleware.py +618 -0
  5. api/forge_bootstrapper_routes.py +377 -0
  6. api/forge_routes.py +630 -0
  7. api/forge_system_routes.py +294 -0
  8. api/openai_compat.py +1993 -0
  9. api/server.py +667 -0
  10. api/storyboard_page.py +677 -0
  11. caudate_cli-0.1.0.dist-info/METADATA +354 -0
  12. caudate_cli-0.1.0.dist-info/RECORD +153 -0
  13. caudate_cli-0.1.0.dist-info/WHEEL +5 -0
  14. caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
  15. caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  16. caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
  17. cognos_mcp/__init__.py +4 -0
  18. cognos_mcp/bridge.py +41 -0
  19. cognos_mcp/client.py +70 -0
  20. cognos_mcp/config.py +49 -0
  21. cognos_mcp/server.py +66 -0
  22. config.py +82 -0
  23. core/__init__.py +0 -0
  24. core/agent.py +468 -0
  25. core/agentic_loop.py +731 -0
  26. core/anthropic_auth.py +91 -0
  27. core/background.py +113 -0
  28. core/banner.py +134 -0
  29. core/bootstrap.py +292 -0
  30. core/citations.py +131 -0
  31. core/compaction.py +109 -0
  32. core/constitution.py +198 -0
  33. core/diff_viewer.py +87 -0
  34. core/export.py +85 -0
  35. core/file_refs.py +119 -0
  36. core/files.py +199 -0
  37. core/hooks.py +209 -0
  38. core/image.py +599 -0
  39. core/input.py +91 -0
  40. core/loop.py +238 -0
  41. core/memory_md.py +147 -0
  42. core/notifications.py +99 -0
  43. core/ownership.py +181 -0
  44. core/paste.py +81 -0
  45. core/permissions.py +210 -0
  46. core/plan_mode.py +215 -0
  47. core/sandbox_prompt.py +185 -0
  48. core/scheduler.py +195 -0
  49. core/schemas.py +202 -0
  50. core/session.py +90 -0
  51. core/settings.py +132 -0
  52. core/skills.py +398 -0
  53. core/slash_commands.py +977 -0
  54. core/statusline.py +61 -0
  55. core/subagent.py +300 -0
  56. core/thinking.py +50 -0
  57. core/updater.py +122 -0
  58. core/usage.py +109 -0
  59. core/worktree.py +93 -0
  60. execution/__init__.py +0 -0
  61. execution/executor.py +329 -0
  62. execution/plugins.py +108 -0
  63. execution/tools/__init__.py +0 -0
  64. execution/tools/agent_tool.py +107 -0
  65. execution/tools/agentic_tool.py +297 -0
  66. execution/tools/artifact_tool.py +191 -0
  67. execution/tools/ask_user_question_tool.py +137 -0
  68. execution/tools/base.py +81 -0
  69. execution/tools/calculator_tool.py +137 -0
  70. execution/tools/cognos_card_tool.py +124 -0
  71. execution/tools/cron_tool.py +215 -0
  72. execution/tools/datetime_tool.py +215 -0
  73. execution/tools/describe_image_tool.py +161 -0
  74. execution/tools/draw_tool.py +164 -0
  75. execution/tools/edit_image_tool.py +262 -0
  76. execution/tools/edit_tool.py +245 -0
  77. execution/tools/file_tool.py +90 -0
  78. execution/tools/find_anywhere_tool.py +255 -0
  79. execution/tools/forge_feature_tools.py +377 -0
  80. execution/tools/glob_tool.py +59 -0
  81. execution/tools/grep_tool.py +89 -0
  82. execution/tools/http_request_tool.py +224 -0
  83. execution/tools/load_skill_tool.py +104 -0
  84. execution/tools/longcat_avatar_tool.py +384 -0
  85. execution/tools/mcp_tool.py +100 -0
  86. execution/tools/notebook_tool.py +279 -0
  87. execution/tools/openapi_tool.py +440 -0
  88. execution/tools/plan_mode_tool.py +95 -0
  89. execution/tools/push_notification_tool.py +157 -0
  90. execution/tools/python_tool.py +61 -0
  91. execution/tools/respond_tool.py +40 -0
  92. execution/tools/sandbox_tool.py +378 -0
  93. execution/tools/search_tool.py +153 -0
  94. execution/tools/semantic_search_tool.py +106 -0
  95. execution/tools/shell_tool.py +283 -0
  96. execution/tools/speak_tool.py +134 -0
  97. execution/tools/storyboard_tool.py +727 -0
  98. execution/tools/system_info_tool.py +212 -0
  99. execution/tools/task_tool.py +323 -0
  100. execution/tools/think_tool.py +49 -0
  101. execution/tools/transcribe_audio_tool.py +86 -0
  102. execution/tools/update_memory_tool.py +92 -0
  103. execution/tools/web_fetch_tool.py +82 -0
  104. execution/tools/worktree_tool.py +174 -0
  105. llm/__init__.py +0 -0
  106. llm/fallback.py +116 -0
  107. llm/models.py +320 -0
  108. llm/provider.py +1356 -0
  109. llm/router.py +373 -0
  110. main.py +1889 -0
  111. memory/__init__.py +0 -0
  112. memory/episodic.py +99 -0
  113. memory/procedural.py +145 -0
  114. memory/semantic.py +71 -0
  115. memory/working.py +64 -0
  116. nn/__init__.py +43 -0
  117. nn/auto_evolve.py +245 -0
  118. nn/caudate.py +136 -0
  119. nn/config.py +141 -0
  120. nn/consolidator.py +81 -0
  121. nn/data.py +1635 -0
  122. nn/encoder.py +258 -0
  123. nn/forge_advisor.py +303 -0
  124. nn/format.py +235 -0
  125. nn/heads.py +432 -0
  126. nn/observer.py +994 -0
  127. nn/policy.py +214 -0
  128. nn/runtime.py +343 -0
  129. nn/scorer.py +175 -0
  130. nn/trainer.py +515 -0
  131. nn/vision.py +352 -0
  132. personality/__init__.py +23 -0
  133. personality/engine.py +129 -0
  134. personality/identity.py +144 -0
  135. personality/inner_voice.py +100 -0
  136. personality/mood.py +205 -0
  137. planning/__init__.py +0 -0
  138. planning/dev_server.py +221 -0
  139. planning/forge_models.py +718 -0
  140. planning/orchestrator.py +1363 -0
  141. planning/planner.py +451 -0
  142. planning/task_graph.py +61 -0
  143. reflection/__init__.py +0 -0
  144. reflection/meta_learner.py +156 -0
  145. reflection/reflector.py +127 -0
  146. ui/__init__.py +5 -0
  147. ui/display.py +88 -0
  148. voice/__init__.py +0 -0
  149. voice/conversation.py +125 -0
  150. voice/listener.py +111 -0
  151. voice/speaker.py +59 -0
  152. voice/stt.py +126 -0
  153. voice/tts.py +214 -0
@@ -0,0 +1,81 @@
1
+ """Base tool interface — Claude SDK compatible tool protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+ from core.schemas import ToolResult, ToolResultStatus
9
+
10
+
11
+ class BaseTool(ABC):
12
+ """Abstract base class for all tools.
13
+
14
+ Tools define their interface via `input_schema` (JSON Schema),
15
+ matching the Claude SDK tool definition format.
16
+
17
+ Plan-mode contract: subclasses that perform any side-effect
18
+ (writing files, running shell commands, mutating external state)
19
+ set `mutates = True`. The executor refuses to run mutating tools
20
+ while plan mode is on. Read-only tools (Read, Grep, Glob,
21
+ SystemInfo, Calculator, etc.) leave `mutates = False`.
22
+ """
23
+
24
+ # Whether this tool produces side effects. Default False — every
25
+ # subclass that writes files / runs commands / sends network
26
+ # requests / changes durable state should override to True.
27
+ mutates: bool = False
28
+
29
+ @property
30
+ @abstractmethod
31
+ def name(self) -> str:
32
+ """Unique tool name."""
33
+ ...
34
+
35
+ @property
36
+ @abstractmethod
37
+ def description(self) -> str:
38
+ """Human-readable description of what the tool does."""
39
+ ...
40
+
41
+ @property
42
+ def input_schema(self) -> dict[str, Any]:
43
+ """JSON Schema defining the tool's input parameters.
44
+
45
+ Override this in subclasses. Default returns empty object schema.
46
+ """
47
+ return {"type": "object", "properties": {}, "required": []}
48
+
49
+ @property
50
+ def parameters(self) -> dict[str, str]:
51
+ """Legacy parameter descriptions for LLM planning.
52
+
53
+ Auto-generated from input_schema for backward compatibility.
54
+ """
55
+ props = self.input_schema.get("properties", {})
56
+ return {k: v.get("description", "") for k, v in props.items()}
57
+
58
+ def to_tool_definition(self) -> dict[str, Any]:
59
+ """Return LiteLLM-compatible tool definition.
60
+
61
+ Format: {"type": "function", "function": {"name", "description", "parameters"}}
62
+ """
63
+ return {
64
+ "type": "function",
65
+ "function": {
66
+ "name": self.name,
67
+ "description": self.description,
68
+ "parameters": self.input_schema,
69
+ },
70
+ }
71
+
72
+ @abstractmethod
73
+ async def execute(self, **kwargs: Any) -> ToolResult:
74
+ """Execute the tool with given arguments."""
75
+ ...
76
+
77
+ def _success(self, output: Any) -> ToolResult:
78
+ return ToolResult(tool_name=self.name, status=ToolResultStatus.SUCCESS, output=output)
79
+
80
+ def _error(self, error: str) -> ToolResult:
81
+ return ToolResult(tool_name=self.name, status=ToolResultStatus.ERROR, error=error)
@@ -0,0 +1,137 @@
1
+ """Calculator — exact arithmetic and symbolic math.
2
+
3
+ LLMs hallucinate digits past ~10-digit precision and get basic
4
+ multi-step arithmetic wrong surprisingly often. This tool does the
5
+ computation deterministically using Python's expression evaluator,
6
+ then optionally falls through to `sympy` for symbolic ops if the
7
+ expression contains symbolic markers.
8
+
9
+ Safety: uses a strict allowlist of names + AST validation. No
10
+ imports, no attribute access, no comprehensions, no function calls
11
+ except a curated math whitelist. Untrusted expressions can't escape
12
+ the sandbox.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+ import logging
19
+ import math
20
+ from typing import Any
21
+
22
+ from core.schemas import ToolResult
23
+ from execution.tools.base import BaseTool
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ # Allowed top-level names — math constants and a curated set of
29
+ # functions. Anything not in this dict raises NameError.
30
+ _ALLOWED_NAMES: dict[str, Any] = {
31
+ # constants
32
+ "pi": math.pi, "e": math.e, "tau": math.tau, "inf": math.inf,
33
+ # safe scalar functions
34
+ "abs": abs, "round": round, "min": min, "max": max,
35
+ "sum": sum, "pow": pow,
36
+ # math module
37
+ "sqrt": math.sqrt, "log": math.log, "log2": math.log2,
38
+ "log10": math.log10, "exp": math.exp,
39
+ "sin": math.sin, "cos": math.cos, "tan": math.tan,
40
+ "asin": math.asin, "acos": math.acos, "atan": math.atan, "atan2": math.atan2,
41
+ "sinh": math.sinh, "cosh": math.cosh, "tanh": math.tanh,
42
+ "ceil": math.ceil, "floor": math.floor, "trunc": math.trunc,
43
+ "factorial": math.factorial, "gcd": math.gcd, "lcm": math.lcm,
44
+ "degrees": math.degrees, "radians": math.radians,
45
+ "hypot": math.hypot, "fabs": math.fabs,
46
+ "fmod": math.fmod, "modf": math.modf,
47
+ "isclose": math.isclose, "isfinite": math.isfinite, "isnan": math.isnan,
48
+ }
49
+
50
+
51
+ # AST node types that are NOT allowed in the expression. We allow
52
+ # arithmetic, comparisons, conditionals, name lookups (gated by the
53
+ # allowlist above), and function calls (also gated).
54
+ _FORBIDDEN_NODES = (
55
+ ast.Import, ast.ImportFrom,
56
+ ast.Lambda, ast.FunctionDef, ast.ClassDef,
57
+ ast.Attribute, # no x.y access — blocks os.system etc
58
+ ast.Subscript, # no list/dict subscripting
59
+ ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp,
60
+ ast.Yield, ast.YieldFrom,
61
+ ast.Await,
62
+ ast.Assign, ast.AugAssign, ast.AnnAssign,
63
+ ast.Delete,
64
+ ast.Global, ast.Nonlocal,
65
+ ast.Try,
66
+ ast.With, ast.AsyncWith, ast.AsyncFor, ast.AsyncFunctionDef,
67
+ )
68
+
69
+
70
+ def _validate_ast(tree: ast.AST) -> None:
71
+ """Raise ValueError if tree contains forbidden nodes."""
72
+ for node in ast.walk(tree):
73
+ if isinstance(node, _FORBIDDEN_NODES):
74
+ raise ValueError(
75
+ f"forbidden expression element: {type(node).__name__}"
76
+ )
77
+ # Function calls must use names from the allowlist
78
+ if isinstance(node, ast.Call):
79
+ func = node.func
80
+ if not isinstance(func, ast.Name):
81
+ raise ValueError("function calls must be on bare names")
82
+ if func.id not in _ALLOWED_NAMES:
83
+ raise ValueError(f"function not allowed: {func.id}")
84
+
85
+
86
+ def _eval_safe(expr: str) -> Any:
87
+ """Parse + validate + eval an arithmetic expression."""
88
+ tree = ast.parse(expr, mode="eval")
89
+ _validate_ast(tree)
90
+ return eval(compile(tree, "<calculator>", "eval"),
91
+ {"__builtins__": {}}, _ALLOWED_NAMES)
92
+
93
+
94
+ class CalculatorTool(BaseTool):
95
+ name = "Calculator"
96
+ description = (
97
+ "Evaluate an arithmetic or scalar-math expression exactly. "
98
+ "Use this whenever the user asks for arithmetic, percentages, "
99
+ "unit conversions, or anything where digits past ~10 places "
100
+ "matter. Supports +, -, *, /, **, math constants (pi, e), and "
101
+ "common math functions (sqrt, log, sin, cos, factorial, etc.). "
102
+ "No variables, no imports — pure expressions."
103
+ )
104
+
105
+ @property
106
+ def input_schema(self) -> dict[str, Any]:
107
+ return {
108
+ "type": "object",
109
+ "properties": {
110
+ "expression": {
111
+ "type": "string",
112
+ "description": "Expression to evaluate, e.g. "
113
+ "'2 ** 32 - 1' or 'sqrt(2) * 5'.",
114
+ },
115
+ },
116
+ "required": ["expression"],
117
+ }
118
+
119
+ async def execute(self, **kwargs: Any) -> ToolResult:
120
+ expr = (kwargs.get("expression") or "").strip()
121
+ if not expr:
122
+ return ToolResult(
123
+ tool_name=self.name, status="error",
124
+ error="empty expression",
125
+ )
126
+ try:
127
+ result = _eval_safe(expr)
128
+ except Exception as e:
129
+ return ToolResult(
130
+ tool_name=self.name, status="error",
131
+ error=f"{type(e).__name__}: {e}",
132
+ )
133
+ return ToolResult(
134
+ tool_name=self.name, status="success",
135
+ output=f"{expr} = {result}",
136
+ metadata={"expression": expr, "result": result},
137
+ )
@@ -0,0 +1,124 @@
1
+ """CognosCard — read the Cognos self-spec card.
2
+
3
+ Mirrors the structure of the Anthropic Opus self-card the user
4
+ showed: confirmable facts / architecture / training / capabilities /
5
+ **what we can't honestly tell you**. The card lives at
6
+ `data/COGNOS_CARD.md` and is human-edited (not auto-generated).
7
+
8
+ The LLM can call this tool to:
9
+ - Answer "what is Cognos?" / "what can you do?" honestly
10
+ - Self-introspect before claiming a capability ("can I do X?")
11
+ - Anchor on the explicit boundaries section before responding
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from core.schemas import ToolResult
21
+ from execution.tools.base import BaseTool
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ _CARD_PATH = Path("/home/raveuk/cognos/data/COGNOS_CARD.md")
27
+
28
+
29
+ def render_cognos_card_short() -> str:
30
+ """One-paragraph reference to the card, suitable for system-prompt
31
+ injection. Tells the LLM the card exists + how to read it; doesn't
32
+ bloat the prompt with the full card body.
33
+ """
34
+ return (
35
+ "## About Cognos\n\n"
36
+ "You are running inside **Cognos** — a local-first cognitive "
37
+ "agent on the user's own machine. The full structured "
38
+ "self-spec lives at `data/COGNOS_CARD.md` and is also "
39
+ "available via the `CognosCard` tool: confirmable facts, "
40
+ "architecture, capabilities, and an explicit list of what "
41
+ "Cognos cannot honestly tell you. **Call CognosCard before "
42
+ "claiming a capability you're unsure about** — the card is "
43
+ "the source of truth, not your model's training defaults."
44
+ )
45
+
46
+
47
+ class CognosCardTool(BaseTool):
48
+ name = "CognosCard"
49
+ description = (
50
+ "Read the Cognos self-spec card — verifiable facts about the "
51
+ "agent you're running inside, its architecture, what it can "
52
+ "do, and (importantly) what it CANNOT honestly tell you. "
53
+ "Call this whenever the user asks 'what are you', 'what can "
54
+ "you do', or whenever you'd otherwise speculate about Cognos "
55
+ "internals. Returns the full markdown card."
56
+ )
57
+
58
+ @property
59
+ def input_schema(self) -> dict[str, Any]:
60
+ return {
61
+ "type": "object",
62
+ "properties": {
63
+ "section": {
64
+ "type": "string",
65
+ "enum": [
66
+ "all",
67
+ "confirm",
68
+ "architecture",
69
+ "training",
70
+ "capabilities",
71
+ "boundaries",
72
+ ],
73
+ "description": "Which section to return. 'all' returns the full card; the others return only that section.",
74
+ },
75
+ },
76
+ "required": [],
77
+ }
78
+
79
+ async def execute(self, **kwargs: Any) -> ToolResult:
80
+ if not _CARD_PATH.exists():
81
+ return ToolResult(
82
+ tool_name=self.name, status="error",
83
+ error=f"card not found at {_CARD_PATH}",
84
+ )
85
+ text = _CARD_PATH.read_text()
86
+ section = (kwargs.get("section") or "all").lower().strip()
87
+ if section == "all":
88
+ return ToolResult(
89
+ tool_name=self.name, status="success",
90
+ output=text,
91
+ metadata={"path": str(_CARD_PATH), "section": "all"},
92
+ )
93
+
94
+ # Section extraction — find the relevant H2 heading and return
95
+ # everything until the next H2.
96
+ section_headings = {
97
+ "confirm": "## What I can publicly confirm",
98
+ "architecture": "## Architecture (publicly known shape)",
99
+ "training": "## Caudate training pipeline (general shape)",
100
+ "capabilities": "## Capabilities at inference",
101
+ "boundaries": "## What I cannot honestly tell you",
102
+ }
103
+ marker = section_headings.get(section)
104
+ if not marker:
105
+ return ToolResult(
106
+ tool_name=self.name, status="error",
107
+ error=f"unknown section {section!r}",
108
+ )
109
+ idx = text.find(marker)
110
+ if idx < 0:
111
+ return ToolResult(
112
+ tool_name=self.name, status="error",
113
+ error=f"section heading not found: {marker!r}",
114
+ )
115
+ # Slice from the heading to the next H2 (or end of file)
116
+ end = text.find("\n## ", idx + len(marker))
117
+ if end < 0:
118
+ end = len(text)
119
+ body = text[idx:end].rstrip()
120
+ return ToolResult(
121
+ tool_name=self.name, status="success",
122
+ output=body,
123
+ metadata={"path": str(_CARD_PATH), "section": section},
124
+ )
@@ -0,0 +1,215 @@
1
+ """Cron tools — schedule, list, and delete recurring agent prompts.
2
+
3
+ Three thin wrappers on top of `core/scheduler.py::CronStore`:
4
+
5
+ - **`CronCreate`** — register a new recurring prompt.
6
+ - **`CronList`** — show all registered jobs (with status).
7
+ - **`CronDelete`** — remove a job by id.
8
+
9
+ Schedule syntax (matching `core/scheduler.py::_parse_schedule`):
10
+
11
+ - `every 30s` / `every 5m` / `every 2h` / `every 1d`
12
+ - `daily HH:MM` (e.g. `daily 09:30`)
13
+ - `weekly mon HH:MM` (mon|tue|wed|thu|fri|sat|sun)
14
+ - `at YYYY-MM-DD HH:MM` (one-shot)
15
+
16
+ Persistence: jobs live in `data/cron.json`. The runtime fires them
17
+ when `cognos cron run` is active (a separate daemon process). These
18
+ tools just manage the registry — they don't fire anything immediately.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ from core.schemas import ToolResult
26
+ from execution.tools.base import BaseTool
27
+
28
+
29
+ def _store():
30
+ """Lazy-init the CronStore against the configured data dir."""
31
+ from pathlib import Path
32
+ from config import DATA_DIR
33
+ from core.scheduler import CronStore
34
+ return CronStore(Path(DATA_DIR) / "cron.json")
35
+
36
+
37
+ def _format_job(job) -> str:
38
+ status = "enabled " if job.enabled else "disabled"
39
+ nxt = job.next_run or "—"
40
+ last = job.last_run or "never"
41
+ return (
42
+ f" {job.id} [{status}] schedule={job.schedule!r}\n"
43
+ f" next: {nxt}\n"
44
+ f" last: {last}\n"
45
+ f" prompt: {job.prompt[:80]}{'…' if len(job.prompt) > 80 else ''}"
46
+ )
47
+
48
+
49
+ class CronCreateTool(BaseTool):
50
+ mutates = True
51
+ name = "CronCreate"
52
+ description = (
53
+ "Schedule a recurring prompt that Cognos will run automatically "
54
+ "when the cron daemon is active. Pass a `prompt` (what the agent "
55
+ "should do each fire) and a `schedule` string. Schedule syntax: "
56
+ "`every 30s|5m|2h|1d`, `daily HH:MM`, `weekly mon HH:MM`, or "
57
+ "`at YYYY-MM-DD HH:MM` for one-shot. Returns the new job id. "
58
+ "Note: the daemon runs separately via `cognos cron run`; this "
59
+ "tool only registers the job."
60
+ )
61
+
62
+ @property
63
+ def input_schema(self) -> dict[str, Any]:
64
+ return {
65
+ "type": "object",
66
+ "properties": {
67
+ "prompt": {
68
+ "type": "string",
69
+ "description": "What the agent should do each fire (e.g. 'Summarize today's storyboard runs').",
70
+ },
71
+ "schedule": {
72
+ "type": "string",
73
+ "description": (
74
+ "When to fire. Examples: 'every 5m', 'daily "
75
+ "09:30', 'weekly mon 09:00', 'at 2026-12-25 08:00'."
76
+ ),
77
+ },
78
+ },
79
+ "required": ["prompt", "schedule"],
80
+ }
81
+
82
+ async def execute(self, **kwargs: Any) -> ToolResult:
83
+ prompt = (kwargs.get("prompt") or "").strip()
84
+ schedule = (kwargs.get("schedule") or "").strip()
85
+ if not prompt or not schedule:
86
+ return ToolResult(
87
+ tool_name=self.name, status="error",
88
+ error="`prompt` and `schedule` are both required",
89
+ )
90
+ try:
91
+ store = _store()
92
+ job = store.add(prompt, schedule)
93
+ except ValueError as e:
94
+ return ToolResult(
95
+ tool_name=self.name, status="error",
96
+ error=(
97
+ f"{e}. Valid schedules: 'every 5m', 'daily 09:30', "
98
+ "'weekly mon 09:00', 'at YYYY-MM-DD HH:MM'."
99
+ ),
100
+ )
101
+ except Exception as e:
102
+ return ToolResult(
103
+ tool_name=self.name, status="error",
104
+ error=f"failed to create cron job: {e}",
105
+ )
106
+ return ToolResult(
107
+ tool_name=self.name, status="success",
108
+ output=(
109
+ f"Cron job created.\n"
110
+ f" id: {job.id}\n"
111
+ f" schedule: {job.schedule}\n"
112
+ f" next run: {job.next_run}\n"
113
+ f" prompt: {job.prompt[:100]}"
114
+ ),
115
+ metadata={
116
+ "job_id": job.id,
117
+ "schedule": job.schedule,
118
+ "next_run": job.next_run,
119
+ },
120
+ )
121
+
122
+
123
+ class CronListTool(BaseTool):
124
+ name = "CronList"
125
+ description = (
126
+ "List all registered cron jobs with their schedule, next run "
127
+ "time, last run time, and the prompt. No arguments. Returns a "
128
+ "human-readable list; the metadata field has the structured "
129
+ "version."
130
+ )
131
+
132
+ @property
133
+ def input_schema(self) -> dict[str, Any]:
134
+ return {"type": "object", "properties": {}}
135
+
136
+ async def execute(self, **_: Any) -> ToolResult:
137
+ try:
138
+ jobs = _store().list()
139
+ except Exception as e:
140
+ return ToolResult(
141
+ tool_name=self.name, status="error",
142
+ error=f"failed to read cron store: {e}",
143
+ )
144
+ if not jobs:
145
+ return ToolResult(
146
+ tool_name=self.name, status="success",
147
+ output="No cron jobs registered.",
148
+ metadata={"jobs": []},
149
+ )
150
+ lines = [f"{len(jobs)} cron job(s):", ""]
151
+ lines.extend(_format_job(j) for j in jobs)
152
+ return ToolResult(
153
+ tool_name=self.name, status="success",
154
+ output="\n".join(lines),
155
+ metadata={
156
+ "jobs": [
157
+ {
158
+ "id": j.id,
159
+ "schedule": j.schedule,
160
+ "enabled": j.enabled,
161
+ "next_run": j.next_run,
162
+ "last_run": j.last_run,
163
+ "prompt": j.prompt,
164
+ }
165
+ for j in jobs
166
+ ],
167
+ },
168
+ )
169
+
170
+
171
+ class CronDeleteTool(BaseTool):
172
+ mutates = True
173
+ name = "CronDelete"
174
+ description = (
175
+ "Delete a cron job by its id. Use `CronList` first to find the "
176
+ "id. Returns success or 'not found'."
177
+ )
178
+
179
+ @property
180
+ def input_schema(self) -> dict[str, Any]:
181
+ return {
182
+ "type": "object",
183
+ "properties": {
184
+ "job_id": {
185
+ "type": "string",
186
+ "description": "Cron job id from CronList.",
187
+ },
188
+ },
189
+ "required": ["job_id"],
190
+ }
191
+
192
+ async def execute(self, **kwargs: Any) -> ToolResult:
193
+ job_id = (kwargs.get("job_id") or "").strip()
194
+ if not job_id:
195
+ return ToolResult(
196
+ tool_name=self.name, status="error",
197
+ error="`job_id` is required",
198
+ )
199
+ try:
200
+ ok = _store().remove(job_id)
201
+ except Exception as e:
202
+ return ToolResult(
203
+ tool_name=self.name, status="error",
204
+ error=f"failed to delete cron job: {e}",
205
+ )
206
+ if not ok:
207
+ return ToolResult(
208
+ tool_name=self.name, status="error",
209
+ error=f"no cron job with id {job_id!r}",
210
+ )
211
+ return ToolResult(
212
+ tool_name=self.name, status="success",
213
+ output=f"Deleted cron job {job_id}.",
214
+ metadata={"job_id": job_id},
215
+ )