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.
- package/CHANGELOG.md +47 -0
- package/bin/_cctally_cache.py +233 -17
- package/bin/_cctally_dashboard.py +169 -26
- package/bin/_cctally_db.py +348 -11
- package/bin/_cctally_doctor.py +69 -0
- package/bin/_cctally_record.py +36 -16
- package/bin/_lib_conversation.py +231 -12
- package/bin/_lib_conversation_export.py +325 -0
- package/bin/_lib_conversation_query.py +1191 -331
- package/bin/_lib_doctor.py +85 -0
- package/dashboard/static/assets/index-DXzPdFUE.js +78 -0
- package/dashboard/static/assets/index-Yau7oYp8.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-CqBt0IM7.js +0 -68
- package/dashboard/static/assets/index-klTHJA52.css +0 -1
package/bin/_cctally_db.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
2456
|
-
|
|
2457
|
-
#
|
|
2458
|
-
#
|
|
2459
|
-
#
|
|
2460
|
-
#
|
|
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
|
-
#
|
|
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")
|
package/bin/_cctally_doctor.py
CHANGED
|
@@ -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
|
|
package/bin/_cctally_record.py
CHANGED
|
@@ -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
|
-
#
|
|
2687
|
-
#
|
|
2688
|
-
#
|
|
2689
|
-
#
|
|
2690
|
-
#
|
|
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
|
|
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
|
|