cctally 1.52.1 → 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.
@@ -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). ``search_aux`` is kept
218
- # physically (legacy column) but NEVER assigned a non-empty value the
219
- # consolidated multi-column FTS reads search_tool/search_thinking instead.
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. search_aux is kept even
466
- # for tool_result/meta rowsonly `text` is zeroed for prose FTS;
467
- # search_aux is the non-prose index (tool content stays searchable).
465
+ # attribution is top-level on the JSONL object. The non-prose index is
466
+ # the split search_tool/search_thinking columnstool/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",