zjcode 0.0.1__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 (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,826 @@
1
+ """Utilities for handling image and video media from clipboard and files."""
2
+
3
+ import base64
4
+ import io
5
+ import logging
6
+ import os
7
+ import pathlib
8
+ import re
9
+ import shutil
10
+
11
+ # S404: subprocess needed for clipboard access via pngpaste/osascript
12
+ import subprocess # noqa: S404
13
+ import sys
14
+ import tempfile
15
+ from collections import Counter
16
+ from collections.abc import Iterable
17
+ from dataclasses import dataclass
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ if TYPE_CHECKING:
21
+ from langchain_core.messages.content import VideoContentBlock
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ IMAGE_EXTENSIONS: frozenset[str] = frozenset(
26
+ {
27
+ ".png",
28
+ ".jpg",
29
+ ".jpeg",
30
+ ".gif",
31
+ ".bmp",
32
+ ".tiff",
33
+ ".tif",
34
+ ".webp",
35
+ ".ico",
36
+ }
37
+ )
38
+ """Common image file extensions supported by PIL."""
39
+
40
+ VIDEO_EXTENSIONS: frozenset[str] = frozenset(
41
+ {
42
+ ".mp4",
43
+ ".mov",
44
+ ".avi",
45
+ ".webm",
46
+ ".m4v",
47
+ ".wmv",
48
+ }
49
+ )
50
+ """Video file extensions with validated magic-byte support."""
51
+
52
+ MAX_MEDIA_BYTES: int = 20 * 1024 * 1024
53
+ """Maximum media file size (20 MB). Keeps base64 payload under ~27 MB."""
54
+
55
+
56
+ def strip_media_placeholders(
57
+ text: str,
58
+ placeholders: Iterable[str],
59
+ *,
60
+ placeholder_spans: Iterable[tuple[int, int]] | None = None,
61
+ ) -> str:
62
+ """Remove display-only media placeholders from user text.
63
+
64
+ Placeholders like `[image 1]` are inserted into the terminal input purely for
65
+ display; the actual media travels as structured content blocks. They must not
66
+ leak into the canonical model-facing message or LangSmith trace as if the user
67
+ typed them.
68
+
69
+ When available, tracked placeholder spans identify the exact display tokens to
70
+ strip so user-authored literal duplicates with the same token are preserved.
71
+ The token fallback removes one matching occurrence per tracked media item for
72
+ callers that only have placeholder text.
73
+
74
+ Args:
75
+ text: Raw user text that may contain media placeholders.
76
+ placeholders: Exact placeholder tokens for the media actually attached to
77
+ this message (e.g. ``["[image 1]", "[video 1]"]``).
78
+ placeholder_spans: Exact `(start, end)` spans for tracked display tokens
79
+ in `text`, when known.
80
+
81
+ Returns:
82
+ Text with the given media placeholders removed and surrounding whitespace
83
+ tidied. Newlines are preserved so multi-line prompts keep their structure.
84
+ Returns an empty string when only whitespace remains after removal, so
85
+ callers can treat a placeholder-only message as having no text block.
86
+ """
87
+ tokens = [p for p in placeholders if p]
88
+ if not tokens:
89
+ return text
90
+
91
+ valid_spans = _valid_placeholder_spans(text, tokens, placeholder_spans)
92
+ spans = [(start, end) for start, end, _token in valid_spans]
93
+ counts = Counter(tokens)
94
+ for _start, _end, token in valid_spans:
95
+ counts[token] -= 1
96
+ for token, count in counts.items():
97
+ if count <= 0:
98
+ continue
99
+ pattern = re.compile(r"[ \t]*" + re.escape(token))
100
+ matches = list(pattern.finditer(text))
101
+ if len(matches) > count:
102
+ # No (or too few) tracked spans, yet the token appears more times
103
+ # than we intend to strip: we can't tell the display token from a
104
+ # user-typed literal and fall back to removing the leading
105
+ # occurrence(s). That guess can strip a literal and leave the real
106
+ # display token behind, so leave a breadcrumb (never the text).
107
+ logger.debug(
108
+ "Ambiguous media placeholder strip: removing %d of %d "
109
+ "occurrences of %r with no tracked span to disambiguate",
110
+ count,
111
+ len(matches),
112
+ token,
113
+ )
114
+ for index, match in enumerate(matches):
115
+ if index >= count:
116
+ break
117
+ spans.append(match.span())
118
+
119
+ # Only strip spaces/tabs (not newlines) so code indentation on lines after a
120
+ # removed placeholder is preserved. A full .strip() would collapse
121
+ # "[image 1]\n def foo():" to "def foo():", losing the leading indent.
122
+ cleaned = text
123
+ for start, end in sorted(spans, reverse=True):
124
+ cleaned = cleaned[:start] + cleaned[end:]
125
+ cleaned = cleaned.strip(" \t")
126
+ return cleaned if cleaned.strip() else ""
127
+
128
+
129
+ def _valid_placeholder_spans(
130
+ text: str,
131
+ tokens: list[str],
132
+ spans: Iterable[tuple[int, int]] | None,
133
+ ) -> list[tuple[int, int, str]]:
134
+ """Return valid display placeholder spans expanded over adjacent padding.
135
+
136
+ A span is kept only when it is in range and its slice equals one of the
137
+ bound tokens, so a stale span (e.g. left by an offset shift) is dropped
138
+ rather than used to delete arbitrary text; the caller then falls back to
139
+ token matching for that item.
140
+
141
+ Args:
142
+ text: Text the spans index into.
143
+ tokens: Bound placeholder tokens for the attached media.
144
+ spans: Candidate `(start, end)` display-token spans, or `None`.
145
+
146
+ Returns:
147
+ `(start, end, token)` triples with `start` expanded left over any
148
+ adjacent spaces/tabs, for spans that validate against `text`.
149
+ """
150
+ if spans is None:
151
+ return []
152
+
153
+ token_set = set(tokens)
154
+ valid: list[tuple[int, int, str]] = []
155
+ for start, end in spans:
156
+ if not (0 <= start < end <= len(text)):
157
+ continue
158
+ token = text[start:end]
159
+ if token not in token_set:
160
+ continue
161
+ expanded_start = start
162
+ while expanded_start > 0 and text[expanded_start - 1] in " \t":
163
+ expanded_start -= 1
164
+ valid.append((expanded_start, end, token))
165
+ return valid
166
+
167
+
168
+ def _get_executable(name: str) -> str | None:
169
+ """Get full path to an executable using shutil.which().
170
+
171
+ Args:
172
+ name: Name of the executable to find
173
+
174
+ Returns:
175
+ Full path to executable, or None if not found.
176
+ """
177
+ return shutil.which(name)
178
+
179
+
180
+ @dataclass
181
+ class ImageData:
182
+ """Represents a pasted image with its base64 encoding."""
183
+
184
+ base64_data: str
185
+ format: str # "png", "jpeg", etc.
186
+ placeholder: str # Display text like "[image 1]"
187
+ placeholder_span: tuple[int, int] | None = None
188
+
189
+ def to_message_content(self) -> dict:
190
+ """Convert to LangChain message content format.
191
+
192
+ Returns:
193
+ Dict with type and image_url for multimodal messages.
194
+ """
195
+ return {
196
+ "type": "image_url",
197
+ "image_url": {"url": f"data:image/{self.format};base64,{self.base64_data}"},
198
+ }
199
+
200
+
201
+ @dataclass
202
+ class VideoData:
203
+ """Represents a pasted video with its base64 encoding."""
204
+
205
+ base64_data: str
206
+ format: str # "mp4", "quicktime", etc.
207
+ placeholder: str # Display text like "[video 1]"
208
+ placeholder_span: tuple[int, int] | None = None
209
+
210
+ def to_message_content(self) -> "VideoContentBlock":
211
+ """Convert to LangChain `VideoContentBlock` format.
212
+
213
+ Returns:
214
+ `VideoContentBlock` with base64 data and mime_type.
215
+ """
216
+ from langchain_core.messages.content import create_video_block
217
+
218
+ return create_video_block(
219
+ base64=self.base64_data,
220
+ mime_type=f"video/{self.format}",
221
+ )
222
+
223
+
224
+ def _get_windows_clipboard_image() -> ImageData | None:
225
+ """Get clipboard image on Windows using PowerShell.
226
+
227
+ Returns:
228
+ ImageData if an image is found, None otherwise.
229
+ """
230
+ from PIL import Image, UnidentifiedImageError
231
+
232
+ powershell_path = _get_executable("powershell")
233
+ if not powershell_path:
234
+ return None
235
+
236
+ # Create temp file for image
237
+ fd, temp_path = tempfile.mkstemp(suffix=".png")
238
+ os.close(fd)
239
+
240
+ try:
241
+ # PowerShell script to get clipboard image and save to file
242
+ script = f"""
243
+ Add-Type -AssemblyName System.Windows.Forms
244
+ if ([System.Windows.Forms.Clipboard]::ContainsImage()) {{
245
+ $image = [System.Windows.Forms.Clipboard]::GetImage()
246
+ $image.Save('{temp_path}', [System.Drawing.Imaging.ImageFormat]::Png)
247
+ Write-Output "success"
248
+ }}
249
+ """
250
+
251
+ try:
252
+ result = subprocess.run( # noqa: S603
253
+ [powershell_path, "-Command", script],
254
+ capture_output=True,
255
+ check=False,
256
+ timeout=5,
257
+ text=True,
258
+ )
259
+ except FileNotFoundError:
260
+ logger.debug("powershell not found for clipboard image paste")
261
+ return None
262
+ except subprocess.TimeoutExpired:
263
+ logger.debug("powershell clipboard read timed out")
264
+ return None
265
+
266
+ if result.returncode != 0 or "success" not in result.stdout:
267
+ return None
268
+
269
+ # Read the image file
270
+ temp_file = pathlib.Path(temp_path)
271
+ if temp_file.exists() and temp_file.stat().st_size > 0:
272
+ image_bytes = temp_file.read_bytes()
273
+ # Reject oversized images before encoding to avoid provider
274
+ # BadRequestError and subsequent thread checkpoint poisoning.
275
+ if len(image_bytes) > MAX_MEDIA_BYTES:
276
+ logger.warning(
277
+ "Clipboard image from Windows is too large "
278
+ "(%d MB, max %d MB)",
279
+ len(image_bytes) // (1024 * 1024),
280
+ MAX_MEDIA_BYTES // (1024 * 1024),
281
+ )
282
+ return None
283
+ try:
284
+ Image.open(io.BytesIO(image_bytes))
285
+ base64_data = base64.b64encode(image_bytes).decode("utf-8")
286
+ return ImageData(
287
+ base64_data=base64_data,
288
+ format="png",
289
+ placeholder="[image]",
290
+ )
291
+ except (UnidentifiedImageError, OSError) as e:
292
+ logger.debug(
293
+ "Invalid image data from Windows clipboard: %s",
294
+ e,
295
+ exc_info=True,
296
+ )
297
+ return None
298
+ finally:
299
+ # Clean up temp file
300
+ if pathlib.Path(temp_path).exists():
301
+ pathlib.Path(temp_path).unlink()
302
+
303
+
304
+ def _get_linux_clipboard_image() -> ImageData | None:
305
+ """Get clipboard image on Linux using xclip (X11) or wl-paste (Wayland).
306
+
307
+ Returns:
308
+ ImageData if an image is found, None otherwise.
309
+ """
310
+ from PIL import Image, UnidentifiedImageError
311
+
312
+ # Try wl-paste first (Wayland)
313
+ wl_paste_path = _get_executable("wl-paste")
314
+ if wl_paste_path:
315
+ try:
316
+ # Check if clipboard has image
317
+ check_result = subprocess.run( # noqa: S603
318
+ [wl_paste_path, "-l"],
319
+ capture_output=True,
320
+ check=False,
321
+ timeout=2,
322
+ text=True,
323
+ )
324
+ if check_result.returncode == 0 and "image/" in check_result.stdout:
325
+ # Get image data
326
+ result = subprocess.run( # noqa: S603
327
+ [wl_paste_path, "-t", "image/png"],
328
+ capture_output=True,
329
+ check=False,
330
+ timeout=3,
331
+ )
332
+ if result.returncode == 0 and result.stdout:
333
+ # Reject oversized images before encoding to avoid provider
334
+ # BadRequestError and subsequent thread checkpoint poisoning.
335
+ if len(result.stdout) > MAX_MEDIA_BYTES:
336
+ logger.warning(
337
+ "Clipboard image from wl-paste is too large "
338
+ "(%d MB, max %d MB)",
339
+ len(result.stdout) // (1024 * 1024),
340
+ MAX_MEDIA_BYTES // (1024 * 1024),
341
+ )
342
+ return None
343
+ try:
344
+ Image.open(io.BytesIO(result.stdout))
345
+ base64_data = base64.b64encode(result.stdout).decode("utf-8")
346
+ return ImageData(
347
+ base64_data=base64_data,
348
+ format="png",
349
+ placeholder="[image]",
350
+ )
351
+ except (UnidentifiedImageError, OSError) as e:
352
+ logger.debug(
353
+ "Invalid image data from wl-paste: %s",
354
+ e,
355
+ exc_info=True,
356
+ )
357
+ except (FileNotFoundError, subprocess.TimeoutExpired):
358
+ pass
359
+
360
+ # Fall back to xclip (X11)
361
+ xclip_path = _get_executable("xclip")
362
+ if xclip_path:
363
+ try:
364
+ # Check if clipboard has image
365
+ check_result = subprocess.run( # noqa: S603
366
+ [xclip_path, "-selection", "clipboard", "-t", "TARGETS", "-o"],
367
+ capture_output=True,
368
+ check=False,
369
+ timeout=2,
370
+ text=True,
371
+ )
372
+ if check_result.returncode == 0 and "image/png" in check_result.stdout:
373
+ # Get image data
374
+ result = subprocess.run( # noqa: S603
375
+ [xclip_path, "-selection", "clipboard", "-t", "image/png", "-o"],
376
+ capture_output=True,
377
+ check=False,
378
+ timeout=3,
379
+ )
380
+ if result.returncode == 0 and result.stdout:
381
+ # Reject oversized images before encoding to avoid provider
382
+ # BadRequestError and subsequent thread checkpoint poisoning.
383
+ if len(result.stdout) > MAX_MEDIA_BYTES:
384
+ logger.warning(
385
+ "Clipboard image from xclip is too large "
386
+ "(%d MB, max %d MB)",
387
+ len(result.stdout) // (1024 * 1024),
388
+ MAX_MEDIA_BYTES // (1024 * 1024),
389
+ )
390
+ return None
391
+ try:
392
+ Image.open(io.BytesIO(result.stdout))
393
+ base64_data = base64.b64encode(result.stdout).decode("utf-8")
394
+ return ImageData(
395
+ base64_data=base64_data,
396
+ format="png",
397
+ placeholder="[image]",
398
+ )
399
+ except (UnidentifiedImageError, OSError) as e:
400
+ logger.debug(
401
+ "Invalid image data from xclip: %s",
402
+ e,
403
+ exc_info=True,
404
+ )
405
+ except (FileNotFoundError, subprocess.TimeoutExpired):
406
+ pass
407
+
408
+ return None
409
+
410
+
411
+ def get_clipboard_image() -> ImageData | None:
412
+ """Attempt to read an image from the system clipboard.
413
+
414
+ Supports:
415
+ - macOS via `pngpaste` or `osascript`
416
+ - Windows via PowerShell
417
+ - Linux via `wl-paste` (Wayland) or `xclip` (X11)
418
+
419
+ Returns:
420
+ ImageData if an image is found, None otherwise.
421
+ """
422
+ if sys.platform == "darwin":
423
+ return _get_macos_clipboard_image()
424
+ if sys.platform == "win32":
425
+ return _get_windows_clipboard_image()
426
+ if sys.platform.startswith("linux"):
427
+ return _get_linux_clipboard_image()
428
+ logger.warning(
429
+ "Clipboard image paste is not supported on %s. "
430
+ "You can still attach images by dragging and dropping file paths.",
431
+ sys.platform,
432
+ )
433
+ return None
434
+
435
+
436
+ def get_image_from_path(path: pathlib.Path) -> ImageData | None:
437
+ """Read and encode an image file from disk.
438
+
439
+ Args:
440
+ path: Path to the image file.
441
+
442
+ Returns:
443
+ `ImageData` when the file is a valid image, otherwise `None`.
444
+ """
445
+ from PIL import Image, UnidentifiedImageError
446
+
447
+ try:
448
+ file_size = path.stat().st_size
449
+ if file_size == 0:
450
+ logger.debug("Image file is empty: %s", path)
451
+ return None
452
+ if file_size > MAX_MEDIA_BYTES:
453
+ logger.warning(
454
+ "Image file %s is too large (%d MB, max %d MB)",
455
+ path,
456
+ file_size // (1024 * 1024),
457
+ MAX_MEDIA_BYTES // (1024 * 1024),
458
+ )
459
+ return None
460
+
461
+ image_bytes = path.read_bytes()
462
+ if not image_bytes:
463
+ return None
464
+
465
+ with Image.open(io.BytesIO(image_bytes)) as image:
466
+ image_format = (image.format or "").lower()
467
+
468
+ if image_format == "jpg":
469
+ image_format = "jpeg"
470
+ if not image_format:
471
+ suffix = path.suffix.lower().removeprefix(".")
472
+ image_format = "jpeg" if suffix == "jpg" else suffix
473
+ if not image_format:
474
+ image_format = "png"
475
+
476
+ return ImageData(
477
+ base64_data=encode_to_base64(image_bytes),
478
+ format=image_format,
479
+ placeholder="[image]",
480
+ )
481
+ except (UnidentifiedImageError, OSError) as e:
482
+ logger.debug("Failed to load image from %s: %s", path, e, exc_info=True)
483
+ return None
484
+
485
+
486
+ def _detect_video_format(data: bytes) -> str | None:
487
+ """Detect video MIME subtype from magic bytes.
488
+
489
+ Args:
490
+ data: Raw file bytes (at least 12 bytes for reliable detection).
491
+
492
+ Returns:
493
+ MIME subtype (e.g. "mp4", "webm") or `None` if unrecognized.
494
+ """
495
+ min_avi_len = 12
496
+ if data[4:8] == b"ftyp":
497
+ # ftyp box: major brand at bytes 8-12 distinguishes MOV vs MP4
498
+ brand = data[8:12]
499
+ if brand == b"qt ":
500
+ return "quicktime"
501
+ return "mp4"
502
+ if data[:4] == b"RIFF" and len(data) >= min_avi_len and data[8:12] == b"AVI ":
503
+ return "avi"
504
+ if data[:4] == b"\x30\x26\xb2\x75": # ASF/WMV
505
+ return "x-ms-wmv"
506
+ if data[:4] == b"\x1a\x45\xdf\xa3": # WebM/Matroska (EBML header)
507
+ return "webm"
508
+ return None
509
+
510
+
511
+ def get_video_from_path(path: pathlib.Path) -> VideoData | None:
512
+ """Read and encode a video file from disk.
513
+
514
+ Args:
515
+ path: Path to the video file.
516
+
517
+ Returns:
518
+ `VideoData` when the file is a valid video, otherwise `None`.
519
+ """
520
+ suffix = path.suffix.lower()
521
+ if suffix not in VIDEO_EXTENSIONS:
522
+ return None
523
+
524
+ try:
525
+ file_size = path.stat().st_size
526
+ if file_size == 0:
527
+ logger.debug("Video file is empty: %s", path)
528
+ return None
529
+ if file_size > MAX_MEDIA_BYTES:
530
+ logger.warning(
531
+ "Video file %s is too large (%d MB, max %d MB)",
532
+ path,
533
+ file_size // (1024 * 1024),
534
+ MAX_MEDIA_BYTES // (1024 * 1024),
535
+ )
536
+ return None
537
+
538
+ video_bytes = path.read_bytes()
539
+
540
+ # Validate it's a real video file by checking magic bytes
541
+ # MP4 starts with ftyp, MOV also uses ftyp, AVI starts with RIFF
542
+ min_video_len = 8
543
+ if len(video_bytes) < min_video_len:
544
+ logger.debug("Video file too small (%d bytes): %s", len(video_bytes), path)
545
+ return None
546
+
547
+ # Detect format from magic bytes (not extension) so renamed files
548
+ # get the correct MIME type.
549
+ detected_format = _detect_video_format(video_bytes)
550
+ if detected_format is None:
551
+ logger.warning(
552
+ "Video file %s has unrecognized signature for extension '%s'; "
553
+ "skipping. If this is a valid video, the format may not be "
554
+ "supported yet.",
555
+ path,
556
+ suffix,
557
+ )
558
+ return None
559
+
560
+ return VideoData(
561
+ base64_data=encode_to_base64(video_bytes),
562
+ format=detected_format,
563
+ placeholder="[video]",
564
+ )
565
+ except OSError as e:
566
+ logger.warning("Failed to load video from %s: %s", path, e, exc_info=True)
567
+ return None
568
+
569
+
570
+ def get_media_from_path(path: pathlib.Path) -> ImageData | VideoData | None:
571
+ """Try to load a file as an image first, then as a video.
572
+
573
+ Args:
574
+ path: Path to the media file.
575
+
576
+ Returns:
577
+ `ImageData` or `VideoData` if the file is valid media, otherwise `None`.
578
+ """
579
+ result: ImageData | VideoData | None = get_image_from_path(path)
580
+ if result is not None:
581
+ return result
582
+ return get_video_from_path(path)
583
+
584
+
585
+ def _get_macos_clipboard_image() -> ImageData | None:
586
+ """Get clipboard image on macOS using pngpaste or osascript.
587
+
588
+ First tries pngpaste (faster if installed), then falls back to osascript.
589
+
590
+ Returns:
591
+ ImageData if an image is found, None otherwise.
592
+ """
593
+ from PIL import Image, UnidentifiedImageError
594
+
595
+ # Try pngpaste first (fast if installed)
596
+ pngpaste_path = _get_executable("pngpaste")
597
+ if pngpaste_path:
598
+ try:
599
+ # S603: pngpaste_path is validated via shutil.which(), args are hardcoded
600
+ result = subprocess.run( # noqa: S603
601
+ [pngpaste_path, "-"],
602
+ capture_output=True,
603
+ check=False,
604
+ timeout=2,
605
+ )
606
+ if result.returncode == 0 and result.stdout:
607
+ # Reject oversized images before encoding to avoid provider
608
+ # BadRequestError and subsequent thread checkpoint poisoning.
609
+ if len(result.stdout) > MAX_MEDIA_BYTES:
610
+ logger.warning(
611
+ "Clipboard image from pngpaste is too large "
612
+ "(%d MB, max %d MB)",
613
+ len(result.stdout) // (1024 * 1024),
614
+ MAX_MEDIA_BYTES // (1024 * 1024),
615
+ )
616
+ return None
617
+ # Successfully got PNG data - validate it's a real image
618
+ try:
619
+ Image.open(io.BytesIO(result.stdout))
620
+ base64_data = base64.b64encode(result.stdout).decode("utf-8")
621
+ return ImageData(
622
+ base64_data=base64_data,
623
+ format="png", # 'pngpaste -' always outputs PNG
624
+ placeholder="[image]",
625
+ )
626
+ except (
627
+ # UnidentifiedImageError: corrupted or non-image data
628
+ UnidentifiedImageError,
629
+ OSError, # OSError: I/O errors during image processing
630
+ ) as e:
631
+ logger.debug(
632
+ "Invalid image data from pngpaste: %s", e, exc_info=True
633
+ )
634
+ except FileNotFoundError:
635
+ # pngpaste not installed - expected on systems without it
636
+ logger.debug("pngpaste not found, falling back to osascript")
637
+ except subprocess.TimeoutExpired:
638
+ logger.debug("pngpaste timed out after 2 seconds")
639
+
640
+ # Fallback to osascript with temp file (built-in but slower)
641
+ return _get_clipboard_via_osascript()
642
+
643
+
644
+ def _get_clipboard_via_osascript() -> ImageData | None:
645
+ """Get clipboard image via osascript using a temp file.
646
+
647
+ osascript outputs data in a special format that can't be captured as raw binary,
648
+ so we write to a temp file instead.
649
+
650
+ Returns:
651
+ ImageData if an image is found, None otherwise.
652
+ """
653
+ from PIL import Image, UnidentifiedImageError
654
+
655
+ # Get osascript path - it's a macOS builtin so should always exist
656
+ osascript_path = _get_executable("osascript")
657
+ if not osascript_path:
658
+ return None
659
+
660
+ # Create a temp file for the image
661
+ fd, temp_path = tempfile.mkstemp(suffix=".png")
662
+ os.close(fd)
663
+
664
+ try:
665
+ # First check if clipboard has PNG data
666
+ # S603: osascript_path is validated via shutil.which(), args are hardcoded
667
+ check_result = subprocess.run( # noqa: S603
668
+ [osascript_path, "-e", "clipboard info"],
669
+ capture_output=True,
670
+ check=False,
671
+ timeout=2,
672
+ text=True,
673
+ )
674
+
675
+ if check_result.returncode != 0:
676
+ return None
677
+
678
+ # Check for PNG or TIFF in clipboard info
679
+ clipboard_info = check_result.stdout.lower()
680
+ if "pngf" not in clipboard_info and "tiff" not in clipboard_info:
681
+ return None
682
+
683
+ # Try to get PNG first, fall back to TIFF
684
+ if "pngf" in clipboard_info:
685
+ get_script = f"""
686
+ set pngData to the clipboard as «class PNGf»
687
+ set theFile to open for access POSIX file "{temp_path}" with write permission
688
+ write pngData to theFile
689
+ close access theFile
690
+ return "success"
691
+ """ # noqa: E501
692
+ else:
693
+ get_script = f"""
694
+ set tiffData to the clipboard as TIFF picture
695
+ set theFile to open for access POSIX file "{temp_path}" with write permission
696
+ write tiffData to theFile
697
+ close access theFile
698
+ return "success"
699
+ """ # noqa: E501
700
+
701
+ # S603: osascript_path validated via shutil.which(), script is internal
702
+ result = subprocess.run( # noqa: S603
703
+ [osascript_path, "-e", get_script],
704
+ capture_output=True,
705
+ check=False,
706
+ timeout=3,
707
+ text=True,
708
+ )
709
+
710
+ if result.returncode != 0 or "success" not in result.stdout:
711
+ return None
712
+
713
+ # Check if file was created and has content
714
+ if (
715
+ not pathlib.Path(temp_path).exists()
716
+ or pathlib.Path(temp_path).stat().st_size == 0
717
+ ):
718
+ return None
719
+
720
+ # Read and validate the image
721
+ image_data = pathlib.Path(temp_path).read_bytes()
722
+
723
+ # Reject oversized images before encoding to avoid provider
724
+ # BadRequestError and subsequent thread checkpoint poisoning.
725
+ if len(image_data) > MAX_MEDIA_BYTES:
726
+ logger.warning(
727
+ "Clipboard image from osascript is too large "
728
+ "(%d MB, max %d MB)",
729
+ len(image_data) // (1024 * 1024),
730
+ MAX_MEDIA_BYTES // (1024 * 1024),
731
+ )
732
+ return None
733
+
734
+ try:
735
+ image = Image.open(io.BytesIO(image_data))
736
+ # Convert to PNG if it's not already (e.g., if we got TIFF)
737
+ buffer = io.BytesIO()
738
+ image.save(buffer, format="PNG")
739
+ buffer.seek(0)
740
+ base64_data = base64.b64encode(buffer.getvalue()).decode("utf-8")
741
+
742
+ return ImageData(
743
+ base64_data=base64_data,
744
+ format="png",
745
+ placeholder="[image]",
746
+ )
747
+ except (
748
+ # UnidentifiedImageError: corrupted or non-image data
749
+ UnidentifiedImageError,
750
+ OSError, # OSError: I/O errors during image processing
751
+ ) as e:
752
+ logger.debug(
753
+ "Failed to process clipboard image via osascript: %s", e, exc_info=True
754
+ )
755
+ return None
756
+
757
+ except subprocess.TimeoutExpired:
758
+ logger.debug("osascript timed out while accessing clipboard")
759
+ return None
760
+ except OSError as e:
761
+ logger.debug("OSError accessing clipboard via osascript: %s", e)
762
+ return None
763
+ finally:
764
+ # Clean up temp file
765
+ try:
766
+ pathlib.Path(temp_path).unlink()
767
+ except OSError as e:
768
+ logger.debug("Failed to clean up temp file %s: %s", temp_path, e)
769
+
770
+
771
+ def encode_to_base64(data: bytes) -> str:
772
+ """Encode raw bytes to a base64 string.
773
+
774
+ Args:
775
+ data: Raw bytes to encode.
776
+
777
+ Returns:
778
+ Base64-encoded string.
779
+ """
780
+ return base64.b64encode(data).decode("utf-8")
781
+
782
+
783
+ def create_multimodal_content(
784
+ text: str, images: list[ImageData], videos: list[VideoData] | None = None
785
+ ) -> list[Any]:
786
+ """Create multimodal message content with text, images, and videos.
787
+
788
+ Args:
789
+ text: Text content of the message
790
+ images: List of ImageData objects
791
+ videos: Optional list of VideoData objects
792
+
793
+ Returns:
794
+ List of content blocks in LangChain message format.
795
+ """
796
+ content_blocks = []
797
+
798
+ # Add text block. Strip only the display-only placeholders bound to the media
799
+ # actually attached here (e.g. "[image 1]") so the canonical/model-facing text
800
+ # never contains fake user-authored placeholder text. When a span is known,
801
+ # text that merely resembles the schema is preserved exactly; without a span
802
+ # `strip_media_placeholders` falls back to removing one occurrence per item,
803
+ # which can catch a look-alike literal. The media itself is carried by the
804
+ # structured blocks below.
805
+ #
806
+ # `placeholders` and `spans` are passed as parallel-but-unzipped lists on
807
+ # purpose: `strip_media_placeholders` recovers each span's token from the
808
+ # text slice, and each tracked media item has a unique token, so it never
809
+ # needs index alignment between the two.
810
+ media = [*images, *(videos or [])]
811
+ placeholders = [item.placeholder for item in media]
812
+ spans = [
813
+ item.placeholder_span for item in media if item.placeholder_span is not None
814
+ ]
815
+ clean_text = strip_media_placeholders(text, placeholders, placeholder_spans=spans)
816
+ if clean_text:
817
+ content_blocks.append({"type": "text", "text": clean_text})
818
+
819
+ # Add image blocks
820
+ content_blocks.extend(image.to_message_content() for image in images)
821
+
822
+ # Add video blocks
823
+ if videos:
824
+ content_blocks.extend(video.to_message_content() for video in videos)
825
+
826
+ return content_blocks