copper-pilot-cli 0.1.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 (39) hide show
  1. copper_pilot_cli/__init__.py +37 -0
  2. copper_pilot_cli/__main__.py +6 -0
  3. copper_pilot_cli/_upstream/__init__.py +0 -0
  4. copper_pilot_cli/_upstream/dcode_0_1_69/PROVENANCE.json +46 -0
  5. copper_pilot_cli/_upstream/dcode_0_1_69/__init__.py +0 -0
  6. copper_pilot_cli/_upstream/dcode_0_1_69/diff_utils.py +222 -0
  7. copper_pilot_cli/_version.py +32 -0
  8. copper_pilot_cli/clipboard.py +107 -0
  9. copper_pilot_cli/copper_api.py +24 -0
  10. copper_pilot_cli/copper_app.py +1071 -0
  11. copper_pilot_cli/copper_auth.py +232 -0
  12. copper_pilot_cli/copper_config.py +62 -0
  13. copper_pilot_cli/copper_features.py +263 -0
  14. copper_pilot_cli/copper_graph.py +178 -0
  15. copper_pilot_cli/copper_hooks.py +49 -0
  16. copper_pilot_cli/copper_main.py +345 -0
  17. copper_pilot_cli/copper_preferences.py +85 -0
  18. copper_pilot_cli/copper_presentation.py +325 -0
  19. copper_pilot_cli/copper_protocol.py +421 -0
  20. copper_pilot_cli/copper_theme.py +34 -0
  21. copper_pilot_cli/copper_tools.py +23 -0
  22. copper_pilot_cli/copper_update.py +32 -0
  23. copper_pilot_cli/copper_widgets.py +1224 -0
  24. copper_pilot_cli/copper_workspace.py +151 -0
  25. copper_pilot_cli/deepagents_tools.py +556 -0
  26. copper_pilot_cli/diagnostics.py +55 -0
  27. copper_pilot_cli/langchain.py +215 -0
  28. copper_pilot_cli/media_utils.py +626 -0
  29. copper_pilot_cli/py.typed +0 -0
  30. copper_pilot_cli/sessions.py +1578 -0
  31. copper_pilot_cli/textual_patches.py +35 -0
  32. copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/NOTICE +13 -0
  33. copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/UPSTREAM.md +44 -0
  34. copper_pilot_cli-0.1.1.dist-info/METADATA +211 -0
  35. copper_pilot_cli-0.1.1.dist-info/RECORD +39 -0
  36. copper_pilot_cli-0.1.1.dist-info/WHEEL +4 -0
  37. copper_pilot_cli-0.1.1.dist-info/entry_points.txt +3 -0
  38. copper_pilot_cli-0.1.1.dist-info/licenses/LICENSE +22 -0
  39. copper_pilot_cli-0.1.1.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,626 @@
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_clipboard_image() -> ImageData | None:
225
+ """Attempt to read an image from the system clipboard.
226
+
227
+ Supports macOS via `pngpaste` or `osascript`.
228
+
229
+ Returns:
230
+ ImageData if an image is found, None otherwise.
231
+ """
232
+ if sys.platform == "darwin":
233
+ return _get_macos_clipboard_image()
234
+ logger.warning(
235
+ "Clipboard image paste is not supported on %s. "
236
+ "Only macOS is currently supported. "
237
+ "You can still attach images by dragging and dropping file paths.",
238
+ sys.platform,
239
+ )
240
+ return None
241
+
242
+
243
+ def get_image_from_path(path: pathlib.Path) -> ImageData | None:
244
+ """Read and encode an image file from disk.
245
+
246
+ Args:
247
+ path: Path to the image file.
248
+
249
+ Returns:
250
+ `ImageData` when the file is a valid image, otherwise `None`.
251
+ """
252
+ from PIL import Image, UnidentifiedImageError
253
+
254
+ try:
255
+ file_size = path.stat().st_size
256
+ if file_size == 0:
257
+ logger.debug("Image file is empty: %s", path)
258
+ return None
259
+ if file_size > MAX_MEDIA_BYTES:
260
+ logger.warning(
261
+ "Image file %s is too large (%d MB, max %d MB)",
262
+ path,
263
+ file_size // (1024 * 1024),
264
+ MAX_MEDIA_BYTES // (1024 * 1024),
265
+ )
266
+ return None
267
+
268
+ image_bytes = path.read_bytes()
269
+ if not image_bytes:
270
+ return None
271
+
272
+ with Image.open(io.BytesIO(image_bytes)) as image:
273
+ image_format = (image.format or "").lower()
274
+
275
+ if image_format == "jpg":
276
+ image_format = "jpeg"
277
+ if not image_format:
278
+ suffix = path.suffix.lower().removeprefix(".")
279
+ image_format = "jpeg" if suffix == "jpg" else suffix
280
+ if not image_format:
281
+ image_format = "png"
282
+
283
+ return ImageData(
284
+ base64_data=encode_to_base64(image_bytes),
285
+ format=image_format,
286
+ placeholder="[image]",
287
+ )
288
+ except (UnidentifiedImageError, OSError) as e:
289
+ logger.debug("Failed to load image from %s: %s", path, e, exc_info=True)
290
+ return None
291
+
292
+
293
+ def _detect_video_format(data: bytes) -> str | None:
294
+ """Detect video MIME subtype from magic bytes.
295
+
296
+ Args:
297
+ data: Raw file bytes (at least 12 bytes for reliable detection).
298
+
299
+ Returns:
300
+ MIME subtype (e.g. "mp4", "webm") or `None` if unrecognized.
301
+ """
302
+ min_avi_len = 12
303
+ if data[4:8] == b"ftyp":
304
+ # ftyp box: major brand at bytes 8-12 distinguishes MOV vs MP4
305
+ brand = data[8:12]
306
+ if brand == b"qt ":
307
+ return "quicktime"
308
+ return "mp4"
309
+ if data[:4] == b"RIFF" and len(data) >= min_avi_len and data[8:12] == b"AVI ":
310
+ return "avi"
311
+ if data[:4] == b"\x30\x26\xb2\x75": # ASF/WMV
312
+ return "x-ms-wmv"
313
+ if data[:4] == b"\x1a\x45\xdf\xa3": # WebM/Matroska (EBML header)
314
+ return "webm"
315
+ return None
316
+
317
+
318
+ def get_video_from_path(path: pathlib.Path) -> VideoData | None:
319
+ """Read and encode a video file from disk.
320
+
321
+ Args:
322
+ path: Path to the video file.
323
+
324
+ Returns:
325
+ `VideoData` when the file is a valid video, otherwise `None`.
326
+ """
327
+ suffix = path.suffix.lower()
328
+ if suffix not in VIDEO_EXTENSIONS:
329
+ return None
330
+
331
+ try:
332
+ file_size = path.stat().st_size
333
+ if file_size == 0:
334
+ logger.debug("Video file is empty: %s", path)
335
+ return None
336
+ if file_size > MAX_MEDIA_BYTES:
337
+ logger.warning(
338
+ "Video file %s is too large (%d MB, max %d MB)",
339
+ path,
340
+ file_size // (1024 * 1024),
341
+ MAX_MEDIA_BYTES // (1024 * 1024),
342
+ )
343
+ return None
344
+
345
+ video_bytes = path.read_bytes()
346
+
347
+ # Validate it's a real video file by checking magic bytes
348
+ # MP4 starts with ftyp, MOV also uses ftyp, AVI starts with RIFF
349
+ min_video_len = 8
350
+ if len(video_bytes) < min_video_len:
351
+ logger.debug("Video file too small (%d bytes): %s", len(video_bytes), path)
352
+ return None
353
+
354
+ # Detect format from magic bytes (not extension) so renamed files
355
+ # get the correct MIME type.
356
+ detected_format = _detect_video_format(video_bytes)
357
+ if detected_format is None:
358
+ logger.warning(
359
+ "Video file %s has unrecognized signature for extension '%s'; "
360
+ "skipping. If this is a valid video, the format may not be "
361
+ "supported yet.",
362
+ path,
363
+ suffix,
364
+ )
365
+ return None
366
+
367
+ return VideoData(
368
+ base64_data=encode_to_base64(video_bytes),
369
+ format=detected_format,
370
+ placeholder="[video]",
371
+ )
372
+ except OSError as e:
373
+ logger.warning("Failed to load video from %s: %s", path, e, exc_info=True)
374
+ return None
375
+
376
+
377
+ def is_media_path(path: pathlib.Path) -> bool:
378
+ """Return whether a path's extension is a known image or video extension.
379
+
380
+ This is a cheap, extension-only check (no file read or decode). Use it to
381
+ classify a dropped file path as media without loading it, e.g. to reject a
382
+ dragged image in a text-only input.
383
+
384
+ It is a heuristic, not a support check, and disagrees with the loaders in
385
+ both directions: `get_image_from_path` accepts anything Pillow can decode
386
+ regardless of extension, while `get_video_from_path` additionally requires a
387
+ recognized magic-byte signature. Call `get_media_from_path` when the answer
388
+ must match what can actually be attached.
389
+
390
+ Args:
391
+ path: Path whose suffix is inspected.
392
+
393
+ Returns:
394
+ `True` when the suffix is a known image or video extension.
395
+ """
396
+ suffix = path.suffix.lower()
397
+ return suffix in IMAGE_EXTENSIONS or suffix in VIDEO_EXTENSIONS
398
+
399
+
400
+ def get_media_from_path(path: pathlib.Path) -> ImageData | VideoData | None:
401
+ """Try to load a file as an image first, then as a video.
402
+
403
+ Args:
404
+ path: Path to the media file.
405
+
406
+ Returns:
407
+ `ImageData` or `VideoData` if the file is valid media, otherwise `None`.
408
+ """
409
+ result: ImageData | VideoData | None = get_image_from_path(path)
410
+ if result is not None:
411
+ return result
412
+ return get_video_from_path(path)
413
+
414
+
415
+ def _get_macos_clipboard_image() -> ImageData | None:
416
+ """Get clipboard image on macOS using pngpaste or osascript.
417
+
418
+ First tries pngpaste (faster if installed), then falls back to osascript.
419
+
420
+ Returns:
421
+ ImageData if an image is found, None otherwise.
422
+ """
423
+ from PIL import Image, UnidentifiedImageError
424
+
425
+ # Try pngpaste first (fast if installed)
426
+ pngpaste_path = _get_executable("pngpaste")
427
+ if pngpaste_path:
428
+ try:
429
+ # S603: pngpaste_path is validated via shutil.which(), args are hardcoded
430
+ result = subprocess.run( # noqa: S603
431
+ [pngpaste_path, "-"],
432
+ capture_output=True,
433
+ check=False,
434
+ timeout=2,
435
+ )
436
+ if result.returncode == 0 and result.stdout:
437
+ # Successfully got PNG data - validate it's a real image
438
+ try:
439
+ Image.open(io.BytesIO(result.stdout))
440
+ base64_data = base64.b64encode(result.stdout).decode("utf-8")
441
+ return ImageData(
442
+ base64_data=base64_data,
443
+ format="png", # 'pngpaste -' always outputs PNG
444
+ placeholder="[image]",
445
+ )
446
+ except (
447
+ # UnidentifiedImageError: corrupted or non-image data
448
+ UnidentifiedImageError,
449
+ OSError, # OSError: I/O errors during image processing
450
+ ) as e:
451
+ logger.debug("Invalid image data from pngpaste: %s", e, exc_info=True)
452
+ except FileNotFoundError:
453
+ # pngpaste not installed - expected on systems without it
454
+ logger.debug("pngpaste not found, falling back to osascript")
455
+ except subprocess.TimeoutExpired:
456
+ logger.debug("pngpaste timed out after 2 seconds")
457
+
458
+ # Fallback to osascript with temp file (built-in but slower)
459
+ return _get_clipboard_via_osascript()
460
+
461
+
462
+ def _get_clipboard_via_osascript() -> ImageData | None:
463
+ """Get clipboard image via osascript using a temp file.
464
+
465
+ osascript outputs data in a special format that can't be captured as raw binary,
466
+ so we write to a temp file instead.
467
+
468
+ Returns:
469
+ ImageData if an image is found, None otherwise.
470
+ """
471
+ from PIL import Image, UnidentifiedImageError
472
+
473
+ # Get osascript path - it's a macOS builtin so should always exist
474
+ osascript_path = _get_executable("osascript")
475
+ if not osascript_path:
476
+ return None
477
+
478
+ # Create a temp file for the image
479
+ fd, temp_path = tempfile.mkstemp(suffix=".png")
480
+ os.close(fd)
481
+
482
+ try:
483
+ # First check if clipboard has PNG data
484
+ # S603: osascript_path is validated via shutil.which(), args are hardcoded
485
+ check_result = subprocess.run( # noqa: S603
486
+ [osascript_path, "-e", "clipboard info"],
487
+ capture_output=True,
488
+ check=False,
489
+ timeout=2,
490
+ text=True,
491
+ )
492
+
493
+ if check_result.returncode != 0:
494
+ return None
495
+
496
+ # Check for PNG or TIFF in clipboard info
497
+ clipboard_info = check_result.stdout.lower()
498
+ if "pngf" not in clipboard_info and "tiff" not in clipboard_info:
499
+ return None
500
+
501
+ # Try to get PNG first, fall back to TIFF
502
+ if "pngf" in clipboard_info:
503
+ get_script = f"""
504
+ set pngData to the clipboard as «class PNGf»
505
+ set theFile to open for access POSIX file "{temp_path}" with write permission
506
+ write pngData to theFile
507
+ close access theFile
508
+ return "success"
509
+ """ # noqa: E501
510
+ else:
511
+ get_script = f"""
512
+ set tiffData to the clipboard as TIFF picture
513
+ set theFile to open for access POSIX file "{temp_path}" with write permission
514
+ write tiffData to theFile
515
+ close access theFile
516
+ return "success"
517
+ """ # noqa: E501
518
+
519
+ # S603: osascript_path validated via shutil.which(), script is internal
520
+ result = subprocess.run( # noqa: S603
521
+ [osascript_path, "-e", get_script],
522
+ capture_output=True,
523
+ check=False,
524
+ timeout=3,
525
+ text=True,
526
+ )
527
+
528
+ if result.returncode != 0 or "success" not in result.stdout:
529
+ return None
530
+
531
+ # Check if file was created and has content
532
+ if not pathlib.Path(temp_path).exists() or pathlib.Path(temp_path).stat().st_size == 0:
533
+ return None
534
+
535
+ # Read and validate the image
536
+ image_data = pathlib.Path(temp_path).read_bytes()
537
+
538
+ try:
539
+ image = Image.open(io.BytesIO(image_data))
540
+ # Convert to PNG if it's not already (e.g., if we got TIFF)
541
+ buffer = io.BytesIO()
542
+ image.save(buffer, format="PNG")
543
+ buffer.seek(0)
544
+ base64_data = base64.b64encode(buffer.getvalue()).decode("utf-8")
545
+
546
+ return ImageData(
547
+ base64_data=base64_data,
548
+ format="png",
549
+ placeholder="[image]",
550
+ )
551
+ except (
552
+ # UnidentifiedImageError: corrupted or non-image data
553
+ UnidentifiedImageError,
554
+ OSError, # OSError: I/O errors during image processing
555
+ ) as e:
556
+ logger.debug("Failed to process clipboard image via osascript: %s", e, exc_info=True)
557
+ return None
558
+
559
+ except subprocess.TimeoutExpired:
560
+ logger.debug("osascript timed out while accessing clipboard")
561
+ return None
562
+ except OSError as e:
563
+ logger.debug("OSError accessing clipboard via osascript: %s", e)
564
+ return None
565
+ finally:
566
+ # Clean up temp file
567
+ try:
568
+ pathlib.Path(temp_path).unlink()
569
+ except OSError as e:
570
+ logger.debug("Failed to clean up temp file %s: %s", temp_path, e)
571
+
572
+
573
+ def encode_to_base64(data: bytes) -> str:
574
+ """Encode raw bytes to a base64 string.
575
+
576
+ Args:
577
+ data: Raw bytes to encode.
578
+
579
+ Returns:
580
+ Base64-encoded string.
581
+ """
582
+ return base64.b64encode(data).decode("utf-8")
583
+
584
+
585
+ def create_multimodal_content(
586
+ text: str, images: list[ImageData], videos: list[VideoData] | None = None
587
+ ) -> list[Any]:
588
+ """Create multimodal message content with text, images, and videos.
589
+
590
+ Args:
591
+ text: Text content of the message
592
+ images: List of ImageData objects
593
+ videos: Optional list of VideoData objects
594
+
595
+ Returns:
596
+ List of content blocks in LangChain message format.
597
+ """
598
+ content_blocks = []
599
+
600
+ # Add text block. Strip only the display-only placeholders bound to the media
601
+ # actually attached here (e.g. "[image 1]") so the canonical/model-facing text
602
+ # never contains fake user-authored placeholder text. When a span is known,
603
+ # text that merely resembles the schema is preserved exactly; without a span
604
+ # `strip_media_placeholders` falls back to removing one occurrence per item,
605
+ # which can catch a look-alike literal. The media itself is carried by the
606
+ # structured blocks below.
607
+ #
608
+ # `placeholders` and `spans` are passed as parallel-but-unzipped lists on
609
+ # purpose: `strip_media_placeholders` recovers each span's token from the
610
+ # text slice, and each tracked media item has a unique token, so it never
611
+ # needs index alignment between the two.
612
+ media = [*images, *(videos or [])]
613
+ placeholders = [item.placeholder for item in media]
614
+ spans = [item.placeholder_span for item in media if item.placeholder_span is not None]
615
+ clean_text = strip_media_placeholders(text, placeholders, placeholder_spans=spans)
616
+ if clean_text:
617
+ content_blocks.append({"type": "text", "text": clean_text})
618
+
619
+ # Add image blocks
620
+ content_blocks.extend(image.to_message_content() for image in images)
621
+
622
+ # Add video blocks
623
+ if videos:
624
+ content_blocks.extend(video.to_message_content() for video in videos)
625
+
626
+ return content_blocks
File without changes