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.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- 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
|
+
)
|