cctally 1.39.0 → 1.41.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.
@@ -53,7 +53,13 @@ class MessageRow:
53
53
  stop_reason: "str | None" = None
54
54
  attribution_skill: "str | None" = None
55
55
  attribution_plugin: "str | None" = None
56
- search_aux: str = ""
56
+ # #177 S6: split non-prose search columns, derived by _derive_search_columns
57
+ # on the FINAL post-augment blocks (see _normalize). ``search_aux`` is kept
58
+ # physically (legacy column) but NEVER assigned a non-empty value — the
59
+ # consolidated multi-column FTS reads search_tool/search_thinking instead.
60
+ search_tool: str = ""
61
+ search_thinking: str = ""
62
+ search_aux: str = "" # documented-dead (#177 S6); always "" on new rows
57
63
 
58
64
 
59
65
  def iter_message_rows(fh, path_str):
@@ -107,7 +113,7 @@ def _normalize(obj, t, offset):
107
113
  msg = obj.get("message")
108
114
  if not isinstance(msg, dict):
109
115
  msg = {}
110
- blocks, text, aux = _blocks_and_text(msg.get("content"))
116
+ blocks, text = _blocks_and_text(msg.get("content"))
111
117
  if t == "assistant":
112
118
  entry_type = ASSISTANT
113
119
  elif any(b["kind"] == "tool_result" for b in blocks):
@@ -140,6 +146,14 @@ def _normalize(obj, t, offset):
140
146
  else:
141
147
  entry_type = HUMAN
142
148
  is_asst = t == "assistant"
149
+ # #177 S6: derive the split search columns on the FINAL post-augment blocks
150
+ # (every _attach_* pass above has already merged bash_stderr / answers /
151
+ # annotations into the blocks). This is the SAME chokepoint the migration-010
152
+ # backfill runs on json.loads(blocks_json), so live ingest and backfill
153
+ # produce byte-identical values (the parity invariant). Computed immediately
154
+ # before json.dumps(blocks) so blocks_json and the columns agree by
155
+ # construction.
156
+ search_tool, search_thinking = _derive_search_columns(blocks)
143
157
  return MessageRow(
144
158
  byte_offset=offset,
145
159
  session_id=obj.get("sessionId"),
@@ -163,26 +177,27 @@ def _normalize(obj, t, offset):
163
177
  stop_reason=msg.get("stop_reason") if is_asst else None,
164
178
  attribution_skill=obj.get("attributionSkill"),
165
179
  attribution_plugin=obj.get("attributionPlugin"),
166
- search_aux=aux,
180
+ search_tool=search_tool,
181
+ search_thinking=search_thinking,
182
+ # search_aux stays "" (documented-dead, #177 S6) — the consolidated
183
+ # multi-column FTS reads search_tool/search_thinking instead.
167
184
  )
168
185
 
169
186
 
170
187
  def _blocks_and_text(content):
171
- """Return (normalized blocks list, indexed-prose string, search_aux string).
188
+ """Return (normalized blocks list, indexed-prose string).
172
189
 
173
190
  Prose (``text``) = joined ``text`` blocks only (thinking / tool_use /
174
- tool_result excluded — those go to the prose FTS via the ``text`` column).
175
- ``search_aux`` (#177) = the non-prose searchable content: bounded tool-input
176
- string leaves, the (capped) tool_result ``text``, and the thinking text
177
- capped at ``_TOOL_RESULT_CAP`` (code-review I2 the FULL thinking still
178
- lives in ``blocks_json`` for rendering; only this aux index entry is capped
179
- so the second FTS index doesn't double the at-rest cost of large thinking) —
180
- indexed by the parallel ``conversation_fts_aux`` so tool content stays
181
- searchable without polluting prose FTS. Prose is deliberately excluded from
182
- aux (it is already in ``text``)."""
191
+ tool_result excluded — those go to the split search columns via
192
+ ``_derive_search_columns``, which runs in ``_normalize`` on the FINAL
193
+ post-augment blocks). #177 S6 dropped the in-loop ``search_aux`` accumulation
194
+ that used to be the third return element: deriving the search columns here
195
+ (pre-augment) would miss bash_stderr / answers / annotations and break the
196
+ ingest==backfill parity invariant, so the derivation moved to the
197
+ post-augment chokepoint."""
183
198
  if isinstance(content, str):
184
- return (([{"kind": "text", "text": content}] if content else []), content, "")
185
- blocks, texts, aux_parts = [], [], []
199
+ return (([{"kind": "text", "text": content}] if content else []), content)
200
+ blocks, texts = [], []
186
201
  if isinstance(content, list):
187
202
  # #177 S4: ordinal among media items at THIS list level, keyed by the
188
203
  # object identity of each image/document item, so the placeholder writers
@@ -199,7 +214,6 @@ def _blocks_and_text(content):
199
214
  elif bt == "thinking":
200
215
  think = b.get("thinking", "") or ""
201
216
  blocks.append({"kind": "thinking", "text": think}) # FULL text for render
202
- aux_parts.append(think[:_TOOL_RESULT_CAP]) # aux index capped (I2)
203
217
  elif bt == "tool_use":
204
218
  bounded, input_trunc = _bound_input(b.get("input"))
205
219
  block = {"kind": "tool_use", "name": b.get("name"),
@@ -211,7 +225,6 @@ def _blocks_and_text(content):
211
225
  st = inp.get("subagent_type") if isinstance(inp, dict) else None
212
226
  if isinstance(st, str) and st: # #166: spawn kind (Agent/Task)
213
227
  block["subagent_type"] = st
214
- aux_parts.extend(_aux_strings(bounded))
215
228
  blocks.append(block)
216
229
  elif bt == "tool_result":
217
230
  raw = _stringify(b.get("content"))
@@ -230,14 +243,12 @@ def _blocks_and_text(content):
230
243
  if media: # omitted when empty (additive)
231
244
  block["media"] = media
232
245
  blocks.append(block)
233
- aux_parts.append(clipped)
234
246
  elif bt in _MEDIA_BLOCK_TYPES:
235
247
  blocks.append({"kind": bt, **_media(b.get("source")),
236
248
  "index": media_index[id(b)]})
237
249
  elif bt == "tool_reference":
238
250
  blocks.append({"kind": "tool_reference", "name": b.get("name")})
239
- return (blocks, "\n".join(t for t in texts if t),
240
- "\n".join(a for a in aux_parts if a))
251
+ return (blocks, "\n".join(t for t in texts if t))
241
252
 
242
253
 
243
254
  _SUBAGENT_META_KEYS = (
@@ -558,8 +569,51 @@ def _bound_input(inp):
558
569
  return (bounded, state["truncated"])
559
570
 
560
571
 
572
+ def _derive_search_columns(blocks):
573
+ """(search_tool, search_thinking) from the FINAL normalized blocks list.
574
+
575
+ MUST run post-augmentation (bash_stderr / answers / annotations already
576
+ merged into the blocks) so live ingest and the migration-010 backfill from
577
+ blocks_json produce byte-identical values (#177 S6 parity invariant — the
578
+ chokepoint runs in _normalize right before json.dumps(blocks), and the
579
+ backfill runs it on json.loads(blocks_json)). Caps are PER-BLOCK
580
+ (_TOOL_RESULT_CAP each), matching the old search_aux semantics — NOT a
581
+ whole-column total. Prose is excluded (already in the ``text`` column).
582
+
583
+ ``search_tool`` = bounded tool-input string leaves + clipped tool_result
584
+ ``text`` + ``bash_stderr`` + bounded AskUserQuestion answers/annotations
585
+ (real ingest stamps these as ``ask_answers``/``ask_annotations``; the raw
586
+ ``answers``/``annotations`` keys are also read for forward compatibility and
587
+ the synthetic-block unit tests). ``search_thinking`` = thinking text."""
588
+ tool_parts, think_parts = [], []
589
+ for b in blocks if isinstance(blocks, list) else []:
590
+ if not isinstance(b, dict):
591
+ continue
592
+ k = b.get("kind")
593
+ if k == "thinking":
594
+ t = b.get("text") or ""
595
+ if t:
596
+ think_parts.append(t[:_TOOL_RESULT_CAP])
597
+ elif k == "tool_use":
598
+ tool_parts.extend(
599
+ s[:_TOOL_RESULT_CAP] for s in _aux_strings(b.get("input")))
600
+ elif k == "tool_result":
601
+ t = b.get("text") or ""
602
+ if t:
603
+ tool_parts.append(t[:_TOOL_RESULT_CAP])
604
+ stderr = b.get("bash_stderr") or ""
605
+ if stderr:
606
+ tool_parts.append(stderr[:_TOOL_RESULT_CAP])
607
+ # answers/annotations: real ingest uses the ``ask_``-prefixed keys;
608
+ # the bare keys are read too (forward-compat + synthetic unit tests).
609
+ for key in ("ask_answers", "ask_annotations", "answers", "annotations"):
610
+ tool_parts.extend(
611
+ s[:_TOOL_RESULT_CAP] for s in _aux_strings(b.get(key)))
612
+ return ("\n".join(tool_parts), "\n".join(think_parts))
613
+
614
+
561
615
  def _aux_strings(v):
562
- """Yield string leaves from a bounded input value (for the search_aux blob)."""
616
+ """Yield string leaves from a bounded input value (for the search columns)."""
563
617
  if isinstance(v, str):
564
618
  if v:
565
619
  yield v