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 +13 -0
- package/bin/_cctally_cache.py +44 -29
- package/bin/_cctally_dashboard.py +92 -0
- package/bin/_cctally_db.py +67 -1
- package/bin/_cctally_statusline.py +5 -8
- package/bin/_lib_conversation.py +120 -7
- package/bin/_lib_conversation_query.py +137 -0
- package/bin/_lib_jsonl.py +4 -2
- package/bin/cctally +5 -4
- 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,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
|
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
|
|
@@ -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
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
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
|
|
1034
|
-
AND
|
|
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
|
|
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,
|
|
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
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
usage
|
|
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.
|
|
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
|
-
|
|
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).
|
package/bin/_cctally_db.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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],
|
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
|