cctally 1.38.1 → 1.40.0

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.40.0] - 2026-06-12
9
+
10
+ ### Added
11
+ - Conversation viewer: a full-session navigation layer — a collapsible outline sidebar (toggle `o`) listing every turn as a landmark with nested thinking entries and scroll-sync highlighting, a stats overview (turn counts, duration, tokens, cost, models, tool histogram, and an error row that jumps to the first error), jump-to-next keys with no wrap-around (`e`/`E` errors, `u`/`U` prompts, `b`/`B` subagents, `p`/`P` plans/questions) mirrored by a clickable glyph cluster with counts, and focus modes (`v` cycles All → Chat → Prompts → Errors; hidden turns coalesce into a clickable `· N hidden ·` marker, and a jump whose target the mode would hide resets to All before landing) (#177 Session 5).
12
+ - Conversation viewer: per-turn timestamps and inter-turn markers — each turn header shows a quiet `· HH:mm` in your `display.tz` (precise instant in a tooltip), with a `⏸ 42 min later` gap rule between turns ten or more minutes apart, a `— Jun 13 —` rule when the calendar day changes, and a combined `⏸ 9.5 h later · Jun 13` when both apply — plus per-turn token footers that extend the assistant cost line to `$0.0214 · in 1.2k · out 4.8k · cache 310k` (tokens-only when a turn has usage but no attributable cost, cost-only for un-reingested turns) (#177 Session 5).
13
+
14
+ ## [1.39.0] - 2026-06-12
15
+
16
+ ### Added
17
+ - Conversation viewer: MCP tool calls now render an action-first chip — the readable action (e.g. `browser_take_screenshot`) leads, with a quiet pill for the friendly server name and a per-server icon (playwright, chrome, computer-use, codex each get a dedicated glyph; any other server gets a generic plug icon and its raw name), instead of the full namespaced `mcp__…` name on a generic box chip; the original name stays in the chip tooltip and expanded request panel (#177 S4).
18
+ - Conversation viewer: WebFetch and WebSearch now render as semantic source cards instead of raw JSON tool chips — WebFetch shows the fetched domain, an HTTP status chip, labeled url/prompt fields, and the Markdown summary (clamped with a show-full reveal); WebSearch shows the quoted query, a result-count chip, and a clickable list of result titles and domains — with only `http`/`https` links made clickable and no outbound requests beyond a link you click yourself (#177 S4).
19
+ - Conversation viewer: images now render inline — a lazy-loaded figure with an `open full size ↗` link and a size/type caption, including screenshots returned inside MCP tool results that were previously dropped at ingest — served on demand by a new privacy-gated `GET /api/conversation/<id>/media` route that re-reads the original session JSONL and decodes the bytes without ever copying pixel data into the cache; PDF documents get an upgraded badge with an `open ↗` link that opens the file in a new tab, and any load failure degrades gracefully back to a byte-count badge (#177 S4).
20
+ - Conversation viewer: a one-time background reingest (cache migration 009) backfills the new MCP/web/media rendering onto your existing transcript history on the first `cache-sync` or dashboard sync after upgrade — resumable, lossless, and re-derivable; until it completes, older rows simply keep their pre-upgrade rendering, and `cache-sync --rebuild` forces it through in one pass (#177 S4).
21
+
8
22
  ## [1.38.1] - 2026-06-12
9
23
 
10
24
  ### Fixed
@@ -638,12 +638,17 @@ def sync_cache(
638
638
  # input / full_length / stop_reason / attribution / search_aux); the
639
639
  # same offset-0 walk re-derives those through the enriched parser, so
640
640
  # drop that flag here too — MISSING this site re-arms the flag on
641
- # every cache-sync --rebuild.
641
+ # every cache-sync --rebuild. #177 S4 migration 009 sets the DISTINCT
642
+ # conversation_media_reingest_pending flag (to land tool_result
643
+ # media[] placeholders + user-content media index + web_search/
644
+ # web_fetch captures); the same offset-0 walk re-derives them, so drop
645
+ # that flag here as well.
642
646
  conn.execute(
643
647
  "DELETE FROM cache_meta WHERE key IN "
644
648
  "('conversation_reingest_pending',"
645
649
  " 'conversation_source_tool_use_reingest_pending',"
646
650
  " 'conversation_reingest_enrichment_pending',"
651
+ " 'conversation_media_reingest_pending',"
647
652
  " 'conversation_reingest_cursor',"
648
653
  " 'conversation_reingest_cursor_gen')")
649
654
  conn.commit()
@@ -712,15 +717,20 @@ def sync_cache(
712
717
  # input_truncated, the raised result cap + full_length, stop_reason /
713
718
  # attribution_skill / attribution_plugin, and the search_aux FTS-aux
714
719
  # blob); the offset-0 re-parse through the enriched parser lands them
715
- # all with zero new consumption code. We trigger the SAME clear +
716
- # offset-0 backfill on ANY of these flags and clear them ALL atomically
717
- # here under the held flock.
720
+ # all with zero new consumption code. #177 S4 migration 009 uses yet
721
+ # ANOTHER distinct flag ``conversation_media_reingest_pending`` to land
722
+ # the tool_result media[] placeholders + user-content media index +
723
+ # web_search/web_fetch captures; same offset-0 re-parse, same reason
724
+ # for a distinct flag. We trigger the SAME clear + offset-0 backfill on
725
+ # ANY of these flags and clear them ALL atomically here under the held
726
+ # flock.
718
727
  try:
719
728
  _reingest = conn.execute(
720
729
  "SELECT 1 FROM cache_meta WHERE key IN "
721
730
  "('conversation_reingest_pending',"
722
731
  " 'conversation_source_tool_use_reingest_pending',"
723
- " 'conversation_reingest_enrichment_pending')"
732
+ " 'conversation_reingest_enrichment_pending',"
733
+ " 'conversation_media_reingest_pending')"
724
734
  ).fetchone() is not None
725
735
  except sqlite3.OperationalError:
726
736
  _reingest = False
@@ -1163,6 +1173,7 @@ _REINGEST_FLAG_KEYS = (
1163
1173
  "conversation_reingest_pending",
1164
1174
  "conversation_source_tool_use_reingest_pending",
1165
1175
  "conversation_reingest_enrichment_pending",
1176
+ "conversation_media_reingest_pending", # #177 S4 (migration 009)
1166
1177
  )
1167
1178
 
1168
1179
 
@@ -1189,8 +1200,8 @@ def _resumable_reingest_conversation_messages(conn):
1189
1200
  fingerprint (the set of pending reingest flags) resets the cursor whenever the
1190
1201
  pending-flag set changes, so a newly-armed flag forces a fresh pass. The caller
1191
1202
  (``sync_cache``) already holds the cache.db flock; per-file commits bound only
1192
- the SQLite write transaction, not the flock. Clears the three flags + cursor +
1193
- gen atomically on completion."""
1203
+ the SQLite write transaction, not the flock. Clears all _REINGEST_FLAG_KEYS +
1204
+ cursor + gen atomically on completion."""
1194
1205
  # 1. Generation guard: reset the cursor if the live pending-flag set differs.
1195
1206
  set_flags = [k for k in _REINGEST_FLAG_KEYS
1196
1207
  if conn.execute("SELECT 1 FROM cache_meta WHERE key=?", (k,)).fetchone()]
@@ -1243,6 +1254,7 @@ def _resumable_reingest_conversation_messages(conn):
1243
1254
  "('conversation_reingest_pending',"
1244
1255
  " 'conversation_source_tool_use_reingest_pending',"
1245
1256
  " 'conversation_reingest_enrichment_pending',"
1257
+ " 'conversation_media_reingest_pending',"
1246
1258
  " 'conversation_reingest_cursor',"
1247
1259
  " 'conversation_reingest_cursor_gen')")
1248
1260
  conn.commit()
@@ -5345,6 +5345,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5345
5345
  # #178: on-demand load-full. Matched BEFORE the <id> reader
5346
5346
  # catch-all (same precedence as /api/conversation/search).
5347
5347
  self._handle_get_conversation_payload(path)
5348
+ elif path.startswith("/api/conversation/") and path.endswith("/media"):
5349
+ # #177 S4: on-demand media bytes. Matched BEFORE the <id> reader.
5350
+ self._handle_get_conversation_media(path)
5351
+ elif path.startswith("/api/conversation/") and path.endswith("/outline"):
5352
+ # #177 S5: full-session outline skeleton + stats. Matched BEFORE
5353
+ # the <id> reader catch-all (Codex F2 — same precedence as /payload).
5354
+ self._handle_get_conversation_outline(path)
5348
5355
  elif path.startswith("/api/conversation/"):
5349
5356
  self._handle_get_conversation_detail(path)
5350
5357
  else:
@@ -7388,6 +7395,116 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7388
7395
  return
7389
7396
  self._respond_json(200, payload)
7390
7397
 
7398
+ def _handle_get_conversation_outline(self, path: str) -> None:
7399
+ """``GET /api/conversation/<sid>/outline`` — full-session skeleton +
7400
+ session stats (#177 S5). Same fail-closed privacy gate; unknown id → 404.
7401
+ """
7402
+ if not self._require_transcripts_allowed():
7403
+ return
7404
+ import urllib.parse as _u
7405
+ session_id = _u.unquote(path[len("/api/conversation/"):-len("/outline")])
7406
+ if not session_id:
7407
+ self.send_error(404, "conversation not found")
7408
+ return
7409
+ ok, body = self._run_conversation_query(
7410
+ lambda conn: self._conversation_query().get_conversation_outline(conn, session_id),
7411
+ "/api/conversation/outline")
7412
+ if not ok:
7413
+ return
7414
+ if body is None:
7415
+ self.send_error(404, "conversation not found")
7416
+ return
7417
+ self._respond_json(200, body)
7418
+
7419
+ _MEDIA_FETCH_SITE_ALLOWED = ("same-origin", "same-site", "none")
7420
+
7421
+ def _handle_get_conversation_media(self, path: str) -> None:
7422
+ """``GET /api/conversation/<sid>/media?tool_use_id=<id>&index=N`` or
7423
+ ``?uuid=<uuid>&index=N`` (#177 S4) — serves decoded image/PDF bytes by
7424
+ re-reading the source JSONL line (the #178 mechanism). Nothing is ever
7425
+ written to cache.db or disk; no outbound requests.
7426
+
7427
+ Gated FIRST by the transcript privacy predicate (fail-closed 403),
7428
+ then by Fetch-Metadata: unlike the JSON routes, images embed
7429
+ cross-origin (an <img src> on any website the user visits passes the
7430
+ Host/loopback gate and leaks existence + dimensions via
7431
+ onload/naturalWidth), so a PRESENT Sec-Fetch-Site header must be
7432
+ same-origin/same-site/none; an absent header (curl, older browsers)
7433
+ is allowed — defense-in-depth, not the primary gate (Codex F1).
7434
+ Exactly one addressing key (tool_use_id XOR uuid) + a non-negative
7435
+ integer index, else 400. Content-Type is the kernel's allowlist
7436
+ constant; images get CSP default-src 'none'; PDFs get inline
7437
+ Content-Disposition instead (a CSP sandbox would break native PDF
7438
+ viewers)."""
7439
+ if not self._require_transcripts_allowed():
7440
+ return
7441
+ sfs = (self.headers.get("Sec-Fetch-Site") or "").strip().lower()
7442
+ if sfs and sfs not in self._MEDIA_FETCH_SITE_ALLOWED:
7443
+ self._respond_json(403, {"error": "cross-site media fetch not allowed"})
7444
+ return
7445
+ import urllib.parse as _u
7446
+ session_id = _u.unquote(path[len("/api/conversation/"):-len("/media")])
7447
+ q = _u.parse_qs(self.path.partition("?")[2])
7448
+ tool_use_id = _qs_str(q, "tool_use_id", "")
7449
+ uuid = _qs_str(q, "uuid", "")
7450
+ index_raw = _qs_str(q, "index", "")
7451
+ if (not session_id or bool(tool_use_id) == bool(uuid)
7452
+ or not index_raw.isdigit()):
7453
+ self._respond_json(400, {"error": "bad request"})
7454
+ return
7455
+ index = int(index_raw)
7456
+ key = ({"tool_use_id": tool_use_id} if tool_use_id else {"uuid": uuid})
7457
+ cq = self._conversation_query()
7458
+ ok, loc = self._run_conversation_query(
7459
+ lambda conn: cq.locate_media(conn, session_id, index=index, **key),
7460
+ "/api/conversation/media")
7461
+ if not ok:
7462
+ return
7463
+ if loc is None:
7464
+ self._respond_json(404, {"error": "not found"})
7465
+ return
7466
+ # Defensive envelope parity with the sibling byte-serving handlers
7467
+ # (`_handle_get_doctor` / `_serve_static_file`): `locate_media` already
7468
+ # runs inside the `_run_conversation_query` 500-envelope, but the
7469
+ # `read_media_bytes` read + the byte emission did not. `read_media_bytes`
7470
+ # is internally defensive (OSError/ValueError → `gone`), so this guards
7471
+ # only an UNEXPECTED escape — but an unguarded one would kill the handler
7472
+ # thread with no logged 500. `response_started` tracks the commit point:
7473
+ # an exception BEFORE `send_response(200)` sends a clean logged 500; one
7474
+ # AFTER (mid-`wfile.write`, headers already out) can't re-send a status,
7475
+ # so it's logged only — never a silent thread death.
7476
+ response_started = False
7477
+ try:
7478
+ status, media_type, raw = cq.read_media_bytes(
7479
+ loc[0], loc[1], index=index, **key)
7480
+ if status == "unsupported":
7481
+ self._respond_json(404, {"error": "not found"})
7482
+ return
7483
+ if status == "too_large":
7484
+ self._respond_json(413, {"error": "media too large"})
7485
+ return
7486
+ if status != "ok":
7487
+ self._respond_json(410, {"error": "source no longer available"})
7488
+ return
7489
+ self.send_response(200)
7490
+ response_started = True
7491
+ self.send_header("Content-Type", media_type)
7492
+ self.send_header("Content-Length", str(len(raw)))
7493
+ self.send_header("X-Content-Type-Options", "nosniff")
7494
+ self.send_header("Cache-Control", "private, max-age=86400")
7495
+ if media_type == "application/pdf":
7496
+ self.send_header("Content-Disposition",
7497
+ f'inline; filename="attachment-{index}.pdf"')
7498
+ else:
7499
+ self.send_header("Content-Security-Policy", "default-src 'none'")
7500
+ self.end_headers()
7501
+ self.wfile.write(raw)
7502
+ except Exception as exc: # noqa: BLE001
7503
+ self.log_error("/api/conversation/media failed: %r", exc)
7504
+ if not response_started:
7505
+ self._respond_json(
7506
+ 500, {"error": f"{type(exc).__name__}: {exc}"})
7507
+
7391
7508
  def _handle_get_project_detail(self) -> None:
7392
7509
  """Return ProjectDetail JSON for ``GET /api/project/<key>?weeks=N``
7393
7510
  (spec §5.3 / §6.5).
@@ -3207,6 +3207,24 @@ def _008_session_entries_speed_backfill(conn: sqlite3.Connection) -> None:
3207
3207
  conn.commit()
3208
3208
 
3209
3209
 
3210
+ @cache_migration("009_conversation_media_reingest")
3211
+ def _009_conversation_media_reingest(conn: sqlite3.Connection) -> None:
3212
+ """Flag-only re-ingest so the #177 S4 media/web enrichment — tool_result
3213
+ ``media[]`` placeholders (iter_media_items ordinals), user-content media
3214
+ ``index``, and the bounded ``web_search``/``web_fetch`` toolUseResult
3215
+ captures — lands on existing history. Sets the DISTINCT
3216
+ ``conversation_media_reingest_pending`` flag (NOT the shared
3217
+ ``conversation_reingest_pending``, which also gates migration 005's
3218
+ read-time human-fallback in the query kernel). Consumption rides the #179
3219
+ RESUMABLE per-file reingest (_resumable_reingest_conversation_messages) —
3220
+ the flag is wired into _REINGEST_FLAG_KEYS + both flag SELECTs + the two
3221
+ cleanup DELETE lists in _cctally_cache.py (all five sites; missing one
3222
+ either never triggers or re-arms forever). Central stamp via the
3223
+ dispatcher (#140); a fresh install stamps it without running."""
3224
+ _set_cache_meta(conn, "conversation_media_reingest_pending", "1")
3225
+ conn.commit()
3226
+
3227
+
3210
3228
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
3211
3229
 
3212
3230
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -24,6 +24,14 @@ _INPUT_MAX_DEPTH = 12 # max nesting depth before subtree elision (Recursion
24
24
  _INPUT_KEY_CAP = 512 # max chars per dict key (else keys are stored verbatim, unbounded)
25
25
  _INPUT_ELISION = "…" # sentinel for elided leaves / subtrees
26
26
 
27
+ # #177 S4: WebSearch link-list capture bounds + the media item types whose
28
+ # placeholders the ordinal chokepoint (iter_media_items) addresses.
29
+ _WEB_SEARCH_LINK_CAP = 50
30
+ _WEB_LINK_TITLE_CAP = 300
31
+ _WEB_LINK_URL_CAP = 2000
32
+ _WEB_FETCH_CODE_TEXT_CAP = 100 # HTTP reason phrases are short; generous bound
33
+ _MEDIA_BLOCK_TYPES = ("image", "document")
34
+
27
35
 
28
36
  @dataclass
29
37
  class MessageRow:
@@ -107,6 +115,8 @@ def _normalize(obj, t, offset):
107
115
  _attach_subagent_result(blocks, obj) # #166: record-level toolUseResult
108
116
  _attach_ask_answers(blocks, obj) # #177 S2: AskUserQuestion answers
109
117
  _attach_bash_streams(blocks, obj) # #177 S3: Bash stderr/interrupted
118
+ _attach_web_search(blocks, obj) # #177 S4: WebSearch link list
119
+ _attach_web_fetch(blocks, obj) # #177 S4: WebFetch HTTP status
110
120
  _attach_task_meta(blocks, obj) # task checklist identity
111
121
  # tool_result rows are stored but NOT indexed as prose (spec §2). A
112
122
  # user line that mixes a text block with a tool_result block must not
@@ -174,6 +184,10 @@ def _blocks_and_text(content):
174
184
  return (([{"kind": "text", "text": content}] if content else []), content, "")
175
185
  blocks, texts, aux_parts = [], [], []
176
186
  if isinstance(content, list):
187
+ # #177 S4: ordinal among media items at THIS list level, keyed by the
188
+ # object identity of each image/document item, so the placeholder writers
189
+ # below stamp the same ``index`` the media-route reader recomputes.
190
+ media_index = {id(item): idx for idx, item in iter_media_items(content)}
177
191
  for b in content:
178
192
  if not isinstance(b, dict):
179
193
  continue
@@ -202,14 +216,24 @@ def _blocks_and_text(content):
202
216
  elif bt == "tool_result":
203
217
  raw = _stringify(b.get("content"))
204
218
  clipped = raw[:_TOOL_RESULT_CAP]
205
- blocks.append({"kind": "tool_result", "text": clipped,
206
- "truncated": len(raw) > _TOOL_RESULT_CAP,
207
- "full_length": len(raw),
208
- "is_error": bool(b.get("is_error")),
209
- "tool_use_id": b.get("tool_use_id")})
219
+ block = {"kind": "tool_result", "text": clipped,
220
+ "truncated": len(raw) > _TOOL_RESULT_CAP,
221
+ "full_length": len(raw),
222
+ "is_error": bool(b.get("is_error")),
223
+ "tool_use_id": b.get("tool_use_id")}
224
+ # #177 S4: media placeholders for image/document items inside the
225
+ # tool_result content array (where every MCP screenshot lives) —
226
+ # ordinals from the shared iter_media_items chokepoint.
227
+ media = [{"kind": item.get("type"), **_media(item.get("source")),
228
+ "index": idx}
229
+ for idx, item in iter_media_items(b.get("content"))]
230
+ if media: # omitted when empty (additive)
231
+ block["media"] = media
232
+ blocks.append(block)
210
233
  aux_parts.append(clipped)
211
- elif bt in ("image", "document"):
212
- blocks.append({"kind": bt, **_media(b.get("source"))})
234
+ elif bt in _MEDIA_BLOCK_TYPES:
235
+ blocks.append({"kind": bt, **_media(b.get("source")),
236
+ "index": media_index[id(b)]})
213
237
  elif bt == "tool_reference":
214
238
  blocks.append({"kind": "tool_reference", "name": b.get("name")})
215
239
  return (blocks, "\n".join(t for t in texts if t),
@@ -319,6 +343,78 @@ def _attach_bash_streams(blocks, obj):
319
343
  results[0]["bash_interrupted"] = True
320
344
 
321
345
 
346
+ def _attach_web_search(blocks, obj):
347
+ """Stash a WebSearch toolUseResult's structured link list onto its single
348
+ tool_result block (#177 S4). Self-identifying: fires only on the WebSearch
349
+ shape (string ``query`` + list ``results``); the query kernel additionally
350
+ joins NAME-KEYED (only onto name=='WebSearch'), so a shape-coincident
351
+ toolUseResult from another tool never decorates the wrong card (Codex F3).
352
+ Links flatten from results[].content[]; items lacking string title+url are
353
+ skipped; bounded (<=_WEB_SEARCH_LINK_CAP links, title/url char caps) with
354
+ ``links_truncated`` when links were dropped. Parser-private key
355
+ ``web_search`` is popped in the query layer's Phase 1 so it never leaks on
356
+ orphan blocks. Same exactly-one-result-block guard as
357
+ _attach_subagent_result."""
358
+ tur = obj.get("toolUseResult")
359
+ if not isinstance(tur, dict):
360
+ return
361
+ query = tur.get("query")
362
+ raw_results = tur.get("results")
363
+ if not isinstance(query, str) or not isinstance(raw_results, list):
364
+ return
365
+ results = [b for b in blocks if b.get("kind") == "tool_result"]
366
+ if len(results) != 1:
367
+ return
368
+ links, dropped = [], False
369
+ for r in raw_results:
370
+ content = r.get("content") if isinstance(r, dict) else None
371
+ if not isinstance(content, list):
372
+ continue
373
+ for item in content:
374
+ if not (isinstance(item, dict) and isinstance(item.get("title"), str)
375
+ and isinstance(item.get("url"), str)):
376
+ continue
377
+ if len(links) >= _WEB_SEARCH_LINK_CAP:
378
+ dropped = True
379
+ break
380
+ links.append({"title": item["title"][:_WEB_LINK_TITLE_CAP],
381
+ "url": item["url"][:_WEB_LINK_URL_CAP]})
382
+ if dropped:
383
+ break
384
+ payload = {"query": query[:_WEB_LINK_TITLE_CAP], "links": links}
385
+ if dropped:
386
+ payload["links_truncated"] = True
387
+ results[0]["web_search"] = payload
388
+
389
+
390
+ def _attach_web_fetch(blocks, obj):
391
+ """Stash a WebFetch toolUseResult's HTTP status onto its single tool_result
392
+ block (#177 S4). Self-identifying on the WebFetch triple — ``code`` +
393
+ ``codeText`` + ``result`` keys all present and ``code`` an int; the query
394
+ kernel additionally joins NAME-KEYED (only onto name=='WebFetch'). A bare
395
+ error-string toolUseResult never matches (the card then renders without a
396
+ status chip — the documented degrade). Only the status is stored (the
397
+ summary already IS result.text). Parser-private key ``web_fetch`` is popped
398
+ in Phase 1. Same exactly-one-result-block guard as
399
+ _attach_subagent_result."""
400
+ tur = obj.get("toolUseResult")
401
+ if not isinstance(tur, dict):
402
+ return
403
+ if "code" not in tur or "codeText" not in tur or "result" not in tur:
404
+ return
405
+ code = tur.get("code")
406
+ if not isinstance(code, int):
407
+ return
408
+ results = [b for b in blocks if b.get("kind") == "tool_result"]
409
+ if len(results) != 1:
410
+ return
411
+ meta = {"code": code}
412
+ code_text = tur.get("codeText")
413
+ if isinstance(code_text, str) and code_text:
414
+ meta["code_text"] = code_text[:_WEB_FETCH_CODE_TEXT_CAP]
415
+ results[0]["web_fetch"] = meta
416
+
417
+
322
418
  # Subagent Task tools record toolUseResult=null and put the identity in the
323
419
  # human-readable result text instead; the id is the only thing the fold needs
324
420
  # from the result (subject/status come from the call input). Anchored to the
@@ -513,3 +609,20 @@ def _media(source):
513
609
  return {"media_type": None, "bytes": 0}
514
610
  data = source.get("data") or ""
515
611
  return {"media_type": source.get("media_type"), "bytes": len(data)}
612
+
613
+
614
+ def iter_media_items(content):
615
+ """Yield ``(index, item)`` for every image/document item in a content list,
616
+ in document order. ``index`` is the ordinal AMONG MEDIA ITEMS (not the list
617
+ position) — the stable address shared by the ingest placeholder writer here
618
+ and the media-route reader (read_media_bytes), so "media item N" can never
619
+ mean two different things (the _canonical_5h_window_key lesson applied to
620
+ media addressing — do NOT write a second walk). Non-list input and
621
+ non-dict / non-media entries are skipped without consuming an ordinal."""
622
+ if not isinstance(content, list):
623
+ return
624
+ idx = 0
625
+ for item in content:
626
+ if isinstance(item, dict) and item.get("type") in _MEDIA_BLOCK_TYPES:
627
+ yield idx, item
628
+ idx += 1