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,61 @@
1
+ """Python execution tool — run Python code in a subprocess."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from core.schemas import ToolResult
11
+ from execution.tools.base import BaseTool
12
+
13
+
14
+ class PythonExecTool(BaseTool):
15
+ mutates = True
16
+ name = "PythonExec"
17
+ description = "Execute Python code in an isolated subprocess and return stdout. Use for calculations, data processing, or testing code snippets."
18
+
19
+ @property
20
+ def input_schema(self) -> dict:
21
+ return {
22
+ "type": "object",
23
+ "properties": {
24
+ "code": {
25
+ "type": "string",
26
+ "description": "Python code to execute",
27
+ },
28
+ },
29
+ "required": ["code"],
30
+ }
31
+
32
+ async def execute(self, **kwargs: Any) -> ToolResult:
33
+ code = kwargs.get("code", "")
34
+ if not code:
35
+ return self._error("No code provided")
36
+
37
+ # Write to temp file and execute in subprocess for isolation
38
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
39
+ f.write(code)
40
+ tmp_path = f.name
41
+
42
+ try:
43
+ proc = await asyncio.create_subprocess_exec(
44
+ "python3", tmp_path,
45
+ stdout=asyncio.subprocess.PIPE,
46
+ stderr=asyncio.subprocess.PIPE,
47
+ )
48
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
49
+ output = stdout.decode().strip()
50
+ errors = stderr.decode().strip()
51
+
52
+ if proc.returncode == 0:
53
+ return self._success(output or "(no output)")
54
+ else:
55
+ return self._error(f"Exit code {proc.returncode}: {errors}")
56
+ except asyncio.TimeoutError:
57
+ return self._error("Execution timed out after 30s")
58
+ except Exception as e:
59
+ return self._error(str(e))
60
+ finally:
61
+ Path(tmp_path).unlink(missing_ok=True)
@@ -0,0 +1,40 @@
1
+ """Respond tool — present information back to the user."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+
10
+ from core.schemas import ToolResult
11
+ from execution.tools.base import BaseTool
12
+
13
+ console = Console()
14
+
15
+
16
+ class RespondTool(BaseTool):
17
+ name = "Respond"
18
+ description = "Present a response or answer to the user. Use this to communicate results, explanations, or any information."
19
+
20
+ @property
21
+ def input_schema(self) -> dict:
22
+ return {
23
+ "type": "object",
24
+ "properties": {
25
+ "message": {
26
+ "type": "string",
27
+ "description": "The message to show the user",
28
+ },
29
+ },
30
+ "required": ["message"],
31
+ }
32
+
33
+ async def execute(self, **kwargs: Any) -> ToolResult:
34
+ message = kwargs.get("message", "")
35
+ if not message:
36
+ return self._error("No message provided")
37
+
38
+ console.print(Panel(message, title="Cognos", border_style="green"))
39
+ preview = message[:100] + "..." if len(message) > 100 else message
40
+ return self._success(f"Responded to user: {preview}")
@@ -0,0 +1,378 @@
1
+ """Sandbox — unified lifecycle + snapshot tool for the local sandbox.
2
+
3
+ Inspired by Vercel Sandbox's API (`Sandbox.create`, `runCommand`,
4
+ `snapshot`, `stop`, source-from-snapshot). Applied to our LOCAL 50GB
5
+ ext4 loopback at `/home/raveuk/cognos/sandbox/` — no cloud dependency,
6
+ no billing, no Vercel account.
7
+
8
+ Modes the LLM can use:
9
+
10
+ status — mount state, free space, snapshot count
11
+ run — execute a shell command with cwd locked to sandbox/
12
+ snapshot [label] — tar+zstd the current sandbox state into a snapshot
13
+ list_snapshots — show all snapshots with size + age + label
14
+ restore <id> — wipe sandbox + extract a snapshot (auto-checkpoints
15
+ the pre-restore state first, so restores are
16
+ reversible)
17
+ delete_snapshot <id> — remove a snapshot artifact
18
+
19
+ Snapshots live at `~/.cognos/sandbox-snapshots/<id>.tar.zst` with a
20
+ sidecar `.meta.json`. Each `restore` auto-creates an
21
+ `auto-pre-restore-<src>` snapshot first, so a bad restore is just
22
+ another restore away.
23
+
24
+ The `run` mode is the key isolation primitive: it locks `cwd` to the
25
+ sandbox dir and uses a constrained env, so the LLM can't accidentally
26
+ mutate the rest of the filesystem via this tool. (For full freedom
27
+ the LLM still has the `Bash` tool.)
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import logging
34
+ import os
35
+ import shutil
36
+ import subprocess
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ from core.schemas import ToolResult
41
+ from execution.tools.base import BaseTool
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ _SANDBOX_DIR = Path("/home/raveuk/cognos/sandbox")
47
+ _SNAPSHOT_DIR = Path.home() / ".cognos" / "sandbox-snapshots"
48
+ _BIN_DIR = Path("/home/raveuk/cognos/bin")
49
+ _RUN_TIMEOUT_S = 60.0
50
+
51
+
52
+ def _fmt_bytes(n: int) -> str:
53
+ for unit in ("B", "KB", "MB", "GB", "TB"):
54
+ if n < 1024 or unit == "TB":
55
+ return f"{n:.1f}{unit}" if unit != "B" else f"{n}B"
56
+ n /= 1024
57
+ return f"{n}TB"
58
+
59
+
60
+ def _is_mounted() -> bool:
61
+ if not _SANDBOX_DIR.exists():
62
+ return False
63
+ # /proc/mounts is fastest + always accurate
64
+ try:
65
+ with open("/proc/mounts") as f:
66
+ return any(str(_SANDBOX_DIR) in line for line in f)
67
+ except OSError:
68
+ return False
69
+
70
+
71
+ def _disk_usage() -> tuple[int, int, int]:
72
+ """Return (total, used, avail) bytes for the sandbox FS."""
73
+ s = shutil.disk_usage(_SANDBOX_DIR)
74
+ return s.total, s.used, s.free
75
+
76
+
77
+ def _list_snapshots() -> list[dict[str, Any]]:
78
+ if not _SNAPSHOT_DIR.exists():
79
+ return []
80
+ out: list[dict[str, Any]] = []
81
+ for archive in sorted(_SNAPSHOT_DIR.glob("*.tar.zst")):
82
+ sid = archive.stem
83
+ meta_path = _SNAPSHOT_DIR / f"{sid}.meta.json"
84
+ meta: dict[str, Any] = {}
85
+ if meta_path.exists():
86
+ try:
87
+ import json
88
+ meta = json.loads(meta_path.read_text())
89
+ except Exception:
90
+ pass
91
+ out.append({
92
+ "id": sid,
93
+ "label": meta.get("label", "-"),
94
+ "created": meta.get("created_utc", ""),
95
+ "size_bytes": archive.stat().st_size,
96
+ "path": str(archive),
97
+ })
98
+ return out
99
+
100
+
101
+ class SandboxTool(BaseTool):
102
+ mutates = True
103
+ name = "Sandbox"
104
+ description = (
105
+ "Unified sandbox lifecycle + snapshot tool. The sandbox is a "
106
+ "50GB ext4 loopback at /home/raveuk/cognos/sandbox/. Modes: "
107
+ "'status' (mount + disk + snapshot summary), 'run' (execute a "
108
+ "shell command with cwd locked to the sandbox), 'snapshot' "
109
+ "(tar+zstd current state to ~/.cognos/sandbox-snapshots/), "
110
+ "'restore' (wipe + extract a snapshot — auto-checkpoints first "
111
+ "so it's reversible), 'list_snapshots', 'delete_snapshot'. "
112
+ "Use 'snapshot' before letting the LLM rewrite a project so "
113
+ "you can roll back. 'run' for sandbox-scoped commands; for "
114
+ "full-system commands use the Bash tool instead."
115
+ )
116
+
117
+ @property
118
+ def input_schema(self) -> dict[str, Any]:
119
+ return {
120
+ "type": "object",
121
+ "properties": {
122
+ "action": {
123
+ "type": "string",
124
+ "enum": [
125
+ "status", "run", "snapshot",
126
+ "list_snapshots", "restore", "delete_snapshot",
127
+ ],
128
+ "description": "What to do.",
129
+ },
130
+ "cmd": {
131
+ "type": "string",
132
+ "description": "For 'run' mode: shell command to execute (cwd locked to sandbox).",
133
+ },
134
+ "label": {
135
+ "type": "string",
136
+ "description": "For 'snapshot' mode: short label for the snapshot.",
137
+ },
138
+ "id": {
139
+ "type": "string",
140
+ "description": "For 'restore' / 'delete_snapshot' mode: snapshot id.",
141
+ },
142
+ "timeout_s": {
143
+ "type": "number",
144
+ "description": "For 'run' mode: timeout in seconds (default 60, max 300).",
145
+ },
146
+ },
147
+ "required": ["action"],
148
+ }
149
+
150
+ # -- mode dispatch ---------------------------------------------------
151
+
152
+ async def execute(self, **kwargs: Any) -> ToolResult:
153
+ action = (kwargs.get("action") or "").lower().strip()
154
+ try:
155
+ if action == "status":
156
+ return self._status()
157
+ if action == "run":
158
+ return await self._run(kwargs)
159
+ if action == "snapshot":
160
+ return self._snapshot(kwargs)
161
+ if action == "list_snapshots":
162
+ return self._list_snapshots()
163
+ if action == "restore":
164
+ return self._restore(kwargs)
165
+ if action == "delete_snapshot":
166
+ return self._delete_snapshot(kwargs)
167
+ return ToolResult(
168
+ tool_name=self.name, status="error",
169
+ error=f"unknown action {action!r}; expected one of: status, run, snapshot, list_snapshots, restore, delete_snapshot",
170
+ )
171
+ except Exception as e:
172
+ logger.exception("Sandbox tool failed")
173
+ return ToolResult(
174
+ tool_name=self.name, status="error",
175
+ error=f"{type(e).__name__}: {e}",
176
+ )
177
+
178
+ # -- modes -----------------------------------------------------------
179
+
180
+ def _status(self) -> ToolResult:
181
+ mounted = _is_mounted()
182
+ snapshots = _list_snapshots()
183
+ out_lines = [f"sandbox path: {_SANDBOX_DIR}"]
184
+ out_lines.append(f" mounted: {mounted}")
185
+ if mounted:
186
+ total, used, avail = _disk_usage()
187
+ out_lines.append(
188
+ f" disk usage: {_fmt_bytes(used)} used / {_fmt_bytes(total)} total "
189
+ f"({_fmt_bytes(avail)} free)"
190
+ )
191
+ out_lines.append(f"snapshots: {len(snapshots)} stored at {_SNAPSHOT_DIR}")
192
+ if snapshots:
193
+ recent = snapshots[-3:]
194
+ for s in recent:
195
+ out_lines.append(
196
+ f" - {s['id']} ({_fmt_bytes(s['size_bytes'])}) {s['label']}"
197
+ )
198
+ if len(snapshots) > 3:
199
+ out_lines.append(f" ... and {len(snapshots) - 3} more (use list_snapshots)")
200
+ return ToolResult(
201
+ tool_name=self.name, status="success",
202
+ output="\n".join(out_lines),
203
+ metadata={
204
+ "mounted": mounted,
205
+ "snapshot_count": len(snapshots),
206
+ },
207
+ )
208
+
209
+ async def _run(self, kwargs: dict) -> ToolResult:
210
+ cmd = (kwargs.get("cmd") or "").strip()
211
+ if not cmd:
212
+ return ToolResult(
213
+ tool_name=self.name, status="error",
214
+ error="`cmd` is required for run mode",
215
+ )
216
+ if not _is_mounted():
217
+ return ToolResult(
218
+ tool_name=self.name, status="error",
219
+ error=f"sandbox not mounted at {_SANDBOX_DIR}",
220
+ )
221
+ timeout = max(1.0, min(300.0, float(kwargs.get("timeout_s") or _RUN_TIMEOUT_S)))
222
+
223
+ # cwd locked to the sandbox; env minimal so commands don't pick
224
+ # up the user's full shell setup
225
+ env = {
226
+ "HOME": str(_SANDBOX_DIR),
227
+ "PATH": "/usr/local/bin:/usr/bin:/bin",
228
+ "USER": os.environ.get("USER", "raveuk"),
229
+ "LANG": "C.UTF-8",
230
+ "TMPDIR": str(_SANDBOX_DIR / ".tmp"),
231
+ }
232
+ (_SANDBOX_DIR / ".tmp").mkdir(exist_ok=True)
233
+
234
+ try:
235
+ proc = await asyncio.create_subprocess_shell(
236
+ cmd,
237
+ cwd=str(_SANDBOX_DIR),
238
+ env=env,
239
+ stdout=asyncio.subprocess.PIPE,
240
+ stderr=asyncio.subprocess.PIPE,
241
+ )
242
+ stdout_b, stderr_b = await asyncio.wait_for(
243
+ proc.communicate(), timeout=timeout
244
+ )
245
+ except asyncio.TimeoutError:
246
+ try: proc.kill()
247
+ except Exception: pass
248
+ return ToolResult(
249
+ tool_name=self.name, status="error",
250
+ error=f"command timed out after {timeout}s",
251
+ )
252
+
253
+ stdout = stdout_b.decode("utf-8", errors="replace")
254
+ stderr = stderr_b.decode("utf-8", errors="replace")
255
+ rc = proc.returncode if proc.returncode is not None else -1
256
+
257
+ out = (
258
+ f"$ {cmd}\n"
259
+ f" cwd: {_SANDBOX_DIR}\n"
260
+ f" exit: {rc}\n"
261
+ )
262
+ if stdout:
263
+ out += f"--- stdout ---\n{stdout[:4000]}"
264
+ if len(stdout) > 4000:
265
+ out += f"\n... ({len(stdout) - 4000} more chars)"
266
+ if stderr:
267
+ out += f"\n--- stderr ---\n{stderr[:2000]}"
268
+ return ToolResult(
269
+ tool_name=self.name,
270
+ status="success" if rc == 0 else "error",
271
+ output=out.rstrip(),
272
+ error=None if rc == 0 else f"exit code {rc}",
273
+ metadata={
274
+ "exit_code": rc,
275
+ "stdout": stdout,
276
+ "stderr": stderr,
277
+ },
278
+ )
279
+
280
+ def _snapshot(self, kwargs: dict) -> ToolResult:
281
+ label = (kwargs.get("label") or "snapshot").strip()
282
+ script = _BIN_DIR / "cognos-sandbox-snapshot"
283
+ if not script.exists():
284
+ return ToolResult(
285
+ tool_name=self.name, status="error",
286
+ error=f"helper script missing: {script}",
287
+ )
288
+ try:
289
+ result = subprocess.run(
290
+ [str(script), label],
291
+ capture_output=True, text=True, timeout=300,
292
+ )
293
+ except subprocess.TimeoutExpired:
294
+ return ToolResult(
295
+ tool_name=self.name, status="error",
296
+ error="snapshot timed out (>5 min)",
297
+ )
298
+ if result.returncode != 0:
299
+ return ToolResult(
300
+ tool_name=self.name, status="error",
301
+ error=f"snapshot failed: {result.stderr or result.stdout}",
302
+ )
303
+ return ToolResult(
304
+ tool_name=self.name, status="success",
305
+ output=result.stdout.strip(),
306
+ metadata={"label": label},
307
+ )
308
+
309
+ def _list_snapshots(self) -> ToolResult:
310
+ snapshots = _list_snapshots()
311
+ if not snapshots:
312
+ return ToolResult(
313
+ tool_name=self.name, status="success",
314
+ output="no snapshots yet — use action='snapshot' to create one",
315
+ metadata={"snapshots": []},
316
+ )
317
+ lines = [f"{len(snapshots)} snapshot(s):"]
318
+ for s in snapshots:
319
+ lines.append(
320
+ f" {s['id']} {_fmt_bytes(s['size_bytes']):>10} "
321
+ f"{s['created']} {s['label']}"
322
+ )
323
+ return ToolResult(
324
+ tool_name=self.name, status="success",
325
+ output="\n".join(lines),
326
+ metadata={"snapshots": snapshots},
327
+ )
328
+
329
+ def _restore(self, kwargs: dict) -> ToolResult:
330
+ sid = (kwargs.get("id") or "").strip()
331
+ if not sid:
332
+ return ToolResult(
333
+ tool_name=self.name, status="error",
334
+ error="`id` is required for restore mode",
335
+ )
336
+ script = _BIN_DIR / "cognos-sandbox-restore"
337
+ try:
338
+ result = subprocess.run(
339
+ [str(script), sid, "-y"],
340
+ capture_output=True, text=True, timeout=300,
341
+ )
342
+ except subprocess.TimeoutExpired:
343
+ return ToolResult(
344
+ tool_name=self.name, status="error",
345
+ error="restore timed out (>5 min)",
346
+ )
347
+ if result.returncode != 0:
348
+ return ToolResult(
349
+ tool_name=self.name, status="error",
350
+ error=f"restore failed: {result.stderr or result.stdout}",
351
+ )
352
+ return ToolResult(
353
+ tool_name=self.name, status="success",
354
+ output=result.stdout.strip(),
355
+ metadata={"restored_id": sid},
356
+ )
357
+
358
+ def _delete_snapshot(self, kwargs: dict) -> ToolResult:
359
+ sid = (kwargs.get("id") or "").strip()
360
+ if not sid:
361
+ return ToolResult(
362
+ tool_name=self.name, status="error",
363
+ error="`id` is required for delete_snapshot mode",
364
+ )
365
+ archive = _SNAPSHOT_DIR / f"{sid}.tar.zst"
366
+ meta = _SNAPSHOT_DIR / f"{sid}.meta.json"
367
+ if not archive.exists():
368
+ return ToolResult(
369
+ tool_name=self.name, status="error",
370
+ error=f"snapshot not found: {sid}",
371
+ )
372
+ archive.unlink()
373
+ meta.unlink(missing_ok=True)
374
+ return ToolResult(
375
+ tool_name=self.name, status="success",
376
+ output=f"deleted snapshot {sid}",
377
+ metadata={"deleted_id": sid},
378
+ )
@@ -0,0 +1,153 @@
1
+ """WebSearch tool — real web search via DuckDuckGo HTML.
2
+
3
+ Uses the no-JS HTML endpoint (`html.duckduckgo.com`) which returns a list
4
+ of titled results without needing an API key. Falls back to the instant-
5
+ answer JSON API if the HTML scrape fails (some networks block it).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import html as html_lib
12
+ import json
13
+ import re
14
+ import urllib.parse
15
+ from typing import Any
16
+
17
+ from core.schemas import ToolResult
18
+ from execution.tools.base import BaseTool
19
+
20
+
21
+ # Loose patterns over the rendered HTML. DuckDuckGo's HTML layout is
22
+ # stable enough that these have held for years; if they break, the JSON
23
+ # fallback below still returns *something*.
24
+ _RESULT_BLOCK_RE = re.compile(
25
+ r'<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)</a>'
26
+ r'(?:.*?<a[^>]*class="result__snippet"[^>]*>(.*?)</a>)?',
27
+ re.DOTALL,
28
+ )
29
+ _TAG_RE = re.compile(r"<[^>]+>")
30
+
31
+
32
+ class WebSearchTool(BaseTool):
33
+ name = "WebSearch"
34
+ description = (
35
+ "Search the web with DuckDuckGo and return ranked results "
36
+ "(title, URL, snippet). Use to find current information, docs, "
37
+ "or sources before fetching a specific page with WebFetch."
38
+ )
39
+
40
+ @property
41
+ def input_schema(self) -> dict:
42
+ return {
43
+ "type": "object",
44
+ "properties": {
45
+ "query": {
46
+ "type": "string",
47
+ "description": "The search query",
48
+ },
49
+ "max_results": {
50
+ "type": "integer",
51
+ "description": "Max results to return (default 8)",
52
+ "default": 8,
53
+ },
54
+ },
55
+ "required": ["query"],
56
+ }
57
+
58
+ async def execute(self, **kwargs: Any) -> ToolResult:
59
+ query = (kwargs.get("query") or "").strip()
60
+ if not query:
61
+ return self._error("No query provided")
62
+ max_results = max(1, int(kwargs.get("max_results", 8)))
63
+
64
+ # 1. Real HTML results
65
+ try:
66
+ html = await _curl(
67
+ f"https://html.duckduckgo.com/html/?q={urllib.parse.quote(query)}",
68
+ method="POST",
69
+ data=f"q={urllib.parse.quote(query)}",
70
+ )
71
+ except Exception as e:
72
+ html = ""
73
+
74
+ results = _parse_html_results(html, max_results) if html else []
75
+
76
+ # 2. Fallback to instant-answer JSON if HTML scrape produced nothing
77
+ if not results:
78
+ try:
79
+ raw = await _curl(
80
+ f"https://api.duckduckgo.com/?q={urllib.parse.quote(query)}"
81
+ f"&format=json&no_html=1",
82
+ )
83
+ data = json.loads(raw) if raw else {}
84
+ except Exception:
85
+ data = {}
86
+ if data.get("Abstract"):
87
+ results.append({
88
+ "title": data.get("AbstractSource", "Summary"),
89
+ "url": data.get("AbstractURL", ""),
90
+ "snippet": data["Abstract"],
91
+ })
92
+ for topic in data.get("RelatedTopics", [])[:max_results]:
93
+ if isinstance(topic, dict) and topic.get("Text"):
94
+ results.append({
95
+ "title": topic.get("Text", "")[:80],
96
+ "url": topic.get("FirstURL", ""),
97
+ "snippet": topic.get("Text", ""),
98
+ })
99
+
100
+ if not results:
101
+ return self._success(f"No results found for: {query}")
102
+
103
+ lines = [f"# Search results for: {query}", ""]
104
+ for i, r in enumerate(results, 1):
105
+ lines.append(f"{i}. **{r['title']}**")
106
+ if r.get("url"):
107
+ lines.append(f" {r['url']}")
108
+ if r.get("snippet"):
109
+ lines.append(f" {r['snippet']}")
110
+ lines.append("")
111
+ return self._success("\n".join(lines))
112
+
113
+
114
+ async def _curl(
115
+ url: str, method: str = "GET", data: str | None = None,
116
+ ) -> str:
117
+ """Run curl in a subprocess, return decoded body (best effort)."""
118
+ args = [
119
+ "curl", "-sSL", "--max-time", "20",
120
+ "-A", "Mozilla/5.0 (cognos)",
121
+ ]
122
+ if method == "POST":
123
+ args += ["-X", "POST"]
124
+ if data:
125
+ args += ["--data", data]
126
+ args.append(url)
127
+ proc = await asyncio.create_subprocess_exec(
128
+ *args,
129
+ stdout=asyncio.subprocess.PIPE,
130
+ stderr=asyncio.subprocess.PIPE,
131
+ )
132
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=22)
133
+ return stdout.decode(errors="ignore")
134
+
135
+
136
+ def _parse_html_results(html: str, limit: int) -> list[dict[str, str]]:
137
+ out: list[dict[str, str]] = []
138
+ for match in _RESULT_BLOCK_RE.finditer(html):
139
+ url = match.group(1)
140
+ # DDG wraps real urls in /l/?uddg=<encoded>
141
+ if "uddg=" in url:
142
+ try:
143
+ qs = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
144
+ url = qs.get("uddg", [url])[0]
145
+ except Exception:
146
+ pass
147
+ title = html_lib.unescape(_TAG_RE.sub("", match.group(2) or "")).strip()
148
+ snippet = html_lib.unescape(_TAG_RE.sub("", match.group(3) or "")).strip()
149
+ if title and url:
150
+ out.append({"title": title, "url": url, "snippet": snippet})
151
+ if len(out) >= limit:
152
+ break
153
+ return out