cctally 1.38.1 → 1.39.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,14 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.39.0] - 2026-06-12
9
+
10
+ ### Added
11
+ - 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).
12
+ - 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).
13
+ - 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).
14
+ - 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).
15
+
8
16
  ## [1.38.1] - 2026-06-12
9
17
 
10
18
  ### 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,9 @@ 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)
5348
5351
  elif path.startswith("/api/conversation/"):
5349
5352
  self._handle_get_conversation_detail(path)
5350
5353
  else:
@@ -7388,6 +7391,95 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7388
7391
  return
7389
7392
  self._respond_json(200, payload)
7390
7393
 
7394
+ _MEDIA_FETCH_SITE_ALLOWED = ("same-origin", "same-site", "none")
7395
+
7396
+ def _handle_get_conversation_media(self, path: str) -> None:
7397
+ """``GET /api/conversation/<sid>/media?tool_use_id=<id>&index=N`` or
7398
+ ``?uuid=<uuid>&index=N`` (#177 S4) — serves decoded image/PDF bytes by
7399
+ re-reading the source JSONL line (the #178 mechanism). Nothing is ever
7400
+ written to cache.db or disk; no outbound requests.
7401
+
7402
+ Gated FIRST by the transcript privacy predicate (fail-closed 403),
7403
+ then by Fetch-Metadata: unlike the JSON routes, images embed
7404
+ cross-origin (an <img src> on any website the user visits passes the
7405
+ Host/loopback gate and leaks existence + dimensions via
7406
+ onload/naturalWidth), so a PRESENT Sec-Fetch-Site header must be
7407
+ same-origin/same-site/none; an absent header (curl, older browsers)
7408
+ is allowed — defense-in-depth, not the primary gate (Codex F1).
7409
+ Exactly one addressing key (tool_use_id XOR uuid) + a non-negative
7410
+ integer index, else 400. Content-Type is the kernel's allowlist
7411
+ constant; images get CSP default-src 'none'; PDFs get inline
7412
+ Content-Disposition instead (a CSP sandbox would break native PDF
7413
+ viewers)."""
7414
+ if not self._require_transcripts_allowed():
7415
+ return
7416
+ sfs = (self.headers.get("Sec-Fetch-Site") or "").strip().lower()
7417
+ if sfs and sfs not in self._MEDIA_FETCH_SITE_ALLOWED:
7418
+ self._respond_json(403, {"error": "cross-site media fetch not allowed"})
7419
+ return
7420
+ import urllib.parse as _u
7421
+ session_id = _u.unquote(path[len("/api/conversation/"):-len("/media")])
7422
+ q = _u.parse_qs(self.path.partition("?")[2])
7423
+ tool_use_id = _qs_str(q, "tool_use_id", "")
7424
+ uuid = _qs_str(q, "uuid", "")
7425
+ index_raw = _qs_str(q, "index", "")
7426
+ if (not session_id or bool(tool_use_id) == bool(uuid)
7427
+ or not index_raw.isdigit()):
7428
+ self._respond_json(400, {"error": "bad request"})
7429
+ return
7430
+ index = int(index_raw)
7431
+ key = ({"tool_use_id": tool_use_id} if tool_use_id else {"uuid": uuid})
7432
+ cq = self._conversation_query()
7433
+ ok, loc = self._run_conversation_query(
7434
+ lambda conn: cq.locate_media(conn, session_id, index=index, **key),
7435
+ "/api/conversation/media")
7436
+ if not ok:
7437
+ return
7438
+ if loc is None:
7439
+ self._respond_json(404, {"error": "not found"})
7440
+ return
7441
+ # Defensive envelope parity with the sibling byte-serving handlers
7442
+ # (`_handle_get_doctor` / `_serve_static_file`): `locate_media` already
7443
+ # runs inside the `_run_conversation_query` 500-envelope, but the
7444
+ # `read_media_bytes` read + the byte emission did not. `read_media_bytes`
7445
+ # is internally defensive (OSError/ValueError → `gone`), so this guards
7446
+ # only an UNEXPECTED escape — but an unguarded one would kill the handler
7447
+ # thread with no logged 500. `response_started` tracks the commit point:
7448
+ # an exception BEFORE `send_response(200)` sends a clean logged 500; one
7449
+ # AFTER (mid-`wfile.write`, headers already out) can't re-send a status,
7450
+ # so it's logged only — never a silent thread death.
7451
+ response_started = False
7452
+ try:
7453
+ status, media_type, raw = cq.read_media_bytes(
7454
+ loc[0], loc[1], index=index, **key)
7455
+ if status == "unsupported":
7456
+ self._respond_json(404, {"error": "not found"})
7457
+ return
7458
+ if status == "too_large":
7459
+ self._respond_json(413, {"error": "media too large"})
7460
+ return
7461
+ if status != "ok":
7462
+ self._respond_json(410, {"error": "source no longer available"})
7463
+ return
7464
+ self.send_response(200)
7465
+ response_started = True
7466
+ self.send_header("Content-Type", media_type)
7467
+ self.send_header("Content-Length", str(len(raw)))
7468
+ self.send_header("X-Content-Type-Options", "nosniff")
7469
+ self.send_header("Cache-Control", "private, max-age=86400")
7470
+ if media_type == "application/pdf":
7471
+ self.send_header("Content-Disposition",
7472
+ f'inline; filename="attachment-{index}.pdf"')
7473
+ else:
7474
+ self.send_header("Content-Security-Policy", "default-src 'none'")
7475
+ self.end_headers()
7476
+ self.wfile.write(raw)
7477
+ except Exception as exc: # noqa: BLE001
7478
+ self.log_error("/api/conversation/media failed: %r", exc)
7479
+ if not response_started:
7480
+ self._respond_json(
7481
+ 500, {"error": f"{type(exc).__name__}: {exc}"})
7482
+
7391
7483
  def _handle_get_project_detail(self) -> None:
7392
7484
  """Return ProjectDetail JSON for ``GET /api/project/<key>?weeks=N``
7393
7485
  (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
@@ -11,6 +11,7 @@ deduped session_entries row (idx_entries_dedup), via the shared pricing helper
11
11
  — never per physical fragment and never from cost_usd_raw (often NULL).
12
12
  """
13
13
  from __future__ import annotations
14
+ import base64 as _base64
14
15
  import json as _json
15
16
  import os
16
17
  import re
@@ -24,6 +25,9 @@ from _lib_pricing import _calculate_entry_cost
24
25
  # content block the same way the parser does at ingest — reuse the parser's
25
26
  # _stringify so the full (un-capped) result text matches the cached/capped one.
26
27
  from _lib_conversation import _stringify
28
+ # #177 S4: the media-route reader walks a content array with the SAME ordinal
29
+ # generator the ingest placeholders used, so "media item N" addresses one item.
30
+ from _lib_conversation import iter_media_items
27
31
 
28
32
 
29
33
  # Mirror of dashboard/web/src/conversations/systemMarkers.ts::MARKER_RE — anchored
@@ -489,6 +493,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
489
493
  agent_link = {} # tool_use id -> (agent_id, raw_meta)
490
494
  ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
491
495
  bash_link = {} # tool_use id -> (stderr, interrupted) (#177 S3)
496
+ web_search_link = {} # tool_use id -> web_search payload (#177 S4)
497
+ web_fetch_link = {} # tool_use id -> web_fetch payload (#177 S4)
492
498
  task_link = {} # tool_use id -> {"task_id", "task_list"} (Task* checklist)
493
499
  for it in items:
494
500
  for b in it["blocks"]:
@@ -510,6 +516,12 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
510
516
  bintr = b.pop("bash_interrupted", None)
511
517
  if b.get("tool_use_id") is not None and (bstderr is not None or bintr):
512
518
  bash_link[b["tool_use_id"]] = (bstderr, bool(bintr))
519
+ ws = b.pop("web_search", None) # #177 S4
520
+ if ws is not None and b.get("tool_use_id") is not None:
521
+ web_search_link[b["tool_use_id"]] = ws
522
+ wf = b.pop("web_fetch", None) # #177 S4
523
+ if wf is not None and b.get("tool_use_id") is not None:
524
+ web_fetch_link[b["tool_use_id"]] = wf
513
525
  tid_ = b.pop("task_id", None) # Task* checklist
514
526
  tlist_ = b.pop("task_list", None)
515
527
  if b.get("tool_use_id") is not None and (tid_ is not None or tlist_ is not None):
@@ -553,6 +565,9 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
553
565
  "truncated": bool(res_block.get("truncated")),
554
566
  "full_length": res_block.get("full_length"),
555
567
  "is_error": bool(res_block.get("is_error"))}
568
+ res_media = res_block.get("media") # #177 S4: public render-ready key
569
+ if res_media:
570
+ use_block["result"]["media"] = res_media
556
571
  owner["member_uuids"].append(tr["anchor"]["uuid"])
557
572
  drop.add(id(tr))
558
573
  # else: leave tr standalone (orphan / multi-owner / mixed) — a folded
@@ -583,6 +598,14 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
583
598
  b["stderr"] = blink[0]
584
599
  if blink[1]:
585
600
  b["interrupted"] = True
601
+ if b.get("name") == "WebSearch": # #177 S4: name-keyed
602
+ wslink = web_search_link.get(b["tool_use_id"])
603
+ if wslink is not None:
604
+ b["web_search"] = wslink
605
+ if b.get("name") == "WebFetch": # #177 S4: name-keyed
606
+ wflink = web_fetch_link.get(b["tool_use_id"])
607
+ if wflink is not None:
608
+ b["web_fetch"] = wflink
586
609
 
587
610
  # ---- Phase 3b: fold the Task* op stream into per-run checklist snapshots ----
588
611
  _fold_task_runs(items, task_link)
@@ -1317,3 +1340,117 @@ def read_full_payload(source_path, byte_offset, tool_use_id, which):
1317
1340
  resp["stderr"] = tur["stderr"][:_FULL_PAYLOAD_CEILING] # per-stream bound (see above)
1318
1341
  return resp
1319
1342
  return None
1343
+
1344
+
1345
+ # ---------------------------------------------------------------------------
1346
+ # #177 S4: on-demand media kernel. The route re-reads the source JSONL line by
1347
+ # byte_offset (the #178 mechanism), decodes the base64 of the addressed media
1348
+ # ordinal, and serves the raw bytes — nothing is ever written to cache.db.
1349
+ # ---------------------------------------------------------------------------
1350
+ _MEDIA_LINE_CEILING = 64 * 1024 * 1024 # raw line read cap (pathological guard)
1351
+ _MEDIA_PAYLOAD_CEILING = 20 * 1024 * 1024 # decoded cap; enforced on ENCODED length
1352
+ # Response Content-Type is the matched constant — never an echoed transcript string.
1353
+ _MEDIA_TYPE_ALLOWLIST = {
1354
+ "image/png": "image/png", "image/jpeg": "image/jpeg",
1355
+ "image/gif": "image/gif", "image/webp": "image/webp",
1356
+ "application/pdf": "application/pdf",
1357
+ }
1358
+
1359
+
1360
+ def locate_media(conn, session_id, *, tool_use_id=None, uuid=None, index=0):
1361
+ """``(source_path, byte_offset)`` for the row whose stored placeholder
1362
+ carries media ordinal ``index`` — tool_use_id mode reads tool_result
1363
+ ``media[]``; uuid mode reads user-content image/document blocks. Mirrors
1364
+ locate_tool_payload: instr() prefilter (never LIKE — ids contain ``_``),
1365
+ candidates parsed + exact-matched, deterministic ORDER BY matching
1366
+ get_conversation. ``None`` -> 404. Pre-reingest rows have no placeholder
1367
+ and correctly 404 (the client renders the badge for them anyway)."""
1368
+ if tool_use_id is not None:
1369
+ rows = conn.execute(
1370
+ "SELECT source_path, byte_offset, blocks_json FROM conversation_messages "
1371
+ "WHERE session_id=? AND instr(blocks_json, ?) > 0 "
1372
+ "ORDER BY timestamp_utc, id", (session_id, tool_use_id)).fetchall()
1373
+ else:
1374
+ rows = conn.execute(
1375
+ "SELECT source_path, byte_offset, blocks_json FROM conversation_messages "
1376
+ "WHERE session_id=? AND uuid=? ORDER BY timestamp_utc, id",
1377
+ (session_id, uuid)).fetchall()
1378
+ for source_path, byte_offset, blocks_json in rows:
1379
+ try:
1380
+ blocks = _json.loads(blocks_json)
1381
+ except (ValueError, TypeError):
1382
+ continue
1383
+ if not isinstance(blocks, list):
1384
+ continue
1385
+ for b in blocks:
1386
+ if not isinstance(b, dict):
1387
+ continue
1388
+ if tool_use_id is not None:
1389
+ if (b.get("kind") == "tool_result"
1390
+ and b.get("tool_use_id") == tool_use_id):
1391
+ for m in b.get("media") or []:
1392
+ if isinstance(m, dict) and m.get("index") == index:
1393
+ return source_path, byte_offset
1394
+ elif (b.get("kind") in ("image", "document")
1395
+ and b.get("index") == index):
1396
+ return source_path, byte_offset
1397
+ return None
1398
+
1399
+
1400
+ def read_media_bytes(source_path, byte_offset, *, tool_use_id=None, uuid=None,
1401
+ index=0):
1402
+ """Re-read the source line and return ``("ok", media_type, raw_bytes)`` for
1403
+ media ordinal ``index``, else ``("unsupported"|"too_large"|"gone", None,
1404
+ None)`` (-> 404 / 413 / 410). The decoded cap is enforced as an
1405
+ ENCODED-length precheck — never decode-then-measure (Codex F4); decode is
1406
+ strict (validate=True; binascii.Error subclasses ValueError). The media
1407
+ walk IS iter_media_items — the same generator the ingest placeholders used,
1408
+ so ordinals cannot drift (spec §4.1 chokepoint)."""
1409
+ try:
1410
+ with open(source_path, "rb") as fh:
1411
+ fh.seek(byte_offset)
1412
+ line = fh.readline(_MEDIA_LINE_CEILING + 1)
1413
+ if len(line) > _MEDIA_LINE_CEILING:
1414
+ return ("too_large", None, None)
1415
+ obj = _json.loads(line)
1416
+ except (OSError, ValueError):
1417
+ return ("gone", None, None)
1418
+ if not isinstance(obj, dict):
1419
+ return ("gone", None, None)
1420
+ content = (obj.get("message") or {}).get("content")
1421
+ if not isinstance(content, list):
1422
+ return ("gone", None, None)
1423
+ if tool_use_id is not None:
1424
+ target = None
1425
+ for b in content:
1426
+ if (isinstance(b, dict) and b.get("type") == "tool_result"
1427
+ and b.get("tool_use_id") == tool_use_id):
1428
+ target = b.get("content")
1429
+ break
1430
+ if target is None:
1431
+ return ("gone", None, None)
1432
+ else:
1433
+ target = content
1434
+ item = None
1435
+ for idx, m in iter_media_items(target):
1436
+ if idx == index:
1437
+ item = m
1438
+ break
1439
+ if item is None:
1440
+ return ("gone", None, None)
1441
+ source = item.get("source")
1442
+ if not isinstance(source, dict):
1443
+ return ("gone", None, None)
1444
+ media_type = _MEDIA_TYPE_ALLOWLIST.get(source.get("media_type"))
1445
+ if media_type is None:
1446
+ return ("unsupported", None, None)
1447
+ data = source.get("data")
1448
+ if not isinstance(data, str):
1449
+ return ("gone", None, None)
1450
+ if len(data) > _MEDIA_PAYLOAD_CEILING * 4 // 3:
1451
+ return ("too_large", None, None)
1452
+ try:
1453
+ raw = _base64.b64decode(data, validate=True)
1454
+ except ValueError:
1455
+ return ("gone", None, None)
1456
+ return ("ok", media_type, raw)