cctally 1.118.0 → 1.118.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,13 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.118.1] - 2026-11-06
9
+
10
+ ### Fixed
11
+ - With an account selected, the `dashboard` Conversation Viewer and `transcript` commands now show conversations' AI titles, and title search ranks, prefix-matches and highlights them; a session spanning two accounts keeps its fallback title.
12
+ - After a rebuild of the conversation cache, the `dashboard` Conversation Viewer no longer shows an AI title that a replaced or truncated transcript no longer contains.
13
+ - The `dashboard` Conversation Viewer no longer hides a conversation's earlier messages and AI title until the next rebuild after an unfinished last line is trimmed from its transcript.
14
+
8
15
  ## [1.118.0] - 2026-11-06
9
16
 
10
17
  ### Added
@@ -289,21 +289,34 @@ _AI_TITLE_UPSERT_SQL = (
289
289
  "ai_title=excluded.ai_title, source_path=excluded.source_path, byte_offset=excluded.byte_offset"
290
290
  )
291
291
 
292
- def _ai_title_upsert_sql(table: str = "conversation_ai_titles") -> str:
292
+ def _ai_title_upsert_sql(table: str = "conversation_ai_titles", *,
293
+ owner_only: bool = False) -> str:
293
294
  """The AI-title upsert, targeted at the live table or its staging twin.
294
295
 
295
296
  A rebuild builds its replacement generation into staging and leaves the
296
- live table alone until the publish (#752), so the ONE statement that writes
297
- a title has to be able to name either. Parameterised through this helper
298
- rather than by two literals, because two literals drift.
297
+ live table alone until the publish (#752) — except for a file replayed
298
+ under a NEW incarnation, whose live title rows are replaced,
299
+ owner-restricted, at its batch commit (#1062 §4.2). So the ONE statement
300
+ that writes a title has to be able to name either table. Parameterised
301
+ through this helper rather than by several literals, because literals
302
+ drift.
303
+
304
+ ``owner_only`` is that #1062 §4.2 live-table write. The file's own live
305
+ rows were just deleted, so a conflicting live row belongs to ANOTHER file
306
+ of the same session and is kept until the publish installs the replayed
307
+ generation's choice; a later title line of the same file still replaces an
308
+ earlier one, as in the walk.
299
309
  """
300
- return (
310
+ sql = (
301
311
  f"INSERT INTO {table}(session_id,ai_title,source_path,byte_offset) "
302
312
  "VALUES(?,?,?,?) "
303
313
  "ON CONFLICT(session_id) DO UPDATE SET "
304
314
  "ai_title=excluded.ai_title, source_path=excluded.source_path, "
305
315
  "byte_offset=excluded.byte_offset"
306
316
  )
317
+ if owner_only:
318
+ sql += f" WHERE {table}.source_path = excluded.source_path"
319
+ return sql
307
320
 
308
321
 
309
322
  # ---------------------------------------------------------------------------
@@ -21527,12 +21540,36 @@ def scope_conversations_db_to_account(
21527
21540
  f"""
21528
21541
  CREATE TEMP VIEW conversation_messages AS
21529
21542
  SELECT * FROM main.conversation_messages WHERE {stored_predicate};
21543
+ -- #1062: a title is in scope iff (a) its own file's message walk has
21544
+ -- committed past the title's line, (b) that file still holds a
21545
+ -- retained in-scope message of the title's session, (c) no retained
21546
+ -- message of the session, in any file, is outside the scope, and
21547
+ -- (d) no Claude conversation rebuild is in progress (its message
21548
+ -- table may be partial). (b) reads the scoped TEMP view; (a), (c)
21549
+ -- and (d) read main. The fragment's unqualified account_key binds to
21550
+ -- the innermost FROM (main.conversation_messages m). The base rowid
21551
+ -- is projected as `rowid` so the title FTS joins resolve on a scoped
21552
+ -- connection and return the base row (C1).
21530
21553
  CREATE TEMP VIEW conversation_ai_titles AS
21531
- SELECT t.* FROM main.conversation_ai_titles t
21554
+ SELECT t.rowid AS rowid, t.* FROM main.conversation_ai_titles t
21532
21555
  WHERE EXISTS (
21556
+ SELECT 1 FROM main.conversation_source_files f
21557
+ WHERE f.path = t.source_path
21558
+ AND f.last_byte_offset > t.byte_offset
21559
+ )
21560
+ AND EXISTS (
21533
21561
  SELECT 1 FROM conversation_messages m
21534
- WHERE m.source_path=t.source_path
21535
- AND m.byte_offset=t.byte_offset
21562
+ WHERE m.session_id = t.session_id
21563
+ AND m.source_path = t.source_path
21564
+ )
21565
+ AND NOT EXISTS (
21566
+ SELECT 1 FROM main.conversation_messages m
21567
+ WHERE m.session_id = t.session_id
21568
+ AND NOT ({stored_predicate})
21569
+ )
21570
+ AND NOT EXISTS (
21571
+ SELECT 1 FROM main.cache_meta
21572
+ WHERE key = 'conversation_rebuild_claude_pending'
21536
21573
  );
21537
21574
  CREATE TEMP VIEW conversation_file_touches AS
21538
21575
  SELECT t.* FROM main.conversation_file_touches t
@@ -23642,6 +23679,19 @@ def _write_claude_prepared_file(
23642
23679
  and the scoped ``_fill_file_touches`` never commits."""
23643
23680
  path_str = prepared.path
23644
23681
  sessions: set[str] = set()
23682
+ # #1062 §4.2: a rebuild writes titles to staging and leaves the live table
23683
+ # to the publish (#752) — except for a file replayed under a NEW
23684
+ # incarnation. `prepared.truncated` means exactly that (#1062 F8): a
23685
+ # replacement, a truncation below the cursor, or missing identity
23686
+ # evidence; a continuous file that merely shrank (a trimmed unfinished
23687
+ # tail) is not truncated and keeps its committed rows. A new incarnation's
23688
+ # live rows get what an incremental sync of the file would leave: the
23689
+ # path's rows deleted and its replayed titles re-upserted, owner-restricted,
23690
+ # here in the file's savepoint, so they commit with its cursor. Without it
23691
+ # the fold carries a title the file has lost into the new generation, and
23692
+ # after an interrupted rebuild no later run detects the replacement again.
23693
+ refresh_live = (prepared.truncated
23694
+ and title_table != "conversation_ai_titles")
23645
23695
  if prepared.truncated:
23646
23696
  sessions.update(
23647
23697
  row[0]
@@ -23664,6 +23714,11 @@ def _write_claude_prepared_file(
23664
23714
  f"DELETE FROM {title_table} WHERE source_path=?",
23665
23715
  (path_str,),
23666
23716
  )
23717
+ if refresh_live:
23718
+ conn.execute(
23719
+ "DELETE FROM conversation_ai_titles WHERE source_path=?",
23720
+ (path_str,),
23721
+ )
23667
23722
  if prepared.conv_rows:
23668
23723
  conn.executemany(_CONV_ACCOUNT_INSERT_SQL, prepared.conv_rows)
23669
23724
  _fill_file_touches(
@@ -23683,6 +23738,9 @@ def _write_claude_prepared_file(
23683
23738
  )
23684
23739
  if prepared.ai_rows:
23685
23740
  conn.executemany(_ai_title_upsert_sql(title_table), prepared.ai_rows)
23741
+ if refresh_live:
23742
+ conn.executemany(
23743
+ _ai_title_upsert_sql(owner_only=True), prepared.ai_rows)
23686
23744
  size, mtime_ns, final_offset, device_id, inode, incarnation_id, prefix = (
23687
23745
  prepared.cursor)
23688
23746
  conn.execute(
@@ -24028,9 +24086,11 @@ def sync_claude_conversations(
24028
24086
  # nothing, so every one of its records is first observed now and takes
24029
24087
  # the active identity rather than being read as historical-and-unknown.
24030
24088
  high_water: "dict[tuple[str, str], int]" = {}
24031
- # #752: a rebuild writes its titles and rollup into staging and does
24032
- # not touch the live tables until the publish, so readers keep seeing
24033
- # the previous complete generation for the whole replay.
24089
+ # #752: a rebuild writes its titles and rollup into staging and
24090
+ # otherwise leaves the live tables to the publish, so readers keep
24091
+ # seeing the previous complete generation for the replay. The one
24092
+ # exception is #1062 §4.2: a file replayed under a NEW incarnation has
24093
+ # its live title rows replaced, owner-restricted, at its batch commit.
24034
24094
  title_table = "conversation_ai_titles"
24035
24095
  rollup_table = "conversation_sessions"
24036
24096
  if rebuild:
@@ -24132,6 +24192,11 @@ def sync_claude_conversations(
24132
24192
  stats.files_skipped_unchanged += 1
24133
24193
  _report_conversation_progress(progress, "ingest", stats)
24134
24194
  continue
24195
+ # The raw shrink decides only the targeted deferral below,
24196
+ # which runs before the incarnation resolver and so cannot yet
24197
+ # tell a truncation below the cursor from a trimmed unfinished
24198
+ # tail; deferring both to a full sync is conservative. Past the
24199
+ # resolver, `truncated` is redefined as "a new incarnation".
24135
24200
  truncated = prev is not None and size < prev[0]
24136
24201
  if targeted and truncated:
24137
24202
  # #939 D1: every exit resolves the open batch.
@@ -24165,13 +24230,20 @@ def sync_claude_conversations(
24165
24230
  start_offset = incarnation.start_offset
24166
24231
  file_high_water = high_water.get(
24167
24232
  (path_str, incarnation.incarnation_id), 0)
24168
- if incarnation.is_new:
24169
- # A new incarnation forces byte-zero treatment: the
24170
- # bytes before the cursor are not the bytes the cursor
24171
- # described, so resuming from it would skip real records
24172
- # and stamp the rest against an identity that never held
24173
- # them.
24174
- truncated = prev is not None
24233
+ # A new incarnation forces byte-zero treatment: the
24234
+ # bytes before the cursor are not the bytes the cursor
24235
+ # described, so resuming from it would skip real records
24236
+ # and stamp the rest against an identity that never held
24237
+ # them. A CONTINUOUS incarnation is never a truncation,
24238
+ # even when the file shrank (#1062 F8): a pass that ended
24239
+ # on an unfinished last line records a size past its
24240
+ # cursor, and trimming that line shrinks the file without
24241
+ # moving a committed byte. Its committed rows stay; the
24242
+ # walk resumes from the cursor and the cursor row records
24243
+ # the smaller size. Treating that shrink as a truncation
24244
+ # deleted every row of the path and re-inserted only the
24245
+ # tail.
24246
+ truncated = incarnation.is_new and prev is not None
24175
24247
  # #939 D1: admission is decided BEFORE the parse. The
24176
24248
  # read limit is the size the incarnation decision saw
24177
24249
  # (the same fstat recorded as the change detector
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.118.0",
3
+ "version": "1.118.1",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {