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.
@@ -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
- "cache.db": ("session_entries", "conversation_messages"),
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")
@@ -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
 
@@ -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="SESSION_ID",
2654
- help="Claude sessionId to export")
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)")