cctally 1.42.0 → 1.43.1

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,19 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.43.1] - 2026-06-13
9
+
10
+ ### Fixed
11
+ - Conversation viewer: harness-injected user-role lines — compaction summaries, background task/Bash completion notifications, remote-control "Message sent at …" stamps, and `!`-mode bash echoes — are no longer rendered as your own messages; they now show as labeled system/notification pills (compaction expandable), and a remote-control reply shows just your text. Already-recorded history is corrected at read time with no migration. (#191)
12
+
13
+ ## [1.43.0] - 2026-06-13
14
+
15
+ ### Fixed
16
+ - Conversation viewer: a slash command you typed with a real prompt in its arguments (e.g. `/frontend-design <your task>`) is no longer hidden as a "System marker" and dropped from the outline — its arguments now render as your "You" bubble with a small command badge, drive the conversation title, and are searchable; bare control commands (`/clear`, `/compact`, `/model`, the `local-command-*` echoes) and empty-argument invocations still fold into hidden system-marker pills. Recognized at ingest plus a read-time fallback that fixes already-recorded history, with a cache migration (`011`) that backfills the search index for past commands (#188).
17
+ - Conversation viewer: clicking an outline entry now selects and highlights exactly that entry instead of one a turn or two above it, and the forward jump-to-next chips/keys (`u`/`e`/`b`/`p`) reliably advance to the next landmark instead of re-selecting the same one — the outline tracks an explicit selection you set by clicking or jumping, which takes precedence over the scroll position until you next scroll (#188, #187).
18
+ - Conversation viewer: clicking a subagent in the outline now flashes its collapsed card in place and marks the subagent as the current selection, instead of silently force-expanding the thread, flashing a buried inner message, and highlighting the most-recent prompt above it (#188).
19
+ - Conversation viewer: the floating "↓ N new" live-update pill no longer counts messages streamed into a collapsed subagent thread you have not expanded — it counts only turns that actually become visible (top-level messages, a newly appearing subagent card, or messages inside an expanded thread) (#188).
20
+
8
21
  ## [1.42.0] - 2026-06-13
9
22
 
10
23
  ### Fixed
@@ -681,6 +681,16 @@ def sync_cache(
681
681
  "DELETE FROM cache_meta WHERE key IN "
682
682
  "('conversation_search_split_pending',"
683
683
  " 'conversation_search_split_cursor')")
684
+ # #188 bug 4: a rebuild repopulates conversation_messages via the
685
+ # offset-0 walk through the parser, which now classifies a
686
+ # command-args invocation as entry_type='human' at INGEST (A2) — so
687
+ # the migration-011 backfill is redundant. Drop its flag + cursor so
688
+ # the post-rebuild sync runs no redundant promotion pass. MISSING
689
+ # this site re-arms the flag on every cache-sync --rebuild.
690
+ conn.execute(
691
+ "DELETE FROM cache_meta WHERE key IN "
692
+ "('conversation_promote_command_args_pending',"
693
+ " 'conversation_promote_command_args_cursor')")
684
694
  conn.commit()
685
695
  eprint("[cache-sync] rebuild: cleared Claude cached entries")
686
696
 
@@ -780,6 +790,15 @@ def sync_cache(
780
790
  # old search keeps working until the final swap.
781
791
  _consume_search_split(conn)
782
792
 
793
+ # #188 bug 4: consume the migration-011 command-args promotion under
794
+ # the SAME held flock, AFTER the search split so a row flipped to
795
+ # entry_type='human' here keeps the fresh search_tool/search_thinking
796
+ # the split just wrote (the consumer recomputes them anyway, but
797
+ # ordering keeps the two passes independent + idempotent). Flips
798
+ # legacy META command rows carrying a real <command-args> prompt to
799
+ # HUMAN(text=args); the split-FTS UPDATE triggers re-index the args.
800
+ _consume_promote_command_args(conn)
801
+
783
802
  paths: list[pathlib.Path] = list(_iter_claude_jsonl_files())
784
803
  stats.files_total = len(paths)
785
804
 
@@ -1357,6 +1376,68 @@ def _consume_search_split(conn) -> None:
1357
1376
  conn.commit()
1358
1377
 
1359
1378
 
1379
+ def _consume_promote_command_args(conn) -> None:
1380
+ """#188 bug 4: flock-held consumer for ``conversation_promote_command_args_pending``
1381
+ (set by cache migration 011). Cursor-resumable walk of
1382
+ ``conversation_messages WHERE entry_type='meta'``: a row whose ``blocks_json``
1383
+ is a pure slash-command marker with a NON-EMPTY ``<command-args>`` is a real
1384
+ user turn, so flip it to ``entry_type='human'`` with ``text=args`` and
1385
+ recompute ``search_tool``/``search_thinking`` via the SHARED
1386
+ ``_lib_conversation._derive_search_columns`` chokepoint (byte-identical to
1387
+ live ingest). ``/clear`` and stdout-only markers (``_extract_command_invocation``
1388
+ returns None) stay META untouched.
1389
+
1390
+ The split-FTS ``AFTER UPDATE OF text, search_tool, search_thinking`` triggers
1391
+ keep the external-content index in sync, so we never hand-write FTS rows.
1392
+ FTS5-unavailable (``fts5_unavailable`` set): no triggers exist, so the
1393
+ base-column UPDATE alone is correct (the index lands later via the
1394
+ rebuild-on-availability path). Checkpoints
1395
+ ``conversation_promote_command_args_cursor`` per 500-row batch; clears both
1396
+ keys when the cursor is exhausted. Interrupted ⇒ resumes from the cursor on
1397
+ the next locked sync; a fresh install never sets the flag → cheap no-op."""
1398
+ if conn.execute(
1399
+ "SELECT 1 FROM cache_meta "
1400
+ "WHERE key='conversation_promote_command_args_pending'"
1401
+ ).fetchone() is None:
1402
+ return
1403
+ row = conn.execute(
1404
+ "SELECT value FROM cache_meta "
1405
+ "WHERE key='conversation_promote_command_args_cursor'").fetchone()
1406
+ last_id = int(row[0]) if row else 0
1407
+ while True:
1408
+ batch = conn.execute(
1409
+ "SELECT id, blocks_json FROM conversation_messages "
1410
+ "WHERE id > ? AND entry_type='meta' ORDER BY id LIMIT 500",
1411
+ (last_id,)).fetchall()
1412
+ if not batch:
1413
+ break
1414
+ ups = []
1415
+ for rid, bj in batch:
1416
+ last_id = rid
1417
+ try:
1418
+ blocks = json.loads(bj) if bj else []
1419
+ except (TypeError, ValueError):
1420
+ blocks = []
1421
+ inv = _lib_conversation._extract_command_invocation(
1422
+ blocks, _lib_conversation._join_text_blocks(blocks))
1423
+ if inv is None:
1424
+ continue
1425
+ st, sth = _lib_conversation._derive_search_columns(blocks)
1426
+ ups.append((inv["args"], st, sth, rid))
1427
+ if ups:
1428
+ conn.executemany(
1429
+ "UPDATE conversation_messages SET entry_type='human', text=?, "
1430
+ "search_tool=?, search_thinking=? WHERE id=?", ups)
1431
+ _cctally_db_sib._set_cache_meta(
1432
+ conn, "conversation_promote_command_args_cursor", str(last_id))
1433
+ conn.commit()
1434
+ conn.execute(
1435
+ "DELETE FROM cache_meta WHERE key IN "
1436
+ "('conversation_promote_command_args_pending',"
1437
+ " 'conversation_promote_command_args_cursor')")
1438
+ conn.commit()
1439
+
1440
+
1360
1441
  def iter_entries(
1361
1442
  conn: sqlite3.Connection,
1362
1443
  range_start: dt.datetime,
@@ -821,7 +821,12 @@ def _run_pending_migrations(
821
821
  "projected_milestones",
822
822
  "codex_budget_milestones",
823
823
  ),
824
- "cache.db": ("session_entries",),
824
+ # conversation_messages joins session_entries as a fresh-install
825
+ # signal (#188): a transcript-populated cache whose cost rows are
826
+ # absent must still be treated as non-fresh so a flag-only
827
+ # conversation migration's consumer actually runs (e.g. 011's
828
+ # command-args promotion) instead of being stamped without it.
829
+ "cache.db": ("session_entries", "conversation_messages"),
825
830
  }.get(db_label, ())
826
831
  for probe_table in probe_tables:
827
832
  # _probe_table_nonempty centralizes the "is there data here?"
@@ -3359,6 +3364,25 @@ def conversation_search_depth(conn: sqlite3.Connection) -> str:
3359
3364
  return "prose-only" if pending else "full"
3360
3365
 
3361
3366
 
3367
+ @cache_migration("011_conversation_promote_command_args")
3368
+ def _011_conversation_promote_command_args(conn: sqlite3.Connection) -> None:
3369
+ """Flag-only arm for #188 bug 4. Sets
3370
+ ``conversation_promote_command_args_pending``; sync_cache consumes it under
3371
+ the cache.db.lock flock (cursor-resumable: flip legacy ``entry_type='META'``
3372
+ command-marker rows whose ``<command-args>`` carry a real user prompt to
3373
+ ``entry_type='human'`` with ``text=args``, recomputing the split search
3374
+ columns so the args enter the entry_type='human' list-title/prompts facet and
3375
+ the FTS index — see _cctally_cache._consume_promote_command_args). The
3376
+ handler does NO data work so the dispatcher's central stamp (#140) marks a
3377
+ genuinely-complete handler; a fresh install stamps it WITHOUT running (its
3378
+ rows are already promoted at ingest, so the consumer finds nothing to flip).
3379
+ DISPLAY is already fixed read-time (no migration needed); the migration's
3380
+ sole job is FTS-searchability + the list-title facet on legacy data. Mirrors
3381
+ the flag-only pattern of 002/003/007/009/010."""
3382
+ _set_cache_meta(conn, "conversation_promote_command_args_pending", "1")
3383
+ conn.commit()
3384
+
3385
+
3362
3386
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
3363
3387
 
3364
3388
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -41,6 +41,53 @@ def _is_system_marker(text) -> bool:
41
41
  return bool(text) and _MARKER_RE.fullmatch(text) is not None
42
42
 
43
43
 
44
+ # #188: a slash-command invocation carries the user's real prompt inside
45
+ # <command-args>. The <command-name>/<command-message> wrappers are plumbing.
46
+ # Anchored mid-string is fine — _is_system_marker already proved the whole text
47
+ # is ONLY markers before these run, so the first match is the lone occurrence.
48
+ _CMD_NAME_RE = re.compile(r"<command-name>([\s\S]*?)</command-name>")
49
+ _CMD_ARGS_RE = re.compile(r"<command-args>([\s\S]*?)</command-args>")
50
+
51
+
52
+ def _join_text_blocks(blocks):
53
+ """'\\n'-join the text-block bodies of a normalized blocks list (mirrors
54
+ _blocks_and_text's prose join + the query kernel's _join_text_blocks). The
55
+ migration-011 consumer rebuilds the marker text from blocks_json to feed
56
+ _extract_command_invocation, so this lives in the parser kernel (the one
57
+ _cctally_cache already imports) to keep the two derivations identical."""
58
+ if not blocks:
59
+ return ""
60
+ return "\n".join(b.get("text", "") or ""
61
+ for b in blocks if b.get("kind") == "text")
62
+
63
+
64
+ def _extract_command_invocation(blocks, text):
65
+ """If a row is a pure slash-command marker whose <command-args> is non-empty
66
+ (after strip), return ``{"name": <command-name or "">, "args": <args>}``;
67
+ else ``None`` (#188 bug 4).
68
+
69
+ Block-aware: promotes ONLY when every block is text (mirrors the all-text
70
+ guard in ``_normalize`` / ``_meta_classify`` so a marker text block PLUS an
71
+ attachment — e.g. an image — is never promoted) AND ``text`` is a pure
72
+ command-marker block (``_is_system_marker``) AND ``<command-args>`` holds a
73
+ real prompt. ``/clear``, ``/exit``, ``/compact``, ``/model`` (empty args) and
74
+ stdout-only markers all return ``None`` so they stay hidden as system
75
+ markers. ``name`` is taken from ``<command-name>`` for the reader's command
76
+ badge; it is purely cosmetic (``""`` when the marker omits the name tag)."""
77
+ if not blocks or not all(b.get("kind") == "text" for b in blocks):
78
+ return None
79
+ if not _is_system_marker(text):
80
+ return None
81
+ am = _CMD_ARGS_RE.search(text)
82
+ if am is None:
83
+ return None
84
+ args = am.group(1).strip()
85
+ if not args:
86
+ return None
87
+ nm = _CMD_NAME_RE.search(text)
88
+ return {"name": (nm.group(1).strip() if nm else ""), "args": args}
89
+
90
+
44
91
  # #186: strip ANSI terminal control sequences from captured prose/thinking so a
45
92
  # slash-command stdout echo (which keeps its SGR styling, e.g. `\x1b[1mFable
46
93
  # 5\x1b[22m`) never leaks literal `^[[1m` control codes into a title, an outline
@@ -65,6 +112,64 @@ def _strip_ansi(text):
65
112
  return _ANSI_RE.sub("", text) if text else text
66
113
 
67
114
 
115
+ # ---- #191: harness-injected user-line discriminators (shared with the query
116
+ # kernel via re-export). All key on a SELF-IDENTIFYING body shape so the
117
+ # read-time recovery half can rescue already-ingested `human` rows. ----
118
+
119
+ # Compaction summary preamble. The ingest authority is the `isCompactSummary`
120
+ # JSONL flag (see _normalize); this body match is the read-time meta_kind label
121
+ # authority (and rescues the rare legacy sentinel-without-flag row).
122
+ _COMPACT_SENTINEL = "This session is being continued from a previous conversation"
123
+
124
+
125
+ def _is_compaction_body(text) -> bool:
126
+ """True iff `text` is a compaction-summary body."""
127
+ return bool(text) and text.lstrip().startswith(_COMPACT_SENTINEL)
128
+
129
+
130
+ # Full open+close-tag wrapper (NOT a bare startswith — Codex P1a): a real prompt
131
+ # that merely begins with the literal tag but isn't a well-formed closed block
132
+ # stays human. Unrolled-lazy body = linear time; \1 backref forces the close tag
133
+ # to match the open. A trailing harness line after the close tag is allowed.
134
+ _NOTIFICATION_RE = re.compile(
135
+ r"\s*<(task|bash)-notification>(?:(?!</\1-notification>)[\s\S])*</\1-notification>")
136
+
137
+
138
+ def _is_notification_body(text) -> bool:
139
+ """True iff `text` opens with a complete <task-notification>…</task-notification>
140
+ or <bash-notification>…</bash-notification> background-completion wrapper."""
141
+ return bool(text) and _NOTIFICATION_RE.match(text) is not None
142
+
143
+
144
+ # `!`-mode local shell echoes. A DEDICATED detector, NOT a `_MARKER_TAGS` entry
145
+ # (Codex P1b) — so a bash echo containing a literal <command-args> can never
146
+ # reach the #188 `_extract_command_invocation` promotion path.
147
+ _BASH_ECHO_RE = re.compile(
148
+ r"\s*<(bash-input|bash-stdout|bash-stderr)>(?:(?!</\1>)[\s\S])*</\1>")
149
+
150
+
151
+ def _is_bash_echo_body(text) -> bool:
152
+ """True iff `text` opens with a complete <bash-input>/<bash-stdout>/
153
+ <bash-stderr> echo wrapper."""
154
+ return bool(text) and _BASH_ECHO_RE.match(text) is not None
155
+
156
+
157
+ # Remote-control replies (sent from the Claude mobile/remote app) arrive as a
158
+ # real user turn prefixed with a `Message sent at <ts> UTC.` system-reminder
159
+ # stamp. NARROW (Codex / approved decision): only this exact leading shape is
160
+ # stripped; no other <system-reminder> is touched.
161
+ _REMOTE_CONTROL_RE = re.compile(
162
+ r"\A\s*<system-reminder>Message sent at [^<]*UTC\.</system-reminder>\s*")
163
+
164
+
165
+ def _strip_remote_control_prefix(text):
166
+ """Remove a leading remote-control `Message sent at … UTC.` system-reminder
167
+ block, returning the real user reply. No-op when absent; None/'' pass through."""
168
+ if not text:
169
+ return text
170
+ return _REMOTE_CONTROL_RE.sub("", text, count=1)
171
+
172
+
68
173
  _TOOL_RESULT_CAP = 16000 # was 4000; full text always re-derivable from JSONL
69
174
  _INPUT_LEAF_CAP = 8000 # max chars per string leaf in a bounded tool input
70
175
  _INPUT_TOTAL_CAP = 32000 # honesty backstop on the serialized bounded input
@@ -192,6 +297,33 @@ def _normalize(obj, t, offset):
192
297
  # tool_result block still folds as a result.
193
298
  entry_type = META
194
299
  text = ""
300
+ elif obj.get("isCompactSummary"):
301
+ # #191: compaction summary injected as a user line — authoritative flag.
302
+ entry_type = META
303
+ text = ""
304
+ elif _is_notification_body(text):
305
+ # #191: <task-notification>/<bash-notification> background completion.
306
+ entry_type = META
307
+ text = ""
308
+ elif _is_bash_echo_body(text):
309
+ # #191: <bash-input>/<bash-stdout>/<bash-stderr> `!`-mode echo. Dedicated
310
+ # branch (NOT _MARKER_TAGS) so it can't reach the #188 promotion path.
311
+ entry_type = META
312
+ text = ""
313
+ elif (blocks and all(b["kind"] == "text" for b in blocks)
314
+ and (_inv := _extract_command_invocation(blocks, text)) is not None):
315
+ # #188: a slash-command invocation carrying a real user prompt in
316
+ # <command-args> IS a user turn — the wrapper is plumbing but the args
317
+ # are what the user typed. Promote it (entry_type=HUMAN, text=args) so it
318
+ # enters FTS / title derivation / the entry_type='human' prompts facet.
319
+ # blocks_json is UNTOUCHED (still the raw <command-name>…), so the read
320
+ # path derives the command-name badge from the blocks. Ordered BEFORE the
321
+ # empty-args/stdout-only system-marker fold below — /clear, /exit,
322
+ # /compact and friends (empty args) fall through to META there. The
323
+ # all-text guard mirrors the marker fold so a marker+attachment row is
324
+ # never folded NOR promoted (it stays a plain HUMAN turn in the else).
325
+ entry_type = HUMAN
326
+ text = _inv["args"]
195
327
  elif blocks and all(b["kind"] == "text" for b in blocks) and _is_system_marker(text):
196
328
  # A slash-command echo carried as a plain user line (NOT isMeta): the
197
329
  # user did not type it. `<local-command-stdout>…</local-command-stdout>`
@@ -206,7 +338,10 @@ def _normalize(obj, t, offset):
206
338
  entry_type = META
207
339
  text = ""
208
340
  else:
341
+ # #191: a leading remote-control `Message sent at … UTC.` system-reminder
342
+ # stamp is stripped from the real user reply (narrow). No-op when absent.
209
343
  entry_type = HUMAN
344
+ text = _strip_remote_control_prefix(text)
210
345
  is_asst = t == "assistant"
211
346
  # #177 S6: derive the split search columns on the FINAL post-augment blocks
212
347
  # (every _attach_* pass above has already merged bash_stderr / answers /
@@ -34,10 +34,21 @@ from _lib_conversation import iter_media_items
34
34
  # here for back-compat so existing `from _lib_conversation_query import
35
35
  # _is_system_marker` importers (and the title-skip path below) keep resolving.
36
36
  from _lib_conversation import _MARKER_TAGS, _MARKER_RE, _is_system_marker
37
+ # #188: the slash-command-with-args promotion helper. Used at read time to
38
+ # present a legacy/ingested command-marker row carrying a real <command-args>
39
+ # prompt as a "You" turn (text=args, command_name badge). Re-exported for the
40
+ # back-compat import surface + the consumer in _cctally_cache.py.
41
+ from _lib_conversation import _extract_command_invocation
37
42
  # #186: read-time ANSI strip for rows already indexed with raw SGR (no forced
38
43
  # re-ingest). Scoped to prose/thinking/title/label — NEVER tool_result (Bash
39
44
  # AnsiText boundary). Shares the parser's regex so ingest and read-time agree.
40
45
  from _lib_conversation import _strip_ansi
46
+ # #191: harness-injected user-line discriminators — defined in the parser,
47
+ # re-exported here so ingest and read-time recovery share one classification.
48
+ from _lib_conversation import (
49
+ _is_compaction_body, _is_notification_body, _is_bash_echo_body,
50
+ _strip_remote_control_prefix,
51
+ )
41
52
 
42
53
 
43
54
  _TITLE_MAX = 120
@@ -139,6 +150,12 @@ def _meta_classify(item, allow_human_fallback):
139
150
  all_text = all(b.get("kind") == "text" for b in (item.get("blocks") or []))
140
151
  if _is_system_marker(body) and (is_meta or all_text):
141
152
  return ("command", None, body)
153
+ if all_text and _is_compaction_body(body):
154
+ return ("compaction", None, body) # #191
155
+ if all_text and _is_notification_body(body):
156
+ return ("notification", None, body) # #191
157
+ if all_text and _is_bash_echo_body(body):
158
+ return ("command", None, body) # #191 (#5 -> System-marker pill)
142
159
  if not is_meta:
143
160
  return None
144
161
  return ("context", None, body)
@@ -204,9 +221,12 @@ def _session_titles_map(conn, session_ids):
204
221
  continue # already resolved to the first non-marker
205
222
  if _is_system_marker(text) or _looks_like_command_plumbing(text):
206
223
  continue
224
+ if (_is_compaction_body(text) or _is_notification_body(text)
225
+ or _is_bash_echo_body(text)): # #191: never a title
226
+ continue
207
227
  if skip_skill_titles and _first_nonblank_line(text).startswith(_SKILL_PREAMBLE):
208
228
  continue
209
- t = _title_from_text(text)
229
+ t = _title_from_text(_strip_remote_control_prefix(text)) # #191: strip the stamp
210
230
  if t:
211
231
  titles[sid] = t
212
232
  return titles
@@ -646,6 +666,23 @@ def _assemble_session(conn, session_id):
646
666
  allow_human_fallback = _reingest_pending(conn)
647
667
  for it in items:
648
668
  if it["kind"] in ("meta", "human"):
669
+ # #188: a slash-command invocation carrying a real prompt in
670
+ # <command-args> is a USER turn. Promote it BEFORE _meta_classify so
671
+ # it never folds into a command-marker pill. Run on the BLOCK-joined
672
+ # text (NOT it["text"]: '' for a legacy META row, args post-migration
673
+ # — neither is the raw marker the command_name parses from). When it
674
+ # yields non-empty args, present kind='human', text=args, and attach
675
+ # the command_name badge (derived from the blocks). Idempotent: a
676
+ # migrated entry_type='human' row re-derives the same badge. /clear &
677
+ # empty-args/stdout markers yield None and fall through to
678
+ # _meta_classify's command/skill/context fold unchanged.
679
+ inv = _extract_command_invocation(
680
+ it.get("blocks"), _join_text_blocks(it.get("blocks")))
681
+ if inv is not None:
682
+ it["kind"] = "human"
683
+ it["text"] = inv["args"]
684
+ it["command_name"] = inv["name"] or None
685
+ continue
649
686
  cls = _meta_classify(it, allow_human_fallback)
650
687
  if cls is not None:
651
688
  meta_kind, skill_name, body = cls
@@ -654,6 +691,13 @@ def _assemble_session(conn, session_id):
654
691
  it["skill_name"] = skill_name
655
692
  it["text"] = body
656
693
 
694
+ # #191: strip a leading remote-control `Message sent at … UTC.` stamp from any
695
+ # turn that remains a genuine human reply. Shared assembly -> covers BOTH the
696
+ # reader and the outline. No-op on #188-promoted command turns (no stamp).
697
+ for it in items:
698
+ if it["kind"] == "human" and it.get("text"):
699
+ it["text"] = _strip_remote_control_prefix(it["text"])
700
+
657
701
  # ---- Phase 4b: fold a Skill-invoked skill body into its Skill tool chip ----
658
702
  # A Skill invocation's injected body (now meta_kind='skill') links to its
659
703
  # Skill tool_use via source_tool_use_id (threaded as the internal