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,440 @@
1
+ """OpenAPI — universal REST adapter from any OpenAPI 3 spec.
2
+
3
+ Inspired by the Vercel AI SDK's "AI Tool Maker" (generate tools from
4
+ OpenAPI specs). Works against any documented REST API: pass a spec
5
+ URL, browse its operations, then call them.
6
+
7
+ This is the universal escape hatch when a service has an OpenAPI/
8
+ Swagger spec but no Cognos-native or Agentic-marketplace tool. Single
9
+ HTTP call to fetch the spec, all operations become callable.
10
+
11
+ Modes:
12
+ - `browse` : list operations in a spec (operation_id, method, path, summary)
13
+ - `call` : execute one operation by operation_id with params
14
+ - `describe`: full input schema + parameter descriptions for one operation
15
+
16
+ Optional auth: `bearer_token` or `headers` parameter; both are
17
+ forwarded with the request. Specs are cached for 10 min so repeated
18
+ calls don't re-fetch.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+ import os
26
+ import re
27
+ import time
28
+ from typing import Any
29
+ from urllib.parse import urljoin, urlparse
30
+
31
+ import httpx
32
+
33
+ from core.schemas import ToolResult
34
+ from execution.tools.base import BaseTool
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ _DEFAULT_TIMEOUT_S = 30.0
40
+ _SPEC_TTL_S = 600.0 # cache OpenAPI specs for 10 min
41
+ _MAX_BODY_BYTES = 5 * 1024 * 1024
42
+
43
+
44
+ # In-memory spec cache: spec_url → (fetched_at, parsed_spec)
45
+ _CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
46
+
47
+
48
+ # ---------------------------------------------------------------------
49
+ # Spec loading
50
+ # ---------------------------------------------------------------------
51
+
52
+
53
+ async def _load_spec(spec_url: str) -> dict[str, Any]:
54
+ """Fetch + parse an OpenAPI spec; cached. Supports JSON and YAML."""
55
+ now = time.time()
56
+ cached = _CACHE.get(spec_url)
57
+ if cached and (now - cached[0]) < _SPEC_TTL_S:
58
+ return cached[1]
59
+
60
+ async with httpx.AsyncClient(timeout=_DEFAULT_TIMEOUT_S) as client:
61
+ resp = await client.get(spec_url, follow_redirects=True)
62
+ resp.raise_for_status()
63
+ text = resp.text
64
+
65
+ # Detect JSON vs YAML by content sniffing first; fall back on extension.
66
+ ctype = resp.headers.get("content-type", "")
67
+ parsed: dict[str, Any]
68
+ text_stripped = text.strip()
69
+ if text_stripped.startswith("{") or "json" in ctype:
70
+ parsed = json.loads(text)
71
+ else:
72
+ try:
73
+ import yaml
74
+ parsed = yaml.safe_load(text)
75
+ except ImportError:
76
+ raise RuntimeError(
77
+ "spec appears to be YAML but PyYAML is not installed; "
78
+ "use a JSON spec or `pip install pyyaml`"
79
+ )
80
+
81
+ if not isinstance(parsed, dict):
82
+ raise ValueError("spec did not parse to a JSON object")
83
+ if not parsed.get("openapi") and not parsed.get("swagger"):
84
+ raise ValueError(
85
+ "document does not look like an OpenAPI/Swagger spec "
86
+ "(missing `openapi` / `swagger` key)"
87
+ )
88
+ _CACHE[spec_url] = (now, parsed)
89
+ return parsed
90
+
91
+
92
+ def _server_base_url(spec: dict[str, Any], spec_url: str) -> str:
93
+ """Resolve the server base URL: spec.servers[0].url, falling back to
94
+ the host of the spec URL itself."""
95
+ servers = spec.get("servers") or []
96
+ if servers and isinstance(servers, list):
97
+ url = (servers[0].get("url") or "").strip()
98
+ if url:
99
+ # If relative (some specs do this), join with spec URL host
100
+ if url.startswith("/") or not urlparse(url).scheme:
101
+ p = urlparse(spec_url)
102
+ return f"{p.scheme}://{p.netloc}{url if url.startswith('/') else '/' + url}"
103
+ return url
104
+ # Swagger 2.0: host + basePath + schemes
105
+ host = spec.get("host")
106
+ base_path = spec.get("basePath", "") or ""
107
+ schemes = spec.get("schemes") or ["https"]
108
+ if host:
109
+ return f"{schemes[0]}://{host}{base_path}"
110
+ # Last resort: use the spec URL's origin
111
+ p = urlparse(spec_url)
112
+ return f"{p.scheme}://{p.netloc}"
113
+
114
+
115
+ def _iter_operations(spec: dict[str, Any]):
116
+ """Yield (operation_id, method, path, op_dict) for each operation."""
117
+ paths = spec.get("paths") or {}
118
+ for path, item in paths.items():
119
+ if not isinstance(item, dict):
120
+ continue
121
+ for method in ("get", "post", "put", "patch", "delete", "head", "options"):
122
+ op = item.get(method)
123
+ if not isinstance(op, dict):
124
+ continue
125
+ op_id = op.get("operationId") or _slug(f"{method}_{path}")
126
+ yield op_id, method.upper(), path, op
127
+
128
+
129
+ def _slug(s: str) -> str:
130
+ return re.sub(r"[^a-zA-Z0-9_]+", "_", s).strip("_")
131
+
132
+
133
+ def _find_operation(spec: dict[str, Any], operation_id: str
134
+ ) -> tuple[str, str, dict[str, Any]] | None:
135
+ for op_id, method, path, op in _iter_operations(spec):
136
+ if op_id == operation_id:
137
+ return method, path, op
138
+ return None
139
+
140
+
141
+ def _build_url_and_body(
142
+ base_url: str, path: str, method: str,
143
+ op: dict[str, Any], params: dict[str, Any],
144
+ ) -> tuple[str, dict[str, Any], dict[str, Any], Any]:
145
+ """Substitute path params, split out query params, locate the body.
146
+
147
+ Returns (url, query, header_overrides, body).
148
+ """
149
+ parameters = op.get("parameters") or []
150
+ path_params: dict[str, str] = {}
151
+ query: dict[str, Any] = {}
152
+ header_overrides: dict[str, Any] = {}
153
+
154
+ consumed = set()
155
+ for pdef in parameters:
156
+ loc = pdef.get("in")
157
+ name = pdef.get("name")
158
+ if name not in params:
159
+ continue
160
+ if loc == "path":
161
+ path_params[name] = str(params[name])
162
+ consumed.add(name)
163
+ elif loc == "query":
164
+ query[name] = params[name]
165
+ consumed.add(name)
166
+ elif loc == "header":
167
+ header_overrides[name] = str(params[name])
168
+ consumed.add(name)
169
+ # cookie params: not commonly used by LLMs; skip
170
+
171
+ # Path substitution
172
+ rendered_path = path
173
+ for k, v in path_params.items():
174
+ rendered_path = rendered_path.replace("{" + k + "}", v)
175
+ url = base_url.rstrip("/") + "/" + rendered_path.lstrip("/")
176
+
177
+ # Body: anything in params not consumed by parameters[] and the op
178
+ # has a requestBody, treat the rest as the body
179
+ body: Any = None
180
+ if method in ("POST", "PUT", "PATCH") and op.get("requestBody"):
181
+ leftover = {k: v for k, v in params.items() if k not in consumed}
182
+ # If user passed `body` explicitly, use that
183
+ body = params.get("body", leftover or None)
184
+
185
+ return url, query, header_overrides, body
186
+
187
+
188
+ # ---------------------------------------------------------------------
189
+ # Tool
190
+ # ---------------------------------------------------------------------
191
+
192
+
193
+ class OpenAPITool(BaseTool):
194
+ name = "OpenAPI"
195
+ description = (
196
+ "Universal REST adapter for any service with an OpenAPI 3 (or "
197
+ "Swagger 2) spec. Three modes: 'browse' lists all operations "
198
+ "in a spec; 'describe' shows one operation's input schema; "
199
+ "'call' executes an operation by operation_id with params. "
200
+ "Use this when the service has a documented spec but no "
201
+ "Cognos-native or marketplace tool. Pass `bearer_token` or "
202
+ "`headers` for auth."
203
+ )
204
+
205
+ @property
206
+ def input_schema(self) -> dict[str, Any]:
207
+ return {
208
+ "type": "object",
209
+ "properties": {
210
+ "action": {
211
+ "type": "string",
212
+ "enum": ["browse", "describe", "call"],
213
+ "description": "What to do: list ops / show one op's schema / execute one op.",
214
+ },
215
+ "spec_url": {
216
+ "type": "string",
217
+ "description": "URL to the OpenAPI/Swagger spec (JSON or YAML).",
218
+ },
219
+ "operation_id": {
220
+ "type": "string",
221
+ "description": "For describe/call: the operation_id from the spec.",
222
+ },
223
+ "params": {
224
+ "type": "object",
225
+ "description": "For call: parameter values keyed by parameter name. Path/query/header params are routed automatically; anything left over for POST/PUT/PATCH becomes the JSON body (or pass `body` explicitly).",
226
+ },
227
+ "bearer_token": {
228
+ "type": "string",
229
+ "description": "Optional: forwarded as `Authorization: Bearer <token>`.",
230
+ },
231
+ "headers": {
232
+ "type": "object",
233
+ "description": "Optional extra request headers (override anything generated automatically).",
234
+ },
235
+ "timeout_s": {
236
+ "type": "number",
237
+ "description": "Override timeout (default 30s, max 120s).",
238
+ },
239
+ },
240
+ "required": ["action", "spec_url"],
241
+ }
242
+
243
+ async def execute(self, **kwargs: Any) -> ToolResult:
244
+ action = (kwargs.get("action") or "").lower().strip()
245
+ spec_url = (kwargs.get("spec_url") or "").strip()
246
+ if not spec_url:
247
+ return ToolResult(
248
+ tool_name=self.name, status="error",
249
+ error="`spec_url` is required",
250
+ )
251
+
252
+ try:
253
+ spec = await _load_spec(spec_url)
254
+ except (httpx.HTTPError, ValueError, RuntimeError) as e:
255
+ return ToolResult(
256
+ tool_name=self.name, status="error",
257
+ error=f"failed to load spec: {e}",
258
+ )
259
+
260
+ if action == "browse":
261
+ return self._browse(spec, spec_url)
262
+ if action == "describe":
263
+ return self._describe(spec, kwargs)
264
+ if action == "call":
265
+ return await self._call(spec, spec_url, kwargs)
266
+ return ToolResult(
267
+ tool_name=self.name, status="error",
268
+ error=f"unknown action {action!r}; expected browse/describe/call",
269
+ )
270
+
271
+ # -- modes -----------------------------------------------------------
272
+
273
+ def _browse(self, spec: dict[str, Any], spec_url: str) -> ToolResult:
274
+ info = spec.get("info") or {}
275
+ title = info.get("title") or "(untitled)"
276
+ version = info.get("version") or "(no version)"
277
+ base = _server_base_url(spec, spec_url)
278
+
279
+ ops: list[dict[str, Any]] = []
280
+ for op_id, method, path, op in _iter_operations(spec):
281
+ ops.append({
282
+ "operation_id": op_id,
283
+ "method": method,
284
+ "path": path,
285
+ "summary": (op.get("summary") or "").strip()[:160],
286
+ "tags": op.get("tags") or [],
287
+ })
288
+
289
+ if not ops:
290
+ return ToolResult(
291
+ tool_name=self.name, status="success",
292
+ output=f"{title} ({version}): spec has no operations",
293
+ metadata={"title": title, "operations": []},
294
+ )
295
+
296
+ lines = [f"{title} ({version}) — {len(ops)} operation(s)",
297
+ f" base url: {base}", ""]
298
+ for op in ops:
299
+ tags = f" [{','.join(op['tags'])}]" if op["tags"] else ""
300
+ line = f" {op['method']:6s} {op['path']:50s} → {op['operation_id']}{tags}"
301
+ lines.append(line)
302
+ if op["summary"]:
303
+ lines.append(f" {op['summary']}")
304
+
305
+ return ToolResult(
306
+ tool_name=self.name, status="success",
307
+ output="\n".join(lines),
308
+ metadata={"title": title, "base_url": base, "operations": ops},
309
+ )
310
+
311
+ def _describe(self, spec: dict[str, Any], kwargs: dict) -> ToolResult:
312
+ op_id = (kwargs.get("operation_id") or "").strip()
313
+ if not op_id:
314
+ return ToolResult(
315
+ tool_name=self.name, status="error",
316
+ error="`operation_id` is required for describe",
317
+ )
318
+ found = _find_operation(spec, op_id)
319
+ if not found:
320
+ return ToolResult(
321
+ tool_name=self.name, status="error",
322
+ error=f"operation {op_id!r} not in spec",
323
+ )
324
+ method, path, op = found
325
+
326
+ lines = [
327
+ f"{method} {path} → operation_id={op_id}",
328
+ f" summary: {op.get('summary', '').strip()}",
329
+ f" description: {op.get('description', '').strip()[:300]}",
330
+ f" tags: {op.get('tags') or []}",
331
+ ]
332
+ params = op.get("parameters") or []
333
+ if params:
334
+ lines.append(" parameters:")
335
+ for pdef in params:
336
+ req = " (required)" if pdef.get("required") else ""
337
+ lines.append(
338
+ f" - {pdef.get('name')} (in={pdef.get('in')}, "
339
+ f"type={(pdef.get('schema') or {}).get('type', '?')}){req}: "
340
+ f"{(pdef.get('description') or '')[:120]}"
341
+ )
342
+ rb = op.get("requestBody")
343
+ if rb:
344
+ content = (rb.get("content") or {})
345
+ mime = next(iter(content.keys()), "?")
346
+ schema = (content.get(mime) or {}).get("schema") or {}
347
+ lines.append(f" requestBody ({mime}):")
348
+ lines.append(f" schema: {json.dumps(schema, indent=2)[:600]}")
349
+ return ToolResult(
350
+ tool_name=self.name, status="success",
351
+ output="\n".join(lines),
352
+ metadata={"operation_id": op_id, "method": method, "path": path,
353
+ "operation": op},
354
+ )
355
+
356
+ async def _call(self, spec: dict[str, Any], spec_url: str,
357
+ kwargs: dict) -> ToolResult:
358
+ op_id = (kwargs.get("operation_id") or "").strip()
359
+ params = kwargs.get("params") or {}
360
+ bearer = (kwargs.get("bearer_token") or "").strip()
361
+ extra_headers = kwargs.get("headers") or {}
362
+ timeout = max(1.0, min(120.0, float(kwargs.get("timeout_s") or _DEFAULT_TIMEOUT_S)))
363
+
364
+ if not op_id:
365
+ return ToolResult(
366
+ tool_name=self.name, status="error",
367
+ error="`operation_id` is required for call",
368
+ )
369
+ found = _find_operation(spec, op_id)
370
+ if not found:
371
+ return ToolResult(
372
+ tool_name=self.name, status="error",
373
+ error=f"operation {op_id!r} not in spec",
374
+ )
375
+ method, path, op = found
376
+ base = _server_base_url(spec, spec_url)
377
+ url, query, header_overrides, body = _build_url_and_body(
378
+ base, path, method, op, params
379
+ )
380
+
381
+ # Merge headers: bearer < spec-derived < user-supplied
382
+ headers: dict[str, Any] = {}
383
+ if bearer:
384
+ headers["Authorization"] = f"Bearer {bearer}"
385
+ headers.update(header_overrides)
386
+ headers.update({str(k): str(v) for k, v in extra_headers.items()})
387
+
388
+ try:
389
+ async with httpx.AsyncClient(
390
+ timeout=timeout, follow_redirects=True
391
+ ) as client:
392
+ resp = await client.request(
393
+ method, url,
394
+ params=query if query else None,
395
+ headers=headers if headers else None,
396
+ json=body if body is not None else None,
397
+ )
398
+ except httpx.TimeoutException:
399
+ return ToolResult(
400
+ tool_name=self.name, status="error",
401
+ error=f"timed out after {timeout}s calling {op_id}",
402
+ )
403
+ except httpx.RequestError as e:
404
+ return ToolResult(
405
+ tool_name=self.name, status="error",
406
+ error=f"network error: {e}",
407
+ )
408
+
409
+ body_bytes = resp.content[:_MAX_BODY_BYTES]
410
+ ctype = resp.headers.get("content-type", "")
411
+ if "json" in ctype:
412
+ try:
413
+ resp_body = resp.json()
414
+ body_str = json.dumps(resp_body, indent=2)[:4000]
415
+ except Exception:
416
+ body_str = body_bytes.decode("utf-8", errors="replace")[:4000]
417
+ resp_body = body_str
418
+ else:
419
+ body_str = body_bytes.decode("utf-8", errors="replace")[:4000]
420
+ resp_body = body_str
421
+
422
+ out = (
423
+ f"{method} {url}\n"
424
+ f" status: {resp.status_code} {resp.reason_phrase}\n"
425
+ f" type: {ctype}\n"
426
+ f"\n--- body ---\n{body_str}"
427
+ )
428
+ return ToolResult(
429
+ tool_name=self.name,
430
+ status="success" if resp.status_code < 400 else "error",
431
+ output=out,
432
+ error=None if resp.status_code < 400 else f"HTTP {resp.status_code}",
433
+ metadata={
434
+ "operation_id": op_id,
435
+ "method": method,
436
+ "url": str(resp.url),
437
+ "status_code": resp.status_code,
438
+ "body": resp_body,
439
+ },
440
+ )
@@ -0,0 +1,95 @@
1
+ """Plan Mode tools — toggle the read-only execution mode.
2
+
3
+ Two tools:
4
+
5
+ - **`EnterPlanMode`** — switch ON. Mutating tools (Bash, Write,
6
+ Edit, NotebookEdit, PythonExec, HttpRequest, Sandbox, etc.)
7
+ will refuse to run; read-only tools (Read, Grep, Glob,
8
+ SystemInfo, Calculator, …) keep working. Use when the user
9
+ wants the agent to *plan* a change and present it before
10
+ actually applying anything.
11
+ - **`ExitPlanMode`** — switch OFF. Mutating tools are allowed
12
+ again. The agent should call this once the user has reviewed
13
+ and approved the plan.
14
+
15
+ These are status-flip tools, not blocking prompts — the agent can
16
+ toggle plan mode mid-conversation and the executor's dispatch
17
+ refuses mutators until the next ExitPlanMode.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ from core.plan_mode import enter, exit_mode, is_active
25
+ from core.schemas import ToolResult
26
+ from execution.tools.base import BaseTool
27
+
28
+
29
+ class EnterPlanModeTool(BaseTool):
30
+ name = "EnterPlanMode"
31
+ description = (
32
+ "Enter plan mode — the executor will refuse any side-effect "
33
+ "tool (Bash, Write, Edit, NotebookEdit, PythonExec, "
34
+ "HttpRequest, Sandbox, UpdateMemory, Cron*, Task*, Worktree*, "
35
+ "Draw, EditImage, Storyboard, Speak, Artifact) until "
36
+ "ExitPlanMode is called. Read-only tools (Read, Grep, Glob, "
37
+ "FindAnywhere, SystemInfo, Calculator, DateTime, "
38
+ "SemanticSearch, DescribeImage, TranscribeAudio, WebSearch, "
39
+ "WebFetch, CognosCard, Think, AskUserQuestion, "
40
+ "MCPListServers, TaskList/Get/Output, CronList) keep "
41
+ "working. Use this when the user wants you to plan first "
42
+ "and apply changes only after review."
43
+ )
44
+
45
+ @property
46
+ def input_schema(self) -> dict[str, Any]:
47
+ return {"type": "object", "properties": {}}
48
+
49
+ async def execute(self, **_: Any) -> ToolResult:
50
+ if is_active():
51
+ return ToolResult(
52
+ tool_name=self.name, status="success",
53
+ output="Plan mode is already ON.",
54
+ metadata={"plan_mode": True, "changed": False},
55
+ )
56
+ enter()
57
+ return ToolResult(
58
+ tool_name=self.name, status="success",
59
+ output=(
60
+ "Plan mode is now ON. Mutating tools are blocked; "
61
+ "read-only tools still work. Use ExitPlanMode to "
62
+ "return to normal operation."
63
+ ),
64
+ metadata={"plan_mode": True, "changed": True},
65
+ )
66
+
67
+
68
+ class ExitPlanModeTool(BaseTool):
69
+ name = "ExitPlanMode"
70
+ description = (
71
+ "Exit plan mode — re-enable mutating tools. Call this once "
72
+ "the user has reviewed the planned changes and wants to "
73
+ "actually apply them."
74
+ )
75
+
76
+ @property
77
+ def input_schema(self) -> dict[str, Any]:
78
+ return {"type": "object", "properties": {}}
79
+
80
+ async def execute(self, **_: Any) -> ToolResult:
81
+ if not is_active():
82
+ return ToolResult(
83
+ tool_name=self.name, status="success",
84
+ output="Plan mode is already OFF.",
85
+ metadata={"plan_mode": False, "changed": False},
86
+ )
87
+ exit_mode()
88
+ return ToolResult(
89
+ tool_name=self.name, status="success",
90
+ output=(
91
+ "Plan mode is now OFF. Mutating tools are allowed "
92
+ "again."
93
+ ),
94
+ metadata={"plan_mode": False, "changed": True},
95
+ )
@@ -0,0 +1,157 @@
1
+ """PushNotification — desktop notification when a long job completes.
2
+
3
+ Sends an OS-level desktop notification so the user sees it even if
4
+ they've alt-tabbed away from the browser/terminal. Tries `notify-send`
5
+ on Linux first (which works under the user's XFCE/GNOME/KDE setup),
6
+ falls back to a terminal bell (BEL char) for headless sessions, and
7
+ returns a clear status either way.
8
+
9
+ Use cases:
10
+ - Storyboard finished generating (15-40 min jobs)
11
+ - Phase-N retrain completed
12
+ - FLUX-Kontext panel rendered
13
+ - Anything that takes long enough that the user's attention has
14
+ drifted elsewhere
15
+
16
+ Not for chat-turn updates — those go in the response text directly.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import shutil
23
+ import sys
24
+ from typing import Any
25
+
26
+ from core.schemas import ToolResult
27
+ from execution.tools.base import BaseTool
28
+
29
+
30
+ # Urgency mapping to notify-send levels. Same vocabulary FreeDesktop
31
+ # uses, exposed as plain string for clarity.
32
+ _URGENCY_MAP = {
33
+ "low": "low",
34
+ "normal": "normal",
35
+ "high": "critical", # notify-send calls it "critical"
36
+ "critical": "critical",
37
+ }
38
+
39
+
40
+ class PushNotificationTool(BaseTool):
41
+ mutates = True
42
+ name = "PushNotification"
43
+ description = (
44
+ "Send a desktop notification to the user. Useful when a "
45
+ "long-running job (storyboard, FLUX edit, retrain) finishes "
46
+ "and the user has switched away from the chat. Provide a "
47
+ "short `title` and optional `message` + `urgency` "
48
+ "(`low` / `normal` / `high`). Uses `notify-send` on Linux; "
49
+ "falls back to a terminal bell if no notification daemon is "
50
+ "available. Don't spam — one notification per completed job."
51
+ )
52
+
53
+ @property
54
+ def input_schema(self) -> dict[str, Any]:
55
+ return {
56
+ "type": "object",
57
+ "properties": {
58
+ "title": {
59
+ "type": "string",
60
+ "description": "Notification headline. Keep under 60 chars.",
61
+ },
62
+ "message": {
63
+ "type": "string",
64
+ "description": (
65
+ "Body text. Keep under 200 chars. Optional; "
66
+ "if omitted only the title appears."
67
+ ),
68
+ },
69
+ "urgency": {
70
+ "type": "string",
71
+ "enum": list(_URGENCY_MAP.keys()),
72
+ "description": "low / normal / high (default normal).",
73
+ "default": "normal",
74
+ },
75
+ "timeout_ms": {
76
+ "type": "integer",
77
+ "description": (
78
+ "Auto-dismiss after N ms. Default 5000. Set "
79
+ "0 for sticky."
80
+ ),
81
+ "default": 5000,
82
+ },
83
+ },
84
+ "required": ["title"],
85
+ }
86
+
87
+ async def execute(self, **kwargs: Any) -> ToolResult:
88
+ title = (kwargs.get("title") or "").strip()
89
+ if not title:
90
+ return ToolResult(
91
+ tool_name=self.name, status="error",
92
+ error="`title` is required",
93
+ )
94
+ message = (kwargs.get("message") or "").strip()
95
+ urgency = (kwargs.get("urgency") or "normal").lower()
96
+ urgency = _URGENCY_MAP.get(urgency, "normal")
97
+ try:
98
+ timeout_ms = int(kwargs.get("timeout_ms", 5000))
99
+ except (TypeError, ValueError):
100
+ timeout_ms = 5000
101
+
102
+ # Channel 1: notify-send (Linux desktop)
103
+ notify = shutil.which("notify-send")
104
+ if notify is not None:
105
+ argv = [
106
+ notify,
107
+ "--app-name", "Cognos",
108
+ "--urgency", urgency,
109
+ "--expire-time", str(max(0, timeout_ms)),
110
+ title,
111
+ ]
112
+ if message:
113
+ argv.append(message)
114
+ try:
115
+ proc = await asyncio.create_subprocess_exec(
116
+ *argv,
117
+ stdout=asyncio.subprocess.PIPE,
118
+ stderr=asyncio.subprocess.PIPE,
119
+ )
120
+ _, stderr = await asyncio.wait_for(
121
+ proc.communicate(), timeout=5,
122
+ )
123
+ if proc.returncode == 0:
124
+ return ToolResult(
125
+ tool_name=self.name, status="success",
126
+ output=f"Notification sent via notify-send: {title!r}",
127
+ metadata={
128
+ "channel": "notify-send",
129
+ "title": title,
130
+ "urgency": urgency,
131
+ },
132
+ )
133
+ # notify-send returned non-zero — fall through to bell
134
+ except (asyncio.TimeoutError, OSError):
135
+ pass
136
+
137
+ # Channel 2: terminal bell (works in any TTY)
138
+ try:
139
+ sys.stderr.write("\a")
140
+ sys.stderr.flush()
141
+ return ToolResult(
142
+ tool_name=self.name, status="success",
143
+ output=(
144
+ f"Notification sent via terminal bell (no desktop "
145
+ f"notifier available): {title!r}"
146
+ ),
147
+ metadata={
148
+ "channel": "bell",
149
+ "title": title,
150
+ "fallback_reason": "notify-send unavailable",
151
+ },
152
+ )
153
+ except Exception as e:
154
+ return ToolResult(
155
+ tool_name=self.name, status="error",
156
+ error=f"could not deliver notification: {e}",
157
+ )