cctally 1.52.1 → 1.53.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.
@@ -2391,6 +2391,39 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2391
2391
  CREATE INDEX IF NOT EXISTS idx_conv_sessions_recent
2392
2392
  ON conversation_sessions(last_activity_utc DESC, session_id DESC);
2393
2393
 
2394
+ -- #217 S2 / I-3: file-path search axis. One row per WRITE-class file
2395
+ -- touch (Edit/MultiEdit/Write/NotebookEdit) inside a conversation message,
2396
+ -- derived from blocks_json by _derive_file_touches. message_id =
2397
+ -- conversation_messages.id (the turn anchor). Derived/re-derivable state:
2398
+ -- _fill_file_touches accumulates via INSERT OR IGNORE, the destructive
2399
+ -- message paths delete it (clear_conversation_messages / per-source
2400
+ -- reingest), and migration 019 arms a one-time history backfill.
2401
+ --
2402
+ -- CRITICAL: this is a PLAIN table with NO dependency on the FTS shape, and
2403
+ -- it is created HERE — inside the unconditional executescript, BEFORE the
2404
+ -- FTS5 ``legacy_present`` early-return below — so it ALWAYS exists
2405
+ -- regardless of FTS topology. (The I-2 title-FTS bug created its vtable
2406
+ -- AFTER that early-return, so its consumer crashed on a legacy-shape +
2407
+ -- both-pending upgrade; the file-touches table must not repeat that class.)
2408
+ CREATE TABLE IF NOT EXISTS conversation_file_touches (
2409
+ message_id INTEGER NOT NULL,
2410
+ session_id TEXT NOT NULL,
2411
+ uuid TEXT,
2412
+ file_path TEXT NOT NULL,
2413
+ tool TEXT NOT NULL,
2414
+ UNIQUE(message_id, file_path, tool)
2415
+ );
2416
+ -- COLLATE NOCASE is load-bearing (#217 S2 / I-3 review Important #1): the
2417
+ -- default ``LIKE`` is case-insensitive, and SQLite only rides a btree index
2418
+ -- for ``file_path LIKE 'prefix%'`` when the index column folds the same way
2419
+ -- (NOCASE) — a BINARY-collated index leaves the prefix probe a full SCAN. So
2420
+ -- the kind=files PREFIX branch (``_search_files``) is genuinely index-assisted
2421
+ -- ONLY with this NOCASE collation. (The substring branch leads with '%' and is
2422
+ -- a documented scan regardless.) NOCASE folds A-Z/a-z the same way the LIKE
2423
+ -- built-in does, so matching semantics are byte-identical to the prior index.
2424
+ CREATE INDEX IF NOT EXISTS idx_file_touches_path
2425
+ ON conversation_file_touches(file_path COLLATE NOCASE);
2426
+
2394
2427
  CREATE TABLE IF NOT EXISTS codex_session_files (
2395
2428
  path TEXT PRIMARY KEY,
2396
2429
  size_bytes INTEGER NOT NULL,
@@ -2447,17 +2480,19 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2447
2480
  add_column_if_missing(conn, "conversation_messages", "source_tool_use_id", "TEXT")
2448
2481
  # #177 Session 1: enriched data-contract columns. Idempotent column-adds (no
2449
2482
  # marker, no version — exactly like source_tool_use_id); cache migration 007
2450
- # then re-ingests so the values actually land on historical rows. search_aux
2451
- # is the parser-populated non-prose blob the conversation_fts_aux index reads.
2483
+ # then re-ingests so the values actually land on historical rows.
2452
2484
  add_column_if_missing(conn, "conversation_messages", "stop_reason", "TEXT")
2453
2485
  add_column_if_missing(conn, "conversation_messages", "attribution_skill", "TEXT")
2454
2486
  add_column_if_missing(conn, "conversation_messages", "attribution_plugin", "TEXT")
2455
- add_column_if_missing(
2456
- conn, "conversation_messages", "search_aux", "TEXT NOT NULL DEFAULT ''")
2457
- # #177 S6: split the non-prose search index into two columns so kind facets
2458
- # (Tools / Thinking) are exact in SQL. ``search_aux`` above is documented-dead
2459
- # the writer stops populating it (always ''); the consolidated multi-column
2460
- # conversation_fts(text, search_tool, search_thinking) indexes these instead.
2487
+ # #217 S1 / U7a: ``search_aux`` (the pre-#177-S6 non-prose FTS blob, always
2488
+ # '' since the split) is NO LONGER emitted here — a fresh install never
2489
+ # carries it, and cache migration ``016_drop_search_aux`` drops it from an
2490
+ # existing install once the migration-010 search split is consumed. The
2491
+ # legacy ``conversation_fts_aux`` test/fixture standup
2492
+ # (``_create_conversation_fts_aux_table``) adds the column locally itself.
2493
+ # #177 S6: the split non-prose search index — two columns so kind facets
2494
+ # (Tools / Thinking) are exact in SQL. The consolidated multi-column
2495
+ # conversation_fts(text, search_tool, search_thinking) indexes these.
2461
2496
  # Idempotent column-adds (no marker, no version); migration 010 backfills the
2462
2497
  # values onto existing history from blocks_json under the cache.db.lock flock.
2463
2498
  add_column_if_missing(
@@ -2515,22 +2550,40 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2515
2550
  # (clear_conversation_messages, which drops + recreates the trigger
2516
2551
  # set) can never drift.
2517
2552
  _create_conversation_fts_triggers(conn)
2553
+ # #217 S2 / E7: the external-content title FTS rides the SAME
2554
+ # FTS5-available envelope (P1-6). It is independent of the message
2555
+ # FTS (external-content over conversation_ai_titles, not
2556
+ # conversation_messages), so a failed message-FTS create above never
2557
+ # reaches here; but a failed title-FTS create must drop both message
2558
+ # and title triggers and mark unavailable, so the shared except below
2559
+ # owns the cleanup. Idempotent (IF NOT EXISTS).
2560
+ conn.execute(_CONV_TITLE_FTS_DDL)
2561
+ _create_conversation_title_fts_triggers(conn)
2518
2562
  if recovering:
2519
2563
  # Repopulate the freshly-(re)created index from the base table
2520
2564
  # so pre-recovery history is searchable. Cheap no-op when
2521
2565
  # conversation_messages is empty.
2522
2566
  conn.execute(
2523
2567
  "INSERT INTO conversation_fts(conversation_fts) VALUES('rebuild')")
2568
+ # The title FTS is external-content over conversation_ai_titles,
2569
+ # so a prior FTS5-unavailable run that ingested titles without the
2570
+ # AI trigger left the title index stale too — rebuild it the same
2571
+ # way (cheap no-op when conversation_ai_titles is empty).
2572
+ conn.execute(
2573
+ "INSERT INTO conversation_title_fts(conversation_title_fts) "
2574
+ "VALUES('rebuild')")
2524
2575
  conn.execute("DELETE FROM cache_meta WHERE key='fts5_unavailable'")
2525
2576
  except sqlite3.OperationalError:
2526
2577
  # partial create cleanup, then mark unavailable. _drop drops the
2527
- # split trigger set (and any legacy aux trigger names, harmlessly),
2528
- # and we drop both possible vtables, so a failed create can't leave a
2529
- # live trigger over a missing table.
2578
+ # split trigger set (and any legacy aux trigger names, harmlessly)
2579
+ # plus the title trigger set, and we drop all three possible vtables,
2580
+ # so a failed create can't leave a live trigger over a missing table.
2530
2581
  _drop_conversation_fts_triggers(conn)
2582
+ _drop_conversation_title_fts_triggers(conn)
2531
2583
  try:
2532
2584
  conn.execute("DROP TABLE IF EXISTS conversation_fts")
2533
2585
  conn.execute("DROP TABLE IF EXISTS conversation_fts_aux")
2586
+ conn.execute("DROP TABLE IF EXISTS conversation_title_fts")
2534
2587
  except sqlite3.OperationalError:
2535
2588
  pass
2536
2589
  _set_cache_meta(conn, "fts5_unavailable", "1")
@@ -2545,6 +2598,14 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2545
2598
  # itself can't be DROPped without the fts5 module, but with no triggers
2546
2599
  # nothing writes to it.)
2547
2600
  _drop_conversation_fts_triggers(conn)
2601
+ # #217 S2 / E7 (P1-6): same hazard for the title FTS — a prior
2602
+ # FTS5-capable run may have left conv_title_fts_* triggers that now
2603
+ # reference an unusable conversation_title_fts. They fire inside the
2604
+ # SAME per-file ingest transaction (the conversation_ai_titles upsert),
2605
+ # so an orphan title trigger would roll back the cost ingest too. Drop
2606
+ # them under the LIKE fallback; kind=title degrades to a LIKE scan over
2607
+ # conversation_ai_titles.
2608
+ _drop_conversation_title_fts_triggers(conn)
2548
2609
  _set_cache_meta(conn, "fts5_unavailable", "1")
2549
2610
  # The FTS branch above issues DML (DELETE/INSERT on cache_meta) which opens
2550
2611
  # an implicit transaction under sqlite3's legacy autocommit mode. Close it
@@ -2616,6 +2677,19 @@ def _create_conversation_fts_aux_table(conn: sqlite3.Connection) -> None:
2616
2677
  # the column — notably ``sqlite3.Connection.iterdump`` (the dump emits
2617
2678
  # ``SELECT quote(aux) FROM conversation_fts_aux`` → "no such column"). Aligning
2618
2679
  # the names keeps the index dumpable and is the documented FTS5 posture.
2680
+ #
2681
+ # #217 S1 / U7a: the live ``_apply_cache_schema`` no longer emits
2682
+ # ``search_aux`` (migration 016 drops it once the search split is consumed),
2683
+ # so this LEGACY standup adds the column LOCALLY here — it stands up the
2684
+ # pre-S6 aux shape and needs the content-table column to exist by NAME.
2685
+ # Idempotent (duplicate-column tolerated) so a DB that still carries the
2686
+ # column (pre-016 existing install) is a no-op.
2687
+ try:
2688
+ conn.execute(
2689
+ "ALTER TABLE conversation_messages "
2690
+ "ADD COLUMN search_aux TEXT NOT NULL DEFAULT ''")
2691
+ except sqlite3.OperationalError:
2692
+ pass # column already present (pre-016 existing install)
2619
2693
  conn.execute(
2620
2694
  "CREATE VIRTUAL TABLE IF NOT EXISTS conversation_fts_aux "
2621
2695
  "USING fts5(search_aux, content='conversation_messages', content_rowid='id')")
@@ -2726,6 +2800,77 @@ def _drop_conversation_fts_triggers(conn: sqlite3.Connection) -> None:
2726
2800
  pass
2727
2801
 
2728
2802
 
2803
+ # #217 S2 / E7: external-content FTS5 over the per-session AI title
2804
+ # (conversation_ai_titles). The single column name MUST match the content table
2805
+ # column (``ai_title``) BY NAME (external-content FTS5 resolves columns through
2806
+ # the content table by name — same rule the message FTS follows). Fresh installs
2807
+ # create this directly inside ``_apply_cache_schema``'s FTS5-available branch;
2808
+ # migration 018 creates it for existing installs. The content-table ``rowid`` is
2809
+ # STABLE across title updates (``_AI_TITLE_UPSERT_SQL`` is ``ON CONFLICT(session_id)
2810
+ # DO UPDATE``, not delete+reinsert), so the AU trigger covers the update path and
2811
+ # the external-content choice is sound.
2812
+ _CONV_TITLE_FTS_DDL = (
2813
+ "CREATE VIRTUAL TABLE IF NOT EXISTS conversation_title_fts USING fts5("
2814
+ "ai_title, content='conversation_ai_titles', content_rowid='rowid')"
2815
+ )
2816
+
2817
+ # Title-FTS sync triggers (external-content FTS5). Mirror the message-FTS
2818
+ # conv_fts_ai/ad/au idiom: AD/AU carry the OLD rowid via the ``'delete'``
2819
+ # command. Defined ONCE here so every create/drop site stays in lockstep.
2820
+ _CONV_TITLE_FTS_TRIGGER_DDL = (
2821
+ "CREATE TRIGGER IF NOT EXISTS conv_title_fts_ai "
2822
+ "AFTER INSERT ON conversation_ai_titles "
2823
+ "BEGIN INSERT INTO conversation_title_fts(rowid, ai_title) "
2824
+ "VALUES (new.rowid, new.ai_title); END",
2825
+ "CREATE TRIGGER IF NOT EXISTS conv_title_fts_ad "
2826
+ "AFTER DELETE ON conversation_ai_titles "
2827
+ "BEGIN INSERT INTO conversation_title_fts(conversation_title_fts, rowid, ai_title) "
2828
+ "VALUES('delete', old.rowid, old.ai_title); END",
2829
+ "CREATE TRIGGER IF NOT EXISTS conv_title_fts_au "
2830
+ "AFTER UPDATE OF ai_title ON conversation_ai_titles "
2831
+ "BEGIN INSERT INTO conversation_title_fts(conversation_title_fts, rowid, ai_title) "
2832
+ "VALUES('delete', old.rowid, old.ai_title); "
2833
+ "INSERT INTO conversation_title_fts(rowid, ai_title) "
2834
+ "VALUES (new.rowid, new.ai_title); END",
2835
+ )
2836
+ _CONV_TITLE_FTS_TRIGGER_NAMES = (
2837
+ "conv_title_fts_au", "conv_title_fts_ad", "conv_title_fts_ai")
2838
+
2839
+
2840
+ def _create_conversation_title_fts_triggers(conn: sqlite3.Connection) -> None:
2841
+ """Create the #217 S2 title-FTS sync trigger set — idempotent (each is
2842
+ ``IF NOT EXISTS``). The caller must have already created
2843
+ ``conversation_title_fts`` (the triggers reference it)."""
2844
+ for stmt in _CONV_TITLE_FTS_TRIGGER_DDL:
2845
+ conn.execute(stmt)
2846
+
2847
+
2848
+ def _drop_conversation_title_fts_triggers(conn: sqlite3.Connection) -> None:
2849
+ """Drop the title-FTS sync trigger set — idempotent (``IF EXISTS``). Swallows
2850
+ ``OperationalError`` per statement so an absent set (FTS5-unavailable build)
2851
+ is tolerated. P1-6: dropping these on a no-FTS5 build is what keeps a
2852
+ ``conversation_ai_titles`` upsert from firing a trigger against the missing
2853
+ vtable and rolling back the shared per-file ingest transaction."""
2854
+ for name in _CONV_TITLE_FTS_TRIGGER_NAMES:
2855
+ try:
2856
+ conn.execute(f"DROP TRIGGER IF EXISTS {name}")
2857
+ except sqlite3.OperationalError:
2858
+ pass
2859
+
2860
+
2861
+ def _clear_conversation_file_touches(conn: sqlite3.Connection) -> None:
2862
+ """#217 S2 / I-3 (P1-4): drop ALL file-touch rows on a full
2863
+ clear/rebuild/truncation. ``conversation_file_touches`` is derived state keyed
2864
+ by ``conversation_messages.id``, and a full clear recycles those rowids — so a
2865
+ surviving touch row would point at a future, unrelated message. Tolerates a
2866
+ missing table defensively (belt-and-suspenders; ``_apply_cache_schema`` always
2867
+ creates it before the FTS branch, so it should exist on any schema'd conn)."""
2868
+ try:
2869
+ conn.execute("DELETE FROM conversation_file_touches")
2870
+ except sqlite3.OperationalError:
2871
+ pass # table not yet created (pre-schema conn); nothing to clear
2872
+
2873
+
2729
2874
  def clear_conversation_messages(conn: sqlite3.Connection) -> None:
2730
2875
  """Full-clear ``conversation_messages`` + its FTS index WITHOUT firing the
2731
2876
  per-row delete trigger O(rows) (#138).
@@ -2765,10 +2910,12 @@ def clear_conversation_messages(conn: sqlite3.Connection) -> None:
2765
2910
 
2766
2911
  if fts_unavailable:
2767
2912
  conn.execute("DELETE FROM conversation_messages")
2913
+ _clear_conversation_file_touches(conn)
2768
2914
  return
2769
2915
 
2770
2916
  _drop_conversation_fts_triggers(conn)
2771
2917
  conn.execute("DELETE FROM conversation_messages")
2918
+ _clear_conversation_file_touches(conn)
2772
2919
  conn.execute("INSERT INTO conversation_fts(conversation_fts) VALUES('delete-all')")
2773
2920
  # #177 S6: the consolidated split table is the only FTS vtable on a swapped /
2774
2921
  # fresh DB. A pre-swap legacy install still carries conversation_fts_aux, so
@@ -3502,6 +3649,195 @@ def _015_conversation_sessions_filter_columns(conn: sqlite3.Connection) -> None:
3502
3649
  conn.commit()
3503
3650
 
3504
3651
 
3652
+ def _conversation_messages_has_column(conn: sqlite3.Connection, column: str) -> bool:
3653
+ """True iff ``conversation_messages`` carries *column*. Tolerates a missing
3654
+ table (a path-less / schema-not-applied connection) -> False."""
3655
+ try:
3656
+ cols = [r[1] for r in conn.execute(
3657
+ "PRAGMA table_info(conversation_messages)")]
3658
+ except sqlite3.OperationalError:
3659
+ return False
3660
+ return column in cols
3661
+
3662
+
3663
+ def _legacy_aux_fts_present(conn: sqlite3.Connection) -> bool:
3664
+ """True iff the pre-#177-S6 ``conversation_fts_aux`` external-content table
3665
+ OR any of its sync triggers (which reference ``search_aux``) still exist.
3666
+
3667
+ While EITHER is live, an ``ALTER TABLE conversation_messages DROP COLUMN
3668
+ search_aux`` FAILS (the trigger/index body references the column). Migration
3669
+ 010's state machine (``_consume_search_split`` in sync_cache) owns tearing
3670
+ these down; 016 only WAITS until they are gone. Tolerates a missing
3671
+ ``sqlite_master`` read -> False (no aux shape to block on)."""
3672
+ try:
3673
+ _trig_ph = ",".join("?" for _ in _CONV_FTS_AUX_TRIGGER_NAMES)
3674
+ row = conn.execute(
3675
+ "SELECT 1 FROM sqlite_master "
3676
+ "WHERE (type='table' AND name='conversation_fts_aux') "
3677
+ f" OR (type='trigger' AND name IN ({_trig_ph})) "
3678
+ "LIMIT 1",
3679
+ _CONV_FTS_AUX_TRIGGER_NAMES,
3680
+ ).fetchone()
3681
+ except sqlite3.OperationalError:
3682
+ return False
3683
+ return row is not None
3684
+
3685
+
3686
+ @cache_migration("016_drop_search_aux")
3687
+ def _016_drop_search_aux(conn: sqlite3.Connection) -> None:
3688
+ """Drop the documented-dead ``conversation_messages.search_aux`` column
3689
+ (#217 S1 / U7a) — it has been ``''`` on every row since #177 S6 split the
3690
+ non-prose search index into ``search_tool``/``search_thinking`` and the live
3691
+ ``conversation_fts`` stopped referencing it.
3692
+
3693
+ THREE guards, in order (the drop is the LAST thing that happens):
3694
+
3695
+ 1. Column-presence — idempotent skip-as-applied when ``search_aux`` is
3696
+ already gone. A fresh install never carries it (``_apply_cache_schema``
3697
+ no longer emits it post-#217), so the dispatcher stamps 016 without the
3698
+ column ever existing; an existing install drops it once.
3699
+ 2. ``sqlite_version() >= 3.35`` — ``ALTER TABLE … DROP COLUMN`` did not
3700
+ exist before SQLite 3.35. On an older build we SKIP-as-applied, leaving
3701
+ the harmless dead column (never a hard fail; cache.db is re-derivable,
3702
+ and a later open on a newer SQLite still can't re-run a stamped
3703
+ migration — the column simply persists, which is benign).
3704
+ 3. Search-split-consumed gate (Codex P1) — DEFER via ``MigrationGateNotMet``
3705
+ (retried next open) when migration 010's
3706
+ ``conversation_search_split_pending`` flag is set OR the legacy
3707
+ ``conversation_fts_aux`` table / its triggers still exist. ``DROP COLUMN``
3708
+ FAILS while a trigger references ``search_aux`` (confirmed in-memory), so
3709
+ 016 must wait for ``_consume_search_split`` (sync_cache) to backfill the
3710
+ split columns + swap the FTS shape. 016 does NOT tear the aux table down
3711
+ itself — 010's state machine owns that. On a ``--no-sync``-forever DB the
3712
+ split never consumes and the column persists harmlessly (re-derivable).
3713
+
3714
+ The handler does its own DDL only; the dispatcher central-stamps the marker
3715
+ (#140) on a clean return (the apply path) — it does NOT stamp on the
3716
+ ``MigrationGateNotMet`` defer path, so the migration stays pending and retries.
3717
+ """
3718
+ if not _conversation_messages_has_column(conn, "search_aux"):
3719
+ # Guard 1: already absent (fresh install, or a prior 016 run). The
3720
+ # dispatcher central-stamps on this clean return.
3721
+ return
3722
+ sqlite_version = tuple(
3723
+ int(p) for p in sqlite3.sqlite_version.split(".")[:3]
3724
+ )
3725
+ if sqlite_version < (3, 35, 0):
3726
+ # Guard 2: no DROP COLUMN on this build. Skip-as-applied (stamp on the
3727
+ # clean return) — the dead column stays, harmless.
3728
+ return
3729
+ try:
3730
+ split_pending = conn.execute(
3731
+ "SELECT 1 FROM cache_meta "
3732
+ "WHERE key='conversation_search_split_pending'"
3733
+ ).fetchone() is not None
3734
+ except sqlite3.OperationalError:
3735
+ # No cache_meta table (a bare / path-less connection) -> no pending
3736
+ # flag to honor; the legacy-aux probe below still guards the topology.
3737
+ split_pending = False
3738
+ if split_pending or _legacy_aux_fts_present(conn):
3739
+ # Guard 3: the search split is not yet consumed — the legacy aux FTS
3740
+ # may still reference search_aux, so DROP COLUMN would fail. DEFER; the
3741
+ # next sync's _consume_search_split clears the flag + drops the aux
3742
+ # shape, and a later open then drops the column.
3743
+ raise MigrationGateNotMet(
3744
+ "search_aux drop deferred: migration-010 search split not yet "
3745
+ "consumed (pending flag or legacy conversation_fts_aux still present)"
3746
+ )
3747
+ conn.execute(
3748
+ "ALTER TABLE conversation_messages DROP COLUMN search_aux")
3749
+ conn.commit()
3750
+
3751
+
3752
+ @cache_migration("017_arm_nested_agent_reingest")
3753
+ def _017_arm_nested_agent_reingest(conn: sqlite3.Connection) -> None:
3754
+ """Flag-only re-ingest so existing nested-subagent (grandchild) results whose
3755
+ ``agentId:`` trailer landed PAST the 16 KB ``_TOOL_RESULT_CAP`` clip re-link
3756
+ on existing history (#217 S1 / U6). The parser now stamps a structured
3757
+ ``block["agent_id"]`` (+ usage) at INGEST — over the FULL raw, before the clip
3758
+ — so the offset-0 re-parse re-derives the link with zero new consumption code
3759
+ (the kernel's existing ``b.pop("agent_id")`` consumer picks it up). Until
3760
+ consumed, old rows fall back to the read-time regex over the (clipped) text —
3761
+ today's behavior, no worse.
3762
+
3763
+ Sets the DISTINCT ``conversation_reingest_nested_agent_pending`` flag (NOT the
3764
+ shared ``conversation_reingest_pending``, which also gates migration 005's
3765
+ read-time human-fallback in the query kernel — re-arming it could misclassify
3766
+ a genuine human prompt during the pre-reingest window). Consumption rides the
3767
+ #179 RESUMABLE per-file reingest (``_resumable_reingest_conversation_messages``)
3768
+ — the flag is wired into ``_TARGETED_DECLINE_FLAGS`` + ``_REINGEST_FLAG_KEYS``
3769
+ + the resumable-reingest flag SELECT + the two cleanup DELETE lists in
3770
+ ``_cctally_cache.py`` (all five sites; missing one either never triggers or
3771
+ re-arms forever). Central stamp via the dispatcher (#140); a fresh install
3772
+ stamps it WITHOUT running (empty table -> the flag, if ever set, is a harmless
3773
+ no-op). Mirrors 014/009/007."""
3774
+ _set_cache_meta(conn, "conversation_reingest_nested_agent_pending", "1")
3775
+ conn.commit()
3776
+
3777
+
3778
+ @cache_migration("018_create_conversation_title_fts")
3779
+ def _018_create_conversation_title_fts(conn: sqlite3.Connection) -> None:
3780
+ """#217 S2 / E7: arm the external-content title FTS over
3781
+ ``conversation_ai_titles`` so AI titles are findable via ``kind=title``.
3782
+
3783
+ Flag-only arm. The ``conversation_title_fts`` virtual table + its
3784
+ conv_title_fts_ai/ad/au sync triggers are created by ``_apply_cache_schema``
3785
+ (runs on every open, fresh + existing installs) inside the SAME FTS5-available
3786
+ envelope as the message FTS (P1-6) — so on a no-FTS5 build the table+triggers
3787
+ are simply absent and a title upsert never rolls back the ingest. This handler
3788
+ does NO DDL (mirrors 012's flag-only pattern): it just arms the DISTINCT
3789
+ ``conversation_title_fts_backfill_pending`` flag (the "distinct reingest flag
3790
+ per enrichment" rule — its own flag, never the shared one) so the next
3791
+ flock-held full sync runs ``_consume_title_fts`` (an FTS5 ``'rebuild'``, P1-7)
3792
+ to populate the index from existing history.
3793
+
3794
+ The flag joins ``_TARGETED_DECLINE_FLAGS`` ONLY — NEVER ``_REINGEST_FLAG_KEYS``
3795
+ (P1-2): that set means "run ``_resumable_reingest_conversation_messages``" (a
3796
+ full message delete/reinsert + rowid churn) which a title-FTS backfill must
3797
+ not trigger; the title index is external-content over conversation_ai_titles
3798
+ and a ``'rebuild'`` repopulates it without touching conversation_messages.
3799
+
3800
+ No data work here -> the dispatcher's central stamp (#140) marks a complete
3801
+ handler; a fresh install stamps WITHOUT a populated history (its incremental
3802
+ walk fills the title FTS via the AI trigger as titles ingest, and the consumed
3803
+ backfill 'rebuild' no-ops). Mirrors 012's flag-only arm."""
3804
+ _set_cache_meta(conn, "conversation_title_fts_backfill_pending", "1")
3805
+ conn.commit()
3806
+
3807
+
3808
+ @cache_migration("019_create_conversation_file_touches")
3809
+ def _019_create_conversation_file_touches(conn: sqlite3.Connection) -> None:
3810
+ """#217 S2 / I-3: arm the file-path search axis backfill so existing history's
3811
+ WRITE-class file touches (Edit/MultiEdit/Write/NotebookEdit) are searchable via
3812
+ ``kind=files``.
3813
+
3814
+ Flag-only arm (mirrors 018's pattern). The ``conversation_file_touches`` table
3815
+ + its ``COLLATE NOCASE`` path index (``idx_file_touches_path``) are created by
3816
+ ``_apply_cache_schema`` (runs on every open, fresh + existing installs) — and
3817
+ CRITICALLY before the FTS5 ``legacy_present`` early-return, since the table is
3818
+ plain and has NO dependency on the FTS shape (so a legacy-shape upgrade still
3819
+ gets it). The NOCASE collation is what lets the kind=files PREFIX search ride
3820
+ the index (default LIKE is case-insensitive; a BINARY index can't serve it —
3821
+ review Important #1). This handler does NO DDL: it just
3822
+ arms the DISTINCT ``conversation_reingest_file_touches_pending`` flag (the
3823
+ "distinct reingest flag per enrichment" rule — its own flag, never the shared
3824
+ one) so the next flock-held full sync runs ``_consume_file_touches`` to derive
3825
+ touches from existing ``blocks_json`` history.
3826
+
3827
+ The flag joins ``_TARGETED_DECLINE_FLAGS`` ONLY — NEVER ``_REINGEST_FLAG_KEYS``
3828
+ (P1-2): that set means "run ``_resumable_reingest_conversation_messages``" (a
3829
+ full message delete/reinsert + rowid churn) which a file-touches backfill must
3830
+ not trigger; the backfill derives from the already-present ``blocks_json`` and
3831
+ INSERT-OR-IGNOREs into a separate table without touching conversation_messages.
3832
+
3833
+ No data work here -> the dispatcher's central stamp (#140) marks a complete
3834
+ handler; a fresh install stamps WITHOUT a populated history (its incremental
3835
+ walk fills touches per ingested tick via _fill_file_touches, and the consumed
3836
+ backfill no-ops). Mirrors 018's flag-only arm."""
3837
+ _set_cache_meta(conn, "conversation_reingest_file_touches_pending", "1")
3838
+ conn.commit()
3839
+
3840
+
3505
3841
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
3506
3842
 
3507
3843
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -274,6 +274,71 @@ def doctor_gather_state(
274
274
  except Exception:
275
275
  pass
276
276
 
277
+ # Conversation-sessions rollup consistency (#217 S1 / U9). Two cheap COUNTs
278
+ # (graceful None on a missing table / unreadable DB) + an in-progress signal
279
+ # so a transient mid-sync mismatch never WARNs. The in-progress signal is a
280
+ # NON-BLOCKING cache.db.lock flock probe (a writer mid-walk holds it) OR the
281
+ # presence of any pending reingest/split/backfill cache_meta flag — doctor
282
+ # stays read-only and never blocks on the lock.
283
+ conv_sessions_rollup_count = None
284
+ conv_messages_distinct_sessions = None
285
+ conv_rollup_sync_in_progress = False
286
+ try:
287
+ if _cctally_core.CACHE_DB_PATH.exists():
288
+ conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
289
+ try:
290
+ try:
291
+ row = conn.execute(
292
+ "SELECT COUNT(*) FROM conversation_sessions"
293
+ ).fetchone()
294
+ if row is not None:
295
+ conv_sessions_rollup_count = int(row[0])
296
+ except sqlite3.OperationalError:
297
+ pass # table absent (pre-rollup) — leave None
298
+ try:
299
+ row = conn.execute(
300
+ "SELECT COUNT(DISTINCT session_id) "
301
+ "FROM conversation_messages WHERE session_id IS NOT NULL"
302
+ ).fetchone()
303
+ if row is not None:
304
+ conv_messages_distinct_sessions = int(row[0])
305
+ except sqlite3.OperationalError:
306
+ pass
307
+ # Pending reingest/split/backfill flags ⇒ a full sync hasn't yet
308
+ # reconciled the rollup. Read the canonical flag set from
309
+ # _cctally_cache so it stays in lockstep with the sync consumers.
310
+ try:
311
+ import _cctally_cache as _cc_sib # lazy sibling
312
+ flags = tuple(_cc_sib._TARGETED_DECLINE_FLAGS)
313
+ placeholders = ",".join("?" for _ in flags)
314
+ pend = conn.execute(
315
+ f"SELECT 1 FROM cache_meta WHERE key IN ({placeholders}) "
316
+ "LIMIT 1", flags).fetchone()
317
+ if pend is not None:
318
+ conv_rollup_sync_in_progress = True
319
+ except Exception:
320
+ pass
321
+ finally:
322
+ conn.close()
323
+ # Non-blocking flock probe: if a writer (sync_cache / a reingest) holds
324
+ # the cache.db.lock, the rollup may be mid-recompute → in progress. We
325
+ # acquire LOCK_EX|LOCK_NB and immediately release; failure (held) is the
326
+ # signal. Never blocks (LOCK_NB), so doctor stays read-only + prompt.
327
+ if not conv_rollup_sync_in_progress:
328
+ lock_path = _cctally_core.CACHE_LOCK_PATH
329
+ if lock_path is not None and pathlib.Path(lock_path).exists():
330
+ import fcntl as _fcntl
331
+ lock_fh = open(str(lock_path), "w")
332
+ try:
333
+ _fcntl.flock(lock_fh, _fcntl.LOCK_EX | _fcntl.LOCK_NB)
334
+ _fcntl.flock(lock_fh, _fcntl.LOCK_UN) # acquired ⇒ quiescent
335
+ except (BlockingIOError, OSError):
336
+ conv_rollup_sync_in_progress = True # held ⇒ writer mid-flight
337
+ finally:
338
+ lock_fh.close()
339
+ except Exception:
340
+ pass
341
+
277
342
  claude_jsonl_present = False
278
343
  try:
279
344
  claude_dir = pathlib.Path.home() / ".claude" / "projects"
@@ -453,6 +518,10 @@ def doctor_gather_state(
453
518
  is_dev_checkout=_cctally_core._is_dev_checkout(),
454
519
  # Pricing-freshness check (spec §5.1): trailing-30d coverage gaps.
455
520
  pricing_coverage=pricing_coverage,
521
+ # Conversation-sessions rollup consistency (#217 S1 / U9).
522
+ conv_sessions_rollup_count=conv_sessions_rollup_count,
523
+ conv_messages_distinct_sessions=conv_messages_distinct_sessions,
524
+ conv_rollup_sync_in_progress=conv_rollup_sync_in_progress,
456
525
  )
457
526
 
458
527
 
@@ -2217,6 +2217,15 @@ def _build_credit_plan(*, week_start_date, week_start_at, week_end_at,
2217
2217
  `at` either way."""
2218
2218
  to_pct = _normalize_percent(to_pct)
2219
2219
  from_pct = _normalize_percent(from_pct)
2220
+ # Defensive None-guard (issue #212 N3). `_normalize_percent` returns None
2221
+ # for a None input. The CLI never reaches here with None (`--to` is
2222
+ # required + type=float; `--from` always resolves to a float or the caller
2223
+ # errors out first), but this is a public pure helper called directly by
2224
+ # tests and any future non-CLI caller — surface a None as a clear ValueError
2225
+ # (caller -> exit 2) instead of a TypeError from the `0.0 <= None` compare
2226
+ # immediately below.
2227
+ if to_pct is None or from_pct is None:
2228
+ raise ValueError("--to/--from must be numeric")
2220
2229
  if not (0.0 <= to_pct <= 100.0) or not (0.0 <= from_pct <= 100.0):
2221
2230
  raise ValueError("--to/--from must be in [0, 100]")
2222
2231
  if to_pct >= from_pct:
@@ -2666,6 +2675,22 @@ def cmd_record_credit(args) -> int:
2666
2675
  eprint("record-credit: --json requires --yes or --dry-run")
2667
2676
  return 2
2668
2677
 
2678
+ # Fully-applied refuse (M2; state classified at step 2a). A credit
2679
+ # already fully recorded for this week (floor row + command-owned
2680
+ # snapshot) is refused by default. Hoisted ABOVE the interactive
2681
+ # confirm prompt so a TTY user is refused immediately, rather than
2682
+ # being shown the preview, prompted, answering y, and only THEN refused
2683
+ # (issue #212 N2). `--force` is intentionally NOT refused here — it
2684
+ # takes the clear + re-record path at step 4a; a half-applied credit
2685
+ # (is_completion) falls through to finish idempotently. This fires
2686
+ # regardless of --yes/TTY so the precondition failure is uniform; the
2687
+ # earlier --dry-run path still previews (writes nothing) and returns 0.
2688
+ if existing is not None and not is_force and not is_completion:
2689
+ eprint(f"record-credit: a credit is already recorded for this "
2690
+ f"week (effective={existing[1]}, pre_credit={existing[2]}); "
2691
+ f"pass --force to re-record")
2692
+ return 2
2693
+
2669
2694
  # No --yes: prompt (TTY) or refuse (non-TTY).
2670
2695
  if not is_yes:
2671
2696
  if not sys.stdin.isatty():
@@ -2682,23 +2707,18 @@ def cmd_record_credit(args) -> int:
2682
2707
  print("aborted — nothing written")
2683
2708
  return 0
2684
2709
 
2685
- # 4a. Existing-floor handling (M2; state classified at step 2a).
2686
- # _apply_credit commits the floor row + cleanup BEFORE the synthetic
2687
- # snapshot, so a crash in between leaves a half-applied credit
2688
- # (floor row, no command-owned snapshot). `existing` /
2689
- # `is_completion` were resolved up front (above) keyed on
2690
- # week_start_date / weekly_credit_floors.
2710
+ # 4a. Existing-floor handling (M2; state classified at step 2a). The
2711
+ # fully-applied refuse was hoisted above the confirm prompt (#212
2712
+ # N2), so only two cases remain here: `--force` clears the prior
2713
+ # floor + re-records at a fresh effective; a half-applied credit
2714
+ # (floor row, no command-owned snapshot a crash between
2715
+ # _apply_credit's floor commit and the synthetic snapshot) falls
2716
+ # through to a completion (the plan reuses the existing effective
2717
+ # and the apply steps are idempotent). `existing`/`is_completion`
2718
+ # were resolved up front keyed on week_start_date /
2719
+ # weekly_credit_floors.
2691
2720
  forced = False
2692
- if existing is not None and not is_force:
2693
- if not is_completion:
2694
- # Fully applied -> refuse by default.
2695
- eprint(f"record-credit: a credit is already recorded for this "
2696
- f"week (effective={existing[1]}, pre_credit={existing[2]}); "
2697
- f"pass --force to re-record")
2698
- return 2
2699
- # else: half-applied -> fall through (completion path; the plan
2700
- # reuses the existing effective and the apply steps are idempotent).
2701
- elif existing is not None and is_force:
2721
+ if existing is not None and is_force:
2702
2722
  _force_clear_credit(conn, plan.week_start_date)
2703
2723
  forced = True
2704
2724