cctally 1.52.0 → 1.53.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 +32 -0
- package/bin/_cctally_cache.py +233 -17
- package/bin/_cctally_dashboard.py +91 -26
- package/bin/_cctally_db.py +347 -11
- package/bin/_cctally_doctor.py +69 -0
- package/bin/_cctally_record.py +36 -16
- package/bin/_lib_conversation.py +225 -9
- package/bin/_lib_conversation_query.py +1020 -316
- package/bin/_lib_doctor.py +85 -0
- package/bin/_lib_share.py +13 -1
- package/dashboard/static/assets/index-Bli-0vNb.css +1 -0
- package/dashboard/static/assets/index-Dwu_Neot.js +71 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-Psq3Ym5M.js +0 -68
- package/dashboard/static/assets/index-klTHJA52.css +0 -1
package/bin/_lib_conversation.py
CHANGED
|
@@ -214,12 +214,12 @@ class MessageRow:
|
|
|
214
214
|
attribution_skill: "str | None" = None
|
|
215
215
|
attribution_plugin: "str | None" = None
|
|
216
216
|
# #177 S6: split non-prose search columns, derived by _derive_search_columns
|
|
217
|
-
# on the FINAL post-augment blocks (see _normalize).
|
|
218
|
-
#
|
|
219
|
-
#
|
|
217
|
+
# on the FINAL post-augment blocks (see _normalize). The consolidated
|
|
218
|
+
# multi-column FTS reads search_tool/search_thinking. (#217 S1 / U7a: the
|
|
219
|
+
# legacy ``search_aux`` field was removed — the column is dropped from the
|
|
220
|
+
# live schema by migration 016 and the writer never carried a value anyway.)
|
|
220
221
|
search_tool: str = ""
|
|
221
222
|
search_thinking: str = ""
|
|
222
|
-
search_aux: str = "" # documented-dead (#177 S6); always "" on new rows
|
|
223
223
|
|
|
224
224
|
|
|
225
225
|
def iter_message_rows(fh, path_str):
|
|
@@ -462,16 +462,15 @@ def _normalize(obj, t, offset):
|
|
|
462
462
|
is_sidechain=1 if obj.get("isSidechain") else 0,
|
|
463
463
|
source_tool_use_id=obj.get("sourceToolUseID"),
|
|
464
464
|
# #177: message-level enrichment. stop_reason is assistant-only;
|
|
465
|
-
# attribution is top-level on the JSONL object.
|
|
466
|
-
#
|
|
467
|
-
#
|
|
465
|
+
# attribution is top-level on the JSONL object. The non-prose index is
|
|
466
|
+
# the split search_tool/search_thinking columns — tool/thinking content
|
|
467
|
+
# stays searchable even though `text` is zeroed for prose FTS on
|
|
468
|
+
# tool_result/meta rows.
|
|
468
469
|
stop_reason=msg.get("stop_reason") if is_asst else None,
|
|
469
470
|
attribution_skill=obj.get("attributionSkill"),
|
|
470
471
|
attribution_plugin=obj.get("attributionPlugin"),
|
|
471
472
|
search_tool=search_tool,
|
|
472
473
|
search_thinking=search_thinking,
|
|
473
|
-
# search_aux stays "" (documented-dead, #177 S6) — the consolidated
|
|
474
|
-
# multi-column FTS reads search_tool/search_thinking instead.
|
|
475
474
|
)
|
|
476
475
|
|
|
477
476
|
|
|
@@ -536,6 +535,18 @@ def _blocks_and_text(content):
|
|
|
536
535
|
"full_length": len(raw),
|
|
537
536
|
"is_error": bool(b.get("is_error")),
|
|
538
537
|
"tool_use_id": b.get("tool_use_id")}
|
|
538
|
+
# #217 S1 / U6: a nested (grandchild) subagent result carries its
|
|
539
|
+
# `agentId:` (+ optional <usage>) in the result STRING, not a
|
|
540
|
+
# record-level toolUseResult. Parse them from the FULL raw HERE,
|
|
541
|
+
# BEFORE the cap clips the text — so a >16 KB result whose agentId:
|
|
542
|
+
# trailer lands past the cut still links (the read-time regex over
|
|
543
|
+
# the clipped text would miss it). Stamp the structured keys the
|
|
544
|
+
# kernel's id consumer already reads (b.pop("agent_id")); the
|
|
545
|
+
# read-time regex stays a fallback for un-reingested rows.
|
|
546
|
+
_nested = _nested_agent_stamp_from_text(raw)
|
|
547
|
+
if _nested is not None:
|
|
548
|
+
block["agent_id"] = _nested[0]
|
|
549
|
+
block["subagent_meta"] = _nested[1]
|
|
539
550
|
# #177 S4: media placeholders for image/document items inside the
|
|
540
551
|
# tool_result content array (where every MCP screenshot lives) —
|
|
541
552
|
# ordinals from the shared iter_media_items chokepoint.
|
|
@@ -561,6 +572,45 @@ _SUBAGENT_META_KEYS = (
|
|
|
561
572
|
)
|
|
562
573
|
|
|
563
574
|
|
|
575
|
+
# #217 S1 / U6 — nested (grandchild) subagent result parse. The new Claude Code
|
|
576
|
+
# format emits a grandchild's spawn result as STRING content (no structured
|
|
577
|
+
# record-level toolUseResult.agentId), with a trailing `agentId: <hash> (use
|
|
578
|
+
# SendMessage …)` line and an OPTIONAL `<usage>` totals wrapper. These constants
|
|
579
|
+
# live HERE (the parser) so the INGEST stamp runs them against the FULL raw
|
|
580
|
+
# content BEFORE the _TOOL_RESULT_CAP clip — a result whose agentId: trailer
|
|
581
|
+
# lands past the 16 KB cut would otherwise survive only as flat text and the
|
|
582
|
+
# read-time regex (over the clipped text) would miss it. _lib_conversation_query
|
|
583
|
+
# re-exports both for its read-time fallback over un-reingested rows.
|
|
584
|
+
_NESTED_AGENT_ID_RE = re.compile(r"agentId:\s*([0-9a-f]+)")
|
|
585
|
+
_NESTED_USAGE_RE = re.compile(
|
|
586
|
+
r"<usage>\s*subagent_tokens:\s*(\d+)\s*tool_uses:\s*(\d+)\s*duration_ms:\s*(\d+)\s*</usage>",
|
|
587
|
+
re.DOTALL,
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _nested_agent_stamp_from_text(raw):
|
|
592
|
+
"""(agent_id, subagent_meta) parsed from a tool_result's FULL string content,
|
|
593
|
+
or None when no `agentId:` line is present (an ordinary result, NOT a spawn
|
|
594
|
+
grandchild). subagent_meta carries the completed `<usage>` totals when the
|
|
595
|
+
wrapper is present + well-formed, else {} — linking on agentId ALONE is
|
|
596
|
+
sufficient, so a missing/clipped/malformed usage degrades usage to {}, never
|
|
597
|
+
the whole link. Keys mirror the snake_case shape _attach_subagent_result /
|
|
598
|
+
the query kernel's structured-id consumer expect (Codex P1-B)."""
|
|
599
|
+
if not raw:
|
|
600
|
+
return None
|
|
601
|
+
m = _NESTED_AGENT_ID_RE.search(raw)
|
|
602
|
+
if not m:
|
|
603
|
+
return None
|
|
604
|
+
meta = {}
|
|
605
|
+
u = _NESTED_USAGE_RE.search(raw)
|
|
606
|
+
if u:
|
|
607
|
+
meta["total_tokens"] = int(u.group(1))
|
|
608
|
+
meta["total_tool_use_count"] = int(u.group(2))
|
|
609
|
+
meta["total_duration_ms"] = int(u.group(3))
|
|
610
|
+
meta["status"] = "completed"
|
|
611
|
+
return m.group(1), meta
|
|
612
|
+
|
|
613
|
+
|
|
564
614
|
def _attach_subagent_result(blocks, obj):
|
|
565
615
|
"""Attach the record-level ``toolUseResult`` agentId + meta (#166) onto the
|
|
566
616
|
tool_result block, but ONLY when the record carries exactly one tool_result
|
|
@@ -580,6 +630,9 @@ def _attach_subagent_result(blocks, obj):
|
|
|
580
630
|
if len(results) != 1:
|
|
581
631
|
return
|
|
582
632
|
block = results[0]
|
|
633
|
+
# The record-level toolUseResult is the AUTHORITATIVE source — it overwrites
|
|
634
|
+
# any nested-text stamp (#217 S1 / U6) for the same id (the two shapes do not
|
|
635
|
+
# co-occur in real data, so this is only a deterministic-precedence guard).
|
|
583
636
|
block["agent_id"] = agent_id
|
|
584
637
|
meta = {}
|
|
585
638
|
for src, dst in _SUBAGENT_META_KEYS:
|
|
@@ -977,11 +1030,142 @@ def _bound_input(inp):
|
|
|
977
1030
|
return v
|
|
978
1031
|
|
|
979
1032
|
bounded = walk(inp, 0)
|
|
1033
|
+
# #217 S1 / U5 (data-contract honesty): the total-cap backstop must actually
|
|
1034
|
+
# CLIP, not merely flag. The four per-axis caps above bound any SINGLE leaf /
|
|
1035
|
+
# key / node-count / depth, but many sub-leaf-cap leaves can still sum past
|
|
1036
|
+
# _INPUT_TOTAL_CAP — and the old code only set truncated=True there, serving
|
|
1037
|
+
# an over-cap payload. _clip_payload_input threads a shared remaining-WIRE-char
|
|
1038
|
+
# budget through the (already per-axis-bounded) dict — measured in the SAME
|
|
1039
|
+
# json.dumps(separators=(",",":")) wire form this check uses (and blocks_json
|
|
1040
|
+
# stores) — so the SERVED payload satisfies
|
|
1041
|
+
# len(json.dumps(bounded, separators=(",",":"))) <= _INPUT_TOTAL_CAP whenever it
|
|
1042
|
+
# clips. Non-ASCII leaves escape to \uXXXX (6 wire chars/char), so a plain char
|
|
1043
|
+
# budget would clip ~6x too late on the wire (round-2 P1 fix).
|
|
980
1044
|
if len(json.dumps(bounded, separators=(",", ":"))) > _INPUT_TOTAL_CAP:
|
|
1045
|
+
bounded, _ = _clip_payload_input(bounded, _INPUT_TOTAL_CAP)
|
|
981
1046
|
state["truncated"] = True
|
|
982
1047
|
return (bounded, state["truncated"])
|
|
983
1048
|
|
|
984
1049
|
|
|
1050
|
+
# #217 S1 / U5: the payload-clip helpers live HERE (the parser/ingest module, low
|
|
1051
|
+
# in the dependency graph) so BOTH _bound_input's total-cap backstop (above) and
|
|
1052
|
+
# read_full_payload's on-demand input clip (the query kernel, which imports this
|
|
1053
|
+
# module) share one bound-guaranteeing implementation. The query layer never
|
|
1054
|
+
# imports back into the parser's callers, so this is the import-safe home (query
|
|
1055
|
+
# depends on the parser, not vice-versa — verified at #217 implementation).
|
|
1056
|
+
def _wire_len(s):
|
|
1057
|
+
"""Serialized char-length of a string leaf in the WIRE form — the SAME
|
|
1058
|
+
``json.dumps(separators=(",",":"))`` (default ``ensure_ascii=True``) used both
|
|
1059
|
+
by ``_bound_input``'s total-cap check and the ``blocks_json`` storage at the
|
|
1060
|
+
top of this module. For ASCII this equals ``len(s)``; for non-ASCII each char
|
|
1061
|
+
escapes to ``\\uXXXX`` (6 chars) so the wire cost is larger — measuring against
|
|
1062
|
+
it (not ``len(s)``) is what makes the clip's post-condition hold on the wire.
|
|
1063
|
+
The surrounding quotes are part of the per-leaf structural cost the post-walk
|
|
1064
|
+
backstop absorbs, so this counts only the escaped CONTENT (no quotes)."""
|
|
1065
|
+
# json.dumps wraps the string in quotes; strip them to get the content cost.
|
|
1066
|
+
return len(json.dumps(s, separators=(",", ":"))) - 2
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _clip_string_to_wire_budget(s, budget):
|
|
1070
|
+
"""Return the longest prefix of ``s`` whose WIRE length is ``<= budget`` (>= 0).
|
|
1071
|
+
ASCII is a fast 1:1 slice; for non-ASCII we walk char-by-char accumulating the
|
|
1072
|
+
per-char wire cost so a 6-chars-per-char escape never overshoots the budget."""
|
|
1073
|
+
if budget <= 0:
|
|
1074
|
+
return ""
|
|
1075
|
+
if s.isascii():
|
|
1076
|
+
return s[:budget]
|
|
1077
|
+
out, used = [], 0
|
|
1078
|
+
for ch in s:
|
|
1079
|
+
c = _wire_len(ch)
|
|
1080
|
+
if used + c > budget:
|
|
1081
|
+
break
|
|
1082
|
+
out.append(ch)
|
|
1083
|
+
used += c
|
|
1084
|
+
return "".join(out)
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _clip_payload_input(inp, ceiling):
|
|
1088
|
+
"""Clip a structured input so the returned dict serializes to ``ceiling`` chars
|
|
1089
|
+
or fewer IN THE WIRE FORM, and report whether anything was clipped — the
|
|
1090
|
+
input-side analogue of the result-side ceiling. A degenerate multi-MB input
|
|
1091
|
+
(one giant leaf OR many sub-ceiling leaves that sum past the ceiling) is bounded
|
|
1092
|
+
so the HTTP server / browser is protected, while every real payload returns
|
|
1093
|
+
whole.
|
|
1094
|
+
|
|
1095
|
+
The guarantee is AGGREGATE, not merely per-leaf: a shared remaining-WIRE-char
|
|
1096
|
+
budget is threaded through the walk (mirroring ``_bound_input``'s total-size
|
|
1097
|
+
backstop) — each string leaf is clipped against the running budget, measured in
|
|
1098
|
+
the wire form, and the budget is decremented by the leaf's wire cost as we go,
|
|
1099
|
+
so once it is exhausted later leaves clip to ''. ``truncated`` is True iff any
|
|
1100
|
+
leaf was clipped (or the post-walk serialized size still exceeds ``ceiling``,
|
|
1101
|
+
the structural-overhead backstop).
|
|
1102
|
+
|
|
1103
|
+
The measurement is the WIRE form ``json.dumps(separators=(",",":"))`` (default
|
|
1104
|
+
``ensure_ascii=True``) — the SAME serialization ``_bound_input`` checks against
|
|
1105
|
+
and the ``blocks_json`` storage uses — NOT ``ensure_ascii=False``. For non-ASCII
|
|
1106
|
+
payloads each char escapes to ``\\uXXXX`` (6 wire chars), so an ``ensure_ascii=
|
|
1107
|
+
False`` measurement clips ~6x too late on the wire; measuring in the wire form
|
|
1108
|
+
is what makes the post-condition real. Post-condition:
|
|
1109
|
+
``len(json.dumps(clipped, separators=(",",":"))) <= ceiling`` always."""
|
|
1110
|
+
truncated = False
|
|
1111
|
+
remaining = [ceiling] # boxed so the nested walk can decrement it (WIRE chars)
|
|
1112
|
+
|
|
1113
|
+
def walk(v):
|
|
1114
|
+
nonlocal truncated
|
|
1115
|
+
if isinstance(v, str):
|
|
1116
|
+
if _wire_len(v) > remaining[0]:
|
|
1117
|
+
truncated = True
|
|
1118
|
+
v = _clip_string_to_wire_budget(v, remaining[0])
|
|
1119
|
+
remaining[0] -= _wire_len(v)
|
|
1120
|
+
return v
|
|
1121
|
+
if isinstance(v, dict):
|
|
1122
|
+
return {k: walk(x) for k, x in v.items()}
|
|
1123
|
+
if isinstance(v, list):
|
|
1124
|
+
return [walk(x) for x in v]
|
|
1125
|
+
return v
|
|
1126
|
+
|
|
1127
|
+
clipped = walk(inp)
|
|
1128
|
+
# Backstop: structural JSON overhead (braces/quotes/keys) can push a
|
|
1129
|
+
# budget-exact payload a few chars past the ceiling. Hard-clip the largest
|
|
1130
|
+
# remaining string leaf(s) until the whole dict serializes within the ceiling —
|
|
1131
|
+
# measured in the wire form so the loop terminates against the same size the
|
|
1132
|
+
# post-condition guards.
|
|
1133
|
+
while len(json.dumps(clipped, separators=(",", ":"))) > ceiling:
|
|
1134
|
+
truncated = True
|
|
1135
|
+
if not _shrink_largest_leaf(clipped):
|
|
1136
|
+
break # no string leaf left to shrink (e.g. pure numeric/structural)
|
|
1137
|
+
return clipped, truncated
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _shrink_largest_leaf(obj):
|
|
1141
|
+
"""Halve the wire-longest string leaf reachable in ``obj`` (a dict/list/scalar),
|
|
1142
|
+
in place, and return True if one was shrunk — the post-walk backstop for the
|
|
1143
|
+
rare structural-overhead overshoot. "Longest" is measured in the WIRE form (so
|
|
1144
|
+
a non-ASCII leaf, 6 wire chars per char, is correctly preferred over a longer
|
|
1145
|
+
ASCII leaf with fewer wire chars). A leaf already at length 1 is truncated to
|
|
1146
|
+
''. Returns False when no non-empty string leaf exists."""
|
|
1147
|
+
best = {"len": 0, "container": None, "key": None}
|
|
1148
|
+
|
|
1149
|
+
def scan(v, container, key):
|
|
1150
|
+
if isinstance(v, str):
|
|
1151
|
+
wl = _wire_len(v)
|
|
1152
|
+
if wl > best["len"]:
|
|
1153
|
+
best.update(len=wl, container=container, key=key)
|
|
1154
|
+
elif isinstance(v, dict):
|
|
1155
|
+
for k, x in v.items():
|
|
1156
|
+
scan(x, v, k)
|
|
1157
|
+
elif isinstance(v, list):
|
|
1158
|
+
for i, x in enumerate(v):
|
|
1159
|
+
scan(x, v, i)
|
|
1160
|
+
|
|
1161
|
+
scan(obj, None, None)
|
|
1162
|
+
if best["container"] is None or best["len"] == 0:
|
|
1163
|
+
return False
|
|
1164
|
+
s = best["container"][best["key"]]
|
|
1165
|
+
best["container"][best["key"]] = s[: len(s) // 2]
|
|
1166
|
+
return True
|
|
1167
|
+
|
|
1168
|
+
|
|
985
1169
|
def _derive_search_columns(blocks):
|
|
986
1170
|
"""(search_tool, search_thinking) from the FINAL normalized blocks list.
|
|
987
1171
|
|
|
@@ -1038,6 +1222,38 @@ def _aux_strings(v):
|
|
|
1038
1222
|
yield from _aux_strings(vv)
|
|
1039
1223
|
|
|
1040
1224
|
|
|
1225
|
+
# #217 S2 / I-3: the WRITE-class file tools — the only tools that actually mutate
|
|
1226
|
+
# a file on disk. Read / Bash / Grep / Glob are deliberately EXCLUDED: a file
|
|
1227
|
+
# "touch" for the file-path search axis means an edit/create, not a read or a
|
|
1228
|
+
# shell that merely names a path. The axis answers "which sessions modified this
|
|
1229
|
+
# file."
|
|
1230
|
+
_FILE_TOUCH_TOOLS = {"Edit", "MultiEdit", "Write", "NotebookEdit"}
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _derive_file_touches(blocks):
|
|
1234
|
+
"""Yield ``(file_path, tool)`` for every WRITE-class tool_use block (#217 S2 /
|
|
1235
|
+
I-3). Reads the same bounded ``input.file_path`` the renderer reads (file paths
|
|
1236
|
+
are well under the leaf cap, so they survive _bound_input intact).
|
|
1237
|
+
|
|
1238
|
+
Tolerates BOTH block shapes: the STORED ``blocks_json`` form keys the kind on
|
|
1239
|
+
``kind`` (the _normalize output that _fill_file_touches reparses), while raw
|
|
1240
|
+
API content blocks key it on ``type``. We accept either so the same chokepoint
|
|
1241
|
+
derives touches from the persisted rows AND from a raw-shape unit fixture."""
|
|
1242
|
+
out = []
|
|
1243
|
+
for b in (blocks or []):
|
|
1244
|
+
if not isinstance(b, dict):
|
|
1245
|
+
continue
|
|
1246
|
+
if (b.get("kind") or b.get("type")) != "tool_use":
|
|
1247
|
+
continue
|
|
1248
|
+
if b.get("name") not in _FILE_TOUCH_TOOLS:
|
|
1249
|
+
continue
|
|
1250
|
+
inp = b.get("input")
|
|
1251
|
+
fp = inp.get("file_path") if isinstance(inp, dict) else None
|
|
1252
|
+
if isinstance(fp, str) and fp:
|
|
1253
|
+
out.append((fp, b["name"]))
|
|
1254
|
+
return out
|
|
1255
|
+
|
|
1256
|
+
|
|
1041
1257
|
_PREVIEW_FIELDS = {
|
|
1042
1258
|
"Read": "file_path", "Write": "file_path", "Edit": "file_path",
|
|
1043
1259
|
"MultiEdit": "file_path", "NotebookEdit": "file_path",
|