cctally 1.52.1 → 1.54.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,40 @@ 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 keeps the index's case-folding identical to the default
2417
+ -- (case-insensitive) ``LIKE`` (#217 S2 / I-3 review Important #1): NOCASE folds
2418
+ -- A-Z/a-z the same way the LIKE built-in does, so matching semantics are
2419
+ -- byte-identical to a BINARY index. (#223: ``_search_files`` now substring-
2420
+ -- matches EVERY query (``file_path LIKE '%q%'``) — a deliberate scan over the
2421
+ -- modest touch table — so this index no longer drives a prefix probe; it is
2422
+ -- retained as-is with no schema change. NOCASE was originally load-bearing so a
2423
+ -- ``LIKE 'prefix%'`` could ride the btree, which SQLite only does when the index
2424
+ -- column folds the same way.)
2425
+ CREATE INDEX IF NOT EXISTS idx_file_touches_path
2426
+ ON conversation_file_touches(file_path COLLATE NOCASE);
2427
+
2394
2428
  CREATE TABLE IF NOT EXISTS codex_session_files (
2395
2429
  path TEXT PRIMARY KEY,
2396
2430
  size_bytes INTEGER NOT NULL,
@@ -2447,17 +2481,19 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2447
2481
  add_column_if_missing(conn, "conversation_messages", "source_tool_use_id", "TEXT")
2448
2482
  # #177 Session 1: enriched data-contract columns. Idempotent column-adds (no
2449
2483
  # 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.
2484
+ # then re-ingests so the values actually land on historical rows.
2452
2485
  add_column_if_missing(conn, "conversation_messages", "stop_reason", "TEXT")
2453
2486
  add_column_if_missing(conn, "conversation_messages", "attribution_skill", "TEXT")
2454
2487
  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.
2488
+ # #217 S1 / U7a: ``search_aux`` (the pre-#177-S6 non-prose FTS blob, always
2489
+ # '' since the split) is NO LONGER emitted here — a fresh install never
2490
+ # carries it, and cache migration ``016_drop_search_aux`` drops it from an
2491
+ # existing install once the migration-010 search split is consumed. The
2492
+ # legacy ``conversation_fts_aux`` test/fixture standup
2493
+ # (``_create_conversation_fts_aux_table``) adds the column locally itself.
2494
+ # #177 S6: the split non-prose search index — two columns so kind facets
2495
+ # (Tools / Thinking) are exact in SQL. The consolidated multi-column
2496
+ # conversation_fts(text, search_tool, search_thinking) indexes these.
2461
2497
  # Idempotent column-adds (no marker, no version); migration 010 backfills the
2462
2498
  # values onto existing history from blocks_json under the cache.db.lock flock.
2463
2499
  add_column_if_missing(
@@ -2515,22 +2551,40 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2515
2551
  # (clear_conversation_messages, which drops + recreates the trigger
2516
2552
  # set) can never drift.
2517
2553
  _create_conversation_fts_triggers(conn)
2554
+ # #217 S2 / E7: the external-content title FTS rides the SAME
2555
+ # FTS5-available envelope (P1-6). It is independent of the message
2556
+ # FTS (external-content over conversation_ai_titles, not
2557
+ # conversation_messages), so a failed message-FTS create above never
2558
+ # reaches here; but a failed title-FTS create must drop both message
2559
+ # and title triggers and mark unavailable, so the shared except below
2560
+ # owns the cleanup. Idempotent (IF NOT EXISTS).
2561
+ conn.execute(_CONV_TITLE_FTS_DDL)
2562
+ _create_conversation_title_fts_triggers(conn)
2518
2563
  if recovering:
2519
2564
  # Repopulate the freshly-(re)created index from the base table
2520
2565
  # so pre-recovery history is searchable. Cheap no-op when
2521
2566
  # conversation_messages is empty.
2522
2567
  conn.execute(
2523
2568
  "INSERT INTO conversation_fts(conversation_fts) VALUES('rebuild')")
2569
+ # The title FTS is external-content over conversation_ai_titles,
2570
+ # so a prior FTS5-unavailable run that ingested titles without the
2571
+ # AI trigger left the title index stale too — rebuild it the same
2572
+ # way (cheap no-op when conversation_ai_titles is empty).
2573
+ conn.execute(
2574
+ "INSERT INTO conversation_title_fts(conversation_title_fts) "
2575
+ "VALUES('rebuild')")
2524
2576
  conn.execute("DELETE FROM cache_meta WHERE key='fts5_unavailable'")
2525
2577
  except sqlite3.OperationalError:
2526
2578
  # 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.
2579
+ # split trigger set (and any legacy aux trigger names, harmlessly)
2580
+ # plus the title trigger set, and we drop all three possible vtables,
2581
+ # so a failed create can't leave a live trigger over a missing table.
2530
2582
  _drop_conversation_fts_triggers(conn)
2583
+ _drop_conversation_title_fts_triggers(conn)
2531
2584
  try:
2532
2585
  conn.execute("DROP TABLE IF EXISTS conversation_fts")
2533
2586
  conn.execute("DROP TABLE IF EXISTS conversation_fts_aux")
2587
+ conn.execute("DROP TABLE IF EXISTS conversation_title_fts")
2534
2588
  except sqlite3.OperationalError:
2535
2589
  pass
2536
2590
  _set_cache_meta(conn, "fts5_unavailable", "1")
@@ -2545,6 +2599,14 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2545
2599
  # itself can't be DROPped without the fts5 module, but with no triggers
2546
2600
  # nothing writes to it.)
2547
2601
  _drop_conversation_fts_triggers(conn)
2602
+ # #217 S2 / E7 (P1-6): same hazard for the title FTS — a prior
2603
+ # FTS5-capable run may have left conv_title_fts_* triggers that now
2604
+ # reference an unusable conversation_title_fts. They fire inside the
2605
+ # SAME per-file ingest transaction (the conversation_ai_titles upsert),
2606
+ # so an orphan title trigger would roll back the cost ingest too. Drop
2607
+ # them under the LIKE fallback; kind=title degrades to a LIKE scan over
2608
+ # conversation_ai_titles.
2609
+ _drop_conversation_title_fts_triggers(conn)
2548
2610
  _set_cache_meta(conn, "fts5_unavailable", "1")
2549
2611
  # The FTS branch above issues DML (DELETE/INSERT on cache_meta) which opens
2550
2612
  # an implicit transaction under sqlite3's legacy autocommit mode. Close it
@@ -2616,6 +2678,19 @@ def _create_conversation_fts_aux_table(conn: sqlite3.Connection) -> None:
2616
2678
  # the column — notably ``sqlite3.Connection.iterdump`` (the dump emits
2617
2679
  # ``SELECT quote(aux) FROM conversation_fts_aux`` → "no such column"). Aligning
2618
2680
  # the names keeps the index dumpable and is the documented FTS5 posture.
2681
+ #
2682
+ # #217 S1 / U7a: the live ``_apply_cache_schema`` no longer emits
2683
+ # ``search_aux`` (migration 016 drops it once the search split is consumed),
2684
+ # so this LEGACY standup adds the column LOCALLY here — it stands up the
2685
+ # pre-S6 aux shape and needs the content-table column to exist by NAME.
2686
+ # Idempotent (duplicate-column tolerated) so a DB that still carries the
2687
+ # column (pre-016 existing install) is a no-op.
2688
+ try:
2689
+ conn.execute(
2690
+ "ALTER TABLE conversation_messages "
2691
+ "ADD COLUMN search_aux TEXT NOT NULL DEFAULT ''")
2692
+ except sqlite3.OperationalError:
2693
+ pass # column already present (pre-016 existing install)
2619
2694
  conn.execute(
2620
2695
  "CREATE VIRTUAL TABLE IF NOT EXISTS conversation_fts_aux "
2621
2696
  "USING fts5(search_aux, content='conversation_messages', content_rowid='id')")
@@ -2726,6 +2801,77 @@ def _drop_conversation_fts_triggers(conn: sqlite3.Connection) -> None:
2726
2801
  pass
2727
2802
 
2728
2803
 
2804
+ # #217 S2 / E7: external-content FTS5 over the per-session AI title
2805
+ # (conversation_ai_titles). The single column name MUST match the content table
2806
+ # column (``ai_title``) BY NAME (external-content FTS5 resolves columns through
2807
+ # the content table by name — same rule the message FTS follows). Fresh installs
2808
+ # create this directly inside ``_apply_cache_schema``'s FTS5-available branch;
2809
+ # migration 018 creates it for existing installs. The content-table ``rowid`` is
2810
+ # STABLE across title updates (``_AI_TITLE_UPSERT_SQL`` is ``ON CONFLICT(session_id)
2811
+ # DO UPDATE``, not delete+reinsert), so the AU trigger covers the update path and
2812
+ # the external-content choice is sound.
2813
+ _CONV_TITLE_FTS_DDL = (
2814
+ "CREATE VIRTUAL TABLE IF NOT EXISTS conversation_title_fts USING fts5("
2815
+ "ai_title, content='conversation_ai_titles', content_rowid='rowid')"
2816
+ )
2817
+
2818
+ # Title-FTS sync triggers (external-content FTS5). Mirror the message-FTS
2819
+ # conv_fts_ai/ad/au idiom: AD/AU carry the OLD rowid via the ``'delete'``
2820
+ # command. Defined ONCE here so every create/drop site stays in lockstep.
2821
+ _CONV_TITLE_FTS_TRIGGER_DDL = (
2822
+ "CREATE TRIGGER IF NOT EXISTS conv_title_fts_ai "
2823
+ "AFTER INSERT ON conversation_ai_titles "
2824
+ "BEGIN INSERT INTO conversation_title_fts(rowid, ai_title) "
2825
+ "VALUES (new.rowid, new.ai_title); END",
2826
+ "CREATE TRIGGER IF NOT EXISTS conv_title_fts_ad "
2827
+ "AFTER DELETE ON conversation_ai_titles "
2828
+ "BEGIN INSERT INTO conversation_title_fts(conversation_title_fts, rowid, ai_title) "
2829
+ "VALUES('delete', old.rowid, old.ai_title); END",
2830
+ "CREATE TRIGGER IF NOT EXISTS conv_title_fts_au "
2831
+ "AFTER UPDATE OF ai_title ON conversation_ai_titles "
2832
+ "BEGIN INSERT INTO conversation_title_fts(conversation_title_fts, rowid, ai_title) "
2833
+ "VALUES('delete', old.rowid, old.ai_title); "
2834
+ "INSERT INTO conversation_title_fts(rowid, ai_title) "
2835
+ "VALUES (new.rowid, new.ai_title); END",
2836
+ )
2837
+ _CONV_TITLE_FTS_TRIGGER_NAMES = (
2838
+ "conv_title_fts_au", "conv_title_fts_ad", "conv_title_fts_ai")
2839
+
2840
+
2841
+ def _create_conversation_title_fts_triggers(conn: sqlite3.Connection) -> None:
2842
+ """Create the #217 S2 title-FTS sync trigger set — idempotent (each is
2843
+ ``IF NOT EXISTS``). The caller must have already created
2844
+ ``conversation_title_fts`` (the triggers reference it)."""
2845
+ for stmt in _CONV_TITLE_FTS_TRIGGER_DDL:
2846
+ conn.execute(stmt)
2847
+
2848
+
2849
+ def _drop_conversation_title_fts_triggers(conn: sqlite3.Connection) -> None:
2850
+ """Drop the title-FTS sync trigger set — idempotent (``IF EXISTS``). Swallows
2851
+ ``OperationalError`` per statement so an absent set (FTS5-unavailable build)
2852
+ is tolerated. P1-6: dropping these on a no-FTS5 build is what keeps a
2853
+ ``conversation_ai_titles`` upsert from firing a trigger against the missing
2854
+ vtable and rolling back the shared per-file ingest transaction."""
2855
+ for name in _CONV_TITLE_FTS_TRIGGER_NAMES:
2856
+ try:
2857
+ conn.execute(f"DROP TRIGGER IF EXISTS {name}")
2858
+ except sqlite3.OperationalError:
2859
+ pass
2860
+
2861
+
2862
+ def _clear_conversation_file_touches(conn: sqlite3.Connection) -> None:
2863
+ """#217 S2 / I-3 (P1-4): drop ALL file-touch rows on a full
2864
+ clear/rebuild/truncation. ``conversation_file_touches`` is derived state keyed
2865
+ by ``conversation_messages.id``, and a full clear recycles those rowids — so a
2866
+ surviving touch row would point at a future, unrelated message. Tolerates a
2867
+ missing table defensively (belt-and-suspenders; ``_apply_cache_schema`` always
2868
+ creates it before the FTS branch, so it should exist on any schema'd conn)."""
2869
+ try:
2870
+ conn.execute("DELETE FROM conversation_file_touches")
2871
+ except sqlite3.OperationalError:
2872
+ pass # table not yet created (pre-schema conn); nothing to clear
2873
+
2874
+
2729
2875
  def clear_conversation_messages(conn: sqlite3.Connection) -> None:
2730
2876
  """Full-clear ``conversation_messages`` + its FTS index WITHOUT firing the
2731
2877
  per-row delete trigger O(rows) (#138).
@@ -2765,10 +2911,12 @@ def clear_conversation_messages(conn: sqlite3.Connection) -> None:
2765
2911
 
2766
2912
  if fts_unavailable:
2767
2913
  conn.execute("DELETE FROM conversation_messages")
2914
+ _clear_conversation_file_touches(conn)
2768
2915
  return
2769
2916
 
2770
2917
  _drop_conversation_fts_triggers(conn)
2771
2918
  conn.execute("DELETE FROM conversation_messages")
2919
+ _clear_conversation_file_touches(conn)
2772
2920
  conn.execute("INSERT INTO conversation_fts(conversation_fts) VALUES('delete-all')")
2773
2921
  # #177 S6: the consolidated split table is the only FTS vtable on a swapped /
2774
2922
  # fresh DB. A pre-swap legacy install still carries conversation_fts_aux, so
@@ -3502,6 +3650,195 @@ def _015_conversation_sessions_filter_columns(conn: sqlite3.Connection) -> None:
3502
3650
  conn.commit()
3503
3651
 
3504
3652
 
3653
+ def _conversation_messages_has_column(conn: sqlite3.Connection, column: str) -> bool:
3654
+ """True iff ``conversation_messages`` carries *column*. Tolerates a missing
3655
+ table (a path-less / schema-not-applied connection) -> False."""
3656
+ try:
3657
+ cols = [r[1] for r in conn.execute(
3658
+ "PRAGMA table_info(conversation_messages)")]
3659
+ except sqlite3.OperationalError:
3660
+ return False
3661
+ return column in cols
3662
+
3663
+
3664
+ def _legacy_aux_fts_present(conn: sqlite3.Connection) -> bool:
3665
+ """True iff the pre-#177-S6 ``conversation_fts_aux`` external-content table
3666
+ OR any of its sync triggers (which reference ``search_aux``) still exist.
3667
+
3668
+ While EITHER is live, an ``ALTER TABLE conversation_messages DROP COLUMN
3669
+ search_aux`` FAILS (the trigger/index body references the column). Migration
3670
+ 010's state machine (``_consume_search_split`` in sync_cache) owns tearing
3671
+ these down; 016 only WAITS until they are gone. Tolerates a missing
3672
+ ``sqlite_master`` read -> False (no aux shape to block on)."""
3673
+ try:
3674
+ _trig_ph = ",".join("?" for _ in _CONV_FTS_AUX_TRIGGER_NAMES)
3675
+ row = conn.execute(
3676
+ "SELECT 1 FROM sqlite_master "
3677
+ "WHERE (type='table' AND name='conversation_fts_aux') "
3678
+ f" OR (type='trigger' AND name IN ({_trig_ph})) "
3679
+ "LIMIT 1",
3680
+ _CONV_FTS_AUX_TRIGGER_NAMES,
3681
+ ).fetchone()
3682
+ except sqlite3.OperationalError:
3683
+ return False
3684
+ return row is not None
3685
+
3686
+
3687
+ @cache_migration("016_drop_search_aux")
3688
+ def _016_drop_search_aux(conn: sqlite3.Connection) -> None:
3689
+ """Drop the documented-dead ``conversation_messages.search_aux`` column
3690
+ (#217 S1 / U7a) — it has been ``''`` on every row since #177 S6 split the
3691
+ non-prose search index into ``search_tool``/``search_thinking`` and the live
3692
+ ``conversation_fts`` stopped referencing it.
3693
+
3694
+ THREE guards, in order (the drop is the LAST thing that happens):
3695
+
3696
+ 1. Column-presence — idempotent skip-as-applied when ``search_aux`` is
3697
+ already gone. A fresh install never carries it (``_apply_cache_schema``
3698
+ no longer emits it post-#217), so the dispatcher stamps 016 without the
3699
+ column ever existing; an existing install drops it once.
3700
+ 2. ``sqlite_version() >= 3.35`` — ``ALTER TABLE … DROP COLUMN`` did not
3701
+ exist before SQLite 3.35. On an older build we SKIP-as-applied, leaving
3702
+ the harmless dead column (never a hard fail; cache.db is re-derivable,
3703
+ and a later open on a newer SQLite still can't re-run a stamped
3704
+ migration — the column simply persists, which is benign).
3705
+ 3. Search-split-consumed gate (Codex P1) — DEFER via ``MigrationGateNotMet``
3706
+ (retried next open) when migration 010's
3707
+ ``conversation_search_split_pending`` flag is set OR the legacy
3708
+ ``conversation_fts_aux`` table / its triggers still exist. ``DROP COLUMN``
3709
+ FAILS while a trigger references ``search_aux`` (confirmed in-memory), so
3710
+ 016 must wait for ``_consume_search_split`` (sync_cache) to backfill the
3711
+ split columns + swap the FTS shape. 016 does NOT tear the aux table down
3712
+ itself — 010's state machine owns that. On a ``--no-sync``-forever DB the
3713
+ split never consumes and the column persists harmlessly (re-derivable).
3714
+
3715
+ The handler does its own DDL only; the dispatcher central-stamps the marker
3716
+ (#140) on a clean return (the apply path) — it does NOT stamp on the
3717
+ ``MigrationGateNotMet`` defer path, so the migration stays pending and retries.
3718
+ """
3719
+ if not _conversation_messages_has_column(conn, "search_aux"):
3720
+ # Guard 1: already absent (fresh install, or a prior 016 run). The
3721
+ # dispatcher central-stamps on this clean return.
3722
+ return
3723
+ sqlite_version = tuple(
3724
+ int(p) for p in sqlite3.sqlite_version.split(".")[:3]
3725
+ )
3726
+ if sqlite_version < (3, 35, 0):
3727
+ # Guard 2: no DROP COLUMN on this build. Skip-as-applied (stamp on the
3728
+ # clean return) — the dead column stays, harmless.
3729
+ return
3730
+ try:
3731
+ split_pending = conn.execute(
3732
+ "SELECT 1 FROM cache_meta "
3733
+ "WHERE key='conversation_search_split_pending'"
3734
+ ).fetchone() is not None
3735
+ except sqlite3.OperationalError:
3736
+ # No cache_meta table (a bare / path-less connection) -> no pending
3737
+ # flag to honor; the legacy-aux probe below still guards the topology.
3738
+ split_pending = False
3739
+ if split_pending or _legacy_aux_fts_present(conn):
3740
+ # Guard 3: the search split is not yet consumed — the legacy aux FTS
3741
+ # may still reference search_aux, so DROP COLUMN would fail. DEFER; the
3742
+ # next sync's _consume_search_split clears the flag + drops the aux
3743
+ # shape, and a later open then drops the column.
3744
+ raise MigrationGateNotMet(
3745
+ "search_aux drop deferred: migration-010 search split not yet "
3746
+ "consumed (pending flag or legacy conversation_fts_aux still present)"
3747
+ )
3748
+ conn.execute(
3749
+ "ALTER TABLE conversation_messages DROP COLUMN search_aux")
3750
+ conn.commit()
3751
+
3752
+
3753
+ @cache_migration("017_arm_nested_agent_reingest")
3754
+ def _017_arm_nested_agent_reingest(conn: sqlite3.Connection) -> None:
3755
+ """Flag-only re-ingest so existing nested-subagent (grandchild) results whose
3756
+ ``agentId:`` trailer landed PAST the 16 KB ``_TOOL_RESULT_CAP`` clip re-link
3757
+ on existing history (#217 S1 / U6). The parser now stamps a structured
3758
+ ``block["agent_id"]`` (+ usage) at INGEST — over the FULL raw, before the clip
3759
+ — so the offset-0 re-parse re-derives the link with zero new consumption code
3760
+ (the kernel's existing ``b.pop("agent_id")`` consumer picks it up). Until
3761
+ consumed, old rows fall back to the read-time regex over the (clipped) text —
3762
+ today's behavior, no worse.
3763
+
3764
+ Sets the DISTINCT ``conversation_reingest_nested_agent_pending`` flag (NOT the
3765
+ shared ``conversation_reingest_pending``, which also gates migration 005's
3766
+ read-time human-fallback in the query kernel — re-arming it could misclassify
3767
+ a genuine human prompt during the pre-reingest window). Consumption rides the
3768
+ #179 RESUMABLE per-file reingest (``_resumable_reingest_conversation_messages``)
3769
+ — the flag is wired into ``_TARGETED_DECLINE_FLAGS`` + ``_REINGEST_FLAG_KEYS``
3770
+ + the resumable-reingest flag SELECT + the two cleanup DELETE lists in
3771
+ ``_cctally_cache.py`` (all five sites; missing one either never triggers or
3772
+ re-arms forever). Central stamp via the dispatcher (#140); a fresh install
3773
+ stamps it WITHOUT running (empty table -> the flag, if ever set, is a harmless
3774
+ no-op). Mirrors 014/009/007."""
3775
+ _set_cache_meta(conn, "conversation_reingest_nested_agent_pending", "1")
3776
+ conn.commit()
3777
+
3778
+
3779
+ @cache_migration("018_create_conversation_title_fts")
3780
+ def _018_create_conversation_title_fts(conn: sqlite3.Connection) -> None:
3781
+ """#217 S2 / E7: arm the external-content title FTS over
3782
+ ``conversation_ai_titles`` so AI titles are findable via ``kind=title``.
3783
+
3784
+ Flag-only arm. The ``conversation_title_fts`` virtual table + its
3785
+ conv_title_fts_ai/ad/au sync triggers are created by ``_apply_cache_schema``
3786
+ (runs on every open, fresh + existing installs) inside the SAME FTS5-available
3787
+ envelope as the message FTS (P1-6) — so on a no-FTS5 build the table+triggers
3788
+ are simply absent and a title upsert never rolls back the ingest. This handler
3789
+ does NO DDL (mirrors 012's flag-only pattern): it just arms the DISTINCT
3790
+ ``conversation_title_fts_backfill_pending`` flag (the "distinct reingest flag
3791
+ per enrichment" rule — its own flag, never the shared one) so the next
3792
+ flock-held full sync runs ``_consume_title_fts`` (an FTS5 ``'rebuild'``, P1-7)
3793
+ to populate the index from existing history.
3794
+
3795
+ The flag joins ``_TARGETED_DECLINE_FLAGS`` ONLY — NEVER ``_REINGEST_FLAG_KEYS``
3796
+ (P1-2): that set means "run ``_resumable_reingest_conversation_messages``" (a
3797
+ full message delete/reinsert + rowid churn) which a title-FTS backfill must
3798
+ not trigger; the title index is external-content over conversation_ai_titles
3799
+ and a ``'rebuild'`` repopulates it without touching conversation_messages.
3800
+
3801
+ No data work here -> the dispatcher's central stamp (#140) marks a complete
3802
+ handler; a fresh install stamps WITHOUT a populated history (its incremental
3803
+ walk fills the title FTS via the AI trigger as titles ingest, and the consumed
3804
+ backfill 'rebuild' no-ops). Mirrors 012's flag-only arm."""
3805
+ _set_cache_meta(conn, "conversation_title_fts_backfill_pending", "1")
3806
+ conn.commit()
3807
+
3808
+
3809
+ @cache_migration("019_create_conversation_file_touches")
3810
+ def _019_create_conversation_file_touches(conn: sqlite3.Connection) -> None:
3811
+ """#217 S2 / I-3: arm the file-path search axis backfill so existing history's
3812
+ WRITE-class file touches (Edit/MultiEdit/Write/NotebookEdit) are searchable via
3813
+ ``kind=files``.
3814
+
3815
+ Flag-only arm (mirrors 018's pattern). The ``conversation_file_touches`` table
3816
+ + its ``COLLATE NOCASE`` path index (``idx_file_touches_path``) are created by
3817
+ ``_apply_cache_schema`` (runs on every open, fresh + existing installs) — and
3818
+ CRITICALLY before the FTS5 ``legacy_present`` early-return, since the table is
3819
+ plain and has NO dependency on the FTS shape (so a legacy-shape upgrade still
3820
+ gets it). The NOCASE collation is what lets the kind=files PREFIX search ride
3821
+ the index (default LIKE is case-insensitive; a BINARY index can't serve it —
3822
+ review Important #1). This handler does NO DDL: it just
3823
+ arms the DISTINCT ``conversation_reingest_file_touches_pending`` flag (the
3824
+ "distinct reingest flag per enrichment" rule — its own flag, never the shared
3825
+ one) so the next flock-held full sync runs ``_consume_file_touches`` to derive
3826
+ touches from existing ``blocks_json`` history.
3827
+
3828
+ The flag joins ``_TARGETED_DECLINE_FLAGS`` ONLY — NEVER ``_REINGEST_FLAG_KEYS``
3829
+ (P1-2): that set means "run ``_resumable_reingest_conversation_messages``" (a
3830
+ full message delete/reinsert + rowid churn) which a file-touches backfill must
3831
+ not trigger; the backfill derives from the already-present ``blocks_json`` and
3832
+ INSERT-OR-IGNOREs into a separate table without touching conversation_messages.
3833
+
3834
+ No data work here -> the dispatcher's central stamp (#140) marks a complete
3835
+ handler; a fresh install stamps WITHOUT a populated history (its incremental
3836
+ walk fills touches per ingested tick via _fill_file_touches, and the consumed
3837
+ backfill no-ops). Mirrors 018's flag-only arm."""
3838
+ _set_cache_meta(conn, "conversation_reingest_file_touches_pending", "1")
3839
+ conn.commit()
3840
+
3841
+
3505
3842
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
3506
3843
 
3507
3844
  @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