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,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
|
+
"``) 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 `` 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"\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
|
+
})
|