cctally 1.38.0 → 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,19 @@ 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
+
16
+ ## [1.38.1] - 2026-06-12
17
+
18
+ ### Fixed
19
+ - Dashboard: stop the steady-state one-core CPU peg on large caches. The background sync thread rebuilds the full snapshot every few seconds, and several panels read wide historical ranges that re-parsed the per-row `usage_extra_json` JSON blob on every tick — on a ~260K-entry cache a single rebuild cost roughly a core-second, so rebuilds ran back-to-back and one core stayed pinned. The only non-token field any reader consumes (`speed`, for the `<model>-fast` label) is now materialized into a real `session_entries.speed` column, so the hot cache read paths reconstruct usage with zero JSON parsing; a one-time cache migration backfills the column from the existing blobs, and new ingests write the column directly (the now-unused blob is written `NULL` and reclaimed on the next `cache-sync --rebuild`). Output is byte-identical everywhere `speed` matters; on a 250K-row synthetic cache the wide read dropped from ~1.6s to ~0.35s per call (#181).
20
+
8
21
  ## [1.38.0] - 2026-06-12
9
22
 
10
23
  ### Added
@@ -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
@@ -928,14 +938,12 @@ def sync_cache(
928
938
  out = int(usage.get("output_tokens", 0) or 0)
929
939
  cc = int(usage.get("cache_creation_input_tokens", 0) or 0)
930
940
  cr = int(usage.get("cache_read_input_tokens", 0) or 0)
931
- extras = {
932
- k: v for k, v in usage.items()
933
- if k not in (
934
- "input_tokens", "output_tokens",
935
- "cache_creation_input_tokens",
936
- "cache_read_input_tokens",
937
- )
938
- }
941
+ # #181: `speed` is the ONLY non-token usage key any
942
+ # consumer reads, so materialize just that scalar and
943
+ # write NULL into usage_extra_json — no more
944
+ # serializing the deeply-nested blob the read paths
945
+ # used to json.loads per row.
946
+ speed = usage.get("speed")
939
947
  rows.append((
940
948
  path_str,
941
949
  offset,
@@ -944,7 +952,8 @@ def sync_cache(
944
952
  msg_id,
945
953
  req_id,
946
954
  inp, out, cc, cr,
947
- json.dumps(extras, sort_keys=True) if extras else None,
955
+ None, # usage_extra_json bloat no longer written (#181)
956
+ speed, # materialized speed column
948
957
  entry.cost_usd,
949
958
  ))
950
959
  if mrow is not None:
@@ -1005,8 +1014,8 @@ def sync_cache(
1005
1014
  (source_path, line_offset, timestamp_utc, model,
1006
1015
  msg_id, req_id, input_tokens, output_tokens,
1007
1016
  cache_create_tokens, cache_read_tokens,
1008
- usage_extra_json, cost_usd_raw)
1009
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
1017
+ usage_extra_json, speed, cost_usd_raw)
1018
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
1010
1019
  ON CONFLICT(msg_id, req_id)
1011
1020
  WHERE msg_id IS NOT NULL AND req_id IS NOT NULL
1012
1021
  DO UPDATE SET
@@ -1017,6 +1026,7 @@ def sync_cache(
1017
1026
  cache_create_tokens = excluded.cache_create_tokens,
1018
1027
  cache_read_tokens = excluded.cache_read_tokens,
1019
1028
  usage_extra_json = excluded.usage_extra_json,
1029
+ speed = excluded.speed,
1020
1030
  cost_usd_raw = excluded.cost_usd_raw
1021
1031
  WHERE
1022
1032
  (excluded.input_tokens + excluded.output_tokens
@@ -1030,8 +1040,8 @@ def sync_cache(
1030
1040
  =
1031
1041
  (session_entries.input_tokens + session_entries.output_tokens
1032
1042
  + session_entries.cache_create_tokens + session_entries.cache_read_tokens)
1033
- AND json_extract(excluded.usage_extra_json, '$.speed') IS NOT NULL
1034
- AND json_extract(session_entries.usage_extra_json, '$.speed') IS NULL
1043
+ AND excluded.speed IS NOT NULL
1044
+ AND session_entries.speed IS NULL
1035
1045
  )""",
1036
1046
  rows,
1037
1047
  )
@@ -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()
@@ -1266,7 +1278,7 @@ def iter_entries(
1266
1278
 
1267
1279
  sql = (
1268
1280
  "SELECT timestamp_utc, model, input_tokens, output_tokens, "
1269
- "cache_create_tokens, cache_read_tokens, usage_extra_json, "
1281
+ "cache_create_tokens, cache_read_tokens, speed, "
1270
1282
  "cost_usd_raw, source_path "
1271
1283
  "FROM session_entries "
1272
1284
  "WHERE timestamp_utc >= ? AND timestamp_utc <= ?"
@@ -1292,12 +1304,12 @@ def iter_entries(
1292
1304
  "cache_creation_input_tokens": row[4],
1293
1305
  "cache_read_input_tokens": row[5],
1294
1306
  }
1295
- if row[6]:
1296
- # Safe because sync_cache strips the four token keys from
1297
- # extras before storing them in usage_extra_json. If that
1298
- # write-side invariant ever changes, extras could shadow
1299
- # the int-normalized token columns.
1300
- usage.update(json.loads(row[6]))
1307
+ # speed is the only non-token usage key any consumer reads (#181);
1308
+ # materialized into its own column so this hot path never parses JSON.
1309
+ # `is not None` (not truthiness) so an empty-string speed still surfaces,
1310
+ # mirroring the SQL `json_extract(...) IS NOT NULL` parity.
1311
+ if row[6] is not None:
1312
+ usage["speed"] = row[6]
1301
1313
  entries.append(UsageEntry(
1302
1314
  timestamp=dt.datetime.fromisoformat(row[0]),
1303
1315
  model=row[1],
@@ -1430,7 +1442,7 @@ def get_claude_session_entries(
1430
1442
  " se.cache_create_tokens, se.cache_read_tokens, "
1431
1443
  " se.source_path, "
1432
1444
  " sf.session_id, sf.project_path, "
1433
- " se.cost_usd_raw, se.usage_extra_json "
1445
+ " se.cost_usd_raw, se.speed "
1434
1446
  "FROM session_entries se "
1435
1447
  "LEFT JOIN session_files sf ON sf.path = se.source_path "
1436
1448
  "WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ?"
@@ -1458,7 +1470,10 @@ def get_claude_session_entries(
1458
1470
  session_id=row[7],
1459
1471
  project_path=row[8],
1460
1472
  cost_usd=row[9],
1461
- usage_extra=(json.loads(row[10]) if row[10] else None),
1473
+ # speed materialized into its own column (#181); reconstruct the
1474
+ # {"speed": …} shape _usage_entry_from_joined already merges, with
1475
+ # zero JSON parsing. `is not None` so an empty-string speed surfaces.
1476
+ usage_extra=({"speed": row[10]} if row[10] is not None else None),
1462
1477
  )
1463
1478
  for row in rows
1464
1479
  ]
@@ -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).
@@ -2304,7 +2304,8 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2304
2304
  cache_create_tokens INTEGER NOT NULL DEFAULT 0,
2305
2305
  cache_read_tokens INTEGER NOT NULL DEFAULT 0,
2306
2306
  usage_extra_json TEXT,
2307
- cost_usd_raw REAL
2307
+ cost_usd_raw REAL,
2308
+ speed TEXT
2308
2309
  );
2309
2310
  CREATE INDEX IF NOT EXISTS idx_entries_timestamp
2310
2311
  ON session_entries(timestamp_utc);
@@ -2384,6 +2385,14 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2384
2385
  # populated lazily in sync_cache() / _ensure_session_files_row().
2385
2386
  add_column_if_missing(conn, "session_files", "session_id", "TEXT")
2386
2387
  add_column_if_missing(conn, "session_files", "project_path", "TEXT")
2388
+ # #181: materialize the only-ever-consumed extra `usage` key (`speed`) into
2389
+ # a real session_entries column so the hot cache read paths (iter_entries /
2390
+ # get_claude_session_entries) stop json.loads-ing the deeply-nested
2391
+ # usage_extra_json blob per row — that per-tick parse was pegging a core in
2392
+ # the dashboard. Idempotent column-add (no marker, no version), appended
2393
+ # after cost_usd_raw to match the CREATE TABLE order; cache migration 008
2394
+ # then backfills it from the legacy blob on existing rows.
2395
+ add_column_if_missing(conn, "session_entries", "speed", "TEXT")
2387
2396
  # Existing-DB guard for the skill-content fold link (cctally-dev
2388
2397
  # skill-content-nesting): the message-level sourceToolUseID. Idempotent
2389
2398
  # column-add (no marker, no version); cache migration 006 then re-ingests
@@ -3171,6 +3180,51 @@ def _007_conversation_reingest_enrichment(conn: sqlite3.Connection) -> None:
3171
3180
  conn.commit()
3172
3181
 
3173
3182
 
3183
+ @cache_migration("008_session_entries_speed_backfill")
3184
+ def _008_session_entries_speed_backfill(conn: sqlite3.Connection) -> None:
3185
+ """Backfill the materialized ``session_entries.speed`` column from the
3186
+ legacy ``usage_extra_json`` blob (#181). ``speed`` is the ONLY non-token
3187
+ ``usage`` key any consumer reads (``<model>-fast`` rendering in
3188
+ _lib_aggregators + the _should_replace dedup tiebreak); materializing it
3189
+ lets the hot read paths (iter_entries / get_claude_session_entries) stop
3190
+ ``json.loads``-ing the deeply-nested blob per row — the per-tick dashboard
3191
+ rebuild was pegging a core on a ~261K-row cache.
3192
+
3193
+ The column is added by _apply_cache_schema's add_column_if_missing (which
3194
+ runs before this dispatcher), so it always exists here. This handler only
3195
+ backfills existing rows; new ingests write the column directly and write
3196
+ NULL to usage_extra_json. ``WHERE speed IS NULL`` self-guards re-runs.
3197
+ We do NOT rewrite/NULL usage_extra_json on existing rows or VACUUM — the
3198
+ stale blob has no reader and is reclaimed on the next cache-sync --rebuild.
3199
+ json_extract returns NULL when '$.speed' is absent, so speed-less rows stay
3200
+ NULL. Central stamp via the dispatcher (#140); a fresh install stamps it
3201
+ without effect (empty table)."""
3202
+ conn.execute(
3203
+ "UPDATE session_entries "
3204
+ "SET speed = json_extract(usage_extra_json, '$.speed') "
3205
+ "WHERE speed IS NULL AND usage_extra_json IS NOT NULL"
3206
+ )
3207
+ conn.commit()
3208
+
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
+
3174
3228
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
3175
3229
 
3176
3230
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -3323,6 +3377,10 @@ def _008_recompute_weekly_cost_snapshots_dedup_fix(
3323
3377
  "cache_creation_input_tokens": cc,
3324
3378
  "cache_read_input_tokens": cr,
3325
3379
  }
3380
+ # #181: usage_extra_json is cost-irrelevant (cost is
3381
+ # token-only); parsed here only for pre-008 rows that may
3382
+ # still carry the legacy blob — NOT a speed reader, so the
3383
+ # write-side NULL going forward is safe.
3326
3384
  if extras_json:
3327
3385
  usage.update(json.loads(extras_json))
3328
3386
  total += _calculate_entry_cost(
@@ -3637,6 +3695,10 @@ def _009_recompute_five_hour_blocks_dedup_fix(
3637
3695
  "cache_creation_input_tokens": cc_t,
3638
3696
  "cache_read_input_tokens": cr_t,
3639
3697
  }
3698
+ # #181: usage_extra_json is cost-irrelevant (cost is
3699
+ # token-only); parsed here only for pre-008 rows that may
3700
+ # still carry the legacy blob — NOT a speed reader, so the
3701
+ # write-side NULL going forward is safe.
3640
3702
  if extras_json:
3641
3703
  usage.update(json.loads(extras_json))
3642
3704
  cost = _calculate_entry_cost(
@@ -3909,6 +3971,10 @@ def _010_recompute_percent_milestones_dedup_fix(
3909
3971
  "cache_creation_input_tokens": cc,
3910
3972
  "cache_read_input_tokens": cr,
3911
3973
  }
3974
+ # #181: usage_extra_json is cost-irrelevant (cost is
3975
+ # token-only); parsed here only for pre-008 rows that may
3976
+ # still carry the legacy blob — NOT a speed reader, so the
3977
+ # write-side NULL going forward is safe.
3912
3978
  if extras_json:
3913
3979
  usage.update(json.loads(extras_json))
3914
3980
  cumulative += _calculate_entry_cost(
@@ -345,7 +345,7 @@ def _build_statusline_injections(warn_once):
345
345
  "SELECT se.timestamp_utc, se.model, "
346
346
  " se.input_tokens, se.output_tokens, "
347
347
  " se.cache_create_tokens, se.cache_read_tokens, "
348
- " se.cost_usd_raw, se.usage_extra_json "
348
+ " se.cost_usd_raw "
349
349
  "FROM session_entries se "
350
350
  "LEFT JOIN session_files sf ON sf.path = se.source_path "
351
351
  "WHERE sf.session_id = ?"
@@ -368,13 +368,10 @@ def _build_statusline_injections(warn_once):
368
368
  "cache_creation_input_tokens": r[4] or 0,
369
369
  "cache_read_input_tokens": r[5] or 0,
370
370
  }
371
- try:
372
- if r[7]:
373
- extras = json.loads(r[7])
374
- if isinstance(extras, dict):
375
- usage.update(extras)
376
- except Exception:
377
- pass
371
+ # #181: cost is token-only (_calculate_entry_cost ignores `speed`),
372
+ # so the statusline cost path no longer selects or json.loads the
373
+ # usage_extra_json blob — output is byte-identical. r[6] is still
374
+ # cost_usd_raw (the dropped column was the trailing slot).
378
375
  try:
379
376
  total += c._calculate_entry_cost(
380
377
  r[1], usage, mode="auto", cost_usd=r[6],
@@ -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