cctally 1.71.0 → 1.73.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 +20 -0
- package/bin/_cctally_cache.py +501 -49
- package/bin/_cctally_core.py +20 -40
- package/bin/_cctally_dashboard_conversation.py +731 -94
- package/bin/_cctally_db.py +286 -1
- package/bin/_cctally_doctor.py +59 -0
- package/bin/_cctally_parser.py +17 -4
- package/bin/_cctally_record.py +162 -33
- package/bin/_cctally_refresh.py +58 -36
- package/bin/_cctally_statusline.py +811 -82
- package/bin/_cctally_transcript.py +223 -7
- package/bin/_lib_codex_conversation.py +509 -0
- package/bin/_lib_codex_conversation_export.py +124 -0
- package/bin/_lib_codex_conversation_query.py +1422 -0
- package/bin/_lib_codex_conversation_watch.py +323 -0
- package/bin/_lib_conversation_dispatch.py +809 -0
- package/bin/_lib_conversation_query.py +88 -0
- package/bin/_lib_conversation_retention.py +344 -0
- package/bin/_lib_doctor.py +62 -0
- package/bin/_lib_statusline_candidates.py +734 -0
- package/bin/cctally +31 -2
- package/package.json +8 -1
package/bin/_cctally_db.py
CHANGED
|
@@ -876,7 +876,16 @@ def _run_pending_migrations(
|
|
|
876
876
|
# absent must still be treated as non-fresh so a flag-only
|
|
877
877
|
# conversation migration's consumer actually runs (e.g. 011's
|
|
878
878
|
# command-args promotion) instead of being stamped without it.
|
|
879
|
-
|
|
879
|
+
# codex_conversation_events joins them (#294 S6): a Codex-bearing
|
|
880
|
+
# cache written by S1's fused ingest but missing schema_migrations
|
|
881
|
+
# would otherwise be classified fresh and stamp 025 WITHOUT replaying
|
|
882
|
+
# normalization, leaving the read kernels authoritative over an empty
|
|
883
|
+
# normalized corpus. Probing it forces 025's handler to run and derive
|
|
884
|
+
# the normalized rows from the retained events.
|
|
885
|
+
"cache.db": (
|
|
886
|
+
"session_entries", "conversation_messages",
|
|
887
|
+
"codex_conversation_events",
|
|
888
|
+
),
|
|
880
889
|
}.get(db_label, ())
|
|
881
890
|
for probe_table in probe_tables:
|
|
882
891
|
# _probe_table_nonempty centralizes the "is there data here?"
|
|
@@ -2621,6 +2630,81 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2621
2630
|
CREATE INDEX IF NOT EXISTS idx_codex_events_timestamp
|
|
2622
2631
|
ON codex_conversation_events(timestamp_utc);
|
|
2623
2632
|
|
|
2633
|
+
-- #294 S6: normalized Codex conversation storage. Like the S1 tables
|
|
2634
|
+
-- above, these live in the UNCONDITIONAL base-schema script — before the
|
|
2635
|
+
-- legacy-FTS topology checks below — so an existing cache gains them on
|
|
2636
|
+
-- every open. The independent Codex FTS layer
|
|
2637
|
+
-- (_apply_codex_conversation_fts) stands up codex_conversation_fts + its
|
|
2638
|
+
-- own triggers separately, BEFORE the Claude legacy-FTS early-return, so
|
|
2639
|
+
-- a legacy-shape Claude cache still gets the Codex search index.
|
|
2640
|
+
--
|
|
2641
|
+
-- codex_conversation_messages: one normalized row per NORMALIZED physical
|
|
2642
|
+
-- event (§3.1). ``id`` is a PLAIN rowid alias (deliberately NOT
|
|
2643
|
+
-- AUTOINCREMENT): migration 025 replays inserts in deterministic
|
|
2644
|
+
-- (source_path ASC, line_offset ASC) order, so a repeated run is
|
|
2645
|
+
-- byte-idempotent (no sqlite_sequence drift), while fresh ingest inserts
|
|
2646
|
+
-- in discovery order — fresh-vs-migrated equality is asserted
|
|
2647
|
+
-- SEMANTICALLY (row content modulo id).
|
|
2648
|
+
CREATE TABLE IF NOT EXISTS codex_conversation_messages (
|
|
2649
|
+
id INTEGER PRIMARY KEY,
|
|
2650
|
+
conversation_key TEXT NOT NULL,
|
|
2651
|
+
source_root_key TEXT NOT NULL,
|
|
2652
|
+
source_path TEXT NOT NULL,
|
|
2653
|
+
line_offset INTEGER NOT NULL,
|
|
2654
|
+
timestamp_utc TEXT,
|
|
2655
|
+
turn_id TEXT,
|
|
2656
|
+
call_id TEXT,
|
|
2657
|
+
kind TEXT NOT NULL,
|
|
2658
|
+
event_type TEXT,
|
|
2659
|
+
record_family TEXT NOT NULL,
|
|
2660
|
+
model TEXT,
|
|
2661
|
+
text TEXT,
|
|
2662
|
+
content_digest TEXT NOT NULL,
|
|
2663
|
+
content_len INTEGER NOT NULL CHECK(content_len >= 0),
|
|
2664
|
+
detail_json TEXT,
|
|
2665
|
+
search_tool TEXT,
|
|
2666
|
+
search_thinking TEXT,
|
|
2667
|
+
UNIQUE(source_path, line_offset)
|
|
2668
|
+
);
|
|
2669
|
+
CREATE INDEX IF NOT EXISTS idx_codex_conv_msgs_conversation
|
|
2670
|
+
ON codex_conversation_messages(conversation_key, timestamp_utc, id);
|
|
2671
|
+
CREATE INDEX IF NOT EXISTS idx_codex_conv_msgs_source
|
|
2672
|
+
ON codex_conversation_messages(source_path);
|
|
2673
|
+
|
|
2674
|
+
-- codex_conversation_rollups: browse-rail materialization (§3.2). A pure
|
|
2675
|
+
-- function of surviving codex_conversation_messages (+ thread metadata),
|
|
2676
|
+
-- recomputed-affected-or-deleted after every write/delete path. item_count
|
|
2677
|
+
-- counts rendered LOGICAL items (mirror-paired), not physical rows.
|
|
2678
|
+
CREATE TABLE IF NOT EXISTS codex_conversation_rollups (
|
|
2679
|
+
conversation_key TEXT NOT NULL PRIMARY KEY,
|
|
2680
|
+
source_root_key TEXT NOT NULL,
|
|
2681
|
+
parent_thread_id TEXT,
|
|
2682
|
+
item_count INTEGER NOT NULL DEFAULT 0,
|
|
2683
|
+
started_utc TEXT,
|
|
2684
|
+
last_activity_utc TEXT,
|
|
2685
|
+
project_key TEXT,
|
|
2686
|
+
project_label TEXT,
|
|
2687
|
+
models_json TEXT,
|
|
2688
|
+
title TEXT
|
|
2689
|
+
);
|
|
2690
|
+
CREATE INDEX IF NOT EXISTS idx_codex_conv_rollups_recent
|
|
2691
|
+
ON codex_conversation_rollups(last_activity_utc DESC, conversation_key DESC);
|
|
2692
|
+
|
|
2693
|
+
-- codex_conversation_file_touches: write-class axis for the `files`
|
|
2694
|
+
-- search kind + outline file stats (§3.3). source_path gives explicit
|
|
2695
|
+
-- lineage so the per-file delete/truncate/prune paths scope deletions
|
|
2696
|
+
-- exactly as they do for the other Codex families.
|
|
2697
|
+
CREATE TABLE IF NOT EXISTS codex_conversation_file_touches (
|
|
2698
|
+
message_id INTEGER NOT NULL,
|
|
2699
|
+
conversation_key TEXT NOT NULL,
|
|
2700
|
+
source_path TEXT NOT NULL,
|
|
2701
|
+
file_path TEXT NOT NULL,
|
|
2702
|
+
tool TEXT NOT NULL,
|
|
2703
|
+
UNIQUE(message_id, file_path, tool)
|
|
2704
|
+
);
|
|
2705
|
+
CREATE INDEX IF NOT EXISTS idx_codex_conv_touches_source
|
|
2706
|
+
ON codex_conversation_file_touches(source_path);
|
|
2707
|
+
|
|
2624
2708
|
CREATE TABLE IF NOT EXISTS cache_meta (
|
|
2625
2709
|
key TEXT PRIMARY KEY,
|
|
2626
2710
|
value TEXT
|
|
@@ -2662,6 +2746,10 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2662
2746
|
add_column_if_missing(conn, "codex_session_files", "last_root_thread_id", "TEXT")
|
|
2663
2747
|
add_column_if_missing(conn, "codex_session_files", "last_parent_thread_id", "TEXT")
|
|
2664
2748
|
add_column_if_missing(conn, "codex_session_files", "last_conversation_key", "TEXT")
|
|
2749
|
+
# #294 S6: the terminal sticky-turn seed for delta resumes, alongside the
|
|
2750
|
+
# existing last_model/thread facts. A batch that ends after a turn_context
|
|
2751
|
+
# and resumes with a response_item stamps the correct effective turn.
|
|
2752
|
+
add_column_if_missing(conn, "codex_session_files", "last_turn_id", "TEXT")
|
|
2665
2753
|
conn.execute(
|
|
2666
2754
|
"CREATE INDEX IF NOT EXISTS idx_codex_files_source_root "
|
|
2667
2755
|
"ON codex_session_files(source_root_key)"
|
|
@@ -2761,6 +2849,12 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2761
2849
|
"CREATE INDEX IF NOT EXISTS idx_session_files_session_id "
|
|
2762
2850
|
"ON session_files(session_id)"
|
|
2763
2851
|
)
|
|
2852
|
+
# #294 S6: the independent Codex conversation FTS lifecycle. Run BEFORE (and
|
|
2853
|
+
# independent of) the Claude FTS branch below — the Claude branch early-returns
|
|
2854
|
+
# on a legacy conversation_fts(text) shape, so the Codex index must be stood up
|
|
2855
|
+
# here or a legacy-shape cache would never gain Codex search. Never touches any
|
|
2856
|
+
# Claude FTS object (conv_fts_* / conv_title_fts_*).
|
|
2857
|
+
_apply_codex_conversation_fts(conn)
|
|
2764
2858
|
# FTS5 is optional in the sqlite build. Create the external-content index +
|
|
2765
2859
|
# sync triggers as separate executes wrapped in one try; on failure create
|
|
2766
2860
|
# NEITHER the table NOR the triggers (a trigger referencing a missing table
|
|
@@ -3188,6 +3282,142 @@ def clear_conversation_messages(conn: sqlite3.Connection) -> None:
|
|
|
3188
3282
|
_create_conversation_fts_triggers(conn)
|
|
3189
3283
|
|
|
3190
3284
|
|
|
3285
|
+
# ── #294 S6: independent Codex conversation FTS lifecycle (§3.4) ──────────────
|
|
3286
|
+
# The Codex normalized search index owns its OWN external-content FTS5 table +
|
|
3287
|
+
# AI/AD/AU trigger trio, entirely separate from the Claude conversation_fts /
|
|
3288
|
+
# conversation_title_fts objects. _apply_codex_conversation_fts runs from
|
|
3289
|
+
# _apply_cache_schema BEFORE the Claude legacy-FTS early-return, so a legacy-shape
|
|
3290
|
+
# Claude cache still gains it. The Codex-scoped `codex_fts_unavailable` cache_meta
|
|
3291
|
+
# marker is INDEPENDENT of the Claude `fts5_unavailable` marker. Column names
|
|
3292
|
+
# match codex_conversation_messages BY NAME (external-content FTS5 rule).
|
|
3293
|
+
_CODEX_CONV_FTS_DDL = (
|
|
3294
|
+
"CREATE VIRTUAL TABLE IF NOT EXISTS codex_conversation_fts USING fts5("
|
|
3295
|
+
"text, search_tool, search_thinking, "
|
|
3296
|
+
"content='codex_conversation_messages', content_rowid='id')"
|
|
3297
|
+
)
|
|
3298
|
+
# Trigger DDL lives in ONE place so the initial create and the storm-free
|
|
3299
|
+
# full-clear (which drops + recreates the trigger set) can never drift. AD/AU use
|
|
3300
|
+
# the external-content `'delete'` idiom; AU fires AFTER UPDATE OF the three
|
|
3301
|
+
# indexed columns.
|
|
3302
|
+
_CODEX_CONV_FTS_TRIGGER_DDL = (
|
|
3303
|
+
"CREATE TRIGGER IF NOT EXISTS codex_conv_fts_ai AFTER INSERT ON codex_conversation_messages "
|
|
3304
|
+
"BEGIN INSERT INTO codex_conversation_fts(rowid, text, search_tool, search_thinking) "
|
|
3305
|
+
"VALUES (new.id, new.text, new.search_tool, new.search_thinking); END",
|
|
3306
|
+
"CREATE TRIGGER IF NOT EXISTS codex_conv_fts_ad AFTER DELETE ON codex_conversation_messages "
|
|
3307
|
+
"BEGIN INSERT INTO codex_conversation_fts(codex_conversation_fts, rowid, text, search_tool, search_thinking) "
|
|
3308
|
+
"VALUES('delete', old.id, old.text, old.search_tool, old.search_thinking); END",
|
|
3309
|
+
"CREATE TRIGGER IF NOT EXISTS codex_conv_fts_au "
|
|
3310
|
+
"AFTER UPDATE OF text, search_tool, search_thinking ON codex_conversation_messages "
|
|
3311
|
+
"BEGIN INSERT INTO codex_conversation_fts(codex_conversation_fts, rowid, text, search_tool, search_thinking) "
|
|
3312
|
+
"VALUES('delete', old.id, old.text, old.search_tool, old.search_thinking); "
|
|
3313
|
+
"INSERT INTO codex_conversation_fts(rowid, text, search_tool, search_thinking) "
|
|
3314
|
+
"VALUES (new.id, new.text, new.search_tool, new.search_thinking); END",
|
|
3315
|
+
)
|
|
3316
|
+
_CODEX_CONV_FTS_TRIGGER_NAMES = ("codex_conv_fts_au", "codex_conv_fts_ad", "codex_conv_fts_ai")
|
|
3317
|
+
|
|
3318
|
+
|
|
3319
|
+
def _create_codex_conversation_fts_triggers(conn: sqlite3.Connection) -> None:
|
|
3320
|
+
"""Create the Codex FTS sync trigger set — idempotent (each ``IF NOT EXISTS``).
|
|
3321
|
+
Single source of truth, shared by ``_apply_codex_conversation_fts`` and the
|
|
3322
|
+
storm-free full-clear. The caller must have already created the vtable."""
|
|
3323
|
+
for stmt in _CODEX_CONV_FTS_TRIGGER_DDL:
|
|
3324
|
+
conn.execute(stmt)
|
|
3325
|
+
|
|
3326
|
+
|
|
3327
|
+
def _drop_codex_conversation_fts_triggers(conn: sqlite3.Connection) -> None:
|
|
3328
|
+
"""Drop the Codex FTS sync trigger set — idempotent (``IF EXISTS``). Swallows
|
|
3329
|
+
``OperationalError`` per statement so an absent set (FTS5-unavailable build) is
|
|
3330
|
+
tolerated. Dropping these on a no-FTS5 build is what keeps a normalized INSERT
|
|
3331
|
+
from firing a trigger against the missing vtable and rolling back the shared
|
|
3332
|
+
per-file ingest transaction. NEVER touches any Claude FTS trigger."""
|
|
3333
|
+
for name in _CODEX_CONV_FTS_TRIGGER_NAMES:
|
|
3334
|
+
try:
|
|
3335
|
+
conn.execute(f"DROP TRIGGER IF EXISTS {name}")
|
|
3336
|
+
except sqlite3.OperationalError:
|
|
3337
|
+
pass
|
|
3338
|
+
|
|
3339
|
+
|
|
3340
|
+
def _apply_codex_conversation_fts(conn: sqlite3.Connection) -> None:
|
|
3341
|
+
"""Stand up / recover / degrade the independent Codex conversation FTS (§3.4).
|
|
3342
|
+
|
|
3343
|
+
Four states: create (FTS5 available, no marker) → codex_conversation_fts +
|
|
3344
|
+
triggers; unavailable-at-creation → set ``codex_fts_unavailable`` + skip DDL;
|
|
3345
|
+
capable→unavailable reopen → drop ONLY the Codex triggers + set the marker;
|
|
3346
|
+
recovery (FTS5 available again, marker set) → drop/recreate the vtable + its
|
|
3347
|
+
triggers, ``rebuild`` from the base rows, clear the marker. Never touches any
|
|
3348
|
+
Claude FTS object."""
|
|
3349
|
+
if _fts5_available(conn):
|
|
3350
|
+
recovering = conn.execute(
|
|
3351
|
+
"SELECT 1 FROM cache_meta WHERE key='codex_fts_unavailable'"
|
|
3352
|
+
).fetchone() is not None
|
|
3353
|
+
try:
|
|
3354
|
+
if recovering:
|
|
3355
|
+
# A prior FTS5-unavailable run ingested normalized rows without a
|
|
3356
|
+
# live trigger (or a capable→unavailable→capable cycle left a stale
|
|
3357
|
+
# vtable). Drop + recreate the index and its triggers, then rebuild
|
|
3358
|
+
# from the base rows.
|
|
3359
|
+
_drop_codex_conversation_fts_triggers(conn)
|
|
3360
|
+
conn.execute("DROP TABLE IF EXISTS codex_conversation_fts")
|
|
3361
|
+
conn.execute(_CODEX_CONV_FTS_DDL)
|
|
3362
|
+
_create_codex_conversation_fts_triggers(conn)
|
|
3363
|
+
if recovering:
|
|
3364
|
+
conn.execute(
|
|
3365
|
+
"INSERT INTO codex_conversation_fts(codex_conversation_fts) VALUES('rebuild')")
|
|
3366
|
+
conn.execute("DELETE FROM cache_meta WHERE key='codex_fts_unavailable'")
|
|
3367
|
+
except sqlite3.OperationalError:
|
|
3368
|
+
# Partial create cleanup, then mark unavailable so search uses LIKE.
|
|
3369
|
+
_drop_codex_conversation_fts_triggers(conn)
|
|
3370
|
+
try:
|
|
3371
|
+
conn.execute("DROP TABLE IF EXISTS codex_conversation_fts")
|
|
3372
|
+
except sqlite3.OperationalError:
|
|
3373
|
+
pass
|
|
3374
|
+
_set_cache_meta(conn, "codex_fts_unavailable", "1")
|
|
3375
|
+
else:
|
|
3376
|
+
# FTS5 unavailable on THIS build. If a prior capable run left the Codex
|
|
3377
|
+
# triggers, they now reference an unusable vtable and every normalized
|
|
3378
|
+
# INSERT would fail and roll back the shared per-file transaction — so
|
|
3379
|
+
# drop ONLY the Codex triggers (the vtable can't be dropped without fts5,
|
|
3380
|
+
# but with no triggers nothing writes to it) and mark unavailable.
|
|
3381
|
+
_drop_codex_conversation_fts_triggers(conn)
|
|
3382
|
+
_set_cache_meta(conn, "codex_fts_unavailable", "1")
|
|
3383
|
+
|
|
3384
|
+
|
|
3385
|
+
def _codex_conversation_fts_full_clear(conn: sqlite3.Connection) -> None:
|
|
3386
|
+
"""Storm-free FULL clear of every Codex normalized derived table (§3.4).
|
|
3387
|
+
|
|
3388
|
+
Drops the Codex FTS triggers, truncates codex_conversation_messages (the
|
|
3389
|
+
no-trigger fast path), resets the external-content index via ``'delete-all'``,
|
|
3390
|
+
recreates the triggers, then clears file-touches + rollups. ``'delete-all'`` is
|
|
3391
|
+
valid here precisely because the WHOLE normalized corpus empties; the sequence
|
|
3392
|
+
makes repeated runs byte-idempotent at the FTS shadow-table level (migration
|
|
3393
|
+
025 re-run, cache-rebuild). Partial deletes (per-file truncation, root-set
|
|
3394
|
+
orphan prune) MUST NOT use this — they ride the per-row delete triggers so
|
|
3395
|
+
surviving conversations keep their postings. Falls back to a plain base DELETE
|
|
3396
|
+
when FTS5 is unavailable (no triggers, no usable vtable)."""
|
|
3397
|
+
try:
|
|
3398
|
+
fts_unavailable = conn.execute(
|
|
3399
|
+
"SELECT 1 FROM cache_meta WHERE key='codex_fts_unavailable'"
|
|
3400
|
+
).fetchone() is not None
|
|
3401
|
+
except sqlite3.OperationalError:
|
|
3402
|
+
fts_unavailable = True
|
|
3403
|
+
if fts_unavailable:
|
|
3404
|
+
conn.execute("DELETE FROM codex_conversation_messages")
|
|
3405
|
+
else:
|
|
3406
|
+
_drop_codex_conversation_fts_triggers(conn)
|
|
3407
|
+
conn.execute("DELETE FROM codex_conversation_messages")
|
|
3408
|
+
conn.execute(
|
|
3409
|
+
"INSERT INTO codex_conversation_fts(codex_conversation_fts) VALUES('delete-all')")
|
|
3410
|
+
_create_codex_conversation_fts_triggers(conn)
|
|
3411
|
+
for stmt in (
|
|
3412
|
+
"DELETE FROM codex_conversation_file_touches",
|
|
3413
|
+
"DELETE FROM codex_conversation_rollups",
|
|
3414
|
+
):
|
|
3415
|
+
try:
|
|
3416
|
+
conn.execute(stmt)
|
|
3417
|
+
except sqlite3.OperationalError:
|
|
3418
|
+
pass # table not yet created (pre-schema conn); nothing to clear
|
|
3419
|
+
|
|
3420
|
+
|
|
3191
3421
|
def _eagerly_apply_cache_migrations() -> None:
|
|
3192
3422
|
"""Open cache.db so its pending migrations (notably
|
|
3193
3423
|
``001_dedup_highest_wins``) apply BEFORE stats migration 008's gate
|
|
@@ -4298,6 +4528,61 @@ def _024_codex_fused_ingest_rebuild(conn: sqlite3.Connection) -> None:
|
|
|
4298
4528
|
lock_fh.close()
|
|
4299
4529
|
|
|
4300
4530
|
|
|
4531
|
+
@cache_migration("025_codex_conversation_normalization")
|
|
4532
|
+
def _025_codex_conversation_normalization(conn: sqlite3.Connection) -> None:
|
|
4533
|
+
"""Derive S6 normalized conversation state for caches whose Codex events were
|
|
4534
|
+
ingested before S6 (#294 S6, §3.5).
|
|
4535
|
+
|
|
4536
|
+
Clears the three derived tables (codex_conversation_messages,
|
|
4537
|
+
codex_conversation_file_touches, codex_conversation_rollups) via the §3.4
|
|
4538
|
+
storm-free full-clear helper, then replays the pure normalization kernel over
|
|
4539
|
+
the stored ``codex_conversation_events`` per file in
|
|
4540
|
+
``(source_path ASC, line_offset ASC)`` order — rebuilding session_meta /
|
|
4541
|
+
turn_context sticky state as it goes. The physical event log and Codex
|
|
4542
|
+
accounting/thread/quota rows are the replay SOURCE and are never touched.
|
|
4543
|
+
|
|
4544
|
+
Takes ``cache.db.codex.lock`` with the same fcntl-then-``BEGIN IMMEDIATE``
|
|
4545
|
+
order as ``sync_codex_cache``, raising ``MigrationGateNotMet`` on contention
|
|
4546
|
+
(defer, never partially normalize) from both the ordinary and eager cache
|
|
4547
|
+
dispatch paths. Deterministic replay order + the non-AUTOINCREMENT ``id``
|
|
4548
|
+
rowid alias + the storm-free full-clear make a re-run (handler success, crash
|
|
4549
|
+
before the central stamp) byte-idempotent INCLUDING the FTS shadow tables.
|
|
4550
|
+
Never self-stamps; the dispatcher owns ``schema_migrations`` and
|
|
4551
|
+
``user_version``.
|
|
4552
|
+
"""
|
|
4553
|
+
lock_path = _cache_db_codex_lock_path_for_conn(conn)
|
|
4554
|
+
lock_fh = None
|
|
4555
|
+
if lock_path is not None:
|
|
4556
|
+
lock_fh = open(lock_path, "w")
|
|
4557
|
+
try:
|
|
4558
|
+
fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
4559
|
+
except BlockingIOError:
|
|
4560
|
+
lock_fh.close()
|
|
4561
|
+
raise MigrationGateNotMet(
|
|
4562
|
+
"cache.db.codex.lock held by a concurrent sync_codex_cache; "
|
|
4563
|
+
"deferring cache 025 conversation normalization (#294 S6)"
|
|
4564
|
+
)
|
|
4565
|
+
try:
|
|
4566
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
4567
|
+
try:
|
|
4568
|
+
# Full clear of only the DERIVED normalized tables (the event log
|
|
4569
|
+
# remains as the replay source), then re-derive from it.
|
|
4570
|
+
_codex_conversation_fts_full_clear(conn)
|
|
4571
|
+
import _cctally_cache
|
|
4572
|
+
_cctally_cache._replay_codex_normalization(conn)
|
|
4573
|
+
conn.commit()
|
|
4574
|
+
except Exception:
|
|
4575
|
+
conn.rollback()
|
|
4576
|
+
raise
|
|
4577
|
+
finally:
|
|
4578
|
+
if lock_fh is not None:
|
|
4579
|
+
try:
|
|
4580
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
4581
|
+
except OSError:
|
|
4582
|
+
pass
|
|
4583
|
+
lock_fh.close()
|
|
4584
|
+
|
|
4585
|
+
|
|
4301
4586
|
# === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
|
|
4302
4587
|
|
|
4303
4588
|
@stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
|
package/bin/_cctally_doctor.py
CHANGED
|
@@ -42,6 +42,54 @@ def _cctally():
|
|
|
42
42
|
return sys.modules["cctally"]
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
def _gather_statusline_pipeline(c, *, now_utc: dt.datetime) -> dict:
|
|
46
|
+
"""Read the #318 statusline pipeline without creating or pruning files."""
|
|
47
|
+
now_epoch = int(now_utc.timestamp())
|
|
48
|
+
result = {
|
|
49
|
+
"transport_age_seconds": None,
|
|
50
|
+
"selected_age_seconds": None,
|
|
51
|
+
"active_candidate_count": 0,
|
|
52
|
+
"control_db_agrees": None,
|
|
53
|
+
"tombstones": {"fiveHour": "absent", "sevenDay": "absent"},
|
|
54
|
+
}
|
|
55
|
+
try:
|
|
56
|
+
result["transport_age_seconds"] = c._statusline_transport_age_seconds()
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
try:
|
|
60
|
+
result["selected_age_seconds"] = c._statusline_observe_age_seconds()
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
try:
|
|
64
|
+
result["active_candidate_count"] = len(
|
|
65
|
+
c._scan_active_candidate_spool(now_epoch=now_epoch)
|
|
66
|
+
)
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
try:
|
|
70
|
+
result["control_db_agrees"] = c._statusline_control_db_agreement(
|
|
71
|
+
now_epoch=now_epoch
|
|
72
|
+
)
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
for axis, path in (
|
|
76
|
+
("fiveHour", _cctally_core.STATUSLINE_AUTHORITATIVE_5H_PATH),
|
|
77
|
+
("sevenDay", _cctally_core.STATUSLINE_AUTHORITATIVE_7D_PATH),
|
|
78
|
+
):
|
|
79
|
+
try:
|
|
80
|
+
if not path.exists():
|
|
81
|
+
continue
|
|
82
|
+
tombstone = c._read_tombstone(
|
|
83
|
+
axis, now_epoch=now_epoch, fail_closed=False
|
|
84
|
+
)
|
|
85
|
+
result["tombstones"][axis] = (
|
|
86
|
+
tombstone.state if tombstone is not None else "invalid"
|
|
87
|
+
)
|
|
88
|
+
except Exception:
|
|
89
|
+
result["tombstones"][axis] = "invalid"
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
45
93
|
def _codex_lifecycle_activity_24h(
|
|
46
94
|
*, root_keys: set[str], now_utc: dt.datetime,
|
|
47
95
|
) -> dict[str, dict]:
|
|
@@ -357,6 +405,16 @@ def doctor_gather_state(
|
|
|
357
405
|
except Exception:
|
|
358
406
|
pass
|
|
359
407
|
|
|
408
|
+
# ── Statusline candidate arbitration (#318) ──────────────────────
|
|
409
|
+
# This inspection is deliberately independent of SQLite mutation: marker
|
|
410
|
+
# mtime, candidate/control files, and tombstones are all read fail-soft.
|
|
411
|
+
# In particular it uses the scan-only candidate helper, never the reducer
|
|
412
|
+
# loader that prunes expired or malformed spool files.
|
|
413
|
+
try:
|
|
414
|
+
statusline_pipeline = _gather_statusline_pipeline(c, now_utc=now_utc)
|
|
415
|
+
except Exception:
|
|
416
|
+
statusline_pipeline = None
|
|
417
|
+
|
|
360
418
|
# Conversation-sessions rollup consistency (#217 S1 / U9). Two cheap COUNTs
|
|
361
419
|
# (graceful None on a missing table / unreadable DB) + an in-progress signal
|
|
362
420
|
# so a transient mid-sync mismatch never WARNs. The in-progress signal is a
|
|
@@ -787,6 +845,7 @@ def doctor_gather_state(
|
|
|
787
845
|
codex_lifecycle_activity_24h=codex_lifecycle_activity_24h,
|
|
788
846
|
# #311: precomputed statusLine.refreshInterval classification.
|
|
789
847
|
statusline_refresh_state=statusline_refresh_state,
|
|
848
|
+
statusline_pipeline=statusline_pipeline,
|
|
790
849
|
)
|
|
791
850
|
|
|
792
851
|
|
package/bin/_cctally_parser.py
CHANGED
|
@@ -2650,15 +2650,21 @@ def _build_transcript_parser(subparsers, name, *, help_text, xref=None):
|
|
|
2650
2650
|
t_export = t_sub.add_parser(
|
|
2651
2651
|
"export", help="Export a whole session as Markdown (anonymized by default)",
|
|
2652
2652
|
formatter_class=CLIHelpFormatter)
|
|
2653
|
-
t_export.add_argument("session_id", metavar="
|
|
2654
|
-
help="Claude sessionId
|
|
2653
|
+
t_export.add_argument("session_id", metavar="ID",
|
|
2654
|
+
help="A Claude sessionId or a v1. conversation key "
|
|
2655
|
+
"(Codex conversations use the v1. key)")
|
|
2655
2656
|
t_export.add_argument(
|
|
2656
2657
|
"--scope", choices=("all", "prompts", "chat", "recipe"), default="all",
|
|
2657
|
-
help="Which slice to export (default: all)")
|
|
2658
|
+
help="Which slice to export (default: all; Codex accepts only 'all')")
|
|
2658
2659
|
t_export.add_argument(
|
|
2659
2660
|
"--raw", action="store_true",
|
|
2660
2661
|
help="Disable the whole scrub (identity + secrets); byte-identical to "
|
|
2661
2662
|
"the dashboard raw export")
|
|
2663
|
+
t_export.add_argument(
|
|
2664
|
+
"--speed", choices=("auto", "standard", "fast"), default=None,
|
|
2665
|
+
help="Codex service tier for per-turn cost (default: auto). Applies only "
|
|
2666
|
+
"to Codex (v1.) conversations; an explicit value on any other ref "
|
|
2667
|
+
"is a usage error")
|
|
2662
2668
|
t_export.add_argument(
|
|
2663
2669
|
"-o", "--output", metavar="PATH", default=None,
|
|
2664
2670
|
help="Write to PATH instead of stdout (same exact bytes)")
|
|
@@ -2668,6 +2674,9 @@ def _build_transcript_parser(subparsers, name, *, help_text, xref=None):
|
|
|
2668
2674
|
"search", help="Search transcripts across sessions (raw output)",
|
|
2669
2675
|
formatter_class=CLIHelpFormatter)
|
|
2670
2676
|
t_search.add_argument("query", metavar="QUERY", help="Search text")
|
|
2677
|
+
t_search.add_argument(
|
|
2678
|
+
"--source", choices=("claude", "codex"), default="claude",
|
|
2679
|
+
help="Which provider's conversations to search (default: claude)")
|
|
2671
2680
|
t_search.add_argument(
|
|
2672
2681
|
"--kind",
|
|
2673
2682
|
choices=("all", "prompts", "assistant", "tools", "thinking",
|
|
@@ -2676,7 +2685,11 @@ def _build_transcript_parser(subparsers, name, *, help_text, xref=None):
|
|
|
2676
2685
|
t_search.add_argument("--limit", type=int, default=50,
|
|
2677
2686
|
help="Max results (default: 50)")
|
|
2678
2687
|
t_search.add_argument("--offset", type=int, default=0,
|
|
2679
|
-
help="Result offset for pagination (default: 0
|
|
2688
|
+
help="Result offset for pagination (default: 0; "
|
|
2689
|
+
"Claude only — Codex paginates with --cursor)")
|
|
2690
|
+
t_search.add_argument("--cursor", default=None, metavar="TOKEN",
|
|
2691
|
+
help="Codex pagination cursor from a prior nextCursor "
|
|
2692
|
+
"(requires --source codex)")
|
|
2680
2693
|
t_search.add_argument("--project", action="append", default=None,
|
|
2681
2694
|
metavar="LABEL",
|
|
2682
2695
|
help="Filter by project label (repeatable)")
|