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,212 @@
1
+ """SystemInfo — one-call summary of host hardware + software state.
2
+
3
+ Saves the LLM from chaining Bash calls (`uname`, `nvidia-smi`, `df`,
4
+ `free`, …) just to answer "what hardware do you have?". Intentionally
5
+ read-only; everything is via well-known commands.
6
+
7
+ The output is a structured text block that's easy for the LLM to
8
+ quote back to the user verbatim or summarise. Sections that are
9
+ unavailable (e.g. no NVIDIA GPU) are simply omitted.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import os
16
+ import platform
17
+ import shutil
18
+ import subprocess
19
+ from typing import Any
20
+
21
+ from core.schemas import ToolResult
22
+ from execution.tools.base import BaseTool
23
+
24
+
25
+ _SECTION_TIMEOUT_S = 5 # per subprocess; SystemInfo isn't worth blocking on
26
+
27
+
28
+ def _run(cmd: list[str]) -> str:
29
+ """Run a command and return stdout (or empty on failure)."""
30
+ try:
31
+ out = subprocess.run(
32
+ cmd, capture_output=True, text=True,
33
+ timeout=_SECTION_TIMEOUT_S,
34
+ )
35
+ return (out.stdout or out.stderr or "").strip()
36
+ except (FileNotFoundError, subprocess.TimeoutExpired):
37
+ return ""
38
+
39
+
40
+ def _section_kernel() -> str:
41
+ bits = []
42
+ bits.append(f"OS: {platform.system()} {platform.release()}")
43
+ if platform.system() == "Linux":
44
+ # Distro name from /etc/os-release
45
+ os_release = ""
46
+ try:
47
+ with open("/etc/os-release") as f:
48
+ kv = dict(
49
+ line.strip().split("=", 1)
50
+ for line in f if "=" in line
51
+ )
52
+ os_release = kv.get("PRETTY_NAME", "").strip('"')
53
+ except OSError:
54
+ pass
55
+ if os_release:
56
+ bits.append(f"Distribution: {os_release}")
57
+ bits.append(f"Architecture: {platform.machine()}")
58
+ bits.append(f"Hostname: {platform.node()}")
59
+ return "\n".join(bits)
60
+
61
+
62
+ def _section_cpu_ram() -> str:
63
+ bits = []
64
+ cpu_count = os.cpu_count() or 0
65
+ bits.append(f"CPUs: {cpu_count} logical")
66
+ # CPU model from /proc/cpuinfo on Linux
67
+ try:
68
+ with open("/proc/cpuinfo") as f:
69
+ for line in f:
70
+ if line.startswith("model name"):
71
+ bits.append(f"CPU model: {line.split(':',1)[1].strip()}")
72
+ break
73
+ except OSError:
74
+ pass
75
+ # Memory from /proc/meminfo
76
+ try:
77
+ mem = {}
78
+ with open("/proc/meminfo") as f:
79
+ for line in f:
80
+ k, _, v = line.partition(":")
81
+ mem[k.strip()] = v.strip()
82
+ total_kb = int(mem.get("MemTotal", "0 kB").split()[0] or "0")
83
+ avail_kb = int(mem.get("MemAvailable", "0 kB").split()[0] or "0")
84
+ gb = lambda x: x / 1024 / 1024
85
+ bits.append(
86
+ f"Memory: {gb(total_kb):.1f} GB total, "
87
+ f"{gb(avail_kb):.1f} GB available"
88
+ )
89
+ except (OSError, ValueError):
90
+ pass
91
+ return "\n".join(bits)
92
+
93
+
94
+ def _section_gpu() -> str:
95
+ if not shutil.which("nvidia-smi"):
96
+ return "GPU: (no nvidia-smi found)"
97
+ raw = _run([
98
+ "nvidia-smi",
99
+ "--query-gpu=name,memory.total,memory.used,utilization.gpu,driver_version",
100
+ "--format=csv,noheader,nounits",
101
+ ])
102
+ if not raw:
103
+ return "GPU: nvidia-smi present but no GPU info"
104
+ lines = ["GPUs:"]
105
+ for entry in raw.splitlines():
106
+ parts = [p.strip() for p in entry.split(",")]
107
+ if len(parts) < 5:
108
+ continue
109
+ name, mem_total, mem_used, util, driver = parts
110
+ lines.append(
111
+ f" - {name} ({mem_used}/{mem_total} MB used, "
112
+ f"{util}% util, driver {driver})"
113
+ )
114
+ return "\n".join(lines)
115
+
116
+
117
+ def _section_disk() -> str:
118
+ raw = _run(["df", "-h", "--output=source,size,used,avail,pcent,target"])
119
+ if not raw:
120
+ return ""
121
+ lines = raw.splitlines()
122
+ # Filter to interesting mounts: skip squashfs, snap, tmpfs, etc.
123
+ keep = []
124
+ header = lines[0] if lines else ""
125
+ keep.append(header)
126
+ for ln in lines[1:]:
127
+ if any(k in ln for k in ("/snap/", "tmpfs", "udev", "squashfs", "overlay")):
128
+ continue
129
+ keep.append(ln)
130
+ return "Disks:\n" + "\n".join(f" {l}" for l in keep[:12])
131
+
132
+
133
+ def _section_python_cuda() -> str:
134
+ bits = [f"Python: {platform.python_version()}"]
135
+ # PyTorch + CUDA detection — best-effort, doesn't fail if torch missing
136
+ try:
137
+ import torch
138
+ bits.append(f"PyTorch: {torch.__version__}")
139
+ if torch.cuda.is_available():
140
+ bits.append(
141
+ f"CUDA: {torch.version.cuda} "
142
+ f"({torch.cuda.device_count()} device(s) visible to torch)"
143
+ )
144
+ else:
145
+ bits.append("CUDA: not available to torch")
146
+ except Exception as e:
147
+ bits.append(f"PyTorch: not importable ({e!s:.60})")
148
+ return "\n".join(bits)
149
+
150
+
151
+ def _section_uptime_load() -> str:
152
+ bits = []
153
+ try:
154
+ with open("/proc/uptime") as f:
155
+ up = float(f.read().split()[0])
156
+ days, rem = divmod(up, 86400)
157
+ hours, rem = divmod(rem, 3600)
158
+ mins = rem // 60
159
+ bits.append(
160
+ f"Uptime: {int(days)}d {int(hours)}h {int(mins)}m"
161
+ )
162
+ except OSError:
163
+ pass
164
+ try:
165
+ with open("/proc/loadavg") as f:
166
+ la = f.read().strip().split()[:3]
167
+ bits.append(f"Load avg: {' / '.join(la)} (1/5/15 min)")
168
+ except OSError:
169
+ pass
170
+ return "\n".join(bits)
171
+
172
+
173
+ class SystemInfoTool(BaseTool):
174
+ name = "SystemInfo"
175
+ description = (
176
+ "Read-only one-call summary of the host machine: OS, CPU, RAM, "
177
+ "GPU (incl. NVIDIA), disk usage, Python/PyTorch/CUDA versions, "
178
+ "uptime. Use this when the user asks about the hardware/software "
179
+ "Cognos is running on, instead of chaining several Bash calls."
180
+ )
181
+
182
+ @property
183
+ def input_schema(self) -> dict[str, Any]:
184
+ return {"type": "object", "properties": {}, "required": []}
185
+
186
+ async def execute(self, **_: Any) -> ToolResult:
187
+ # Run sections in a thread so we don't block the event loop on
188
+ # subprocesses (nvidia-smi can take a second or two).
189
+ loop = asyncio.get_event_loop()
190
+ sections = await loop.run_in_executor(None, self._collect_all)
191
+ return ToolResult(
192
+ tool_name=self.name, status="success", output=sections
193
+ )
194
+
195
+ @staticmethod
196
+ def _collect_all() -> str:
197
+ parts: list[str] = []
198
+ for fn in (
199
+ _section_kernel,
200
+ _section_cpu_ram,
201
+ _section_gpu,
202
+ _section_disk,
203
+ _section_python_cuda,
204
+ _section_uptime_load,
205
+ ):
206
+ try:
207
+ out = fn()
208
+ if out:
209
+ parts.append(out)
210
+ except Exception as e:
211
+ parts.append(f"({fn.__name__} failed: {e!s:.80})")
212
+ return "\n\n".join(parts)
@@ -0,0 +1,323 @@
1
+ """Task tools — durable tracking for long-running async work.
2
+
3
+ Wraps `core/background.py::BackgroundTaskPool` so the LLM can spawn
4
+ fire-and-forget agent turns and check on them later. Useful when the
5
+ user asks for something that takes minutes (e.g. "generate a 12-panel
6
+ storyboard, I'll come back later") — the agent kicks off the work,
7
+ hands back a task id, and the next chat turn can `TaskGet` to surface
8
+ the result.
9
+
10
+ Five tools:
11
+
12
+ - **`TaskCreate`** — spawn a sub-prompt as a background task. The
13
+ agent runs the prompt in the same process; the user's chat turn
14
+ returns immediately with the task id.
15
+ - **`TaskList`** — show all tasks in the pool with status.
16
+ - **`TaskGet`** — fetch a single task by id (status, duration, result).
17
+ - **`TaskOutput`** — same as TaskGet but returns just the result text.
18
+ - **`TaskStop`** — cancel a running task by id.
19
+
20
+ Persistence: in-process only. When the Cognos server restarts, all
21
+ running tasks are lost (per the upstream BackgroundTaskPool design).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import logging
28
+ from typing import Any
29
+
30
+ from core.background import BackgroundTask, get_global_pool
31
+ from core.schemas import ToolResult
32
+ from execution.tools.base import BaseTool
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ def _format_task(t: BackgroundTask, *, brief: bool = False) -> str:
38
+ head = (
39
+ f" {t.id} [{t.status:>9}] duration={t.duration:6.1f}s\n"
40
+ f" label: {t.label[:80]}"
41
+ )
42
+ if brief:
43
+ return head
44
+ extra = []
45
+ if t.error:
46
+ extra.append(f" error: {t.error[:200]}")
47
+ if t.result:
48
+ extra.append(f" result (head): {t.result[:160]}")
49
+ return head + ("\n" + "\n".join(extra) if extra else "")
50
+
51
+
52
+ class TaskCreateTool(BaseTool):
53
+ mutates = True
54
+ name = "TaskCreate"
55
+ description = (
56
+ "Spawn a sub-prompt as a background task. The agent runs the "
57
+ "given `prompt` in the same Cognos process but doesn't block "
58
+ "the current chat turn — you get a task id back immediately. "
59
+ "Use this when the work will take longer than ~30 seconds and "
60
+ "the user doesn't want to wait. Use `TaskGet` / `TaskOutput` "
61
+ "later to fetch the result. Limitation: tasks run in-process, "
62
+ "so they die if the Cognos server restarts."
63
+ )
64
+
65
+ def __init__(self, agent: Any | None = None):
66
+ self._agent = agent
67
+
68
+ def set_agent(self, agent: Any) -> None:
69
+ self._agent = agent
70
+
71
+ @property
72
+ def input_schema(self) -> dict[str, Any]:
73
+ return {
74
+ "type": "object",
75
+ "properties": {
76
+ "prompt": {
77
+ "type": "string",
78
+ "description": "What the background agent turn should do.",
79
+ },
80
+ "label": {
81
+ "type": "string",
82
+ "description": "Short human-readable label shown in TaskList.",
83
+ },
84
+ },
85
+ "required": ["prompt"],
86
+ }
87
+
88
+ async def execute(self, **kwargs: Any) -> ToolResult:
89
+ prompt = (kwargs.get("prompt") or "").strip()
90
+ label = (kwargs.get("label") or prompt[:60]).strip()
91
+ if not prompt:
92
+ return ToolResult(
93
+ tool_name=self.name, status="error",
94
+ error="`prompt` is required",
95
+ )
96
+ if self._agent is None or not hasattr(self._agent, "chat"):
97
+ return ToolResult(
98
+ tool_name=self.name, status="error",
99
+ error=(
100
+ "TaskCreate is not wired to a CognosAgent. The "
101
+ "agent must call set_agent() at startup."
102
+ ),
103
+ )
104
+
105
+ pool = get_global_pool()
106
+ agent = self._agent
107
+
108
+ async def _run() -> str:
109
+ return await agent.chat(prompt)
110
+
111
+ bg = pool.submit(_run, label=label)
112
+ return ToolResult(
113
+ tool_name=self.name, status="success",
114
+ output=(
115
+ f"Background task started.\n"
116
+ f" task_id: {bg.id}\n"
117
+ f" label: {bg.label}\n"
118
+ f"Use TaskGet({bg.id!r}) or TaskOutput({bg.id!r}) to "
119
+ f"check on it. Use TaskStop({bg.id!r}) to cancel."
120
+ ),
121
+ metadata={"task_id": bg.id, "label": bg.label, "status": bg.status},
122
+ )
123
+
124
+
125
+ class TaskListTool(BaseTool):
126
+ name = "TaskList"
127
+ description = (
128
+ "List all background tasks in this process with their status, "
129
+ "duration, label, and (for finished ones) the result-head or "
130
+ "error. No arguments. Tasks are sorted newest-first."
131
+ )
132
+
133
+ @property
134
+ def input_schema(self) -> dict[str, Any]:
135
+ return {"type": "object", "properties": {}}
136
+
137
+ async def execute(self, **_: Any) -> ToolResult:
138
+ tasks = get_global_pool().list()
139
+ if not tasks:
140
+ return ToolResult(
141
+ tool_name=self.name, status="success",
142
+ output="No background tasks.",
143
+ metadata={"tasks": []},
144
+ )
145
+ lines = [f"{len(tasks)} background task(s):", ""]
146
+ lines.extend(_format_task(t) for t in tasks)
147
+ return ToolResult(
148
+ tool_name=self.name, status="success",
149
+ output="\n".join(lines),
150
+ metadata={
151
+ "tasks": [
152
+ {
153
+ "id": t.id,
154
+ "status": t.status,
155
+ "label": t.label,
156
+ "duration_s": t.duration,
157
+ "started_at": t.started_at,
158
+ "finished_at": t.finished_at or None,
159
+ "error": t.error,
160
+ }
161
+ for t in tasks
162
+ ],
163
+ },
164
+ )
165
+
166
+
167
+ class TaskGetTool(BaseTool):
168
+ name = "TaskGet"
169
+ description = (
170
+ "Fetch one background task by id. Returns its status, duration, "
171
+ "label, error (if any), and the full result text (if done). Use "
172
+ "this to check on work spawned via TaskCreate."
173
+ )
174
+
175
+ @property
176
+ def input_schema(self) -> dict[str, Any]:
177
+ return {
178
+ "type": "object",
179
+ "properties": {
180
+ "task_id": {
181
+ "type": "string",
182
+ "description": "Task id from TaskCreate / TaskList.",
183
+ },
184
+ },
185
+ "required": ["task_id"],
186
+ }
187
+
188
+ async def execute(self, **kwargs: Any) -> ToolResult:
189
+ task_id = (kwargs.get("task_id") or "").strip()
190
+ if not task_id:
191
+ return ToolResult(
192
+ tool_name=self.name, status="error",
193
+ error="`task_id` is required",
194
+ )
195
+ bg = get_global_pool().get(task_id)
196
+ if bg is None:
197
+ return ToolResult(
198
+ tool_name=self.name, status="error",
199
+ error=f"no task with id {task_id!r}",
200
+ )
201
+ lines = [
202
+ f"task {bg.id}",
203
+ f" status: {bg.status}",
204
+ f" label: {bg.label}",
205
+ f" duration: {bg.duration:.1f}s",
206
+ ]
207
+ if bg.error:
208
+ lines.append(f" error: {bg.error[:400]}")
209
+ if bg.result:
210
+ lines.append(f" result:")
211
+ lines.append(bg.result)
212
+ return ToolResult(
213
+ tool_name=self.name, status="success",
214
+ output="\n".join(lines),
215
+ metadata={
216
+ "id": bg.id,
217
+ "status": bg.status,
218
+ "label": bg.label,
219
+ "duration_s": bg.duration,
220
+ "error": bg.error,
221
+ "result": bg.result,
222
+ },
223
+ )
224
+
225
+
226
+ class TaskOutputTool(BaseTool):
227
+ name = "TaskOutput"
228
+ description = (
229
+ "Return only the final output (result text) of a finished "
230
+ "background task. Errors if the task is still running, has "
231
+ "no output yet, or doesn't exist. Use this when you just "
232
+ "want the answer and don't care about metadata."
233
+ )
234
+
235
+ @property
236
+ def input_schema(self) -> dict[str, Any]:
237
+ return {
238
+ "type": "object",
239
+ "properties": {
240
+ "task_id": {
241
+ "type": "string",
242
+ "description": "Task id from TaskCreate / TaskList.",
243
+ },
244
+ },
245
+ "required": ["task_id"],
246
+ }
247
+
248
+ async def execute(self, **kwargs: Any) -> ToolResult:
249
+ task_id = (kwargs.get("task_id") or "").strip()
250
+ if not task_id:
251
+ return ToolResult(
252
+ tool_name=self.name, status="error",
253
+ error="`task_id` is required",
254
+ )
255
+ bg = get_global_pool().get(task_id)
256
+ if bg is None:
257
+ return ToolResult(
258
+ tool_name=self.name, status="error",
259
+ error=f"no task with id {task_id!r}",
260
+ )
261
+ if bg.status in ("pending", "running"):
262
+ return ToolResult(
263
+ tool_name=self.name, status="error",
264
+ error=f"task {task_id} is still {bg.status} ({bg.duration:.1f}s elapsed)",
265
+ )
266
+ if bg.status == "failed":
267
+ return ToolResult(
268
+ tool_name=self.name, status="error",
269
+ error=f"task {task_id} failed: {bg.error}",
270
+ )
271
+ return ToolResult(
272
+ tool_name=self.name, status="success",
273
+ output=bg.result or "(empty)",
274
+ metadata={"id": bg.id, "status": bg.status},
275
+ )
276
+
277
+
278
+ class TaskStopTool(BaseTool):
279
+ mutates = True
280
+ name = "TaskStop"
281
+ description = (
282
+ "Cancel a running background task by id. No-op if the task is "
283
+ "already finished or doesn't exist. Returns success or 'not "
284
+ "cancellable'."
285
+ )
286
+
287
+ @property
288
+ def input_schema(self) -> dict[str, Any]:
289
+ return {
290
+ "type": "object",
291
+ "properties": {
292
+ "task_id": {
293
+ "type": "string",
294
+ "description": "Task id from TaskList.",
295
+ },
296
+ },
297
+ "required": ["task_id"],
298
+ }
299
+
300
+ async def execute(self, **kwargs: Any) -> ToolResult:
301
+ task_id = (kwargs.get("task_id") or "").strip()
302
+ if not task_id:
303
+ return ToolResult(
304
+ tool_name=self.name, status="error",
305
+ error="`task_id` is required",
306
+ )
307
+ ok = get_global_pool().cancel(task_id)
308
+ if not ok:
309
+ bg = get_global_pool().get(task_id)
310
+ if bg is None:
311
+ return ToolResult(
312
+ tool_name=self.name, status="error",
313
+ error=f"no task with id {task_id!r}",
314
+ )
315
+ return ToolResult(
316
+ tool_name=self.name, status="error",
317
+ error=f"task {task_id} is {bg.status} — not cancellable",
318
+ )
319
+ return ToolResult(
320
+ tool_name=self.name, status="success",
321
+ output=f"Cancellation requested for task {task_id}.",
322
+ metadata={"task_id": task_id},
323
+ )
@@ -0,0 +1,49 @@
1
+ """Think tool — use the LLM to reason, generate knowledge, or synthesize information."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from core.schemas import ToolResult
8
+ from execution.tools.base import BaseTool
9
+
10
+
11
+ class ThinkTool(BaseTool):
12
+ name = "Think"
13
+ description = "Use the LLM to reason about a topic, answer a question, or synthesize information. Use this for knowledge questions, analysis, explanations, and any task that requires thinking rather than external tools."
14
+
15
+ @property
16
+ def input_schema(self) -> dict:
17
+ return {
18
+ "type": "object",
19
+ "properties": {
20
+ "question": {
21
+ "type": "string",
22
+ "description": "The question or topic to think about",
23
+ },
24
+ },
25
+ "required": ["question"],
26
+ }
27
+
28
+ def __init__(self, llm=None):
29
+ self._llm = llm
30
+
31
+ def set_llm(self, llm) -> None:
32
+ self._llm = llm
33
+
34
+ async def execute(self, **kwargs: Any) -> ToolResult:
35
+ question = kwargs.get("question", "")
36
+ if not question:
37
+ return self._error("No question provided")
38
+
39
+ if not self._llm:
40
+ return self._error("LLM not configured for think tool")
41
+
42
+ try:
43
+ response = await self._llm.complete(
44
+ prompt=question,
45
+ system="You are a knowledgeable assistant. Provide clear, accurate, and helpful responses. Be thorough but concise.",
46
+ )
47
+ return self._success(response.content)
48
+ except Exception as e:
49
+ return self._error(f"Thinking failed: {e}")
@@ -0,0 +1,86 @@
1
+ """TranscribeAudio — speech-to-text for a local audio file.
2
+
3
+ Uses Cognos's existing Whisper STT backend from `voice/stt.py`.
4
+ Loaded lazily on first call.
5
+
6
+ Accepts wav/mp3/flac/ogg/m4a — anything Whisper can read. Returns
7
+ the transcript as a single text block.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from core.schemas import ToolResult
17
+ from execution.tools.base import BaseTool
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class TranscribeAudioTool(BaseTool):
23
+ name = "TranscribeAudio"
24
+ description = (
25
+ "Convert a local audio file to text via Whisper. Use for "
26
+ "voice notes, meeting recordings, podcasts, voicemails, or "
27
+ "any 'what does this audio say?' request. Path must be a "
28
+ "local file (wav/mp3/flac/ogg/m4a/etc.). Returns the full "
29
+ "transcript."
30
+ )
31
+
32
+ def __init__(self):
33
+ self._stt: Any | None = None
34
+
35
+ def _get_stt(self):
36
+ if self._stt is None:
37
+ from voice.stt import make_stt
38
+ self._stt = make_stt(name="whisper")
39
+ return self._stt
40
+
41
+ @property
42
+ def input_schema(self) -> dict[str, Any]:
43
+ return {
44
+ "type": "object",
45
+ "properties": {
46
+ "path": {
47
+ "type": "string",
48
+ "description": "Path to a local audio file.",
49
+ },
50
+ },
51
+ "required": ["path"],
52
+ }
53
+
54
+ async def execute(self, **kwargs: Any) -> ToolResult:
55
+ path = (kwargs.get("path") or "").strip()
56
+ p = Path(path).expanduser().resolve()
57
+ if not p.exists() or not p.is_file():
58
+ return ToolResult(
59
+ tool_name=self.name, status="error",
60
+ error=f"audio file not found: {p}",
61
+ )
62
+
63
+ try:
64
+ audio_bytes = p.read_bytes()
65
+ stt = self._get_stt()
66
+ transcript = stt.transcribe(audio_bytes)
67
+ except Exception as e:
68
+ logger.exception("TranscribeAudio failed")
69
+ return ToolResult(
70
+ tool_name=self.name, status="error",
71
+ error=f"{type(e).__name__}: {e}",
72
+ )
73
+
74
+ if not transcript or not transcript.strip():
75
+ return ToolResult(
76
+ tool_name=self.name, status="success",
77
+ output="(empty transcript — no speech detected)",
78
+ metadata={"path": str(p), "transcript": ""},
79
+ )
80
+ return ToolResult(
81
+ tool_name=self.name, status="success",
82
+ output=transcript,
83
+ metadata={"path": str(p),
84
+ "transcript": transcript,
85
+ "char_count": len(transcript)},
86
+ )