cctally 1.37.2 → 1.38.1
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 +12 -0
- package/bin/_cctally_cache.py +25 -22
- package/bin/_cctally_dashboard.py +44 -0
- package/bin/_cctally_db.py +49 -1
- package/bin/_cctally_statusline.py +5 -8
- package/bin/_lib_conversation.py +30 -0
- package/bin/_lib_conversation_query.py +190 -0
- package/bin/_lib_jsonl.py +4 -2
- package/bin/cctally +5 -4
- package/dashboard/static/assets/{index-a_BOAOJ_.css → index-BEpz2fLq.css} +1 -1
- package/dashboard/static/assets/index-CTfLoXHj.js +66 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-BTGcbDcB.js +0 -59
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.38.1] - 2026-06-12
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- 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).
|
|
12
|
+
|
|
13
|
+
## [1.38.0] - 2026-06-12
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Conversation viewer: Edit, MultiEdit, and Write now render as a unified red/green word-diff card — a `+N −M` stat header with the file's basename and dir, intra-line word-level highlighting on the changed text, per-language syntax coloring on the unchanged context lines, one hunk per edit for MultiEdit, and an all-added "wrote N lines" view for Write — with a collapsed `result · cat -n` sub-panel beneath carrying the real file line numbers, instead of a raw JSON tool chip (#177 S3).
|
|
17
|
+
- Conversation viewer: Bash now renders as a terminal — a `$ <command>` prompt over the output, with `stderr` split into a red block, an `error`/`interrupted` status badge, and ANSI foreground colors honored — instead of an undifferentiated output blob; legacy transcripts captured before the stream split degrade to a merged terminal view (#177 S3).
|
|
18
|
+
- Conversation viewer: a truncated tool input or result can now be expanded on demand — a "load full input"/"load full output" affordance re-reads the original session JSONL behind the existing transcript privacy gate to recompute the diff or re-render the terminal, without enlarging the on-disk cache; a rotated or deleted source surfaces a clear "source no longer available" note (#178).
|
|
19
|
+
|
|
8
20
|
## [1.37.2] - 2026-06-12
|
|
9
21
|
|
|
10
22
|
### Fixed
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -928,14 +928,12 @@ def sync_cache(
|
|
|
928
928
|
out = int(usage.get("output_tokens", 0) or 0)
|
|
929
929
|
cc = int(usage.get("cache_creation_input_tokens", 0) or 0)
|
|
930
930
|
cr = int(usage.get("cache_read_input_tokens", 0) or 0)
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
)
|
|
938
|
-
}
|
|
931
|
+
# #181: `speed` is the ONLY non-token usage key any
|
|
932
|
+
# consumer reads, so materialize just that scalar and
|
|
933
|
+
# write NULL into usage_extra_json — no more
|
|
934
|
+
# serializing the deeply-nested blob the read paths
|
|
935
|
+
# used to json.loads per row.
|
|
936
|
+
speed = usage.get("speed")
|
|
939
937
|
rows.append((
|
|
940
938
|
path_str,
|
|
941
939
|
offset,
|
|
@@ -944,7 +942,8 @@ def sync_cache(
|
|
|
944
942
|
msg_id,
|
|
945
943
|
req_id,
|
|
946
944
|
inp, out, cc, cr,
|
|
947
|
-
|
|
945
|
+
None, # usage_extra_json — bloat no longer written (#181)
|
|
946
|
+
speed, # materialized speed column
|
|
948
947
|
entry.cost_usd,
|
|
949
948
|
))
|
|
950
949
|
if mrow is not None:
|
|
@@ -1005,8 +1004,8 @@ def sync_cache(
|
|
|
1005
1004
|
(source_path, line_offset, timestamp_utc, model,
|
|
1006
1005
|
msg_id, req_id, input_tokens, output_tokens,
|
|
1007
1006
|
cache_create_tokens, cache_read_tokens,
|
|
1008
|
-
usage_extra_json, cost_usd_raw)
|
|
1009
|
-
VALUES (
|
|
1007
|
+
usage_extra_json, speed, cost_usd_raw)
|
|
1008
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
1010
1009
|
ON CONFLICT(msg_id, req_id)
|
|
1011
1010
|
WHERE msg_id IS NOT NULL AND req_id IS NOT NULL
|
|
1012
1011
|
DO UPDATE SET
|
|
@@ -1017,6 +1016,7 @@ def sync_cache(
|
|
|
1017
1016
|
cache_create_tokens = excluded.cache_create_tokens,
|
|
1018
1017
|
cache_read_tokens = excluded.cache_read_tokens,
|
|
1019
1018
|
usage_extra_json = excluded.usage_extra_json,
|
|
1019
|
+
speed = excluded.speed,
|
|
1020
1020
|
cost_usd_raw = excluded.cost_usd_raw
|
|
1021
1021
|
WHERE
|
|
1022
1022
|
(excluded.input_tokens + excluded.output_tokens
|
|
@@ -1030,8 +1030,8 @@ def sync_cache(
|
|
|
1030
1030
|
=
|
|
1031
1031
|
(session_entries.input_tokens + session_entries.output_tokens
|
|
1032
1032
|
+ session_entries.cache_create_tokens + session_entries.cache_read_tokens)
|
|
1033
|
-
AND
|
|
1034
|
-
AND
|
|
1033
|
+
AND excluded.speed IS NOT NULL
|
|
1034
|
+
AND session_entries.speed IS NULL
|
|
1035
1035
|
)""",
|
|
1036
1036
|
rows,
|
|
1037
1037
|
)
|
|
@@ -1266,7 +1266,7 @@ def iter_entries(
|
|
|
1266
1266
|
|
|
1267
1267
|
sql = (
|
|
1268
1268
|
"SELECT timestamp_utc, model, input_tokens, output_tokens, "
|
|
1269
|
-
"cache_create_tokens, cache_read_tokens,
|
|
1269
|
+
"cache_create_tokens, cache_read_tokens, speed, "
|
|
1270
1270
|
"cost_usd_raw, source_path "
|
|
1271
1271
|
"FROM session_entries "
|
|
1272
1272
|
"WHERE timestamp_utc >= ? AND timestamp_utc <= ?"
|
|
@@ -1292,12 +1292,12 @@ def iter_entries(
|
|
|
1292
1292
|
"cache_creation_input_tokens": row[4],
|
|
1293
1293
|
"cache_read_input_tokens": row[5],
|
|
1294
1294
|
}
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
usage
|
|
1295
|
+
# speed is the only non-token usage key any consumer reads (#181);
|
|
1296
|
+
# materialized into its own column so this hot path never parses JSON.
|
|
1297
|
+
# `is not None` (not truthiness) so an empty-string speed still surfaces,
|
|
1298
|
+
# mirroring the SQL `json_extract(...) IS NOT NULL` parity.
|
|
1299
|
+
if row[6] is not None:
|
|
1300
|
+
usage["speed"] = row[6]
|
|
1301
1301
|
entries.append(UsageEntry(
|
|
1302
1302
|
timestamp=dt.datetime.fromisoformat(row[0]),
|
|
1303
1303
|
model=row[1],
|
|
@@ -1430,7 +1430,7 @@ def get_claude_session_entries(
|
|
|
1430
1430
|
" se.cache_create_tokens, se.cache_read_tokens, "
|
|
1431
1431
|
" se.source_path, "
|
|
1432
1432
|
" sf.session_id, sf.project_path, "
|
|
1433
|
-
" se.cost_usd_raw, se.
|
|
1433
|
+
" se.cost_usd_raw, se.speed "
|
|
1434
1434
|
"FROM session_entries se "
|
|
1435
1435
|
"LEFT JOIN session_files sf ON sf.path = se.source_path "
|
|
1436
1436
|
"WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ?"
|
|
@@ -1458,7 +1458,10 @@ def get_claude_session_entries(
|
|
|
1458
1458
|
session_id=row[7],
|
|
1459
1459
|
project_path=row[8],
|
|
1460
1460
|
cost_usd=row[9],
|
|
1461
|
-
|
|
1461
|
+
# speed materialized into its own column (#181); reconstruct the
|
|
1462
|
+
# {"speed": …} shape _usage_entry_from_joined already merges, with
|
|
1463
|
+
# zero JSON parsing. `is not None` so an empty-string speed surfaces.
|
|
1464
|
+
usage_extra=({"speed": row[10]} if row[10] is not None else None),
|
|
1462
1465
|
)
|
|
1463
1466
|
for row in rows
|
|
1464
1467
|
]
|
|
@@ -5341,6 +5341,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5341
5341
|
self._handle_get_conversations()
|
|
5342
5342
|
elif path == "/api/conversation/search":
|
|
5343
5343
|
self._handle_get_conversation_search()
|
|
5344
|
+
elif path.startswith("/api/conversation/") and path.endswith("/payload"):
|
|
5345
|
+
# #178: on-demand load-full. Matched BEFORE the <id> reader
|
|
5346
|
+
# catch-all (same precedence as /api/conversation/search).
|
|
5347
|
+
self._handle_get_conversation_payload(path)
|
|
5344
5348
|
elif path.startswith("/api/conversation/"):
|
|
5345
5349
|
self._handle_get_conversation_detail(path)
|
|
5346
5350
|
else:
|
|
@@ -7344,6 +7348,46 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7344
7348
|
return
|
|
7345
7349
|
self._respond_json(200, body)
|
|
7346
7350
|
|
|
7351
|
+
def _handle_get_conversation_payload(self, path: str) -> None:
|
|
7352
|
+
"""``GET /api/conversation/<sid>/payload?tool_use_id=<id>&which=<result|input>``
|
|
7353
|
+
— the #178 on-demand load-full route. Re-reads the source JSONL line so
|
|
7354
|
+
a clipped result/input can be expanded without enlarging the cache.
|
|
7355
|
+
|
|
7356
|
+
Gated FIRST by the same loopback/Host transcript privacy predicate the
|
|
7357
|
+
three other conversation routes use (fail-closed 403). ``locate_tool_payload``
|
|
7358
|
+
runs against cache.db (via the shared 500-envelope scaffold); the actual
|
|
7359
|
+
full body is re-read from disk by ``read_full_payload`` (no cache conn).
|
|
7360
|
+
``which`` is validated to ``result``/``input`` (else 400); a missing
|
|
7361
|
+
tool_use_id is 400; an unknown id is 404; a gone/unparseable source line
|
|
7362
|
+
is 410 (the documented consequence of storing only capped text).
|
|
7363
|
+
"""
|
|
7364
|
+
if not self._require_transcripts_allowed():
|
|
7365
|
+
return
|
|
7366
|
+
import urllib.parse as _u
|
|
7367
|
+
session_id = _u.unquote(
|
|
7368
|
+
path[len("/api/conversation/"):-len("/payload")])
|
|
7369
|
+
q = _u.parse_qs(self.path.partition("?")[2])
|
|
7370
|
+
tool_use_id = _qs_str(q, "tool_use_id", "")
|
|
7371
|
+
which = _qs_str(q, "which", "result")
|
|
7372
|
+
if not session_id or which not in ("result", "input") or not tool_use_id:
|
|
7373
|
+
self._respond_json(400, {"error": "bad request"})
|
|
7374
|
+
return
|
|
7375
|
+
cq = self._conversation_query()
|
|
7376
|
+
ok, loc = self._run_conversation_query(
|
|
7377
|
+
lambda conn: cq.locate_tool_payload(
|
|
7378
|
+
conn, session_id, tool_use_id, which),
|
|
7379
|
+
"/api/conversation/payload")
|
|
7380
|
+
if not ok:
|
|
7381
|
+
return
|
|
7382
|
+
if loc is None:
|
|
7383
|
+
self._respond_json(404, {"error": "not found"})
|
|
7384
|
+
return
|
|
7385
|
+
payload = cq.read_full_payload(loc[0], loc[1], tool_use_id, which)
|
|
7386
|
+
if payload is None:
|
|
7387
|
+
self._respond_json(410, {"error": "source no longer available"})
|
|
7388
|
+
return
|
|
7389
|
+
self._respond_json(200, payload)
|
|
7390
|
+
|
|
7347
7391
|
def _handle_get_project_detail(self) -> None:
|
|
7348
7392
|
"""Return ProjectDetail JSON for ``GET /api/project/<key>?weeks=N``
|
|
7349
7393
|
(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,33 @@ 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
|
+
|
|
3174
3210
|
# === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
|
|
3175
3211
|
|
|
3176
3212
|
@stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
|
|
@@ -3323,6 +3359,10 @@ def _008_recompute_weekly_cost_snapshots_dedup_fix(
|
|
|
3323
3359
|
"cache_creation_input_tokens": cc,
|
|
3324
3360
|
"cache_read_input_tokens": cr,
|
|
3325
3361
|
}
|
|
3362
|
+
# #181: usage_extra_json is cost-irrelevant (cost is
|
|
3363
|
+
# token-only); parsed here only for pre-008 rows that may
|
|
3364
|
+
# still carry the legacy blob — NOT a speed reader, so the
|
|
3365
|
+
# write-side NULL going forward is safe.
|
|
3326
3366
|
if extras_json:
|
|
3327
3367
|
usage.update(json.loads(extras_json))
|
|
3328
3368
|
total += _calculate_entry_cost(
|
|
@@ -3637,6 +3677,10 @@ def _009_recompute_five_hour_blocks_dedup_fix(
|
|
|
3637
3677
|
"cache_creation_input_tokens": cc_t,
|
|
3638
3678
|
"cache_read_input_tokens": cr_t,
|
|
3639
3679
|
}
|
|
3680
|
+
# #181: usage_extra_json is cost-irrelevant (cost is
|
|
3681
|
+
# token-only); parsed here only for pre-008 rows that may
|
|
3682
|
+
# still carry the legacy blob — NOT a speed reader, so the
|
|
3683
|
+
# write-side NULL going forward is safe.
|
|
3640
3684
|
if extras_json:
|
|
3641
3685
|
usage.update(json.loads(extras_json))
|
|
3642
3686
|
cost = _calculate_entry_cost(
|
|
@@ -3909,6 +3953,10 @@ def _010_recompute_percent_milestones_dedup_fix(
|
|
|
3909
3953
|
"cache_creation_input_tokens": cc,
|
|
3910
3954
|
"cache_read_input_tokens": cr,
|
|
3911
3955
|
}
|
|
3956
|
+
# #181: usage_extra_json is cost-irrelevant (cost is
|
|
3957
|
+
# token-only); parsed here only for pre-008 rows that may
|
|
3958
|
+
# still carry the legacy blob — NOT a speed reader, so the
|
|
3959
|
+
# write-side NULL going forward is safe.
|
|
3912
3960
|
if extras_json:
|
|
3913
3961
|
usage.update(json.loads(extras_json))
|
|
3914
3962
|
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
|
@@ -106,6 +106,7 @@ def _normalize(obj, t, offset):
|
|
|
106
106
|
entry_type = TOOL_RESULT
|
|
107
107
|
_attach_subagent_result(blocks, obj) # #166: record-level toolUseResult
|
|
108
108
|
_attach_ask_answers(blocks, obj) # #177 S2: AskUserQuestion answers
|
|
109
|
+
_attach_bash_streams(blocks, obj) # #177 S3: Bash stderr/interrupted
|
|
109
110
|
_attach_task_meta(blocks, obj) # task checklist identity
|
|
110
111
|
# tool_result rows are stored but NOT indexed as prose (spec §2). A
|
|
111
112
|
# user line that mixes a text block with a tool_result block must not
|
|
@@ -289,6 +290,35 @@ def _attach_ask_answers(blocks, obj):
|
|
|
289
290
|
results[0]["ask_annotations"] = bounded_anno
|
|
290
291
|
|
|
291
292
|
|
|
293
|
+
def _attach_bash_streams(blocks, obj):
|
|
294
|
+
"""Stash a Bash tool_result's structured stderr + interrupted onto its single
|
|
295
|
+
tool_result block (#177 S3). Self-identifying: fires only when toolUseResult
|
|
296
|
+
is a dict carrying a ``stdout``/``stderr`` key — a shape distinctive to Bash —
|
|
297
|
+
so no cross-record tool-name lookup is needed (same posture as
|
|
298
|
+
_attach_ask_answers' ``answers`` gate). We do NOT store stdout: the existing
|
|
299
|
+
``result.text`` already equals stdout+stderr (the merged Bash output), so
|
|
300
|
+
storing stdout would roughly double the at-rest payload; the stdout/stderr
|
|
301
|
+
split is derived client-side by stripping the stderr suffix.
|
|
302
|
+
|
|
303
|
+
Parser-private keys ``bash_stderr`` / ``bash_interrupted`` are popped in the
|
|
304
|
+
query layer's Phase 1 so they never leak into emitted/orphan blocks. stderr is
|
|
305
|
+
bounded with the same cap as result.text (_TOOL_RESULT_CAP). Empty stderr +
|
|
306
|
+
not-interrupted is a no-op (the common case — stderr is empty in ~99% of
|
|
307
|
+
results), keeping the additive contract: absent on every non-Bash result and
|
|
308
|
+
on old rows. Same exactly-one-result-block guard as _attach_subagent_result."""
|
|
309
|
+
tur = obj.get("toolUseResult")
|
|
310
|
+
if not isinstance(tur, dict) or ("stdout" not in tur and "stderr" not in tur):
|
|
311
|
+
return
|
|
312
|
+
results = [b for b in blocks if b.get("kind") == "tool_result"]
|
|
313
|
+
if len(results) != 1:
|
|
314
|
+
return
|
|
315
|
+
stderr = tur.get("stderr")
|
|
316
|
+
if isinstance(stderr, str) and stderr:
|
|
317
|
+
results[0]["bash_stderr"] = stderr[:_TOOL_RESULT_CAP]
|
|
318
|
+
if bool(tur.get("interrupted")):
|
|
319
|
+
results[0]["bash_interrupted"] = True
|
|
320
|
+
|
|
321
|
+
|
|
292
322
|
# Subagent Task tools record toolUseResult=null and put the identity in the
|
|
293
323
|
# human-readable result text instead; the id is the only thing the fold needs
|
|
294
324
|
# from the result (subject/status come from the call input). Anchored to the
|
|
@@ -20,6 +20,10 @@ import sqlite3
|
|
|
20
20
|
# mirror — imported by the dashboard's conversation endpoints at runtime.
|
|
21
21
|
|
|
22
22
|
from _lib_pricing import _calculate_entry_cost
|
|
23
|
+
# #178: the on-demand load-full re-read helper re-stringifies a raw tool_result
|
|
24
|
+
# content block the same way the parser does at ingest — reuse the parser's
|
|
25
|
+
# _stringify so the full (un-capped) result text matches the cached/capped one.
|
|
26
|
+
from _lib_conversation import _stringify
|
|
23
27
|
|
|
24
28
|
|
|
25
29
|
# Mirror of dashboard/web/src/conversations/systemMarkers.ts::MARKER_RE — anchored
|
|
@@ -484,6 +488,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
484
488
|
spawn_kind = {} # tool_use id -> subagent_type
|
|
485
489
|
agent_link = {} # tool_use id -> (agent_id, raw_meta)
|
|
486
490
|
ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
|
|
491
|
+
bash_link = {} # tool_use id -> (stderr, interrupted) (#177 S3)
|
|
487
492
|
task_link = {} # tool_use id -> {"task_id", "task_list"} (Task* checklist)
|
|
488
493
|
for it in items:
|
|
489
494
|
for b in it["blocks"]:
|
|
@@ -501,6 +506,10 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
501
506
|
anno = b.pop("ask_annotations", None)
|
|
502
507
|
if ans is not None and b.get("tool_use_id") is not None:
|
|
503
508
|
ask_link[b["tool_use_id"]] = (ans, anno)
|
|
509
|
+
bstderr = b.pop("bash_stderr", None) # #177 S3
|
|
510
|
+
bintr = b.pop("bash_interrupted", None)
|
|
511
|
+
if b.get("tool_use_id") is not None and (bstderr is not None or bintr):
|
|
512
|
+
bash_link[b["tool_use_id"]] = (bstderr, bool(bintr))
|
|
504
513
|
tid_ = b.pop("task_id", None) # Task* checklist
|
|
505
514
|
tlist_ = b.pop("task_list", None)
|
|
506
515
|
if b.get("tool_use_id") is not None and (tid_ is not None or tlist_ is not None):
|
|
@@ -568,6 +577,12 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
568
577
|
b["answers"] = link[0]
|
|
569
578
|
if link[1]:
|
|
570
579
|
b["annotations"] = link[1]
|
|
580
|
+
blink = bash_link.get(b["tool_use_id"]) # #177 S3
|
|
581
|
+
if blink is not None:
|
|
582
|
+
if blink[0] is not None:
|
|
583
|
+
b["stderr"] = blink[0]
|
|
584
|
+
if blink[1]:
|
|
585
|
+
b["interrupted"] = True
|
|
571
586
|
|
|
572
587
|
# ---- Phase 3b: fold the Task* op stream into per-run checklist snapshots ----
|
|
573
588
|
_fold_task_runs(items, task_link)
|
|
@@ -1127,3 +1142,178 @@ def _manual_snippet(text, q, width=80):
|
|
|
1127
1142
|
end = min(len(text), lo + len(q) + width // 2)
|
|
1128
1143
|
s = text[start:end]
|
|
1129
1144
|
return ("… " if start else "") + s + (" …" if end < len(text) else "")
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
# ---------------------------------------------------------------------------
|
|
1148
|
+
# #178: on-demand "load full result/input" kernels. Back the dashboard's
|
|
1149
|
+
# /api/conversation/<sid>/payload route. The cache stores only CAPPED tool text
|
|
1150
|
+
# (result clipped to _TOOL_RESULT_CAP, input leaves clipped to _INPUT_LEAF_CAP),
|
|
1151
|
+
# so the full body is re-derived from the source JSONL line here — the cache at
|
|
1152
|
+
# rest never grows. Pure (sqlite3.Connection + filesystem); no clock/network.
|
|
1153
|
+
# ---------------------------------------------------------------------------
|
|
1154
|
+
_FULL_PAYLOAD_CEILING = 1_000_000 # serve up to ~1 MB; protects the HTTP server / browser
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def locate_tool_payload(conn, session_id, tool_use_id, which):
|
|
1158
|
+
"""``(source_path, byte_offset)`` for the JSONL line holding the tool_use
|
|
1159
|
+
(``which='input'``) or tool_result (``which='result'``) carrying this
|
|
1160
|
+
``tool_use_id`` in this session, else ``None``.
|
|
1161
|
+
|
|
1162
|
+
The prefilter uses ``instr(blocks_json, ?) > 0`` — NOT ``LIKE`` (Codex P1.4):
|
|
1163
|
+
tool_use_ids contain ``_`` (e.g. ``toolu_01SEQ…``), which ``LIKE`` treats as a
|
|
1164
|
+
single-char wildcard, so a near-miss id would false-match. ``instr`` is a
|
|
1165
|
+
literal substring test. It is also NOT a ``≤2 rows`` situation — the DB holds
|
|
1166
|
+
duplicate physical rows for one logical message (``get_conversation`` dedups
|
|
1167
|
+
by uuid) — so every candidate is parsed and EXACT-matched on the block id
|
|
1168
|
+
(``tool_use.id`` for input / ``tool_result.tool_use_id`` for result), under the
|
|
1169
|
+
same deterministic ``ORDER BY timestamp_utc, id`` as ``get_conversation``. The
|
|
1170
|
+
SELECT runs here (not via ``get_conversation``) because that reader omits
|
|
1171
|
+
``byte_offset`` (Codex P2.5)."""
|
|
1172
|
+
rows = conn.execute(
|
|
1173
|
+
"SELECT source_path, byte_offset, blocks_json FROM conversation_messages "
|
|
1174
|
+
"WHERE session_id=? AND instr(blocks_json, ?) > 0 "
|
|
1175
|
+
"ORDER BY timestamp_utc, id", (session_id, tool_use_id)).fetchall()
|
|
1176
|
+
for source_path, byte_offset, blocks_json in rows:
|
|
1177
|
+
try:
|
|
1178
|
+
blocks = _json.loads(blocks_json)
|
|
1179
|
+
except (ValueError, TypeError):
|
|
1180
|
+
continue
|
|
1181
|
+
if not isinstance(blocks, list):
|
|
1182
|
+
continue
|
|
1183
|
+
for b in blocks:
|
|
1184
|
+
if not isinstance(b, dict):
|
|
1185
|
+
continue
|
|
1186
|
+
k = b.get("kind")
|
|
1187
|
+
if which == "input" and k == "tool_use" and b.get("id") == tool_use_id:
|
|
1188
|
+
return source_path, byte_offset
|
|
1189
|
+
if which == "result" and k == "tool_result" and b.get("tool_use_id") == tool_use_id:
|
|
1190
|
+
return source_path, byte_offset
|
|
1191
|
+
return None
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def _clip_payload_input(inp, ceiling):
|
|
1195
|
+
"""Clip a structured input so the returned dict serializes to ``ceiling`` chars
|
|
1196
|
+
or fewer, and report whether anything was clipped — the input-side analogue of
|
|
1197
|
+
the result-side ceiling. A degenerate multi-MB input (one giant leaf OR many
|
|
1198
|
+
sub-ceiling leaves that sum past the ceiling) is bounded so the HTTP server /
|
|
1199
|
+
browser is protected, while every real payload returns whole.
|
|
1200
|
+
|
|
1201
|
+
The guarantee is AGGREGATE, not merely per-leaf: a shared remaining-char budget
|
|
1202
|
+
is threaded through the walk (mirroring ``_bound_input``'s total-size backstop) —
|
|
1203
|
+
each string leaf is clipped against the running budget and the budget is
|
|
1204
|
+
decremented as we go, so once it is exhausted later leaves clip to ''.
|
|
1205
|
+
``truncated`` is True iff any leaf was clipped (or the post-walk serialized size
|
|
1206
|
+
still exceeds ``ceiling``, the structural-overhead backstop). Post-condition:
|
|
1207
|
+
``len(json.dumps(clipped, ensure_ascii=False)) <= ceiling`` always."""
|
|
1208
|
+
truncated = False
|
|
1209
|
+
remaining = [ceiling] # boxed so the nested walk can decrement it
|
|
1210
|
+
|
|
1211
|
+
def walk(v):
|
|
1212
|
+
nonlocal truncated
|
|
1213
|
+
if isinstance(v, str):
|
|
1214
|
+
if len(v) > remaining[0]:
|
|
1215
|
+
truncated = True
|
|
1216
|
+
v = v[:remaining[0]]
|
|
1217
|
+
remaining[0] -= len(v)
|
|
1218
|
+
return v
|
|
1219
|
+
if isinstance(v, dict):
|
|
1220
|
+
return {k: walk(x) for k, x in v.items()}
|
|
1221
|
+
if isinstance(v, list):
|
|
1222
|
+
return [walk(x) for x in v]
|
|
1223
|
+
return v
|
|
1224
|
+
|
|
1225
|
+
clipped = walk(inp)
|
|
1226
|
+
# Backstop: structural JSON overhead (braces/quotes/keys) can push a
|
|
1227
|
+
# budget-exact payload a few chars past the ceiling. Hard-clip the largest
|
|
1228
|
+
# remaining string leaf(s) until the whole dict serializes within the ceiling.
|
|
1229
|
+
while len(_json.dumps(clipped, ensure_ascii=False)) > ceiling:
|
|
1230
|
+
truncated = True
|
|
1231
|
+
if not _shrink_largest_leaf(clipped):
|
|
1232
|
+
break # no string leaf left to shrink (e.g. pure numeric/structural)
|
|
1233
|
+
return clipped, truncated
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def _shrink_largest_leaf(obj):
|
|
1237
|
+
"""Halve the longest string leaf reachable in ``obj`` (a dict/list/scalar),
|
|
1238
|
+
in place, and return True if one was shrunk — the post-walk backstop for the
|
|
1239
|
+
rare structural-overhead overshoot. A leaf already at length 1 is truncated to
|
|
1240
|
+
''. Returns False when no non-empty string leaf exists."""
|
|
1241
|
+
best = {"len": 0, "container": None, "key": None}
|
|
1242
|
+
|
|
1243
|
+
def scan(v, container, key):
|
|
1244
|
+
if isinstance(v, str):
|
|
1245
|
+
if len(v) > best["len"]:
|
|
1246
|
+
best.update(len=len(v), container=container, key=key)
|
|
1247
|
+
elif isinstance(v, dict):
|
|
1248
|
+
for k, x in v.items():
|
|
1249
|
+
scan(x, v, k)
|
|
1250
|
+
elif isinstance(v, list):
|
|
1251
|
+
for i, x in enumerate(v):
|
|
1252
|
+
scan(x, v, i)
|
|
1253
|
+
|
|
1254
|
+
scan(obj, None, None)
|
|
1255
|
+
if best["container"] is None or best["len"] == 0:
|
|
1256
|
+
return False
|
|
1257
|
+
s = best["container"][best["key"]]
|
|
1258
|
+
best["container"][best["key"]] = s[: len(s) // 2]
|
|
1259
|
+
return True
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def read_full_payload(source_path, byte_offset, tool_use_id, which):
|
|
1263
|
+
"""Re-read the raw JSONL line at ``(source_path, byte_offset)`` and return the
|
|
1264
|
+
FULL (un-capped) payload for ``tool_use_id``:
|
|
1265
|
+
|
|
1266
|
+
- ``which='input'`` -> ``{"which":"input", "tool_use_id", "input", "full_length",
|
|
1267
|
+
"truncated"}`` — the matching tool_use block's complete ``input`` dict, so the
|
|
1268
|
+
DiffCard can pull old/new strings straight into computeDiff.
|
|
1269
|
+
- ``which='result'`` -> ``{"which":"result", "tool_use_id", "text", "full_length",
|
|
1270
|
+
"truncated", "is_error", [stderr]}`` — the full ``_stringify(content)`` plus,
|
|
1271
|
+
for Bash, the full ``toolUseResult.stderr``.
|
|
1272
|
+
|
|
1273
|
+
``None`` when the source is gone / the line is unparseable (rotated or deleted
|
|
1274
|
+
JSONL — the documented 410 path) or the id is no longer present in that line.
|
|
1275
|
+
``full_length``/``truncated`` describe the payload against ``_FULL_PAYLOAD_CEILING``
|
|
1276
|
+
— honoring #178's "un-capped" spirit for real payloads while bounding the
|
|
1277
|
+
degenerate multi-MB case."""
|
|
1278
|
+
try:
|
|
1279
|
+
with open(source_path, "rb") as fh:
|
|
1280
|
+
fh.seek(byte_offset)
|
|
1281
|
+
line = fh.readline()
|
|
1282
|
+
obj = _json.loads(line)
|
|
1283
|
+
except (OSError, ValueError):
|
|
1284
|
+
return None
|
|
1285
|
+
if not isinstance(obj, dict):
|
|
1286
|
+
return None
|
|
1287
|
+
content = (obj.get("message") or {}).get("content")
|
|
1288
|
+
if not isinstance(content, list):
|
|
1289
|
+
return None
|
|
1290
|
+
if which == "input":
|
|
1291
|
+
for b in content:
|
|
1292
|
+
if (isinstance(b, dict) and b.get("type") == "tool_use"
|
|
1293
|
+
and b.get("id") == tool_use_id):
|
|
1294
|
+
inp = b.get("input") if isinstance(b.get("input"), dict) else {}
|
|
1295
|
+
full_length = len(_json.dumps(inp, ensure_ascii=False))
|
|
1296
|
+
clipped, truncated = _clip_payload_input(inp, _FULL_PAYLOAD_CEILING)
|
|
1297
|
+
return {"which": "input", "tool_use_id": tool_use_id,
|
|
1298
|
+
"input": clipped, "full_length": full_length,
|
|
1299
|
+
"truncated": truncated}
|
|
1300
|
+
return None
|
|
1301
|
+
for b in content:
|
|
1302
|
+
if (isinstance(b, dict) and b.get("type") == "tool_result"
|
|
1303
|
+
and b.get("tool_use_id") == tool_use_id):
|
|
1304
|
+
raw = _stringify(b.get("content"))
|
|
1305
|
+
# The bound is PER-STREAM by design: `text` and the Bash `stderr`
|
|
1306
|
+
# below are each clipped to _FULL_PAYLOAD_CEILING independently, so a
|
|
1307
|
+
# result carrying both can serialize to ~2× the ceiling. That is
|
|
1308
|
+
# intentional — they are distinct streams the DiffCard renders side by
|
|
1309
|
+
# side, and each is individually bounded against the HTTP/browser DoS.
|
|
1310
|
+
resp = {"which": "result", "tool_use_id": tool_use_id,
|
|
1311
|
+
"text": raw[:_FULL_PAYLOAD_CEILING], "full_length": len(raw),
|
|
1312
|
+
"truncated": len(raw) > _FULL_PAYLOAD_CEILING,
|
|
1313
|
+
"is_error": bool(b.get("is_error"))}
|
|
1314
|
+
tur = obj.get("toolUseResult")
|
|
1315
|
+
if (isinstance(tur, dict) and isinstance(tur.get("stderr"), str)
|
|
1316
|
+
and tur.get("stderr")):
|
|
1317
|
+
resp["stderr"] = tur["stderr"][:_FULL_PAYLOAD_CEILING] # per-stream bound (see above)
|
|
1318
|
+
return resp
|
|
1319
|
+
return None
|
package/bin/_lib_jsonl.py
CHANGED
|
@@ -85,8 +85,10 @@ def _should_replace(
|
|
|
85
85
|
finalization row carries `speed`; streaming intermediates don't).
|
|
86
86
|
|
|
87
87
|
The `usage.get("speed") is not None` check matches the SQL UPDATE WHERE
|
|
88
|
-
clause's `
|
|
89
|
-
|
|
88
|
+
clause's `excluded.speed IS NOT NULL` in `sync_cache`'s INSERT … ON
|
|
89
|
+
CONFLICT … DO UPDATE — `speed` is materialized into its own
|
|
90
|
+
`session_entries.speed` column (#181), so the tiebreak no longer
|
|
91
|
+
`json_extract`s the blob — keeping the direct-parse fallback and
|
|
90
92
|
cache-ingest paths in lockstep on the rare-but-possible "explicit JSON
|
|
91
93
|
null" payload.
|
|
92
94
|
"""
|
package/bin/cctally
CHANGED
|
@@ -1614,13 +1614,14 @@ def _usage_entry_from_joined(je) -> "UsageEntry":
|
|
|
1614
1614
|
and the per-token integers; this adapter is pure shape conversion
|
|
1615
1615
|
with no cache re-read.
|
|
1616
1616
|
|
|
1617
|
-
Non-token ``usage`` extras (``je.usage_extra``) —
|
|
1617
|
+
Non-token ``usage`` extras (``je.usage_extra``) — now just ``speed`` —
|
|
1618
1618
|
are merged AFTER the four token keys, mirroring ``iter_entries``'
|
|
1619
|
-
``usage
|
|
1619
|
+
reconstruction of ``usage["speed"]`` from the materialized
|
|
1620
|
+
``session_entries.speed`` column (#181). Without this, the project-axis
|
|
1620
1621
|
``daily`` path (and the diff/report joined-entry consumers) would
|
|
1621
1622
|
drop the fast-tier flag and render ``<model>`` where the normal path
|
|
1622
|
-
renders ``<model>-fast``.
|
|
1623
|
-
|
|
1623
|
+
renders ``<model>-fast``. ``je.usage_extra`` is ``{"speed": …}`` or
|
|
1624
|
+
``None``, so the merge never shadows the token integers.
|
|
1624
1625
|
"""
|
|
1625
1626
|
usage = {
|
|
1626
1627
|
"input_tokens": je.input_tokens,
|