cctally 1.76.0 → 1.77.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.
@@ -60,8 +60,10 @@ def _init_paths_from_env() -> None:
60
60
  `import _cctally_core`).
61
61
  """
62
62
  global APP_DIR, LEGACY_APP_DIR, LOG_DIR, DEV_MODE
63
- global DB_PATH, CACHE_DB_PATH
63
+ global DB_PATH, CACHE_DB_PATH, CONVERSATIONS_DB_PATH
64
64
  global CACHE_LOCK_PATH, CACHE_LOCK_CODEX_PATH, CACHE_LOCK_MAINTENANCE_PATH
65
+ global CONVERSATIONS_LOCK_PATH, CONVERSATIONS_LOCK_CODEX_PATH
66
+ global CONVERSATIONS_LOCK_MAINTENANCE_PATH
65
67
  global CONFIG_LOCK_PATH
66
68
  global CONFIG_PATH, MIGRATION_ERROR_LOG_PATH, CHANGELOG_PATH
67
69
  global HOOK_TICK_LOG_DIR, HOOK_TICK_LOG_PATH, HOOK_TICK_LOG_ROTATED_PATH
@@ -101,6 +103,7 @@ def _init_paths_from_env() -> None:
101
103
 
102
104
  DB_PATH = APP_DIR / "stats.db"
103
105
  CACHE_DB_PATH = APP_DIR / "cache.db"
106
+ CONVERSATIONS_DB_PATH = APP_DIR / "conversations.db"
104
107
 
105
108
  CACHE_LOCK_PATH = APP_DIR / "cache.db.lock"
106
109
  CACHE_LOCK_CODEX_PATH = APP_DIR / "cache.db.codex.lock"
@@ -108,6 +111,14 @@ def _init_paths_from_env() -> None:
108
111
  # retention prune across processes, held ABOVE the two provider flocks so a
109
112
  # rebuild/reingest cannot land between candidate selection and deletion.
110
113
  CACHE_LOCK_MAINTENANCE_PATH = APP_DIR / "cache.db.maintenance.lock"
114
+ # #320: transcript ingest and maintenance are physically independent from
115
+ # the latency-sensitive token/quota cache. Never reuse the cache.db flock
116
+ # namespace for conversations.db writes.
117
+ CONVERSATIONS_LOCK_PATH = APP_DIR / "conversations.db.lock"
118
+ CONVERSATIONS_LOCK_CODEX_PATH = APP_DIR / "conversations.db.codex.lock"
119
+ CONVERSATIONS_LOCK_MAINTENANCE_PATH = (
120
+ APP_DIR / "conversations.db.maintenance.lock"
121
+ )
111
122
  CONFIG_LOCK_PATH = APP_DIR / "config.json.lock"
112
123
 
113
124
  CONFIG_PATH = APP_DIR / "config.json"
@@ -359,7 +359,9 @@ import _lib_log
359
359
  from _cctally_config import save_config, _load_config_unlocked
360
360
  from _cctally_db import _render_migration_error_banner
361
361
  from _cctally_cache import (
362
- get_entries, iter_entries, iter_entries_with_id, open_cache_db, sync_cache,
362
+ get_entries, iter_entries, iter_entries_with_id, open_cache_db,
363
+ open_conversations_db, sync_cache, sync_claude_conversations,
364
+ sync_codex_conversations,
363
365
  _prune_orphaned_cache_entries,
364
366
  )
365
367
  from _lib_snapshot_cache import (
@@ -1175,8 +1177,7 @@ def _dashboard_sync_loop(
1175
1177
 
1176
1178
  def _dashboard_maybe_prune_retention() -> None:
1177
1179
  """#313 P3 (F7): throttled transcript retention prune driven from the
1178
- dashboard sync thread. Runs once per iteration INSIDE the measured cooldown
1179
- work (Task 6), so its cost paces the deadline. Opens a dedicated cache
1180
+ independent conversation sync thread. Opens a dedicated transcript
1180
1181
  connection (no provider flock, no open transaction) for the orchestrator.
1181
1182
  No-op when retention is disabled; never raises (a prune failure must not
1182
1183
  crash the sync thread)."""
@@ -1187,7 +1188,7 @@ def _dashboard_maybe_prune_retention() -> None:
1187
1188
  retention_days = resolve_retention_days(c.load_config())
1188
1189
  if retention_days <= 0:
1189
1190
  return
1190
- conn = c.open_cache_db()
1191
+ conn = c.open_conversations_db(attach_cache=False)
1191
1192
  try:
1192
1193
  retention._maybe_prune_conversation_retention(
1193
1194
  conn,
@@ -4100,10 +4101,6 @@ def _qs_str(q: dict, key: str, default: str | None) -> str | None:
4100
4101
  _DEBUG_CACHE_TABLES = (
4101
4102
  "session_entries",
4102
4103
  "session_files",
4103
- "conversation_messages",
4104
- "conversation_sessions",
4105
- "conversation_ai_titles",
4106
- "conversation_file_touches",
4107
4104
  "codex_session_entries",
4108
4105
  "codex_session_files",
4109
4106
  )
@@ -6769,11 +6766,6 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
6769
6766
  and _time.monotonic() - last_heal[0] >= 60.0):
6770
6767
  last_heal[0] = _time.monotonic()
6771
6768
  _dashboard_self_heal_orphans(skip_sync=self._skip_sync)
6772
- # #313 P3 (F7): throttled transcript retention prune, inside the
6773
- # measured iteration so its cost is in the cooldown work. Gated
6774
- # off under --no-sync (frozen mode makes no cache writes).
6775
- if not self._skip_sync:
6776
- _dashboard_maybe_prune_retention()
6777
6769
 
6778
6770
  # Work-proportional cooldown (F10): sleep to t0 + max(interval, work)
6779
6771
  # so a slow rebuild cannot peg a full core. The manual POST /api/sync
@@ -6797,6 +6789,47 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
6797
6789
  if sync_thread is not None:
6798
6790
  sync_thread.start()
6799
6791
 
6792
+ # #320: transcript/search ingestion runs on its own thread and SQLite file.
6793
+ # A multi-GB first rebuild or a contended conversations.db therefore cannot
6794
+ # delay `_run_sync_now`, its `last_sync_at` stamp, or core SSE publication.
6795
+ conversation_sync_stop = threading.Event()
6796
+
6797
+ def _conversation_sync_loop() -> None:
6798
+ interval = max(5.0, float(args.sync_interval))
6799
+ while not conversation_sync_stop.is_set():
6800
+ conn = None
6801
+ try:
6802
+ conn = open_conversations_db()
6803
+ sync_claude_conversations(conn)
6804
+ sync_codex_conversations(conn)
6805
+ _dashboard_maybe_prune_retention()
6806
+ except (OSError, sqlite3.DatabaseError) as exc:
6807
+ eprint(f"[conversations] background sync unavailable: {exc}")
6808
+ except Exception as exc: # noqa: BLE001
6809
+ # Transcript parsing/normalization is deliberately outside the
6810
+ # core freshness loop. Keep this worker alive so a later clean
6811
+ # tick can self-heal instead of permanently stopping after one
6812
+ # malformed provider record.
6813
+ eprint(
6814
+ "[conversations] background sync failed: "
6815
+ f"{type(exc).__name__}: {exc}"
6816
+ )
6817
+ finally:
6818
+ if conn is not None:
6819
+ conn.close()
6820
+ conversation_sync_stop.wait(interval)
6821
+
6822
+ conversation_sync_thread = (
6823
+ None if args.no_sync
6824
+ else threading.Thread(
6825
+ target=_conversation_sync_loop,
6826
+ daemon=True,
6827
+ name="dashboard-conversations-sync",
6828
+ )
6829
+ )
6830
+ if conversation_sync_thread is not None:
6831
+ conversation_sync_thread.start()
6832
+
6800
6833
  # Spec §3.5 (codex review fix #5): update-check thread runs even
6801
6834
  # under --no-sync (frozen-data sessions still surface new versions).
6802
6835
  # Dedicated stop event so we can join cleanly on shutdown without
@@ -6879,6 +6912,9 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
6879
6912
  finally:
6880
6913
  if sync_thread is not None:
6881
6914
  sync_thread.stop()
6915
+ conversation_sync_stop.set()
6916
+ if conversation_sync_thread is not None:
6917
+ conversation_sync_thread.join(timeout=2)
6882
6918
  update_check_stop.set()
6883
6919
  srv.shutdown()
6884
6920
  http_thread.join(timeout=2)
@@ -20,9 +20,9 @@ What lives here (spec §6):
20
20
  (``_require_transcripts_allowed`` / ``_transcript_gate`` / …) STAY on the
21
21
  class and are reached via the ``handler`` parameter (spec §6).
22
22
 
23
- Late-binding (spec §6): the two bare ``open_cache_db`` calls (in
23
+ Late-binding (spec §6): the conversation DB opener calls (in
24
24
  ``_run_conversation_query_impl`` + the events handler) reach
25
- ``sys.modules["_cctally_dashboard"].open_cache_db(...)`` — patched on the
25
+ ``sys.modules["_cctally_dashboard"].open_conversations_db(...)`` — patched on the
26
26
  dashboard module object at ``tests/test_conversation_endpoints.py:674``.
27
27
 
28
28
  Cross-module reaches (spec §2.1 "fully-qualify cross-module refs"): the
@@ -44,7 +44,13 @@ import socket
44
44
  import sqlite3
45
45
  import sys
46
46
 
47
- from _cctally_cache import sync_cache, sync_codex_cache, _codex_provider_roots
47
+ from _cctally_cache import (
48
+ open_cache_db,
49
+ sync_codex_cache,
50
+ sync_claude_conversations,
51
+ sync_codex_conversations,
52
+ _codex_provider_roots,
53
+ )
48
54
 
49
55
  # Live-tail watch-loop tuning — used ONLY by _handle_get_conversation_events_impl
50
56
  # below, so moved here with the events handler (spec §4.1 / §6).
@@ -91,7 +97,7 @@ class _BadConversationFilter(Exception):
91
97
 
92
98
 
93
99
  def _cached_file_sigs(conn, paths):
94
- """{path: size_bytes} from session_files for the given paths — the cache's
100
+ """{path: size_bytes} from conversation_source_files for the given paths — the transcript cache's
95
101
  own view of how far each file is ingested. Size-only by design, matching the
96
102
  watch kernel's size-only signature (`file_sig`) and sync_cache's size-only
97
103
  delta signal: mtime is NOT consulted, because a size-unchanged ingest does
@@ -105,7 +111,7 @@ def _cached_file_sigs(conn, paths):
105
111
  placeholders = ",".join("?" for _ in paths)
106
112
  try:
107
113
  rows = conn.execute(
108
- f"SELECT path, size_bytes FROM session_files "
114
+ f"SELECT path, size_bytes FROM conversation_source_files "
109
115
  f"WHERE path IN ({placeholders})", list(paths)).fetchall()
110
116
  except sqlite3.OperationalError:
111
117
  return out
@@ -115,7 +121,7 @@ def _cached_file_sigs(conn, paths):
115
121
 
116
122
 
117
123
  def _codex_cached_file_sigs(conn, paths):
118
- """{path: size_bytes} from ``codex_session_files`` for the given paths — the
124
+ """{path: size_bytes} from ``codex_conversation_source_files`` for the given paths — the
119
125
  Codex analogue of ``_cached_file_sigs`` (spec §5.2). Size-only, matching the
120
126
  watch kernel's size-only signature and ``sync_codex_cache``'s size-only
121
127
  delta. Paths with no row are absent → treated as changed. Baselines the
@@ -129,7 +135,7 @@ def _codex_cached_file_sigs(conn, paths):
129
135
  placeholders = ",".join("?" for _ in paths)
130
136
  try:
131
137
  rows = conn.execute(
132
- f"SELECT path, size_bytes FROM codex_session_files "
138
+ f"SELECT path, size_bytes FROM codex_conversation_source_files "
133
139
  f"WHERE path IN ({placeholders})", list(paths)).fetchall()
134
140
  except sqlite3.OperationalError:
135
141
  return out
@@ -144,7 +150,7 @@ def _codex_all_committed_sizes(conn):
144
150
  baseline. A plain SELECT (no lock), so it never widens the SSE lock scope."""
145
151
  try:
146
152
  rows = conn.execute(
147
- "SELECT path, size_bytes FROM codex_session_files").fetchall()
153
+ "SELECT path, size_bytes FROM codex_conversation_source_files").fetchall()
148
154
  except sqlite3.OperationalError:
149
155
  return {}
150
156
  return {p: size for (p, size) in rows}
@@ -162,7 +168,7 @@ def _codex_classified_paths(conn, paths):
162
168
  placeholders = ",".join("?" for _ in paths)
163
169
  try:
164
170
  rows = conn.execute(
165
- f"SELECT path FROM codex_session_files "
171
+ f"SELECT path FROM codex_conversation_source_files "
166
172
  f"WHERE path IN ({placeholders}) AND last_conversation_key IS NOT NULL",
167
173
  list(paths)).fetchall()
168
174
  except sqlite3.OperationalError:
@@ -444,12 +450,12 @@ def _run_conversation_query_impl(handler, kernel_call, log_label):
444
450
  has ALREADY been sent and the caller must just ``return``; ``ok=True``
445
451
  carries the kernel result (which may itself be ``None`` — the reader's
446
452
  404 sentinel — so the explicit flag, not ``body is None``, signals
447
- failure). An ``open_cache_db`` failure is a ``cache unavailable:`` 500;
453
+ failure). An ``open_conversations_db`` failure is a ``cache unavailable:`` 500;
448
454
  a kernel exception is logged as ``<log_label> failed: %r`` and returned
449
455
  as a ``{type}: {msg}`` 500 — byte-identical to the inlined handlers.
450
456
  """
451
457
  try:
452
- conn = sys.modules["_cctally_dashboard"].open_cache_db() # late-binding: patched at test_conversation_endpoints.py:674
458
+ conn = sys.modules["_cctally_dashboard"].open_conversations_db()
453
459
  except (sqlite3.DatabaseError, OSError) as exc:
454
460
  handler._respond_json(500, {"error": f"cache unavailable: {exc}"})
455
461
  return False, None
@@ -785,7 +791,7 @@ def _bare_conversation_events(handler, session_id: str) -> None:
785
791
  _send_sse_headers(handler)
786
792
  passive = bool(type(handler).no_sync)
787
793
  try:
788
- conn = sys.modules["_cctally_dashboard"].open_cache_db() # late-binding: patched at test_conversation_endpoints.py:674
794
+ conn = sys.modules["_cctally_dashboard"].open_conversations_db()
789
795
  except (sqlite3.DatabaseError, OSError):
790
796
  # Cache unavailable — degrade to keep-alive only; client backstop
791
797
  # tick still surfaces turns. (Headers already sent; can't 500.)
@@ -796,7 +802,7 @@ def _bare_conversation_events(handler, session_id: str) -> None:
796
802
  return cq.session_source_paths(conn, session_id) if conn else []
797
803
 
798
804
  def _ingest(changed):
799
- return sync_cache(conn, only_paths=set(changed))
805
+ return sync_claude_conversations(conn, only_paths=set(changed))
800
806
 
801
807
  try:
802
808
  _run_conversation_events_stream(
@@ -840,7 +846,24 @@ def _make_codex_discovery_step(handler, conn, conversation_key, cq_codex):
840
846
  if not to_ingest:
841
847
  return files, False
842
848
  try:
843
- sync_codex_cache(conn, only_paths=set(to_ingest))
849
+ # A newly discovered child has no compact thread row yet. Advance
850
+ # the independent core cursor first so transcript normalization can
851
+ # link the child through the read-only attached cache, then commit
852
+ # the transcript cursor. Neither lock is held across the other.
853
+ core = open_cache_db()
854
+ try:
855
+ core_stats = sync_codex_cache(
856
+ core, only_paths=set(to_ingest)
857
+ )
858
+ finally:
859
+ core.close()
860
+ if not core_stats.targeted_clean:
861
+ return files, False
862
+ transcript_stats = sync_codex_conversations(
863
+ conn, only_paths=set(to_ingest)
864
+ )
865
+ if not transcript_stats.targeted_clean:
866
+ return files, False
844
867
  except sqlite3.DatabaseError:
845
868
  return files, False
846
869
  # Reap the now-classified candidates (child → already widened; non-child
@@ -866,7 +889,7 @@ def _qualified_conversation_events(handler, key: str) -> None:
866
889
  targeted ingest + the directory-frontier child discovery."""
867
890
  disp = _conversation_dispatch_impl()
868
891
  try:
869
- conn = sys.modules["_cctally_dashboard"].open_cache_db() # late-binding: patched at test_conversation_endpoints.py:674
892
+ conn = sys.modules["_cctally_dashboard"].open_conversations_db()
870
893
  except (sqlite3.DatabaseError, OSError):
871
894
  conn = None
872
895
 
@@ -921,7 +944,7 @@ def _qualified_conversation_events(handler, key: str) -> None:
921
944
  return cq_codex.codex_conversation_source_paths(conn, key)
922
945
 
923
946
  def _ingest(changed):
924
- return sync_codex_cache(conn, only_paths=set(changed))
947
+ return sync_codex_conversations(conn, only_paths=set(changed))
925
948
 
926
949
  cached = lambda paths: _codex_cached_file_sigs(conn, paths)
927
950
  discovery = _make_codex_discovery_step(handler, conn, key, cq_codex)
@@ -932,7 +955,7 @@ def _qualified_conversation_events(handler, key: str) -> None:
932
955
  return cq.session_source_paths(conn, native)
933
956
 
934
957
  def _ingest(changed):
935
- return sync_cache(conn, only_paths=set(changed))
958
+ return sync_claude_conversations(conn, only_paths=set(changed))
936
959
 
937
960
  cached = lambda paths: _cached_file_sigs(conn, paths)
938
961
  discovery = None
@@ -733,22 +733,33 @@ def _codex_conversation_metadata(
733
733
 
734
734
  ``state_5.sqlite.threads.title`` is Codex's persisted user-facing task name.
735
735
  Conversation rollup titles are derived from prompt text and therefore must
736
- never be substituted for that name on the dashboard. Accounting remains
737
- authoritative for totals; conversation rollups only decorate projects.
736
+ never be substituted for that name on the dashboard. Project attribution
737
+ is derived from the compact thread ``cwd``/``git_json`` retained in
738
+ ``cache.db`` so non-conversation panels never open ``conversations.db``.
738
739
  """
739
740
  metadata: dict[tuple[str, str], dict[str, object]] = {}
740
741
  try:
741
- rows = tuple(cache_conn.execute(
742
+ core_rows = tuple(cache_conn.execute(
742
743
  "SELECT t.source_root_key, t.source_path, t.native_thread_id, "
743
744
  "(SELECT e.session_id FROM codex_session_entries AS e "
744
745
  " WHERE e.source_root_key=t.source_root_key AND e.source_path=t.source_path "
745
746
  " ORDER BY e.id LIMIT 1) AS accounting_session_id, "
746
- "r.project_key, r.project_label, r.started_utc "
747
+ "t.cwd, t.git_json, t.first_seen_utc, t.last_seen_utc "
747
748
  "FROM codex_conversation_threads AS t "
748
- "LEFT JOIN codex_conversation_rollups AS r "
749
- "ON r.conversation_key=t.conversation_key "
750
- "ORDER BY r.last_activity_utc DESC, t.conversation_key DESC"
749
+ "ORDER BY t.last_seen_utc DESC, t.conversation_key DESC"
751
750
  ))
751
+ from _cctally_cache import _codex_conversation_project_attribution
752
+ rows = tuple(
753
+ (
754
+ root_key, source_path, native_thread_id, accounting_session_id,
755
+ *_codex_conversation_project_attribution(root_key, cwd, git_json),
756
+ first_seen_at,
757
+ )
758
+ for (
759
+ root_key, source_path, native_thread_id, accounting_session_id,
760
+ cwd, git_json, first_seen_at, _last_seen_at,
761
+ ) in core_rows
762
+ )
752
763
  file_aliases = tuple(cache_conn.execute(
753
764
  "SELECT source_root_key, path, last_native_thread_id, last_session_id "
754
765
  "FROM codex_session_files "
@@ -2648,6 +2648,7 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2648
2648
  plan_type TEXT,
2649
2649
  individual_limit_json TEXT,
2650
2650
  reached_type TEXT,
2651
+ observed_model TEXT,
2651
2652
  UNIQUE(source, source_path, line_offset, logical_limit_key),
2652
2653
  CHECK(source != 'codex' OR source_root_key IS NOT NULL)
2653
2654
  );
@@ -2893,6 +2894,12 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2893
2894
  add_column_if_missing(conn, "conversation_sessions", "git_branch", "TEXT")
2894
2895
  add_column_if_missing(conn, "conversation_sessions", "models_json", "TEXT")
2895
2896
  add_column_if_missing(conn, "conversation_sessions", "title", "TEXT")
2897
+ # #320: quota pool identity cannot depend on transcript events after the
2898
+ # store split. Stamp the active model directly on each compact physical
2899
+ # quota observation. Existing caches receive the nullable column here; 028
2900
+ # backfills it from the still-present legacy event corpus before dropping
2901
+ # that corpus.
2902
+ add_column_if_missing(conn, "quota_window_snapshots", "observed_model", "TEXT")
2896
2903
  conn.execute(
2897
2904
  "CREATE INDEX IF NOT EXISTS idx_session_files_session_id "
2898
2905
  "ON session_files(session_id)"
@@ -3014,6 +3021,72 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
3014
3021
  conn.commit()
3015
3022
 
3016
3023
 
3024
+ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
3025
+ """Create the #320 transcript/search database schema.
3026
+
3027
+ The historical cache schema remains the migration-fixture source of truth.
3028
+ Reuse it here, then remove the compact accounting families so conversation
3029
+ queries can resolve those names through the read-only ``cache_db``
3030
+ attachment. Empty compatibility tables may still exist in cache.db for old
3031
+ migration handlers, but all live transcript rows belong here.
3032
+ """
3033
+ # The first open projects the historical monolithic schema into the new
3034
+ # store. Avoid repeating that create-then-drop work on every conversation
3035
+ # endpoint: it writes sqlite_schema and WAL pages even when no transcript
3036
+ # data changed. Future conversation schema revisions must bump this marker.
3037
+ try:
3038
+ current = conn.execute(
3039
+ "SELECT value FROM cache_meta "
3040
+ "WHERE key='conversation_schema_version'"
3041
+ ).fetchone()
3042
+ except sqlite3.OperationalError:
3043
+ current = None
3044
+ if current is not None and current[0] == "1":
3045
+ return
3046
+
3047
+ _apply_cache_schema(conn)
3048
+ conn.executescript(
3049
+ """
3050
+ DROP TABLE IF EXISTS session_entries;
3051
+ DROP TABLE IF EXISTS session_files;
3052
+ DROP TABLE IF EXISTS codex_session_entries;
3053
+ DROP TABLE IF EXISTS codex_session_files;
3054
+ DROP TABLE IF EXISTS quota_window_snapshots;
3055
+ DROP TABLE IF EXISTS codex_conversation_threads;
3056
+ DROP TABLE IF EXISTS codex_source_roots;
3057
+
3058
+ CREATE TABLE IF NOT EXISTS conversation_source_files (
3059
+ path TEXT PRIMARY KEY,
3060
+ size_bytes INTEGER NOT NULL,
3061
+ mtime_ns INTEGER NOT NULL,
3062
+ last_byte_offset INTEGER NOT NULL,
3063
+ last_ingested_at TEXT NOT NULL
3064
+ );
3065
+ CREATE TABLE IF NOT EXISTS codex_conversation_source_files (
3066
+ path TEXT PRIMARY KEY,
3067
+ size_bytes INTEGER NOT NULL,
3068
+ mtime_ns INTEGER NOT NULL,
3069
+ last_byte_offset INTEGER NOT NULL,
3070
+ last_ingested_at TEXT NOT NULL,
3071
+ source_root_key TEXT,
3072
+ last_session_id TEXT,
3073
+ last_model TEXT,
3074
+ last_total_tokens INTEGER,
3075
+ last_native_thread_id TEXT,
3076
+ last_root_thread_id TEXT,
3077
+ last_parent_thread_id TEXT,
3078
+ last_conversation_key TEXT,
3079
+ last_turn_id TEXT
3080
+ );
3081
+ """
3082
+ )
3083
+ conn.execute(
3084
+ "INSERT INTO cache_meta(key,value) VALUES "
3085
+ "('conversation_schema_version','1') "
3086
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value"
3087
+ )
3088
+
3089
+
3017
3090
  def _fts5_available(conn: sqlite3.Connection) -> bool:
3018
3091
  """True if this sqlite build can create an FTS5 table. Cheap probe on a
3019
3092
  temp table that is created then dropped. Hidden test seam: tests monkeypatch
@@ -4730,6 +4803,118 @@ def _027_codex_fork_preamble_rebuild(conn: sqlite3.Connection) -> None:
4730
4803
  lock_fh.close()
4731
4804
 
4732
4805
 
4806
+ @cache_migration("028_split_conversation_store")
4807
+ def _028_split_conversation_store(conn: sqlite3.Connection) -> None:
4808
+ """Move the re-derivable transcript corpus out of cache.db (#320).
4809
+
4810
+ The upgrade deliberately does not copy gigabytes of prose/events. It creates
4811
+ the independent current-schema store, arms provider-local byte-zero replay,
4812
+ then drops the legacy transcript tables from the hot cache. Compact Codex
4813
+ thread metadata stays in cache.db because accounting/project attribution
4814
+ joins it directly.
4815
+ """
4816
+ core = _cctally_core
4817
+ lock_paths = (
4818
+ core.CACHE_LOCK_MAINTENANCE_PATH,
4819
+ core.CACHE_LOCK_PATH,
4820
+ core.CACHE_LOCK_CODEX_PATH,
4821
+ core.CONVERSATIONS_LOCK_MAINTENANCE_PATH,
4822
+ core.CONVERSATIONS_LOCK_PATH,
4823
+ core.CONVERSATIONS_LOCK_CODEX_PATH,
4824
+ )
4825
+ held = []
4826
+ try:
4827
+ core.APP_DIR.mkdir(parents=True, exist_ok=True)
4828
+ for path in lock_paths:
4829
+ fh = open(path, "w")
4830
+ try:
4831
+ fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
4832
+ except BlockingIOError:
4833
+ fh.close()
4834
+ raise MigrationGateNotMet(
4835
+ "cache/conversation sync lock held; deferring cache 028 split"
4836
+ )
4837
+ held.append(fh)
4838
+
4839
+ conv = sqlite3.connect(core.CONVERSATIONS_DB_PATH)
4840
+ try:
4841
+ conv.execute("PRAGMA auto_vacuum=INCREMENTAL")
4842
+ conv.execute("PRAGMA journal_mode=WAL")
4843
+ _apply_conversations_schema(conv)
4844
+ _set_cache_meta(conv, "conversation_rebuild_claude_pending", "1")
4845
+ _set_cache_meta(conv, "conversation_rebuild_codex_pending", "1")
4846
+ conv.commit()
4847
+ finally:
4848
+ conv.close()
4849
+
4850
+ conn.execute("BEGIN IMMEDIATE")
4851
+ try:
4852
+ # Preserve exact model-scoped quota-pool identity before discarding
4853
+ # the legacy physical event corpus. The correlated lookup is the
4854
+ # same nearest-prior context rule the quota reader used pre-split.
4855
+ if conn.execute(
4856
+ "SELECT 1 FROM sqlite_master "
4857
+ "WHERE type='table' AND name='codex_conversation_events'"
4858
+ ).fetchone() is not None:
4859
+ conn.execute(
4860
+ "UPDATE quota_window_snapshots AS q SET observed_model=("
4861
+ " SELECT json_extract(e.payload_json, '$.payload.model')"
4862
+ " FROM codex_conversation_events AS e"
4863
+ " WHERE e.source_path=q.source_path"
4864
+ " AND e.line_offset<=q.line_offset"
4865
+ " AND e.record_type IN ('turn_context','session_meta')"
4866
+ " AND json_valid(e.payload_json)"
4867
+ " AND json_type(e.payload_json, '$.payload.model')='text'"
4868
+ " ORDER BY e.line_offset DESC LIMIT 1)"
4869
+ " WHERE q.source='codex' AND q.observed_model IS NULL"
4870
+ )
4871
+ for drop in (
4872
+ "DROP TRIGGER IF EXISTS conv_fts_ai",
4873
+ "DROP TRIGGER IF EXISTS conv_fts_ad",
4874
+ "DROP TRIGGER IF EXISTS conv_fts_au",
4875
+ "DROP TRIGGER IF EXISTS conv_fts_aux_ai",
4876
+ "DROP TRIGGER IF EXISTS conv_fts_aux_ad",
4877
+ "DROP TRIGGER IF EXISTS conv_fts_aux_au",
4878
+ "DROP TRIGGER IF EXISTS conv_title_fts_ai",
4879
+ "DROP TRIGGER IF EXISTS conv_title_fts_ad",
4880
+ "DROP TRIGGER IF EXISTS conv_title_fts_au",
4881
+ "DROP TRIGGER IF EXISTS codex_conv_fts_ai",
4882
+ "DROP TRIGGER IF EXISTS codex_conv_fts_ad",
4883
+ "DROP TRIGGER IF EXISTS codex_conv_fts_au",
4884
+ "DROP TABLE IF EXISTS conversation_fts_aux",
4885
+ "DROP TABLE IF EXISTS conversation_fts",
4886
+ "DROP TABLE IF EXISTS conversation_title_fts",
4887
+ "DROP TABLE IF EXISTS codex_conversation_fts",
4888
+ "DROP TABLE IF EXISTS conversation_file_touches",
4889
+ "DROP TABLE IF EXISTS conversation_sessions",
4890
+ "DROP TABLE IF EXISTS conversation_ai_titles",
4891
+ "DROP TABLE IF EXISTS conversation_messages",
4892
+ "DROP TABLE IF EXISTS codex_conversation_file_touches",
4893
+ "DROP TABLE IF EXISTS codex_conversation_rollups",
4894
+ "DROP TABLE IF EXISTS codex_conversation_messages",
4895
+ "DROP TABLE IF EXISTS codex_conversation_events",
4896
+ ):
4897
+ conn.execute(drop)
4898
+ conn.commit()
4899
+ except Exception:
4900
+ conn.rollback()
4901
+ raise
4902
+ try:
4903
+ if conn.execute("PRAGMA auto_vacuum").fetchone()[0] == 2:
4904
+ # Python 3.11/Linux may defer this zero-column PRAGMA until the
4905
+ # cursor is exhausted; fetchall drives it to completion.
4906
+ conn.execute("PRAGMA incremental_vacuum").fetchall()
4907
+ except sqlite3.DatabaseError:
4908
+ pass
4909
+ finally:
4910
+ for fh in reversed(held):
4911
+ try:
4912
+ fcntl.flock(fh, fcntl.LOCK_UN)
4913
+ except OSError:
4914
+ pass
4915
+ fh.close()
4916
+
4917
+
4733
4918
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
4734
4919
 
4735
4920
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -7006,7 +7191,12 @@ def _vacuum_one_db(path, label: str, provider_locked: bool) -> int:
7006
7191
  # Serialize against the retention prune and concurrent vacuums via the
7007
7192
  # dedicated maintenance flock (F13/F7), then the provider flocks for
7008
7193
  # cache.db. All non-blocking: fail promptly rather than hang.
7009
- maint_fh = open(core.CACHE_LOCK_MAINTENANCE_PATH, "w")
7194
+ conversation_store = path == core.CONVERSATIONS_DB_PATH
7195
+ maintenance_path = (
7196
+ core.CONVERSATIONS_LOCK_MAINTENANCE_PATH
7197
+ if conversation_store else core.CACHE_LOCK_MAINTENANCE_PATH
7198
+ )
7199
+ maint_fh = open(maintenance_path, "w")
7010
7200
  try:
7011
7201
  try:
7012
7202
  fcntl.flock(maint_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -7019,10 +7209,18 @@ def _vacuum_one_db(path, label: str, provider_locked: bool) -> int:
7019
7209
  held = []
7020
7210
  try:
7021
7211
  if provider_locked:
7022
- for lock_path, lname in (
7023
- (core.CACHE_LOCK_PATH, "claude"),
7024
- (core.CACHE_LOCK_CODEX_PATH, "codex"),
7025
- ):
7212
+ provider_locks = (
7213
+ (
7214
+ (core.CONVERSATIONS_LOCK_PATH, "claude conversations"),
7215
+ (core.CONVERSATIONS_LOCK_CODEX_PATH, "codex conversations"),
7216
+ )
7217
+ if conversation_store else
7218
+ (
7219
+ (core.CACHE_LOCK_PATH, "claude"),
7220
+ (core.CACHE_LOCK_CODEX_PATH, "codex"),
7221
+ )
7222
+ )
7223
+ for lock_path, lname in provider_locks:
7026
7224
  fh = open(lock_path, "w")
7027
7225
  try:
7028
7226
  fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -7063,6 +7261,10 @@ def cmd_db_vacuum(args: argparse.Namespace) -> int:
7063
7261
  targets = []
7064
7262
  if which in ("cache", "all"):
7065
7263
  targets.append((_cctally_core.CACHE_DB_PATH, "cache.db", True))
7264
+ if which in ("conversations", "all"):
7265
+ targets.append((
7266
+ _cctally_core.CONVERSATIONS_DB_PATH, "conversations.db", True,
7267
+ ))
7066
7268
  if which in ("stats", "all"):
7067
7269
  targets.append((_cctally_core.DB_PATH, "stats.db", False))
7068
7270
  overall = 0