cctally 1.41.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 CHANGED
@@ -5,6 +5,16 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.42.0] - 2026-06-13
9
+
10
+ ### Fixed
11
+ - Conversation viewer: system and slash-command messages (`/clear`, `/model`, the `local-command-stdout`/`local-command-stderr` echoes, and the caveats) were being attributed to you as "You" turns, and the first such line became the whole conversation's title; they now fold into collapsed "System marker" pills and the title falls back to your real first prompt. Such rows are recognized at ingest (stored as meta and kept out of search and the prompts facet) with a read-time fallback that also cleans already-recorded history, plus a belt-and-suspenders title skip that degrades unknown `command-*`/`local-command-*` tags to "skip" rather than letting them poison the title (#186).
12
+ - Conversation viewer: terminal ANSI escape codes (for example the bold `^[[1m…^[[22m` wrapping "Fable 5") no longer leak into titles, outline labels, or message and command body text — they are stripped at ingest (which also keeps the search index clean for new rows) and again at the render chokepoints (which cleans already-recorded history without a re-ingest), while Bash tool output keeps its intentional terminal colors (#186).
13
+
14
+ ### Changed
15
+ - Conversation viewer: the outline rail is redesigned from a flat wall of ~200 truncated prose lines into a bold prompt spine (your prompts, each with a divider above it) with curated landmarks demoted beneath each prompt — section headings, plans, questions, errors (red), and subagent spawns (magenta) — where a turn's thinking blocks collapse to a single `🧠 ×N` badge on its prompt instead of doubling the list, session-start plumbing rows are gone, role glyphs are color-coded for at-a-glance scanning, and the scroll-sync highlight tracks the enclosing prompt (#186).
16
+ - Conversation viewer: the outline header is merged into one "session at a glance" card — time, tokens, and cost become labeled stat tiles, models/tools/errors become labeled rows, the formerly cryptic unlabeled glyph jump-chips gain text labels and move into the card under a "Jump to" row, and the error count reconciles to one phrase ("14 errors in 13 turns") so the previously-disagreeing 13 and 14 no longer read as two competing numbers (#186).
17
+
8
18
  ## [1.41.0] - 2026-06-13
9
19
 
10
20
  ### Added
@@ -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
@@ -143,6 +192,19 @@ def _normalize(obj, t, offset):
143
192
  # tool_result block still folds as a result.
144
193
  entry_type = META
145
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 = ""
146
208
  else:
147
209
  entry_type = HUMAN
148
210
  is_asst = t == "assistant"
@@ -196,6 +258,9 @@ def _blocks_and_text(content):
196
258
  ingest==backfill parity invariant, so the derivation moved to the
197
259
  post-augment chokepoint."""
198
260
  if isinstance(content, str):
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)
199
264
  return (([{"kind": "text", "text": content}] if content else []), content)
200
265
  blocks, texts = [], []
201
266
  if isinstance(content, list):
@@ -208,11 +273,11 @@ def _blocks_and_text(content):
208
273
  continue
209
274
  bt = b.get("type")
210
275
  if bt == "text":
211
- txt = b.get("text", "") or ""
276
+ txt = _strip_ansi(b.get("text", "") or "") # #186: strip SGR
212
277
  blocks.append({"kind": "text", "text": txt})
213
278
  texts.append(txt)
214
279
  elif bt == "thinking":
215
- think = b.get("thinking", "") or ""
280
+ think = _strip_ansi(b.get("thinking", "") or "") # #186: strip SGR
216
281
  blocks.append({"kind": "thinking", "text": think}) # FULL text for render
217
282
  elif bt == "tool_use":
218
283
  bounded, input_trunc = _bound_input(b.get("input"))
@@ -29,25 +29,15 @@ from _lib_conversation import _stringify
29
29
  # #177 S4: the media-route reader walks a content array with the SAME ordinal
30
30
  # generator the ingest placeholders used, so "media item N" addresses one item.
31
31
  from _lib_conversation import iter_media_items
32
-
33
-
34
- # Mirror of dashboard/web/src/conversations/systemMarkers.ts::MARKER_RE anchored
35
- # whole-string (fullmatch), unrolled-lazy body for linear time (no ReDoS), \1
36
- # backref forces each close tag to match its open tag. Used to SKIP slash-command
37
- # plumbing when deriving a conversation title (#165 Q2). MUST stay equivalent to
38
- # the TS predicate over ASCII whitespace (parity-tested); exotic Unicode/control
39
- # whitespace is an explicit non-goal. See docs/dashboard-gotchas.md.
40
- _MARKER_TAGS = ("command-name", "command-message", "command-args", "local-command-caveat")
41
- _MARKER_RE = re.compile(
42
- r"\s*(?:<(" + "|".join(_MARKER_TAGS) + r")>(?:(?!</\1>)[\s\S])*</\1>\s*)+"
43
- )
44
-
45
-
46
- def _is_system_marker(text) -> bool:
47
- """True iff `text` is ONLY concatenated command-marker wrappers (slash-command
48
- plumbing) — the title-derivation skip predicate. `fullmatch` reproduces the TS
49
- `^\\s*…\\s*$` anchor (no `$`-before-trailing-`\\n` foot-gun)."""
50
- return bool(text) and _MARKER_RE.fullmatch(text) is not None
32
+ # #186: the marker predicate moved DOWN to the parser layer (the parser now
33
+ # classifies command-marker user rows as META at ingest). Re-export the names
34
+ # here for back-compat so existing `from _lib_conversation_query import
35
+ # _is_system_marker` importers (and the title-skip path below) keep resolving.
36
+ from _lib_conversation import _MARKER_TAGS, _MARKER_RE, _is_system_marker
37
+ # #186: read-time ANSI strip for rows already indexed with raw SGR (no forced
38
+ # re-ingest). Scoped to prose/thinking/title/label NEVER tool_result (Bash
39
+ # AnsiText boundary). Shares the parser's regex so ingest and read-time agree.
40
+ from _lib_conversation import _strip_ansi
51
41
 
52
42
 
53
43
  _TITLE_MAX = 120
@@ -58,7 +48,7 @@ def _title_from_text(text) -> str:
58
48
  trailing '…' ONLY when truncated (rstrip before the ellipsis). '' if none.
59
49
  Semantics IDENTICAL to the client deriveReaderTitle (#165 P2.5)."""
60
50
  for line in (text or "").split("\n"):
61
- s = line.strip()
51
+ s = _strip_ansi(line).strip() # #186: strip SGR from pre-fix dirty rows
62
52
  if s:
63
53
  return (s[:_TITLE_MAX].rstrip() + "…") if len(s) > _TITLE_MAX else s
64
54
  return ""
@@ -126,22 +116,56 @@ def _meta_classify(item, allow_human_fallback):
126
116
  pre-reingest window — see _reingest_pending). After the reingest a 'human'
127
117
  row keeping the preamble is a real user prompt, so it stays a "You" turn
128
118
  rather than being hidden in a collapsed skill pill (Codex code-review P1).
129
- - command/context: ONLY for a true 'meta' row (slash-command plumbing vs the
130
- rest). A 'human' row that is not a skill body stays human generic injected
131
- context can't be recovered read-time without isMeta; it lands on the next
119
+ - command: a true 'meta' row ALWAYS, plus — the #186 read-time fallback a
120
+ 'human' row whose body is command plumbing AND whose blocks are all text.
121
+ The marker regex is self-identifying (no read-time-recovery hazard, unlike
122
+ generic injected context), so command recovery for a pre-fix human row is
123
+ ungated by ``allow_human_fallback``. The all-text guard mirrors the ingest
124
+ branch (Codex P1b) so an attachment-bearing row is never folded.
125
+ - context: ONLY for a true 'meta' row (the remaining injected-content case).
126
+ A non-skill, non-command 'human' row stays human — generic injected context
127
+ can't be recovered read-time without isMeta; it lands on the next
132
128
  sync-triggered reingest."""
133
129
  is_meta = item["kind"] == "meta"
134
130
  body = item.get("text") or _join_text_blocks(item.get("blocks"))
135
131
  first = _first_nonblank_line(body)
136
132
  if first.startswith(_SKILL_PREAMBLE) and (is_meta or allow_human_fallback):
137
133
  return ("skill", _skill_name_from_preamble(first), body)
134
+ # Command plumbing: self-identifying, so safe to recover for a pre-fix human
135
+ # row too — but only when ALL blocks are text (mirror the ingest all-text
136
+ # guard so an attachment-bearing row is never folded). Runs ABOVE the
137
+ # not-is_meta guard precisely so a stale entry_type='human' command echo
138
+ # reclassifies read-time (#186).
139
+ all_text = all(b.get("kind") == "text" for b in (item.get("blocks") or []))
140
+ if _is_system_marker(body) and (is_meta or all_text):
141
+ return ("command", None, body)
138
142
  if not is_meta:
139
143
  return None
140
- if _is_system_marker(body):
141
- return ("command", None, body)
142
144
  return ("context", None, body)
143
145
 
144
146
 
147
+ # #186 belt-and-suspenders, title-only: a deliberately-broader skip predicate
148
+ # that drops a title candidate wrapped entirely in `command-*` / `local-command-*`
149
+ # plumbing — a tag-name PREFIX shape, NOT the strict known-tag list. The \1
150
+ # backref forces each close tag to match its open tag; the unrolled-lazy body is
151
+ # linear-time (no ReDoS). Used ONLY in title selection, where being liberal is
152
+ # safe: the worst case is the title falls back to the next line or the project
153
+ # label — never hiding content (that fold-to-pill decision keeps strict
154
+ # `_is_system_marker`, where a false positive WOULD hide real user text). A
155
+ # future unrecognized `local-command-foo` tag thus degrades to "skip the title"
156
+ # rather than "poison the title."
157
+ _CMD_FAMILY_RE = re.compile(
158
+ r"\s*(?:<((?:local-)?command-[a-z-]+)>(?:(?!</\1>)[\s\S])*</\1>\s*)+"
159
+ )
160
+
161
+
162
+ def _looks_like_command_plumbing(text) -> bool:
163
+ """Title-only liberal skip: the whole text is one or more
164
+ command-*/local-command-* wrappers (prefix shape). `fullmatch` anchors the
165
+ whole string. See `_CMD_FAMILY_RE`."""
166
+ return bool(text) and _CMD_FAMILY_RE.fullmatch(text) is not None
167
+
168
+
145
169
  def _session_titles_map(conn, session_ids):
146
170
  """{sid: title} for the first non-marker, non-blank MAIN-session human line
147
171
  per session (read-time, no migration). Windowed to the earliest 12 human
@@ -178,7 +202,7 @@ def _session_titles_map(conn, session_ids):
178
202
  for sid, text in rows:
179
203
  if sid in titles:
180
204
  continue # already resolved to the first non-marker
181
- if _is_system_marker(text):
205
+ if _is_system_marker(text) or _looks_like_command_plumbing(text):
182
206
  continue
183
207
  if skip_skill_titles and _first_nonblank_line(text).startswith(_SKILL_PREAMBLE):
184
208
  continue
@@ -745,8 +769,17 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
745
769
  # Stamp the session_id into each anchor (spec anchor is (session_id, uuid);
746
770
  # the dict literals are built session-agnostic, so fill it here where the
747
771
  # session id is known). NOT a no-op — the endpoint/clients rely on it.
772
+ # #186: ALSO strip ANSI from the displayed prose/thinking text of each page
773
+ # item before emit, so a pre-fix row already indexed with raw SGR renders
774
+ # clean (the read-time half of the no-forced-reingest contract). tool_result
775
+ # blocks are EXCLUDED — Bash AnsiText (#177 S3) renders their SGR colors.
748
776
  for it in page:
749
777
  it["anchor"]["session_id"] = session_id
778
+ if it.get("text"):
779
+ it["text"] = _strip_ansi(it["text"])
780
+ for b in it["blocks"]:
781
+ if b.get("kind") in ("text", "thinking") and b.get("text"):
782
+ b["text"] = _strip_ansi(b["text"])
750
783
 
751
784
  first = logical[0]
752
785
  last = logical[-1]
@@ -769,9 +802,10 @@ _OUTLINE_LABEL_CAP = 120
769
802
 
770
803
 
771
804
  def _outline_label(text):
772
- """First non-blank line, capped at _OUTLINE_LABEL_CAP chars ('' when none)."""
805
+ """First non-blank line, capped at _OUTLINE_LABEL_CAP chars ('' when none).
806
+ Read-time ANSI strip (#186) so a pre-fix dirty row's label is clean."""
773
807
  for ln in (text or "").splitlines():
774
- s = ln.strip()
808
+ s = _strip_ansi(ln).strip()
775
809
  if s:
776
810
  return s[:_OUTLINE_LABEL_CAP]
777
811
  return ""