synapse-cli-agent 0.1.13__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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
"""Multimodal user content: image placeholders, clipboard, compose blocks.
|
|
2
|
+
|
|
3
|
+
Placeholder syntax in the prompt: ``[image#1]``, ``[image#2]``, ...
|
|
4
|
+
Each id maps to an in-memory (or file-backed) attachment held by the TUI composer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
|
|
19
|
+
PLACEHOLDER_RE = re.compile(r"\[image#(\d+)\]", re.IGNORECASE)
|
|
20
|
+
|
|
21
|
+
# Common vision-friendly types.
|
|
22
|
+
ALLOWED_MIME = frozenset(
|
|
23
|
+
{
|
|
24
|
+
"image/png",
|
|
25
|
+
"image/jpeg",
|
|
26
|
+
"image/jpg",
|
|
27
|
+
"image/webp",
|
|
28
|
+
"image/gif",
|
|
29
|
+
"image/bmp",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
EXT_TO_MIME = {
|
|
34
|
+
".png": "image/png",
|
|
35
|
+
".jpg": "image/jpeg",
|
|
36
|
+
".jpeg": "image/jpeg",
|
|
37
|
+
".webp": "image/webp",
|
|
38
|
+
".gif": "image/gif",
|
|
39
|
+
".bmp": "image/bmp",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
MIME_TO_EXT = {
|
|
43
|
+
"image/png": ".png",
|
|
44
|
+
"image/jpeg": ".jpg",
|
|
45
|
+
"image/jpg": ".jpg",
|
|
46
|
+
"image/webp": ".webp",
|
|
47
|
+
"image/gif": ".gif",
|
|
48
|
+
"image/bmp": ".bmp",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AttachmentError(ValueError):
|
|
53
|
+
"""Invalid image attachment."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class Attachment:
|
|
58
|
+
"""One image ready to embed in a user message."""
|
|
59
|
+
|
|
60
|
+
id: int
|
|
61
|
+
name: str
|
|
62
|
+
mime: str
|
|
63
|
+
data: bytes
|
|
64
|
+
source: str = "clipboard" # clipboard | file | path-text
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def size(self) -> int:
|
|
68
|
+
return len(self.data)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def placeholder(self) -> str:
|
|
72
|
+
return f"[image#{self.id}]"
|
|
73
|
+
|
|
74
|
+
def data_url(self) -> str:
|
|
75
|
+
b64 = base64.standard_b64encode(self.data).decode("ascii")
|
|
76
|
+
mime = "image/jpeg" if self.mime == "image/jpg" else self.mime
|
|
77
|
+
return f"data:{mime};base64,{b64}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class ImageBank:
|
|
82
|
+
"""Composer-side store: placeholder id -> attachment."""
|
|
83
|
+
|
|
84
|
+
items: dict[int, Attachment] = field(default_factory=dict)
|
|
85
|
+
_next_id: int = 1
|
|
86
|
+
max_images: int = 8
|
|
87
|
+
max_bytes: int = 4_000_000
|
|
88
|
+
|
|
89
|
+
def clear(self) -> None:
|
|
90
|
+
self.items.clear()
|
|
91
|
+
self._next_id = 1
|
|
92
|
+
|
|
93
|
+
def remove(self, image_id: int) -> Attachment | None:
|
|
94
|
+
"""Drop one attachment by id. Returns removed item or None."""
|
|
95
|
+
return self.items.pop(int(image_id), None)
|
|
96
|
+
|
|
97
|
+
def __len__(self) -> int:
|
|
98
|
+
return len(self.items)
|
|
99
|
+
|
|
100
|
+
def next_id(self) -> int:
|
|
101
|
+
return self._next_id
|
|
102
|
+
|
|
103
|
+
def add_bytes(
|
|
104
|
+
self,
|
|
105
|
+
data: bytes,
|
|
106
|
+
*,
|
|
107
|
+
mime: str,
|
|
108
|
+
name: str | None = None,
|
|
109
|
+
source: str = "clipboard",
|
|
110
|
+
) -> Attachment:
|
|
111
|
+
if not data:
|
|
112
|
+
raise AttachmentError("empty image data")
|
|
113
|
+
mime_n = (mime or "").strip().lower()
|
|
114
|
+
if mime_n == "image/jpg":
|
|
115
|
+
mime_n = "image/jpeg"
|
|
116
|
+
if mime_n not in ALLOWED_MIME:
|
|
117
|
+
raise AttachmentError(f"unsupported image type: {mime_n or '?'}")
|
|
118
|
+
if len(data) > self.max_bytes:
|
|
119
|
+
raise AttachmentError(
|
|
120
|
+
f"image too large: {len(data)} bytes (max {self.max_bytes})"
|
|
121
|
+
)
|
|
122
|
+
if len(self.items) >= self.max_images:
|
|
123
|
+
raise AttachmentError(f"too many images (max {self.max_images})")
|
|
124
|
+
idx = self._next_id
|
|
125
|
+
self._next_id += 1
|
|
126
|
+
ext = MIME_TO_EXT.get(mime_n, ".png")
|
|
127
|
+
att = Attachment(
|
|
128
|
+
id=idx,
|
|
129
|
+
name=name or f"clipboard-{idx}{ext}",
|
|
130
|
+
mime=mime_n,
|
|
131
|
+
data=data,
|
|
132
|
+
source=source,
|
|
133
|
+
)
|
|
134
|
+
self.items[idx] = att
|
|
135
|
+
return att
|
|
136
|
+
|
|
137
|
+
def add_path(self, path: Path | str) -> Attachment:
|
|
138
|
+
p = Path(path).expanduser()
|
|
139
|
+
if not p.is_file():
|
|
140
|
+
raise AttachmentError(f"not a file: {p}")
|
|
141
|
+
mime = EXT_TO_MIME.get(p.suffix.lower())
|
|
142
|
+
if not mime:
|
|
143
|
+
raise AttachmentError(f"unsupported extension: {p.suffix or '(none)'}")
|
|
144
|
+
data = p.read_bytes()
|
|
145
|
+
return self.add_bytes(data, mime=mime, name=p.name, source="file")
|
|
146
|
+
|
|
147
|
+
def summary_line(self) -> str:
|
|
148
|
+
if not self.items:
|
|
149
|
+
return ""
|
|
150
|
+
parts = [f"#{a.id} {a.name} ({_fmt_size(a.size)})" for a in self.items.values()]
|
|
151
|
+
return "images: " + ", ".join(parts)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _fmt_size(n: int) -> str:
|
|
155
|
+
if n < 1024:
|
|
156
|
+
return f"{n}B"
|
|
157
|
+
if n < 1024 * 1024:
|
|
158
|
+
return f"{n / 1024:.0f}KB"
|
|
159
|
+
return f"{n / (1024 * 1024):.1f}MB"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def find_placeholders(text: str) -> list[int]:
|
|
163
|
+
"""Return placeholder ids in order of appearance (may repeat)."""
|
|
164
|
+
return [int(m.group(1)) for m in PLACEHOLDER_RE.finditer(text or "")]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def strip_placeholder(text: str, image_id: int) -> str:
|
|
168
|
+
"""Remove ``[image#N]`` tokens for one id (and surrounding extra spaces)."""
|
|
169
|
+
raw = text or ""
|
|
170
|
+
out = re.sub(
|
|
171
|
+
rf"\s*\[image#{int(image_id)}\]",
|
|
172
|
+
"",
|
|
173
|
+
raw,
|
|
174
|
+
flags=re.IGNORECASE,
|
|
175
|
+
)
|
|
176
|
+
# Collapse leftover double-spaces but keep intentional newlines out of scope
|
|
177
|
+
# (composer is single-line input).
|
|
178
|
+
out = re.sub(r"[ \t]{2,}", " ", out).strip(" ")
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def extract_image_payloads(
|
|
183
|
+
content: Any,
|
|
184
|
+
*,
|
|
185
|
+
max_images: int = 8,
|
|
186
|
+
max_bytes: int = 4_000_000,
|
|
187
|
+
) -> list[tuple[bytes, str]]:
|
|
188
|
+
"""Pull (raw_bytes, mime) pairs from multimodal user content blocks.
|
|
189
|
+
|
|
190
|
+
Supports OpenAI ``image_url`` data-URLs and Anthropic ``image`` base64
|
|
191
|
+
sources. Oversized / invalid blocks are skipped.
|
|
192
|
+
"""
|
|
193
|
+
if not isinstance(content, list):
|
|
194
|
+
return []
|
|
195
|
+
out: list[tuple[bytes, str]] = []
|
|
196
|
+
for block in content:
|
|
197
|
+
if len(out) >= max_images:
|
|
198
|
+
break
|
|
199
|
+
if not isinstance(block, dict):
|
|
200
|
+
continue
|
|
201
|
+
btype = str(block.get("type") or "").casefold()
|
|
202
|
+
try:
|
|
203
|
+
if btype == "image_url":
|
|
204
|
+
url_obj = block.get("image_url")
|
|
205
|
+
url = ""
|
|
206
|
+
if isinstance(url_obj, dict):
|
|
207
|
+
url = str(url_obj.get("url") or "")
|
|
208
|
+
elif isinstance(url_obj, str):
|
|
209
|
+
url = url_obj
|
|
210
|
+
parsed = _decode_data_url(url)
|
|
211
|
+
if parsed is None:
|
|
212
|
+
continue
|
|
213
|
+
data, mime = parsed
|
|
214
|
+
if 0 < len(data) <= max_bytes:
|
|
215
|
+
out.append((data, mime))
|
|
216
|
+
elif btype == "image":
|
|
217
|
+
# Anthropic native / LC-style
|
|
218
|
+
src = block.get("source")
|
|
219
|
+
if isinstance(src, dict) and str(src.get("type") or "") == "base64":
|
|
220
|
+
mime = str(src.get("media_type") or src.get("mime_type") or "image/png")
|
|
221
|
+
raw_b64 = str(src.get("data") or "")
|
|
222
|
+
data = base64.standard_b64decode(raw_b64)
|
|
223
|
+
if 0 < len(data) <= max_bytes:
|
|
224
|
+
out.append((data, "image/jpeg" if mime == "image/jpg" else mime))
|
|
225
|
+
elif block.get("base64"):
|
|
226
|
+
mime = str(block.get("mime_type") or block.get("media_type") or "image/png")
|
|
227
|
+
data = base64.standard_b64decode(str(block.get("base64")))
|
|
228
|
+
if 0 < len(data) <= max_bytes:
|
|
229
|
+
out.append((data, "image/jpeg" if mime == "image/jpg" else mime))
|
|
230
|
+
except Exception: # noqa: BLE001
|
|
231
|
+
continue
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _decode_data_url(url: str) -> tuple[bytes, str] | None:
|
|
236
|
+
raw = (url or "").strip()
|
|
237
|
+
if not raw.startswith("data:") or ";base64," not in raw:
|
|
238
|
+
return None
|
|
239
|
+
header, b64 = raw.split(";base64,", 1)
|
|
240
|
+
mime = header[5:] if header.startswith("data:") else "image/png"
|
|
241
|
+
mime = (mime or "image/png").strip().lower() or "image/png"
|
|
242
|
+
if mime == "image/jpg":
|
|
243
|
+
mime = "image/jpeg"
|
|
244
|
+
try:
|
|
245
|
+
data = base64.standard_b64decode(b64)
|
|
246
|
+
except Exception: # noqa: BLE001
|
|
247
|
+
return None
|
|
248
|
+
if not data:
|
|
249
|
+
return None
|
|
250
|
+
return data, mime
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def insert_at(text: str, index: int, token: str) -> tuple[str, int]:
|
|
254
|
+
"""Insert token at index; return (new_text, new_cursor)."""
|
|
255
|
+
raw = text or ""
|
|
256
|
+
pos = max(0, min(int(index), len(raw)))
|
|
257
|
+
out = raw[:pos] + token + raw[pos:]
|
|
258
|
+
return out, pos + len(token)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def compose_user_content(
|
|
262
|
+
text: str,
|
|
263
|
+
bank: ImageBank | None = None,
|
|
264
|
+
*,
|
|
265
|
+
attachments: list[Attachment] | None = None,
|
|
266
|
+
provider: str = "openai",
|
|
267
|
+
) -> str | list[dict[str, Any]]:
|
|
268
|
+
"""Build message content from prompt text + image bank or explicit list.
|
|
269
|
+
|
|
270
|
+
- ``attachments=[]`` / no images -> plain string (legacy path).
|
|
271
|
+
- ``attachments`` with ``[image#N]`` in text -> interleave at placeholders.
|
|
272
|
+
- ``attachments`` without placeholders -> text then images.
|
|
273
|
+
- ``bank``: resolve placeholders from bank.
|
|
274
|
+
"""
|
|
275
|
+
raw = text or ""
|
|
276
|
+
if attachments is not None:
|
|
277
|
+
if not attachments:
|
|
278
|
+
return raw
|
|
279
|
+
return _compose_with_attachments(raw, attachments, provider=provider)
|
|
280
|
+
if bank is not None:
|
|
281
|
+
return _compose_from_placeholders(raw, bank, provider=provider)
|
|
282
|
+
return raw
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _bank_from_attachments(attachments: list[Attachment]) -> ImageBank:
|
|
286
|
+
"""Build a temporary bank keyed by attachment id (for placeholder resolve)."""
|
|
287
|
+
bank = ImageBank()
|
|
288
|
+
for att in attachments:
|
|
289
|
+
bank.items[int(att.id)] = att
|
|
290
|
+
if bank.items:
|
|
291
|
+
bank._next_id = max(bank.items) + 1
|
|
292
|
+
return bank
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _compose_with_attachments(
|
|
296
|
+
text: str,
|
|
297
|
+
attachments: list[Attachment],
|
|
298
|
+
*,
|
|
299
|
+
provider: str,
|
|
300
|
+
) -> str | list[dict[str, Any]]:
|
|
301
|
+
"""Prefer placeholder interleave; otherwise append images after text."""
|
|
302
|
+
raw = text or ""
|
|
303
|
+
if find_placeholders(raw):
|
|
304
|
+
return _compose_from_placeholders(
|
|
305
|
+
raw, _bank_from_attachments(attachments), provider=provider
|
|
306
|
+
)
|
|
307
|
+
return _compose_ordered_blocks(raw, attachments, provider=provider)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _compose_ordered_blocks(
|
|
311
|
+
text: str,
|
|
312
|
+
attachments: list[Attachment],
|
|
313
|
+
*,
|
|
314
|
+
provider: str,
|
|
315
|
+
) -> str | list[dict[str, Any]]:
|
|
316
|
+
"""Text (or default caption) followed by images — no placeholder tokens."""
|
|
317
|
+
raw = text or ""
|
|
318
|
+
# Drop stale placeholders if present without a matching interleave path.
|
|
319
|
+
cleaned = PLACEHOLDER_RE.sub("", raw)
|
|
320
|
+
cleaned = " ".join(cleaned.split()).strip()
|
|
321
|
+
blocks: list[dict[str, Any]] = []
|
|
322
|
+
if cleaned:
|
|
323
|
+
blocks.append({"type": "text", "text": cleaned})
|
|
324
|
+
elif attachments:
|
|
325
|
+
blocks.append({"type": "text", "text": "(see attached image)"})
|
|
326
|
+
for att in attachments:
|
|
327
|
+
blocks.append(_image_block(att, provider=provider))
|
|
328
|
+
return blocks
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _compose_from_placeholders(
|
|
332
|
+
text: str,
|
|
333
|
+
bank: ImageBank,
|
|
334
|
+
*,
|
|
335
|
+
provider: str,
|
|
336
|
+
) -> str | list[dict[str, Any]]:
|
|
337
|
+
raw = text or ""
|
|
338
|
+
ids = find_placeholders(raw)
|
|
339
|
+
if not ids:
|
|
340
|
+
return raw
|
|
341
|
+
|
|
342
|
+
missing = sorted({i for i in ids if i not in bank.items})
|
|
343
|
+
if missing:
|
|
344
|
+
raise AttachmentError(
|
|
345
|
+
"missing images for placeholders: "
|
|
346
|
+
+ ", ".join(f"[image#{i}]" for i in missing)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
blocks: list[dict[str, Any]] = []
|
|
350
|
+
pos = 0
|
|
351
|
+
for m in PLACEHOLDER_RE.finditer(raw):
|
|
352
|
+
if m.start() > pos:
|
|
353
|
+
seg = raw[pos : m.start()]
|
|
354
|
+
if seg:
|
|
355
|
+
blocks.append({"type": "text", "text": seg})
|
|
356
|
+
att = bank.items[int(m.group(1))]
|
|
357
|
+
blocks.append(_image_block(att, provider=provider))
|
|
358
|
+
pos = m.end()
|
|
359
|
+
if pos < len(raw):
|
|
360
|
+
tail = raw[pos:]
|
|
361
|
+
if tail:
|
|
362
|
+
blocks.append({"type": "text", "text": tail})
|
|
363
|
+
|
|
364
|
+
if not any(b.get("type") == "text" and str(b.get("text") or "").strip() for b in blocks):
|
|
365
|
+
blocks.insert(0, {"type": "text", "text": "(see attached image)"})
|
|
366
|
+
return blocks
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def normalize_provider_family(provider: str | None) -> str:
|
|
370
|
+
"""Map provider / model prefix to a content-block family.
|
|
371
|
+
|
|
372
|
+
Families:
|
|
373
|
+
- ``anthropic``: Anthropic Messages image source blocks
|
|
374
|
+
- ``google``: Google GenAI / Vertex (accepts OpenAI-style image_url)
|
|
375
|
+
- ``openai``: OpenAI Chat Completions image_url (default for most gateways)
|
|
376
|
+
"""
|
|
377
|
+
raw = (provider or "openai").strip().lower()
|
|
378
|
+
if not raw:
|
|
379
|
+
return "openai"
|
|
380
|
+
# Allow full model ids like ``anthropic:claude-...``.
|
|
381
|
+
prefix = raw.split(":", 1)[0].strip() if ":" in raw else raw
|
|
382
|
+
if prefix in {"anthropic", "claude"} or prefix.startswith("anthropic"):
|
|
383
|
+
return "anthropic"
|
|
384
|
+
if prefix in {
|
|
385
|
+
"google",
|
|
386
|
+
"google_genai",
|
|
387
|
+
"google_vertexai",
|
|
388
|
+
"gemini",
|
|
389
|
+
"vertexai",
|
|
390
|
+
} or prefix.startswith("google"):
|
|
391
|
+
return "google"
|
|
392
|
+
# openai / azure_openai / deepseek / groq / together / openrouter / ...
|
|
393
|
+
return "openai"
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _image_block(att: Attachment, *, provider: str) -> dict[str, Any]:
|
|
397
|
+
"""Shape an image content block for the resolved provider family.
|
|
398
|
+
|
|
399
|
+
Most OpenAI-compatible gateways (DeepSeek, Qwen, Groq, OpenRouter, ...) and
|
|
400
|
+
Google GenAI accept ``image_url`` data-URLs. Anthropic uses native
|
|
401
|
+
``image`` + ``source.base64`` blocks.
|
|
402
|
+
"""
|
|
403
|
+
family = normalize_provider_family(provider)
|
|
404
|
+
mime = "image/jpeg" if att.mime == "image/jpg" else att.mime
|
|
405
|
+
if family == "anthropic":
|
|
406
|
+
return {
|
|
407
|
+
"type": "image",
|
|
408
|
+
"source": {
|
|
409
|
+
"type": "base64",
|
|
410
|
+
"media_type": mime,
|
|
411
|
+
"data": base64.standard_b64encode(att.data).decode("ascii"),
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
# openai-compatible + google_genai
|
|
415
|
+
return {
|
|
416
|
+
"type": "image_url",
|
|
417
|
+
"image_url": {"url": att.data_url()},
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# ---------------------------------------------------------------------------
|
|
422
|
+
# Clipboard
|
|
423
|
+
# ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@dataclass(frozen=True, slots=True)
|
|
427
|
+
class ClipboardResult:
|
|
428
|
+
kind: Literal["text", "image", "empty", "error"]
|
|
429
|
+
text: str | None = None
|
|
430
|
+
data: bytes | None = None
|
|
431
|
+
mime: str | None = None
|
|
432
|
+
name: str | None = None
|
|
433
|
+
detail: str | None = None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def read_clipboard() -> ClipboardResult:
|
|
437
|
+
"""Best-effort clipboard read: prefer image, else text.
|
|
438
|
+
|
|
439
|
+
Order:
|
|
440
|
+
1. Platform image (Windows Forms / PIL / xclip / pngpaste)
|
|
441
|
+
2. Platform text
|
|
442
|
+
3. If text is an existing image path -> treat as image file
|
|
443
|
+
"""
|
|
444
|
+
img = _read_clipboard_image()
|
|
445
|
+
if img is not None:
|
|
446
|
+
data, mime, name = img
|
|
447
|
+
if data:
|
|
448
|
+
return ClipboardResult(kind="image", data=data, mime=mime, name=name)
|
|
449
|
+
|
|
450
|
+
text = _read_clipboard_text()
|
|
451
|
+
if text is None or text == "":
|
|
452
|
+
return ClipboardResult(kind="empty", detail="clipboard empty")
|
|
453
|
+
|
|
454
|
+
# Path-to-image convenience: copied file path in explorer / terminal.
|
|
455
|
+
stripped = text.strip().strip('"').strip("'")
|
|
456
|
+
try:
|
|
457
|
+
p = Path(stripped).expanduser()
|
|
458
|
+
if p.is_file() and p.suffix.lower() in EXT_TO_MIME:
|
|
459
|
+
data = p.read_bytes()
|
|
460
|
+
return ClipboardResult(
|
|
461
|
+
kind="image",
|
|
462
|
+
data=data,
|
|
463
|
+
mime=EXT_TO_MIME[p.suffix.lower()],
|
|
464
|
+
name=p.name,
|
|
465
|
+
)
|
|
466
|
+
except OSError:
|
|
467
|
+
pass
|
|
468
|
+
|
|
469
|
+
return ClipboardResult(kind="text", text=text)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _read_clipboard_text() -> str | None:
|
|
473
|
+
# 1) Windows: CF_UNICODETEXT via ctypes
|
|
474
|
+
if os.name == "nt":
|
|
475
|
+
t = _win_clipboard_text()
|
|
476
|
+
if t is not None:
|
|
477
|
+
return t
|
|
478
|
+
t = _ps_clipboard_text()
|
|
479
|
+
if t is not None:
|
|
480
|
+
return t
|
|
481
|
+
# 2) macOS
|
|
482
|
+
if sys_platform() == "darwin":
|
|
483
|
+
out = _run_capture(["pbpaste"])
|
|
484
|
+
if out is not None:
|
|
485
|
+
return out.decode("utf-8", errors="replace")
|
|
486
|
+
# 3) Linux
|
|
487
|
+
for cmd in (
|
|
488
|
+
["wl-paste", "-n"],
|
|
489
|
+
["xclip", "-selection", "clipboard", "-o"],
|
|
490
|
+
["xsel", "--clipboard", "--output"],
|
|
491
|
+
):
|
|
492
|
+
out = _run_capture(cmd)
|
|
493
|
+
if out is not None:
|
|
494
|
+
return out.decode("utf-8", errors="replace")
|
|
495
|
+
return None
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _read_clipboard_image() -> tuple[bytes, str, str] | None:
|
|
499
|
+
# Prefer PIL if available (cross-platform).
|
|
500
|
+
grabbed = _pil_clipboard_image()
|
|
501
|
+
if grabbed is not None:
|
|
502
|
+
return grabbed
|
|
503
|
+
|
|
504
|
+
if os.name == "nt":
|
|
505
|
+
grabbed = _ps_clipboard_image()
|
|
506
|
+
if grabbed is not None:
|
|
507
|
+
return grabbed
|
|
508
|
+
|
|
509
|
+
if sys_platform() == "darwin":
|
|
510
|
+
grabbed = _mac_clipboard_image()
|
|
511
|
+
if grabbed is not None:
|
|
512
|
+
return grabbed
|
|
513
|
+
|
|
514
|
+
if sys_platform().startswith("linux"):
|
|
515
|
+
grabbed = _linux_clipboard_image()
|
|
516
|
+
if grabbed is not None:
|
|
517
|
+
return grabbed
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def sys_platform() -> str:
|
|
522
|
+
return sys.platform
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _pil_clipboard_image() -> tuple[bytes, str, str] | None:
|
|
526
|
+
try:
|
|
527
|
+
from io import BytesIO
|
|
528
|
+
|
|
529
|
+
from PIL import ImageGrab # type: ignore[import-not-found]
|
|
530
|
+
except Exception: # noqa: BLE001
|
|
531
|
+
return None
|
|
532
|
+
try:
|
|
533
|
+
im = ImageGrab.grabclipboard()
|
|
534
|
+
except Exception: # noqa: BLE001
|
|
535
|
+
return None
|
|
536
|
+
if im is None:
|
|
537
|
+
return None
|
|
538
|
+
# ImageGrab may return a list of file paths
|
|
539
|
+
if isinstance(im, list):
|
|
540
|
+
for item in im:
|
|
541
|
+
try:
|
|
542
|
+
p = Path(str(item))
|
|
543
|
+
if p.is_file() and p.suffix.lower() in EXT_TO_MIME:
|
|
544
|
+
return p.read_bytes(), EXT_TO_MIME[p.suffix.lower()], p.name
|
|
545
|
+
except OSError:
|
|
546
|
+
continue
|
|
547
|
+
return None
|
|
548
|
+
try:
|
|
549
|
+
buf = BytesIO()
|
|
550
|
+
# Normalize to PNG for broad model support.
|
|
551
|
+
if getattr(im, "mode", "") not in {"RGB", "RGBA"}:
|
|
552
|
+
im = im.convert("RGBA")
|
|
553
|
+
im.save(buf, format="PNG")
|
|
554
|
+
return buf.getvalue(), "image/png", "clipboard.png"
|
|
555
|
+
except Exception: # noqa: BLE001
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _win_clipboard_text() -> str | None:
|
|
560
|
+
try:
|
|
561
|
+
import ctypes
|
|
562
|
+
except Exception: # noqa: BLE001
|
|
563
|
+
return None
|
|
564
|
+
|
|
565
|
+
user32 = ctypes.windll.user32 # type: ignore[attr-defined]
|
|
566
|
+
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
567
|
+
CF_UNICODETEXT = 13
|
|
568
|
+
if not user32.OpenClipboard(None):
|
|
569
|
+
return None
|
|
570
|
+
try:
|
|
571
|
+
handle = user32.GetClipboardData(CF_UNICODETEXT)
|
|
572
|
+
if not handle:
|
|
573
|
+
return None
|
|
574
|
+
ptr = kernel32.GlobalLock(handle)
|
|
575
|
+
if not ptr:
|
|
576
|
+
return None
|
|
577
|
+
try:
|
|
578
|
+
data = ctypes.wstring_at(ptr)
|
|
579
|
+
return data
|
|
580
|
+
finally:
|
|
581
|
+
kernel32.GlobalUnlock(handle)
|
|
582
|
+
except Exception: # noqa: BLE001
|
|
583
|
+
return None
|
|
584
|
+
finally:
|
|
585
|
+
user32.CloseClipboard()
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _ps_clipboard_text() -> str | None:
|
|
589
|
+
script = (
|
|
590
|
+
"Add-Type -AssemblyName System.Windows.Forms; "
|
|
591
|
+
"[System.Windows.Forms.Clipboard]::GetText()"
|
|
592
|
+
)
|
|
593
|
+
out = _run_capture(
|
|
594
|
+
["powershell", "-NoProfile", "-STA", "-Command", script],
|
|
595
|
+
timeout=5,
|
|
596
|
+
)
|
|
597
|
+
if out is None:
|
|
598
|
+
return None
|
|
599
|
+
return out.decode("utf-8", errors="replace")
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _ps_clipboard_image() -> tuple[bytes, str, str] | None:
|
|
603
|
+
"""Windows: System.Windows.Forms.Clipboard.GetImage -> temp PNG."""
|
|
604
|
+
tmp = Path(tempfile.gettempdir()) / f"coding-agent-clip-{os.getpid()}.png"
|
|
605
|
+
# Remove stale
|
|
606
|
+
try:
|
|
607
|
+
if tmp.is_file():
|
|
608
|
+
tmp.unlink()
|
|
609
|
+
except OSError:
|
|
610
|
+
pass
|
|
611
|
+
script = f"""
|
|
612
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
613
|
+
Add-Type -AssemblyName System.Drawing
|
|
614
|
+
$img = [System.Windows.Forms.Clipboard]::GetImage()
|
|
615
|
+
if ($null -eq $img) {{ exit 2 }}
|
|
616
|
+
$img.Save('{str(tmp).replace("'", "''")}', [System.Drawing.Imaging.ImageFormat]::Png)
|
|
617
|
+
$img.Dispose()
|
|
618
|
+
"""
|
|
619
|
+
try:
|
|
620
|
+
proc = subprocess.run(
|
|
621
|
+
["powershell", "-NoProfile", "-STA", "-Command", script],
|
|
622
|
+
capture_output=True,
|
|
623
|
+
timeout=8,
|
|
624
|
+
check=False,
|
|
625
|
+
)
|
|
626
|
+
except Exception: # noqa: BLE001
|
|
627
|
+
return None
|
|
628
|
+
if proc.returncode != 0 or not tmp.is_file():
|
|
629
|
+
return None
|
|
630
|
+
try:
|
|
631
|
+
data = tmp.read_bytes()
|
|
632
|
+
except OSError:
|
|
633
|
+
return None
|
|
634
|
+
finally:
|
|
635
|
+
try:
|
|
636
|
+
tmp.unlink(missing_ok=True)
|
|
637
|
+
except OSError:
|
|
638
|
+
pass
|
|
639
|
+
if not data:
|
|
640
|
+
return None
|
|
641
|
+
return data, "image/png", "clipboard.png"
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _mac_clipboard_image() -> tuple[bytes, str, str] | None:
|
|
645
|
+
tmp = Path(tempfile.gettempdir()) / f"coding-agent-clip-{os.getpid()}.png"
|
|
646
|
+
try:
|
|
647
|
+
if tmp.is_file():
|
|
648
|
+
tmp.unlink()
|
|
649
|
+
except OSError:
|
|
650
|
+
pass
|
|
651
|
+
# pngpaste is optional; osascript fallback is heavy — try pngpaste only.
|
|
652
|
+
out = _run_capture(["pngpaste", str(tmp)], timeout=5)
|
|
653
|
+
if out is None and not tmp.is_file():
|
|
654
|
+
return None
|
|
655
|
+
if not tmp.is_file():
|
|
656
|
+
return None
|
|
657
|
+
try:
|
|
658
|
+
data = tmp.read_bytes()
|
|
659
|
+
except OSError:
|
|
660
|
+
return None
|
|
661
|
+
finally:
|
|
662
|
+
try:
|
|
663
|
+
tmp.unlink(missing_ok=True)
|
|
664
|
+
except OSError:
|
|
665
|
+
pass
|
|
666
|
+
if not data:
|
|
667
|
+
return None
|
|
668
|
+
return data, "image/png", "clipboard.png"
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _linux_clipboard_image() -> tuple[bytes, str, str] | None:
|
|
672
|
+
for cmd in (
|
|
673
|
+
["wl-paste", "-t", "image/png"],
|
|
674
|
+
["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
|
|
675
|
+
):
|
|
676
|
+
out = _run_capture(cmd, timeout=5)
|
|
677
|
+
if out:
|
|
678
|
+
return out, "image/png", "clipboard.png"
|
|
679
|
+
return None
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _run_capture(
|
|
683
|
+
cmd: list[str],
|
|
684
|
+
*,
|
|
685
|
+
timeout: float = 3.0,
|
|
686
|
+
) -> bytes | None:
|
|
687
|
+
try:
|
|
688
|
+
proc = subprocess.run(
|
|
689
|
+
cmd,
|
|
690
|
+
capture_output=True,
|
|
691
|
+
timeout=timeout,
|
|
692
|
+
check=False,
|
|
693
|
+
)
|
|
694
|
+
except Exception: # noqa: BLE001
|
|
695
|
+
return None
|
|
696
|
+
if proc.returncode != 0:
|
|
697
|
+
return None
|
|
698
|
+
return proc.stdout if proc.stdout else None
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def provider_from_settings(settings: Any | None) -> str:
|
|
702
|
+
"""Resolve content-block provider from settings.model prefix.
|
|
703
|
+
|
|
704
|
+
Prefer explicit ``init_chat_model``-style prefixes:
|
|
705
|
+
``openai:...``, ``anthropic:...``, ``google_genai:...``, ``azure_openai:...``.
|
|
706
|
+
|
|
707
|
+
Falls back to openai-compatible blocks. Does **not** treat bare ``claude``
|
|
708
|
+
substrings inside an ``openai:`` model id as Anthropic (common gateway case).
|
|
709
|
+
"""
|
|
710
|
+
if settings is None:
|
|
711
|
+
return "openai"
|
|
712
|
+
|
|
713
|
+
candidates: list[str] = []
|
|
714
|
+
for attr in ("model", "active_model"):
|
|
715
|
+
val = str(getattr(settings, attr, "") or "").strip()
|
|
716
|
+
if val:
|
|
717
|
+
candidates.append(val)
|
|
718
|
+
|
|
719
|
+
for cand in candidates:
|
|
720
|
+
if ":" in cand:
|
|
721
|
+
prefix = cand.split(":", 1)[0].strip().lower()
|
|
722
|
+
if prefix:
|
|
723
|
+
return normalize_provider_family(prefix)
|
|
724
|
+
|
|
725
|
+
# Unprefixed model ids (rare): only treat clear Anthropic/Google family names.
|
|
726
|
+
for cand in candidates:
|
|
727
|
+
low = cand.lower()
|
|
728
|
+
if low.startswith("claude") or low.startswith("anthropic"):
|
|
729
|
+
return "anthropic"
|
|
730
|
+
if low.startswith("gemini") or low.startswith("google"):
|
|
731
|
+
return "google"
|
|
732
|
+
|
|
733
|
+
return "openai"
|