cctally 1.40.0 → 1.42.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.
@@ -16,6 +16,55 @@ ASSISTANT = "assistant"
16
16
  TOOL_RESULT = "tool_result"
17
17
  META = "meta"
18
18
 
19
+ # Mirror of dashboard/web/src/conversations/systemMarkers.ts::MARKER_RE — anchored
20
+ # whole-string (fullmatch), unrolled-lazy body for linear time (no ReDoS), \1
21
+ # backref forces each close tag to match its open tag. Used to SKIP slash-command
22
+ # plumbing when deriving a conversation title (#165 Q2) AND — at the parser layer
23
+ # (#186) — to classify a slash-command echo carried as a plain user line as META
24
+ # at ingest. MUST stay equivalent to the TS predicate over ASCII whitespace
25
+ # (parity-tested); exotic Unicode/control whitespace is an explicit non-goal.
26
+ # The query kernel re-exports these names for back-compat. See
27
+ # docs/dashboard-gotchas.md.
28
+ _MARKER_TAGS = ("command-name", "command-message", "command-args",
29
+ "local-command-caveat", "local-command-stdout",
30
+ "local-command-stderr")
31
+ _MARKER_RE = re.compile(
32
+ r"\s*(?:<(" + "|".join(_MARKER_TAGS) + r")>(?:(?!</\1>)[\s\S])*</\1>\s*)+"
33
+ )
34
+
35
+
36
+ def _is_system_marker(text) -> bool:
37
+ """True iff `text` is ONLY concatenated command-marker wrappers (slash-command
38
+ plumbing) — the title-derivation skip predicate AND the parser-layer ingest
39
+ classifier (#186). `fullmatch` reproduces the TS `^\\s*…\\s*$` anchor (no
40
+ `$`-before-trailing-`\\n` foot-gun)."""
41
+ return bool(text) and _MARKER_RE.fullmatch(text) is not None
42
+
43
+
44
+ # #186: strip ANSI terminal control sequences from captured prose/thinking so a
45
+ # slash-command stdout echo (which keeps its SGR styling, e.g. `\x1b[1mFable
46
+ # 5\x1b[22m`) never leaks literal `^[[1m` control codes into a title, an outline
47
+ # label, or body text — and so FTS indexes clean tokens (`Fable`, not
48
+ # `1mFable`). Three alternatives: CSI (covers all SGR `…m`), OSC (BEL- or
49
+ # ST-terminated, or unterminated), and a lone/truncated ESC. Ordinary `[`,
50
+ # digits, `m` in prose are untouched — they match only as part of a real
51
+ # `\x1b[`-led sequence. Conceptually mirrors the client `parseAnsi` cleanup.
52
+ # Deliberately NOT applied in `_stringify` / tool_result text: `AnsiText` (#177
53
+ # S3) renders Bash stdout/stderr SGR colors, so that path keeps raw ANSI.
54
+ _ANSI_RE = re.compile(
55
+ r"\x1b\[[0-9;:?]*[ -/]*[@-~]" # CSI (covers all SGR …m)
56
+ r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?" # OSC (BEL/ST terminated or unterminated)
57
+ r"|\x1b" # lone/truncated ESC
58
+ )
59
+
60
+
61
+ def _strip_ansi(text):
62
+ """Remove ANSI control sequences (terminal SGR/CSI/OSC). Ordinary '[',
63
+ digits, 'm' in prose are untouched — they match only inside a real \\x1b[
64
+ run. Empty / None passes through unchanged. (#186)"""
65
+ return _ANSI_RE.sub("", text) if text else text
66
+
67
+
19
68
  _TOOL_RESULT_CAP = 16000 # was 4000; full text always re-derivable from JSONL
20
69
  _INPUT_LEAF_CAP = 8000 # max chars per string leaf in a bounded tool input
21
70
  _INPUT_TOTAL_CAP = 32000 # honesty backstop on the serialized bounded input
@@ -53,7 +102,13 @@ class MessageRow:
53
102
  stop_reason: "str | None" = None
54
103
  attribution_skill: "str | None" = None
55
104
  attribution_plugin: "str | None" = None
56
- search_aux: str = ""
105
+ # #177 S6: split non-prose search columns, derived by _derive_search_columns
106
+ # on the FINAL post-augment blocks (see _normalize). ``search_aux`` is kept
107
+ # physically (legacy column) but NEVER assigned a non-empty value — the
108
+ # consolidated multi-column FTS reads search_tool/search_thinking instead.
109
+ search_tool: str = ""
110
+ search_thinking: str = ""
111
+ search_aux: str = "" # documented-dead (#177 S6); always "" on new rows
57
112
 
58
113
 
59
114
  def iter_message_rows(fh, path_str):
@@ -107,7 +162,7 @@ def _normalize(obj, t, offset):
107
162
  msg = obj.get("message")
108
163
  if not isinstance(msg, dict):
109
164
  msg = {}
110
- blocks, text, aux = _blocks_and_text(msg.get("content"))
165
+ blocks, text = _blocks_and_text(msg.get("content"))
111
166
  if t == "assistant":
112
167
  entry_type = ASSISTANT
113
168
  elif any(b["kind"] == "tool_result" for b in blocks):
@@ -137,9 +192,30 @@ def _normalize(obj, t, offset):
137
192
  # tool_result block still folds as a result.
138
193
  entry_type = META
139
194
  text = ""
195
+ elif blocks and all(b["kind"] == "text" for b in blocks) and _is_system_marker(text):
196
+ # A slash-command echo carried as a plain user line (NOT isMeta): the
197
+ # user did not type it. `<local-command-stdout>…</local-command-stdout>`
198
+ # & friends arrive as ordinary user content, so without this branch they
199
+ # render as a "YOU" prompt and poison the conversation title (#186).
200
+ # Classify META + text="" exactly like the isMeta branch above so the
201
+ # body stays out of FTS, title derivation, and the entry_type='human'
202
+ # prompts facet; the body survives in blocks_json for rendering as a
203
+ # "System marker" pill. The all-text guard mirrors the tool_result
204
+ # block-shape gate (Codex P1b) so an attachment-bearing row — a marker
205
+ # text block PLUS an image — is never folded.
206
+ entry_type = META
207
+ text = ""
140
208
  else:
141
209
  entry_type = HUMAN
142
210
  is_asst = t == "assistant"
211
+ # #177 S6: derive the split search columns on the FINAL post-augment blocks
212
+ # (every _attach_* pass above has already merged bash_stderr / answers /
213
+ # annotations into the blocks). This is the SAME chokepoint the migration-010
214
+ # backfill runs on json.loads(blocks_json), so live ingest and backfill
215
+ # produce byte-identical values (the parity invariant). Computed immediately
216
+ # before json.dumps(blocks) so blocks_json and the columns agree by
217
+ # construction.
218
+ search_tool, search_thinking = _derive_search_columns(blocks)
143
219
  return MessageRow(
144
220
  byte_offset=offset,
145
221
  session_id=obj.get("sessionId"),
@@ -163,26 +239,30 @@ def _normalize(obj, t, offset):
163
239
  stop_reason=msg.get("stop_reason") if is_asst else None,
164
240
  attribution_skill=obj.get("attributionSkill"),
165
241
  attribution_plugin=obj.get("attributionPlugin"),
166
- search_aux=aux,
242
+ search_tool=search_tool,
243
+ search_thinking=search_thinking,
244
+ # search_aux stays "" (documented-dead, #177 S6) — the consolidated
245
+ # multi-column FTS reads search_tool/search_thinking instead.
167
246
  )
168
247
 
169
248
 
170
249
  def _blocks_and_text(content):
171
- """Return (normalized blocks list, indexed-prose string, search_aux string).
250
+ """Return (normalized blocks list, indexed-prose string).
172
251
 
173
252
  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``)."""
253
+ tool_result excluded — those go to the split search columns via
254
+ ``_derive_search_columns``, which runs in ``_normalize`` on the FINAL
255
+ post-augment blocks). #177 S6 dropped the in-loop ``search_aux`` accumulation
256
+ that used to be the third return element: deriving the search columns here
257
+ (pre-augment) would miss bash_stderr / answers / annotations and break the
258
+ ingest==backfill parity invariant, so the derivation moved to the
259
+ post-augment chokepoint."""
183
260
  if isinstance(content, str):
184
- return (([{"kind": "text", "text": content}] if content else []), content, "")
185
- blocks, texts, aux_parts = [], [], []
261
+ # #186: strip ANSI on the str-content path (the slash-command-stdout
262
+ # carrier) so neither the indexed prose nor the rendered block leaks SGR.
263
+ content = _strip_ansi(content)
264
+ return (([{"kind": "text", "text": content}] if content else []), content)
265
+ blocks, texts = [], []
186
266
  if isinstance(content, list):
187
267
  # #177 S4: ordinal among media items at THIS list level, keyed by the
188
268
  # object identity of each image/document item, so the placeholder writers
@@ -193,13 +273,12 @@ def _blocks_and_text(content):
193
273
  continue
194
274
  bt = b.get("type")
195
275
  if bt == "text":
196
- txt = b.get("text", "") or ""
276
+ txt = _strip_ansi(b.get("text", "") or "") # #186: strip SGR
197
277
  blocks.append({"kind": "text", "text": txt})
198
278
  texts.append(txt)
199
279
  elif bt == "thinking":
200
- think = b.get("thinking", "") or ""
280
+ think = _strip_ansi(b.get("thinking", "") or "") # #186: strip SGR
201
281
  blocks.append({"kind": "thinking", "text": think}) # FULL text for render
202
- aux_parts.append(think[:_TOOL_RESULT_CAP]) # aux index capped (I2)
203
282
  elif bt == "tool_use":
204
283
  bounded, input_trunc = _bound_input(b.get("input"))
205
284
  block = {"kind": "tool_use", "name": b.get("name"),
@@ -211,7 +290,6 @@ def _blocks_and_text(content):
211
290
  st = inp.get("subagent_type") if isinstance(inp, dict) else None
212
291
  if isinstance(st, str) and st: # #166: spawn kind (Agent/Task)
213
292
  block["subagent_type"] = st
214
- aux_parts.extend(_aux_strings(bounded))
215
293
  blocks.append(block)
216
294
  elif bt == "tool_result":
217
295
  raw = _stringify(b.get("content"))
@@ -230,14 +308,12 @@ def _blocks_and_text(content):
230
308
  if media: # omitted when empty (additive)
231
309
  block["media"] = media
232
310
  blocks.append(block)
233
- aux_parts.append(clipped)
234
311
  elif bt in _MEDIA_BLOCK_TYPES:
235
312
  blocks.append({"kind": bt, **_media(b.get("source")),
236
313
  "index": media_index[id(b)]})
237
314
  elif bt == "tool_reference":
238
315
  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))
316
+ return (blocks, "\n".join(t for t in texts if t))
241
317
 
242
318
 
243
319
  _SUBAGENT_META_KEYS = (
@@ -558,8 +634,51 @@ def _bound_input(inp):
558
634
  return (bounded, state["truncated"])
559
635
 
560
636
 
637
+ def _derive_search_columns(blocks):
638
+ """(search_tool, search_thinking) from the FINAL normalized blocks list.
639
+
640
+ MUST run post-augmentation (bash_stderr / answers / annotations already
641
+ merged into the blocks) so live ingest and the migration-010 backfill from
642
+ blocks_json produce byte-identical values (#177 S6 parity invariant — the
643
+ chokepoint runs in _normalize right before json.dumps(blocks), and the
644
+ backfill runs it on json.loads(blocks_json)). Caps are PER-BLOCK
645
+ (_TOOL_RESULT_CAP each), matching the old search_aux semantics — NOT a
646
+ whole-column total. Prose is excluded (already in the ``text`` column).
647
+
648
+ ``search_tool`` = bounded tool-input string leaves + clipped tool_result
649
+ ``text`` + ``bash_stderr`` + bounded AskUserQuestion answers/annotations
650
+ (real ingest stamps these as ``ask_answers``/``ask_annotations``; the raw
651
+ ``answers``/``annotations`` keys are also read for forward compatibility and
652
+ the synthetic-block unit tests). ``search_thinking`` = thinking text."""
653
+ tool_parts, think_parts = [], []
654
+ for b in blocks if isinstance(blocks, list) else []:
655
+ if not isinstance(b, dict):
656
+ continue
657
+ k = b.get("kind")
658
+ if k == "thinking":
659
+ t = b.get("text") or ""
660
+ if t:
661
+ think_parts.append(t[:_TOOL_RESULT_CAP])
662
+ elif k == "tool_use":
663
+ tool_parts.extend(
664
+ s[:_TOOL_RESULT_CAP] for s in _aux_strings(b.get("input")))
665
+ elif k == "tool_result":
666
+ t = b.get("text") or ""
667
+ if t:
668
+ tool_parts.append(t[:_TOOL_RESULT_CAP])
669
+ stderr = b.get("bash_stderr") or ""
670
+ if stderr:
671
+ tool_parts.append(stderr[:_TOOL_RESULT_CAP])
672
+ # answers/annotations: real ingest uses the ``ask_``-prefixed keys;
673
+ # the bare keys are read too (forward-compat + synthetic unit tests).
674
+ for key in ("ask_answers", "ask_annotations", "answers", "annotations"):
675
+ tool_parts.extend(
676
+ s[:_TOOL_RESULT_CAP] for s in _aux_strings(b.get(key)))
677
+ return ("\n".join(tool_parts), "\n".join(think_parts))
678
+
679
+
561
680
  def _aux_strings(v):
562
- """Yield string leaves from a bounded input value (for the search_aux blob)."""
681
+ """Yield string leaves from a bounded input value (for the search columns)."""
563
682
  if isinstance(v, str):
564
683
  if v:
565
684
  yield v