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,215 @@
1
+ """DateTime — current time, parse, format, relative dates.
2
+
3
+ LLMs always have a stale "today" because their training cut-off is
4
+ in the past, and even if they hold a notion of "now" it drifts
5
+ through a long conversation. This tool gives them an accurate
6
+ single source of truth for time-related questions.
7
+
8
+ Modes:
9
+ - now: return the current datetime (UTC + local + ISO).
10
+ - parse: parse a string into a datetime, return its components.
11
+ - format: format a datetime string to a different layout.
12
+ - delta: compute time elapsed between two datetimes, OR add/
13
+ subtract a duration from a base datetime.
14
+
15
+ Pure stdlib. No timezone DB beyond what Python ships with.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from datetime import datetime, timedelta, timezone
22
+ from typing import Any
23
+
24
+ from core.schemas import ToolResult
25
+ from execution.tools.base import BaseTool
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def _now_block() -> dict[str, str]:
31
+ now_utc = datetime.now(timezone.utc)
32
+ now_local = datetime.now().astimezone()
33
+ return {
34
+ "iso_utc": now_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
35
+ "iso_local": now_local.strftime("%Y-%m-%dT%H:%M:%S%z"),
36
+ "weekday": now_local.strftime("%A"),
37
+ "human": now_local.strftime("%Y-%m-%d %H:%M:%S %Z"),
38
+ }
39
+
40
+
41
+ def _parse_datetime(s: str) -> datetime:
42
+ """Best-effort datetime parse. Tries ISO 8601 first, then a few
43
+ common human formats."""
44
+ s = s.strip()
45
+ # ISO 8601 (most common LLM output)
46
+ try:
47
+ # Replace trailing Z with +00:00 for fromisoformat
48
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
49
+ except ValueError:
50
+ pass
51
+ # Common human formats
52
+ for fmt in (
53
+ "%Y-%m-%d %H:%M:%S",
54
+ "%Y-%m-%d %H:%M",
55
+ "%Y-%m-%d",
56
+ "%d/%m/%Y",
57
+ "%d-%m-%Y",
58
+ "%m/%d/%Y",
59
+ "%Y/%m/%d",
60
+ "%A, %d %B %Y %H:%M:%S",
61
+ ):
62
+ try:
63
+ return datetime.strptime(s, fmt)
64
+ except ValueError:
65
+ continue
66
+ raise ValueError(f"could not parse datetime: {s!r}")
67
+
68
+
69
+ def _parse_duration(s: str) -> timedelta:
70
+ """Parse '3d', '24h', '90m', '60s', or signed combos like '-7d', '+30m'."""
71
+ s = s.strip().lower()
72
+ if not s:
73
+ raise ValueError("empty duration")
74
+ sign = 1
75
+ if s[0] in "+-":
76
+ sign = -1 if s[0] == "-" else 1
77
+ s = s[1:]
78
+ if s.endswith("d"):
79
+ return sign * timedelta(days=float(s[:-1]))
80
+ if s.endswith("h"):
81
+ return sign * timedelta(hours=float(s[:-1]))
82
+ if s.endswith("m"):
83
+ return sign * timedelta(minutes=float(s[:-1]))
84
+ if s.endswith("s"):
85
+ return sign * timedelta(seconds=float(s[:-1]))
86
+ if s.endswith("w"):
87
+ return sign * timedelta(weeks=float(s[:-1]))
88
+ raise ValueError(f"could not parse duration: {s!r}")
89
+
90
+
91
+ class DateTimeTool(BaseTool):
92
+ name = "DateTime"
93
+ description = (
94
+ "Authoritative time tool. Use whenever the user asks 'what time "
95
+ "is it', 'what's today's date', 'how long ago was X', or "
96
+ "anything time-relative. Modes: 'now' (current time), 'parse' "
97
+ "(parse a string), 'format' (re-format a date), 'delta' "
98
+ "(compute elapsed time, or add/subtract duration). "
99
+ "Don't compute dates in your head — call this; you'll be wrong."
100
+ )
101
+
102
+ @property
103
+ def input_schema(self) -> dict[str, Any]:
104
+ return {
105
+ "type": "object",
106
+ "properties": {
107
+ "mode": {
108
+ "type": "string",
109
+ "enum": ["now", "parse", "format", "delta"],
110
+ "description": "What to do.",
111
+ },
112
+ "input": {
113
+ "type": "string",
114
+ "description": "For parse: the date string. For format: the date to re-format. For delta: the base datetime.",
115
+ },
116
+ "format": {
117
+ "type": "string",
118
+ "description": "strftime format for 'format' mode (e.g. '%Y-%m-%d').",
119
+ },
120
+ "duration": {
121
+ "type": "string",
122
+ "description": "For delta mode: a duration like '3d', '-24h', '+90m' to add to `input`.",
123
+ },
124
+ "until": {
125
+ "type": "string",
126
+ "description": "For delta mode: a second datetime to compute (until - input).",
127
+ },
128
+ },
129
+ "required": ["mode"],
130
+ }
131
+
132
+ async def execute(self, **kwargs: Any) -> ToolResult:
133
+ mode = (kwargs.get("mode") or "").lower().strip()
134
+ try:
135
+ if mode == "now":
136
+ return ToolResult(
137
+ tool_name=self.name, status="success",
138
+ output="\n".join(f"{k}: {v}" for k, v in _now_block().items()),
139
+ metadata=_now_block(),
140
+ )
141
+
142
+ if mode == "parse":
143
+ s = kwargs.get("input") or ""
144
+ dt = _parse_datetime(s)
145
+ meta = {
146
+ "iso": dt.isoformat(),
147
+ "year": dt.year, "month": dt.month, "day": dt.day,
148
+ "hour": dt.hour, "minute": dt.minute, "second": dt.second,
149
+ "weekday": dt.strftime("%A"),
150
+ "tz": str(dt.tzinfo) if dt.tzinfo else "naive",
151
+ }
152
+ return ToolResult(
153
+ tool_name=self.name, status="success",
154
+ output="\n".join(f"{k}: {v}" for k, v in meta.items()),
155
+ metadata=meta,
156
+ )
157
+
158
+ if mode == "format":
159
+ s = kwargs.get("input") or ""
160
+ fmt = kwargs.get("format") or "%Y-%m-%d"
161
+ dt = _parse_datetime(s)
162
+ formatted = dt.strftime(fmt)
163
+ return ToolResult(
164
+ tool_name=self.name, status="success",
165
+ output=formatted,
166
+ metadata={"formatted": formatted},
167
+ )
168
+
169
+ if mode == "delta":
170
+ base = _parse_datetime(kwargs.get("input") or "")
171
+ # Two sub-modes: until (compute elapsed) OR duration (add/subtract)
172
+ until = kwargs.get("until")
173
+ duration = kwargs.get("duration")
174
+ if until and duration:
175
+ return ToolResult(
176
+ tool_name=self.name, status="error",
177
+ error="provide either 'until' or 'duration', not both",
178
+ )
179
+ if until:
180
+ other = _parse_datetime(until)
181
+ delta = other - base
182
+ return ToolResult(
183
+ tool_name=self.name, status="success",
184
+ output=f"{base.isoformat()} → {other.isoformat()}: "
185
+ f"{delta.total_seconds():.0f}s "
186
+ f"({delta.days}d {delta.seconds//3600}h)",
187
+ metadata={
188
+ "from": base.isoformat(),
189
+ "to": other.isoformat(),
190
+ "seconds": delta.total_seconds(),
191
+ "days": delta.days,
192
+ },
193
+ )
194
+ if duration:
195
+ delta = _parse_duration(duration)
196
+ target = base + delta
197
+ return ToolResult(
198
+ tool_name=self.name, status="success",
199
+ output=f"{base.isoformat()} {duration} → {target.isoformat()}",
200
+ metadata={"result": target.isoformat()},
201
+ )
202
+ return ToolResult(
203
+ tool_name=self.name, status="error",
204
+ error="delta mode needs either 'until' or 'duration'",
205
+ )
206
+
207
+ return ToolResult(
208
+ tool_name=self.name, status="error",
209
+ error=f"unknown mode {mode!r}; expected now/parse/format/delta",
210
+ )
211
+ except Exception as e:
212
+ return ToolResult(
213
+ tool_name=self.name, status="error",
214
+ error=f"{type(e).__name__}: {e}",
215
+ )
@@ -0,0 +1,161 @@
1
+ """DescribeImage — caption / OCR / Q&A about a local image file.
2
+
3
+ Uses the chat LLM (typically Haiku via system2) with a multimodal
4
+ message — base64-encoded image + a text question. Both Haiku and Opus
5
+ natively support image input via the OpenAI-compat content-blocks
6
+ shape that LiteLLM forwards to Anthropic.
7
+
8
+ Earlier versions tried to reach into Cognos's `InternVLVisionEncoder`
9
+ for a `.chat()` method that doesn't exist (that encoder only produces
10
+ 4096-D embeddings, not natural-language output). This version uses
11
+ the chat LLM the user already has configured — costs a small per-call
12
+ quota burn but works against any multimodal provider.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import json
19
+ import logging
20
+ import mimetypes
21
+ import os
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from core.schemas import ToolResult
26
+ from execution.tools.base import BaseTool
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def _guess_mime(path: Path) -> str:
32
+ mime, _ = mimetypes.guess_type(str(path))
33
+ if mime and mime.startswith("image/"):
34
+ return mime
35
+ suffix = path.suffix.lower().lstrip(".")
36
+ return f"image/{suffix}" if suffix in ("png", "jpeg", "jpg", "webp", "gif") else "image/png"
37
+
38
+
39
+ def _read_settings_model() -> str:
40
+ """Pick the LLM to ask. Prefer system2 (smarter, multimodal) so
41
+ the description quality is good. Fall back to system1 or a
42
+ sensible default."""
43
+ settings_path = Path.home() / ".cognos" / "settings.json"
44
+ try:
45
+ d = json.loads(settings_path.read_text())
46
+ return (d.get("system2")
47
+ or d.get("system1")
48
+ or "anthropic/claude-haiku-4-5")
49
+ except Exception:
50
+ return "anthropic/claude-haiku-4-5"
51
+
52
+
53
+ class DescribeImageTool(BaseTool):
54
+ name = "DescribeImage"
55
+ description = (
56
+ "Describe / OCR / answer questions about a local image file. "
57
+ "Supports PNG, JPG, WEBP, GIF. Sends the image to the chat LLM "
58
+ "(Haiku / Opus / whichever multimodal provider is configured) "
59
+ "via a multimodal message. Use for screenshots, photos, "
60
+ "diagrams, OCR-style text extraction, or any 'what does this "
61
+ "image show?' question. Path must be a local file; use "
62
+ "WebFetch first if you need to pull a URL."
63
+ )
64
+
65
+ @property
66
+ def input_schema(self) -> dict[str, Any]:
67
+ return {
68
+ "type": "object",
69
+ "properties": {
70
+ "path": {
71
+ "type": "string",
72
+ "description": "Path to a local image file (png/jpg/webp/gif/...).",
73
+ },
74
+ "prompt": {
75
+ "type": "string",
76
+ "description": "Optional question or framing (default: 'Describe this image in detail.')",
77
+ "default": "Describe this image in detail.",
78
+ },
79
+ "model": {
80
+ "type": "string",
81
+ "description": "Override the LLM model id. Default: settings.json system2.",
82
+ },
83
+ },
84
+ "required": ["path"],
85
+ }
86
+
87
+ async def execute(self, **kwargs: Any) -> ToolResult:
88
+ path_str = (kwargs.get("path") or "").strip()
89
+ prompt = (kwargs.get("prompt") or "Describe this image in detail.").strip()
90
+ model = (kwargs.get("model") or "").strip() or _read_settings_model()
91
+
92
+ path = Path(path_str).expanduser().resolve()
93
+ if not path.exists() or not path.is_file():
94
+ return ToolResult(
95
+ tool_name=self.name, status="error",
96
+ error=f"image file not found: {path}",
97
+ )
98
+
99
+ # Bound the body — multimodal messages with > a few MB of base64
100
+ # are wasteful and may hit provider limits.
101
+ try:
102
+ raw = path.read_bytes()
103
+ except OSError as e:
104
+ return ToolResult(
105
+ tool_name=self.name, status="error",
106
+ error=f"failed to read image: {e}",
107
+ )
108
+ if len(raw) > 8 * 1024 * 1024:
109
+ return ToolResult(
110
+ tool_name=self.name, status="error",
111
+ error=f"image too large ({len(raw)/1024/1024:.1f}MB) "
112
+ "— max 8MB; resize first",
113
+ )
114
+
115
+ mime = _guess_mime(path)
116
+ b64 = base64.b64encode(raw).decode("ascii")
117
+ data_url = f"data:{mime};base64,{b64}"
118
+
119
+ # Build multimodal message — OpenAI-compat content blocks shape
120
+ # that LiteLLM translates per provider (Anthropic uses its own
121
+ # `image` block; OpenAI uses `image_url`; the SDK handles both).
122
+ messages = [{
123
+ "role": "user",
124
+ "content": [
125
+ {"type": "text", "text": prompt},
126
+ {"type": "image_url", "image_url": {"url": data_url}},
127
+ ],
128
+ }]
129
+
130
+ try:
131
+ from llm.provider import LLMProvider
132
+ from core.anthropic_auth import subscription_auth_scope
133
+ llm = LLMProvider(model=model)
134
+ # Subscription-auth scope so anthropic/* models work without
135
+ # an API key (mirrors the chat path's behavior).
136
+ with subscription_auth_scope():
137
+ resp = await llm.chat(messages=messages, max_tokens=2048)
138
+ except Exception as e:
139
+ logger.exception("DescribeImage LLM call failed")
140
+ return ToolResult(
141
+ tool_name=self.name, status="error",
142
+ error=f"LLM call failed: {e}",
143
+ )
144
+
145
+ description = (resp.content or "").strip()
146
+ if not description:
147
+ return ToolResult(
148
+ tool_name=self.name, status="error",
149
+ error="LLM returned empty content",
150
+ )
151
+ return ToolResult(
152
+ tool_name=self.name, status="success",
153
+ output=description,
154
+ metadata={
155
+ "path": str(path),
156
+ "mime": mime,
157
+ "size_bytes": len(raw),
158
+ "model": model,
159
+ "prompt": prompt,
160
+ },
161
+ )
@@ -0,0 +1,164 @@
1
+ """Draw tool — generate an image from a text prompt.
2
+
3
+ The agent calls `Draw(prompt=..., size=...)`, the tool runs the
4
+ configured image-gen backend, persists the result via FileStore, and
5
+ returns a markdown image link the agent can paste directly into the
6
+ chat reply so the user's UI (Open WebUI / Claude Code) renders the
7
+ image inline.
8
+
9
+ Backends:
10
+ - "diffusers" (default, local)
11
+ - "comfyui" (HTTP proxy)
12
+ - "litellm" (cloud)
13
+
14
+ Backend choice + model id come from settings (`image.backend`,
15
+ `image.model`) so a project can override the default without touching
16
+ code. Backend instances are cached per-tool so the diffusers pipeline
17
+ loads once and stays in VRAM.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import os
24
+ from typing import Any
25
+
26
+ from core.image import ImageGen, generate_to_file_store, make_image_gen
27
+ from core.schemas import ToolResult
28
+ from execution.tools.base import BaseTool
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ def _public_base_url() -> str:
34
+ """Where files served at /files/{id}/content are reachable from.
35
+
36
+ Defaults to http://127.0.0.1:8000 (same-host browser). Override via
37
+ COGNOS_PUBLIC_URL when Cognos is being accessed remotely (mobile,
38
+ LAN), so generated images render in the remote browser too.
39
+ """
40
+ return os.environ.get("COGNOS_PUBLIC_URL", "http://127.0.0.1:8000").rstrip("/")
41
+
42
+
43
+ class DrawTool(BaseTool):
44
+ mutates = True
45
+ name = "Draw"
46
+ description = (
47
+ "Generate an image from a text description. Use when the user "
48
+ "asks for a drawing, illustration, diagram, picture, or visual "
49
+ "concept. The tool returns a `markdown` field — copy that "
50
+ "string VERBATIM into your reply (looks like "
51
+ "`![alt](http://.../files/<id>/content)`) so the user's chat "
52
+ "UI renders the image inline. Be specific in the prompt: "
53
+ "subject, composition, style (e.g. 'watercolor', 'pixel art', "
54
+ "'photorealistic'), lighting, mood. Don't include text-in-"
55
+ "image instructions; image models render text poorly."
56
+ )
57
+
58
+ def __init__(
59
+ self,
60
+ file_store: Any | None = None,
61
+ backend: str = "diffusers",
62
+ backend_kwargs: dict | None = None,
63
+ ):
64
+ self._file_store = file_store
65
+ self._backend_name = backend
66
+ self._backend_kwargs = backend_kwargs or {}
67
+ self._backend: ImageGen | None = None
68
+
69
+ @property
70
+ def input_schema(self) -> dict:
71
+ return {
72
+ "type": "object",
73
+ "properties": {
74
+ "prompt": {
75
+ "type": "string",
76
+ "description": "Text description of the image to generate.",
77
+ },
78
+ "size": {
79
+ "type": "string",
80
+ "description": "Pixel dimensions. Common: 1024x1024 (square), 1024x768 (4:3), 768x1024 (3:4), 1280x720 (16:9). Default 1024x1024.",
81
+ "default": "1024x1024",
82
+ },
83
+ "negative_prompt": {
84
+ "type": "string",
85
+ "description": "Things to avoid (e.g. 'blurry, deformed, text'). Optional.",
86
+ },
87
+ "seed": {
88
+ "type": "integer",
89
+ "description": "Random seed for reproducibility. Omit for random.",
90
+ },
91
+ "steps": {
92
+ "type": "integer",
93
+ "description": "Inference steps. Defaults vary by model; only set if you know the model.",
94
+ },
95
+ "style": {
96
+ "type": "string",
97
+ "description": "Optional style hint, prepended to the prompt (e.g. 'watercolor', 'photorealistic', 'oil painting').",
98
+ },
99
+ },
100
+ "required": ["prompt"],
101
+ }
102
+
103
+ def set_file_store(self, file_store: Any) -> None:
104
+ self._file_store = file_store
105
+
106
+ def _get_backend(self) -> ImageGen:
107
+ if self._backend is None:
108
+ self._backend = make_image_gen(self._backend_name, **self._backend_kwargs)
109
+ return self._backend
110
+
111
+ async def execute(self, **kwargs: Any) -> ToolResult:
112
+ prompt = (kwargs.get("prompt") or "").strip()
113
+ if not prompt:
114
+ return self._error("No prompt provided")
115
+ if self._file_store is None:
116
+ return self._error("Draw is not wired to a FileStore. Configure one on the tool.")
117
+
118
+ size = kwargs.get("size") or "1024x1024"
119
+ style = (kwargs.get("style") or "").strip()
120
+ if style:
121
+ prompt = f"{style}, {prompt}"
122
+
123
+ gen_kwargs: dict[str, Any] = {}
124
+ if "negative_prompt" in kwargs and kwargs["negative_prompt"]:
125
+ gen_kwargs["negative_prompt"] = kwargs["negative_prompt"]
126
+ if kwargs.get("seed") is not None:
127
+ gen_kwargs["seed"] = int(kwargs["seed"])
128
+ if kwargs.get("steps") is not None:
129
+ gen_kwargs["steps"] = int(kwargs["steps"])
130
+
131
+ try:
132
+ backend = self._get_backend()
133
+ record = await generate_to_file_store(
134
+ backend, self._file_store, prompt, size=size, **gen_kwargs,
135
+ )
136
+ except Exception as e:
137
+ logger.exception("Draw failed")
138
+ return self._error(f"Image generation failed: {e}")
139
+
140
+ # Build URLs:
141
+ # - raw bytes URL drives the inline `![alt](url)` markdown so
142
+ # chat UIs render the image directly in the conversation
143
+ # - viewer URL (Phase 1 of COGNOS_UI_ROADMAP.md) gives a
144
+ # polished standalone page with download + larger view
145
+ url = f"{_public_base_url()}/files/{record.id}/content"
146
+ viewer_url = f"{_public_base_url()}/artifact/{record.id}"
147
+ alt = (prompt or record.filename)[:120].replace("]", "")
148
+ # Inline image first (so it shows in chat), then a small
149
+ # "open in viewer" link for download / full-size.
150
+ markdown = f"![{alt}]({url})\n\n[📄 Open in viewer]({viewer_url})"
151
+
152
+ return self._success({
153
+ "file_id": record.id,
154
+ "filename": record.filename,
155
+ "path": record.path,
156
+ "url": url,
157
+ "markdown": markdown,
158
+ "size_bytes": record.size_bytes,
159
+ "kind": record.kind,
160
+ "note": (
161
+ "Image generated. Paste the `markdown` field above into "
162
+ "your reply VERBATIM — the chat UI will render it inline."
163
+ ),
164
+ })