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 +8 -0
- package/bin/_cctally_cache.py +19 -7
- package/bin/_cctally_dashboard.py +92 -0
- package/bin/_cctally_db.py +18 -0
- package/bin/_lib_conversation.py +120 -7
- package/bin/_lib_conversation_query.py +137 -0
- package/dashboard/static/assets/index-E2kVc-MU.js +67 -0
- package/dashboard/static/assets/{index-BEpz2fLq.css → index-JAqj_fCI.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-CTfLoXHj.js +0 -66
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
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -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.
|
|
716
|
-
#
|
|
717
|
-
#
|
|
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
|
|
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).
|
package/bin/_cctally_db.py
CHANGED
|
@@ -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")
|
package/bin/_lib_conversation.py
CHANGED
|
@@ -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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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)
|