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.
- package/CHANGELOG.md +15 -0
- package/bin/_cctally_cache.py +103 -6
- package/bin/_cctally_dashboard.py +60 -3
- package/bin/_cctally_db.py +179 -45
- package/bin/_lib_conversation.py +142 -23
- package/bin/_lib_conversation_query.py +447 -60
- package/dashboard/static/assets/{index-BXm_vBKA.css → index-3yVZt8cV.css} +1 -1
- package/dashboard/static/assets/{index-BtbVKnkm.js → index-DDuPO7EI.js} +34 -34
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
package/bin/_lib_conversation.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
175
|
-
``
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
|
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
|