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,106 @@
1
+ """SemanticSearch — vector-similarity recall over the SemanticMemory store.
2
+
3
+ Cognos's `memory/semantic.py` keeps a chromadb collection of durable
4
+ facts/concepts the agent has learned. Stuff goes in via `.store()`
5
+ calls (currently from a few places in the agent loop); this tool is
6
+ the read side, exposed to the LLM so it can ask "have I seen this
7
+ topic before?" without the user having to type the answer.
8
+
9
+ Complements `UpdateMemory` (which writes a flat markdown file): this
10
+ one handles the *vector-indexed* knowledge — semantic similarity
11
+ beats string match for paraphrased recall.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from typing import Any
18
+
19
+ from core.schemas import ToolResult
20
+ from execution.tools.base import BaseTool
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class SemanticSearchTool(BaseTool):
26
+ name = "SemanticSearch"
27
+ description = (
28
+ "Vector-similarity search over Cognos's long-term semantic "
29
+ "memory (durable facts the agent has learned). Use this when "
30
+ "the user asks 'do you remember anything about X?', when "
31
+ "you'd benefit from prior context on a topic, or when "
32
+ "answering a question that might have been answered before. "
33
+ "Complements UpdateMemory (which is for the flat markdown "
34
+ "memory file): use this for fuzzy/semantic recall, "
35
+ "UpdateMemory for explicit append/replace."
36
+ )
37
+
38
+ def __init__(self):
39
+ # Lazy-initialise the store so cold-start of the executor
40
+ # doesn't pay the chromadb load cost. First call materialises.
41
+ self._store: Any | None = None
42
+
43
+ def _get_store(self):
44
+ if self._store is None:
45
+ from memory.semantic import SemanticMemory
46
+ self._store = SemanticMemory()
47
+ return self._store
48
+
49
+ @property
50
+ def input_schema(self) -> dict[str, Any]:
51
+ return {
52
+ "type": "object",
53
+ "properties": {
54
+ "query": {
55
+ "type": "string",
56
+ "description": "Natural-language query — paraphrasing is fine, the search uses semantic similarity.",
57
+ },
58
+ "limit": {
59
+ "type": "integer",
60
+ "description": "Max results to return. Default 5.",
61
+ "default": 5,
62
+ },
63
+ "category": {
64
+ "type": "string",
65
+ "description": "Optional category filter (e.g. 'preferences', 'project').",
66
+ },
67
+ },
68
+ "required": ["query"],
69
+ }
70
+
71
+ async def execute(self, **kwargs: Any) -> ToolResult:
72
+ query = (kwargs.get("query") or "").strip()
73
+ if not query:
74
+ return ToolResult(
75
+ tool_name=self.name, status="error",
76
+ error="empty query",
77
+ )
78
+ limit = max(1, int(kwargs.get("limit") or 5))
79
+ category = kwargs.get("category") or None
80
+
81
+ try:
82
+ store = self._get_store()
83
+ hits = store.recall(query=query, limit=limit, category=category)
84
+ except Exception as e:
85
+ logger.exception("SemanticSearch failed")
86
+ return ToolResult(
87
+ tool_name=self.name, status="error",
88
+ error=f"{type(e).__name__}: {e}",
89
+ )
90
+
91
+ if not hits:
92
+ return ToolResult(
93
+ tool_name=self.name, status="success",
94
+ output=f"No semantic matches for: {query!r}",
95
+ metadata={"query": query, "hits": []},
96
+ )
97
+ lines = [f"Found {len(hits)} semantic match(es) for: {query!r}"]
98
+ for i, h in enumerate(hits, 1):
99
+ cat = h.get("category", "general")
100
+ content = h.get("content", "")[:300]
101
+ lines.append(f" {i}. [{cat}] {content}")
102
+ return ToolResult(
103
+ tool_name=self.name, status="success",
104
+ output="\n".join(lines),
105
+ metadata={"query": query, "hits": hits},
106
+ )
@@ -0,0 +1,283 @@
1
+ """Shell tools — synchronous + background execution.
2
+
3
+ Two tools live here:
4
+
5
+ - **`Bash`** runs a command via `/bin/sh -c` (or `bash -c`). When
6
+ `run_in_background: true` the process is detached, its output is
7
+ redirected to a temp log file, and the tool returns the PID + log
8
+ path immediately so the LLM can keep going while the job runs.
9
+ - **`PowerShell`** invokes PowerShell Core (`pwsh`) with the same
10
+ `run_in_background` semantics. Useful for cross-platform scripts
11
+ that target Windows / .NET-flavoured tooling.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import os
18
+ import shutil
19
+ import shlex
20
+ import tempfile
21
+ import time
22
+ import uuid
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from core.schemas import ToolResult
27
+ from execution.tools.base import BaseTool
28
+
29
+
30
+ # Where background-job logs live. Tucked under tempdir so they're
31
+ # auto-cleaned by OS housekeeping; the path is returned to the caller
32
+ # so they can `Read` it any time before then.
33
+ _BG_LOG_DIR = Path(tempfile.gettempdir()) / "cognos-bg-jobs"
34
+ _BG_LOG_DIR.mkdir(parents=True, exist_ok=True)
35
+
36
+ # Patterns we never run, regardless of mode.
37
+ _BLOCKED = ("rm -rf /", "mkfs", "dd if=", ":(){", "fork bomb")
38
+
39
+
40
+ def _is_blocked(cmd: str) -> bool:
41
+ low = cmd.lower()
42
+ return any(b in low for b in _BLOCKED)
43
+
44
+
45
+ async def _run_foreground(
46
+ argv: list[str] | str, *, shell: bool, timeout: int,
47
+ env: dict[str, str] | None = None,
48
+ ) -> tuple[int, str, str, bool]:
49
+ """Run a command foreground; returns (returncode, stdout, stderr, timed_out)."""
50
+ if shell:
51
+ proc = await asyncio.create_subprocess_shell(
52
+ argv if isinstance(argv, str) else " ".join(argv),
53
+ stdout=asyncio.subprocess.PIPE,
54
+ stderr=asyncio.subprocess.PIPE,
55
+ env=env,
56
+ )
57
+ else:
58
+ proc = await asyncio.create_subprocess_exec(
59
+ *(argv if isinstance(argv, list) else shlex.split(argv)),
60
+ stdout=asyncio.subprocess.PIPE,
61
+ stderr=asyncio.subprocess.PIPE,
62
+ env=env,
63
+ )
64
+ try:
65
+ stdout, stderr = await asyncio.wait_for(
66
+ proc.communicate(), timeout=timeout,
67
+ )
68
+ return (
69
+ proc.returncode or 0,
70
+ stdout.decode("utf-8", errors="replace").strip(),
71
+ stderr.decode("utf-8", errors="replace").strip(),
72
+ False,
73
+ )
74
+ except asyncio.TimeoutError:
75
+ try:
76
+ proc.kill()
77
+ except Exception:
78
+ pass
79
+ return (-1, "", f"command timed out after {timeout}s", True)
80
+
81
+
82
+ def _spawn_background(
83
+ argv: list[str] | str, *, shell: bool,
84
+ label: str, env: dict[str, str] | None = None,
85
+ ) -> tuple[int, Path]:
86
+ """Detach a long-running process; returns (pid, log_path).
87
+
88
+ Stdout + stderr are merged into a single log file under
89
+ `_BG_LOG_DIR` so the caller can read it incrementally with `Read`
90
+ or `Grep` and decide when to consider the job done.
91
+ """
92
+ job_id = f"{int(time.time())}-{uuid.uuid4().hex[:6]}-{label}"
93
+ log_path = _BG_LOG_DIR / f"{job_id}.log"
94
+ log_fh = open(log_path, "wb", buffering=0)
95
+
96
+ # `setsid` so the child gets its own session and outlives us if we
97
+ # exit. `start_new_session=True` is the Python equivalent.
98
+ if shell:
99
+ cmd_str = argv if isinstance(argv, str) else " ".join(argv)
100
+ proc = asyncio.subprocess.subprocess.Popen( # type: ignore[attr-defined]
101
+ cmd_str, shell=True,
102
+ stdout=log_fh, stderr=log_fh,
103
+ stdin=asyncio.subprocess.subprocess.DEVNULL, # type: ignore[attr-defined]
104
+ start_new_session=True,
105
+ env=env,
106
+ )
107
+ else:
108
+ proc = asyncio.subprocess.subprocess.Popen( # type: ignore[attr-defined]
109
+ argv if isinstance(argv, list) else shlex.split(argv),
110
+ stdout=log_fh, stderr=log_fh,
111
+ stdin=asyncio.subprocess.subprocess.DEVNULL, # type: ignore[attr-defined]
112
+ start_new_session=True,
113
+ env=env,
114
+ )
115
+ log_fh.close() # the subprocess holds the fd; we don't need ours
116
+ return proc.pid, log_path
117
+
118
+
119
+ class ShellTool(BaseTool):
120
+ mutates = True
121
+ name = "Bash"
122
+ description = (
123
+ "Execute a shell command and return its output. Use for system "
124
+ "commands, file operations, git, and any CLI tool. Set "
125
+ "`run_in_background: true` for long-running commands — Cognos "
126
+ "spawns the process detached, returns the PID + log path "
127
+ "immediately, and you can `Read` the log file to track "
128
+ "progress without blocking the conversation."
129
+ )
130
+
131
+ @property
132
+ def input_schema(self) -> dict:
133
+ return {
134
+ "type": "object",
135
+ "properties": {
136
+ "command": {
137
+ "type": "string",
138
+ "description": "The shell command to execute (passed to sh -c).",
139
+ },
140
+ "timeout": {
141
+ "type": "integer",
142
+ "description": "Timeout in seconds (default 30, foreground only).",
143
+ "default": 30,
144
+ },
145
+ "run_in_background": {
146
+ "type": "boolean",
147
+ "description": (
148
+ "If true, the command is spawned detached. "
149
+ "Returns the PID + path to a log file capturing "
150
+ "stdout+stderr; the tool returns immediately "
151
+ "without waiting for the process to finish."
152
+ ),
153
+ "default": False,
154
+ },
155
+ },
156
+ "required": ["command"],
157
+ }
158
+
159
+ async def execute(self, **kwargs: Any) -> ToolResult:
160
+ command = kwargs.get("command", "")
161
+ if not command:
162
+ return self._error("No command provided")
163
+ if _is_blocked(command):
164
+ return self._error(f"Blocked dangerous command: {command}")
165
+
166
+ if kwargs.get("run_in_background"):
167
+ try:
168
+ pid, log_path = _spawn_background(
169
+ command, shell=True, label="bash",
170
+ )
171
+ except Exception as e:
172
+ return self._error(f"failed to spawn background job: {e}")
173
+ return self._success(
174
+ f"Background job started.\n"
175
+ f" pid: {pid}\n"
176
+ f" log file: {log_path}\n"
177
+ f"Use `Read` on the log path to inspect output. The "
178
+ f"process continues to run after this turn finishes."
179
+ )
180
+
181
+ timeout = int(kwargs.get("timeout", 30))
182
+ try:
183
+ rc, stdout, stderr, timed_out = await _run_foreground(
184
+ command, shell=True, timeout=timeout,
185
+ )
186
+ except Exception as e:
187
+ return self._error(str(e))
188
+
189
+ if timed_out:
190
+ return self._error(f"Command timed out after {timeout}s")
191
+ if rc == 0:
192
+ return self._success(stdout or "(no output)")
193
+ return self._error(f"Exit code {rc}: {stderr or stdout}")
194
+
195
+
196
+ class PowerShellTool(BaseTool):
197
+ mutates = True
198
+ name = "PowerShell"
199
+ description = (
200
+ "Execute a PowerShell Core (`pwsh`) command. Same semantics as "
201
+ "Bash but runs in a PowerShell session — useful for "
202
+ "cross-platform scripts targeting Windows tooling, .NET "
203
+ "objects, or PowerShell modules. Set `run_in_background: true` "
204
+ "for long-running commands."
205
+ )
206
+
207
+ @property
208
+ def input_schema(self) -> dict:
209
+ return {
210
+ "type": "object",
211
+ "properties": {
212
+ "command": {
213
+ "type": "string",
214
+ "description": (
215
+ "PowerShell command or script. Equivalent to "
216
+ "running `pwsh -NoProfile -Command \"<command>\"`."
217
+ ),
218
+ },
219
+ "timeout": {
220
+ "type": "integer",
221
+ "description": "Timeout in seconds (default 30, foreground only).",
222
+ "default": 30,
223
+ },
224
+ "run_in_background": {
225
+ "type": "boolean",
226
+ "description": (
227
+ "If true, spawn detached and return the PID + "
228
+ "log path immediately. See Bash for details."
229
+ ),
230
+ "default": False,
231
+ },
232
+ },
233
+ "required": ["command"],
234
+ }
235
+
236
+ async def execute(self, **kwargs: Any) -> ToolResult:
237
+ command = kwargs.get("command", "")
238
+ if not command:
239
+ return self._error("No command provided")
240
+
241
+ pwsh_path = shutil.which("pwsh") or shutil.which("powershell")
242
+ if not pwsh_path:
243
+ return self._error(
244
+ "PowerShell Core (`pwsh`) is not installed. Install it "
245
+ "via `snap install powershell --classic` (Linux) or "
246
+ "from https://github.com/PowerShell/PowerShell."
247
+ )
248
+
249
+ # Refuse the blocked set on the underlying string too — easy to
250
+ # try `Invoke-Expression "rm -rf /"` from PowerShell.
251
+ if _is_blocked(command):
252
+ return self._error(f"Blocked dangerous command: {command}")
253
+
254
+ argv = [pwsh_path, "-NoProfile", "-NonInteractive",
255
+ "-Command", command]
256
+
257
+ if kwargs.get("run_in_background"):
258
+ try:
259
+ pid, log_path = _spawn_background(
260
+ argv, shell=False, label="pwsh",
261
+ )
262
+ except Exception as e:
263
+ return self._error(f"failed to spawn background pwsh job: {e}")
264
+ return self._success(
265
+ f"Background PowerShell job started.\n"
266
+ f" pid: {pid}\n"
267
+ f" log file: {log_path}\n"
268
+ f"Use `Read` on the log path to inspect output."
269
+ )
270
+
271
+ timeout = int(kwargs.get("timeout", 30))
272
+ try:
273
+ rc, stdout, stderr, timed_out = await _run_foreground(
274
+ argv, shell=False, timeout=timeout,
275
+ )
276
+ except Exception as e:
277
+ return self._error(str(e))
278
+
279
+ if timed_out:
280
+ return self._error(f"Command timed out after {timeout}s")
281
+ if rc == 0:
282
+ return self._success(stdout or "(no output)")
283
+ return self._error(f"Exit code {rc}: {stderr or stdout}")
@@ -0,0 +1,134 @@
1
+ """Speak — text-to-speech, returns a markdown audio link.
2
+
3
+ Uses Cognos's existing Kokoro TTS backend (`voice/tts.py`). The
4
+ synthesized audio is saved to FileStore so the chat UI can play it
5
+ back via the `/files/{id}/content` endpoint, just like images.
6
+
7
+ Returns a `markdown` field with `[🔊 alt](url)` that the LLM should
8
+ echo verbatim in its reply — Open WebUI renders the link as a
9
+ playable audio control.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ import tempfile
17
+ import wave
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
+ def _public_base_url() -> str:
27
+ return os.environ.get("COGNOS_PUBLIC_URL", "http://127.0.0.1:8000").rstrip("/")
28
+
29
+
30
+ class SpeakTool(BaseTool):
31
+ mutates = True
32
+ name = "Speak"
33
+ description = (
34
+ "Synthesize text into speech via Kokoro TTS, save the audio "
35
+ "to FileStore, and return a markdown audio link the chat UI "
36
+ "can play. Use when the user asks you to read something aloud, "
37
+ "produce a voice clip, or generate a TTS preview. Returns a "
38
+ "`markdown` field — paste it VERBATIM into your reply."
39
+ )
40
+
41
+ def __init__(self, file_store: Any | None = None):
42
+ self._file_store = file_store
43
+ self._tts: Any | None = None
44
+
45
+ def set_file_store(self, file_store: Any) -> None:
46
+ self._file_store = file_store
47
+
48
+ def _get_tts(self):
49
+ if self._tts is None:
50
+ from voice.tts import make_tts
51
+ self._tts = make_tts(name="kokoro")
52
+ return self._tts
53
+
54
+ @property
55
+ def input_schema(self) -> dict[str, Any]:
56
+ return {
57
+ "type": "object",
58
+ "properties": {
59
+ "text": {
60
+ "type": "string",
61
+ "description": "What to speak. Keep under ~500 chars for fast synthesis.",
62
+ },
63
+ "title": {
64
+ "type": "string",
65
+ "description": "Optional short label (used as audio link text).",
66
+ },
67
+ },
68
+ "required": ["text"],
69
+ }
70
+
71
+ async def execute(self, **kwargs: Any) -> ToolResult:
72
+ text = (kwargs.get("text") or "").strip()
73
+ title = (kwargs.get("title") or "Audio clip").strip()
74
+ if not text:
75
+ return ToolResult(
76
+ tool_name=self.name, status="error",
77
+ error="empty text",
78
+ )
79
+ if self._file_store is None:
80
+ return ToolResult(
81
+ tool_name=self.name, status="error",
82
+ error="Speak tool not wired to a FileStore",
83
+ )
84
+
85
+ try:
86
+ tts = self._get_tts()
87
+ audio, sr = tts.synthesize(text)
88
+ except Exception as e:
89
+ logger.exception("Speak: synthesis failed")
90
+ return ToolResult(
91
+ tool_name=self.name, status="error",
92
+ error=f"synthesis failed: {e}",
93
+ )
94
+
95
+ # Persist as 16-bit PCM WAV
96
+ try:
97
+ with tempfile.NamedTemporaryFile(
98
+ "wb", suffix=".wav", delete=False
99
+ ) as tmp:
100
+ tmp_path = tmp.name
101
+ with wave.open(tmp_path, "wb") as wav:
102
+ wav.setnchannels(1)
103
+ wav.setsampwidth(2)
104
+ wav.setframerate(int(sr))
105
+ wav.writeframes(audio.tobytes())
106
+ safe_title = "".join(
107
+ c if c.isalnum() or c in "-_" else "_" for c in title
108
+ )[:40] or "speech"
109
+ stored_name = f"{safe_title}.wav"
110
+ record = self._file_store.upload(tmp_path, filename=stored_name)
111
+ except Exception as e:
112
+ logger.exception("Speak: persist failed")
113
+ return ToolResult(
114
+ tool_name=self.name, status="error",
115
+ error=f"persist failed: {e}",
116
+ )
117
+
118
+ url = f"{_public_base_url()}/files/{record.id}/content"
119
+ markdown = f"[🔊 {title}]({url})"
120
+ return ToolResult(
121
+ tool_name=self.name, status="success",
122
+ output=(
123
+ f"Speech synthesized.\n"
124
+ f" text: {text[:80]}{'...' if len(text) > 80 else ''}\n"
125
+ f" duration: ~{len(audio) / int(sr):.1f}s\n"
126
+ f" url: {url}\n\n"
127
+ f"Paste in your reply VERBATIM:\n"
128
+ f" {markdown}"
129
+ ),
130
+ metadata={
131
+ "file_id": record.id, "url": url, "markdown": markdown,
132
+ "duration_s": len(audio) / int(sr),
133
+ },
134
+ )