thread-archive 0.0.2__py3-none-any.whl

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.
Files changed (132) hide show
  1. thread_archive/__init__.py +34 -0
  2. thread_archive/_api.py +2235 -0
  3. thread_archive/_config.py +126 -0
  4. thread_archive/_importers/__init__.py +81 -0
  5. thread_archive/_importers/_continuation.py +136 -0
  6. thread_archive/_importers/_cursor.py +103 -0
  7. thread_archive/_importers/_events.py +244 -0
  8. thread_archive/_importers/_line_stream.py +193 -0
  9. thread_archive/_importers/_read.py +82 -0
  10. thread_archive/_importers/_result.py +17 -0
  11. thread_archive/_importers/_sidecar.py +113 -0
  12. thread_archive/_importers/_state.py +240 -0
  13. thread_archive/_importers/_titles.py +179 -0
  14. thread_archive/_importers/antigravity.py +254 -0
  15. thread_archive/_importers/claude_code.py +293 -0
  16. thread_archive/_importers/claude_science.py +330 -0
  17. thread_archive/_importers/cloth.py +20 -0
  18. thread_archive/_importers/codex.py +324 -0
  19. thread_archive/_importers/cowork.py +107 -0
  20. thread_archive/_importers/cursor.py +432 -0
  21. thread_archive/_importers/exports.py +389 -0
  22. thread_archive/_importers/grok.py +600 -0
  23. thread_archive/_importers/opencode.py +538 -0
  24. thread_archive/_knowledge/__init__.py +67 -0
  25. thread_archive/_knowledge/_claims.py +116 -0
  26. thread_archive/_knowledge/_community.py +75 -0
  27. thread_archive/_knowledge/graph.py +208 -0
  28. thread_archive/_knowledge/materialize.py +212 -0
  29. thread_archive/_knowledge/write.py +438 -0
  30. thread_archive/_launchd.py +167 -0
  31. thread_archive/_mcp/__init__.py +1 -0
  32. thread_archive/_mcp/librarian.py +151 -0
  33. thread_archive/_mcp/server.py +307 -0
  34. thread_archive/_retrieval/__init__.py +280 -0
  35. thread_archive/_retrieval/_classify.py +80 -0
  36. thread_archive/_retrieval/_codex.py +157 -0
  37. thread_archive/_retrieval/_context.py +123 -0
  38. thread_archive/_retrieval/_extract.py +184 -0
  39. thread_archive/_retrieval/embed.py +186 -0
  40. thread_archive/_retrieval/format.py +149 -0
  41. thread_archive/_retrieval/fts.py +538 -0
  42. thread_archive/_retrieval/rank.py +228 -0
  43. thread_archive/_retrieval/read.py +908 -0
  44. thread_archive/_retrieval/rerank.py +143 -0
  45. thread_archive/_retrieval/vectors.py +611 -0
  46. thread_archive/_scripts/__init__.py +1 -0
  47. thread_archive/_scripts/backfill_recompute.py +328 -0
  48. thread_archive/_scripts/backfill_reconcile.py +296 -0
  49. thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
  50. thread_archive/_scripts/recover_dropped_events.py +190 -0
  51. thread_archive/_scripts/repair_grok_tool_names.py +118 -0
  52. thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
  53. thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
  54. thread_archive/_setup/__init__.py +12 -0
  55. thread_archive/_setup/clients.py +89 -0
  56. thread_archive/_setup/wizard.py +474 -0
  57. thread_archive/_store/__init__.py +45 -0
  58. thread_archive/_store/_base.py +173 -0
  59. thread_archive/_store/_defaults.py +57 -0
  60. thread_archive/_store/_types.py +38 -0
  61. thread_archive/_store/models.py +370 -0
  62. thread_archive/_store/schema.py +84 -0
  63. thread_archive/_thread_import/__init__.py +40 -0
  64. thread_archive/_thread_import/api.py +78 -0
  65. thread_archive/_thread_import/event_builder.py +810 -0
  66. thread_archive/_thread_import/exporters/__init__.py +9 -0
  67. thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
  68. thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
  69. thread_archive/_thread_import/exporters/cursor.py +582 -0
  70. thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
  71. thread_archive/_thread_import/parsers/__init__.py +191 -0
  72. thread_archive/_thread_import/parsers/base.py +698 -0
  73. thread_archive/_thread_import/parsers/chatgpt.py +722 -0
  74. thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
  75. thread_archive/_thread_import/parsers/claude.py +443 -0
  76. thread_archive/_thread_import/parsers/claude_code.py +1035 -0
  77. thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
  78. thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
  79. thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
  80. thread_archive/_thread_import/parsers/config/__init__.py +26 -0
  81. thread_archive/_thread_import/parsers/config/base.py +220 -0
  82. thread_archive/_thread_import/parsers/cursor.py +538 -0
  83. thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
  84. thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
  85. thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
  86. thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
  87. thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
  88. thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
  89. thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
  90. thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
  91. thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
  92. thread_archive/_thread_import/parsers/types/__init__.py +56 -0
  93. thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
  94. thread_archive/_thread_import/parsers/types/claude.py +120 -0
  95. thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
  96. thread_archive/_thread_import/parsers/types/cursor.py +109 -0
  97. thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
  98. thread_archive/_thread_import/parsers/validators/base.py +168 -0
  99. thread_archive/_thread_import/parsers/validators/content.py +80 -0
  100. thread_archive/_thread_import/parsers/validators/referential.py +57 -0
  101. thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
  102. thread_archive/_thread_import/parsers/validators/types.py +87 -0
  103. thread_archive/_thread_import/schemas/__init__.py +231 -0
  104. thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
  105. thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
  106. thread_archive/_thread_import/schemas/claude/v1.json +188 -0
  107. thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
  108. thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
  109. thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
  110. thread_archive/_thread_import/timestamps.py +57 -0
  111. thread_archive/_thread_import/tool_names.py +48 -0
  112. thread_archive/_truth/__init__.py +40 -0
  113. thread_archive/_truth/jsonl_log.py +2340 -0
  114. thread_archive/_truth/repair.py +322 -0
  115. thread_archive/_watcher/__init__.py +57 -0
  116. thread_archive/_watcher/base.py +65 -0
  117. thread_archive/_watcher/daemon.py +289 -0
  118. thread_archive/_watcher/export_drop.py +203 -0
  119. thread_archive/_watcher/exthost.py +316 -0
  120. thread_archive/_watcher/lazy.py +117 -0
  121. thread_archive/_watcher/sources.py +616 -0
  122. thread_archive/_web/__init__.py +15 -0
  123. thread_archive/_web/server.py +299 -0
  124. thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
  125. thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
  126. thread_archive/_web/static/index.html +13 -0
  127. thread_archive/cli.py +746 -0
  128. thread_archive-0.0.2.dist-info/METADATA +296 -0
  129. thread_archive-0.0.2.dist-info/RECORD +132 -0
  130. thread_archive-0.0.2.dist-info/WHEEL +4 -0
  131. thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
  132. thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
thread_archive/cli.py ADDED
@@ -0,0 +1,746 @@
1
+ """The ``archive`` command — a thin CLI over the :mod:`thread_archive._api` surface.
2
+
3
+ **Private operational tooling, not public API.** The package's public surface
4
+ is the retrieval MCP tools plus the truth format (see the package docstring);
5
+ this CLI is the process seam launchd, cron, and operators use to run the
6
+ private machinery — ingest (``import``, ``import-export``, ``watch``,
7
+ ``embed``), the durability kit (``backup``, ``verify``, ``restore-drill``,
8
+ ``reindex``, ``repair``, ``status``, ``nightly``), and the LaunchAgent
9
+ lifecycle (``daemon``). Verbs may change without
10
+ external notice, but they are *wired into* the LaunchAgent plists, lab's cron
11
+ script, the /ci skill, and the monitor's heartbeat contract — renaming one
12
+ means updating those in the same change (``tests/test_public_api.py`` pins the
13
+ set so the change is deliberate).
14
+
15
+ Retrieval deliberately has no CLI verbs: search and read are the public MCP
16
+ tools, and the web viewer is cohosted by the always-on watcher
17
+ (``archive watch --web``). One retrieval surface, not three.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import sys
24
+
25
+ from . import __version__
26
+ from ._config import resolve_paths
27
+
28
+
29
+ def _add_home_arg(p: argparse.ArgumentParser) -> None:
30
+ p.add_argument(
31
+ "--home",
32
+ default=None,
33
+ help="archive home dir (default: $THREAD_ARCHIVE_HOME or ~/.thread/archive)",
34
+ )
35
+
36
+
37
+ def _self_throttle() -> None:
38
+ """Drop this process to background priority so a full rebuild never starves the
39
+ interactive machine. CPU via ``nice``; on macOS also throttle disk I/O (the FTS
40
+ rebuild is I/O-bound) — the in-process equivalent of ``taskpolicy -d throttle``.
41
+ Best-effort: on an idle box it still runs full speed, and a failure to throttle
42
+ must never stop the work. Renice up if you want a rebuild to go flat-out."""
43
+ try:
44
+ import os
45
+
46
+ os.nice(10)
47
+ except OSError: # pragma: no cover — nice() can be restricted
48
+ pass
49
+ if sys.platform == "darwin":
50
+ try:
51
+ import ctypes
52
+
53
+ libc = ctypes.CDLL("libc.dylib", use_errno=True)
54
+ # setiopolicy_np(IOPOL_TYPE_DISK=0, IOPOL_SCOPE_PROCESS=1, IOPOL_THROTTLE=5)
55
+ libc.setiopolicy_np(0, 1, 5)
56
+ except Exception: # pragma: no cover — platform best-effort
57
+ pass
58
+
59
+
60
+ def cmd_import(args: argparse.Namespace) -> int:
61
+ from . import _api as api
62
+ from ._importers import DB_SCANNERS, LINE_STREAM_IMPORTERS
63
+
64
+ provider = args.provider or "claude-code"
65
+ if provider not in LINE_STREAM_IMPORTERS and provider not in DB_SCANNERS:
66
+ raise SystemExit(f"archive import: unknown provider '{provider}'")
67
+
68
+ result = api.import_path(args.path, home=args.home, provider=provider) # checkpoints internally
69
+
70
+ if provider in LINE_STREAM_IMPORTERS:
71
+ summary = (
72
+ f"thread={result.thread_id} events={result.events_created} new={result.is_new_thread}"
73
+ )
74
+ else: # DB scanner (cursor / opencode): scans many sessions in one file
75
+ summary = " ".join(f"{k}={v}" for k, v in vars(result).items())
76
+ print(f"imported {args.path} ({provider}): {summary}")
77
+ return 0
78
+
79
+
80
+ def cmd_import_export(args: argparse.Namespace) -> int:
81
+ from . import _api as api
82
+ from ._importers.exports import import_export
83
+ from ._truth import shared_ingest_lock
84
+
85
+ api.open_archive(args.home)
86
+ # Shared across the truth appends AND the SQLite commits (same coverage as
87
+ # api.import_path): an unlocked export import racing a reindex can land in the
88
+ # truth after the rebuild's read point and commit into the inode the swap
89
+ # replaces. Blocks (bounded by one rebuild) — a one-shot import has no retry.
90
+ with shared_ingest_lock():
91
+ result = import_export(args.path, force=args.force)
92
+ api.checkpoint(home=args.home) # snapshot the new threads' metadata to truth
93
+ print(
94
+ f"imported export {args.path}: processed={result.processed} "
95
+ f"imported={result.imported} skipped={result.skipped} events={result.events_created}"
96
+ )
97
+ return 0
98
+
99
+
100
+ def cmd_watch(args: argparse.Namespace) -> int:
101
+ import logging
102
+
103
+ from . import _api as api
104
+ from ._watcher import Watcher
105
+
106
+ # Configure logging so the daemon's import/maintenance activity actually lands
107
+ # in the log files (launchd routes stderr to watcher-stderr.log). Without this
108
+ # the long-running process is silent.
109
+ logging.basicConfig(
110
+ level=logging.INFO,
111
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
112
+ )
113
+
114
+ api.open_archive(args.home)
115
+ watcher = Watcher(
116
+ home=args.home,
117
+ interval=args.interval,
118
+ embed=args.embed,
119
+ embed_interval=args.embed_interval,
120
+ embed_batch=args.embed_batch,
121
+ )
122
+
123
+ if args.once:
124
+ from ._truth import shared_ingest_lock
125
+
126
+ # Same coverage as api.watch(once=True): the poll appends truth and
127
+ # commits, so it must hold the reindex lock shared — an unlocked
128
+ # one-shot racing a reindex can land truth after the rebuild's read
129
+ # point and commit into the inode the swap replaces. Blocking (bounded
130
+ # by one rebuild): a one-shot has no later pass to retry on.
131
+ with shared_ingest_lock():
132
+ result = watcher.poll_once()
133
+ if result.events_created > 0:
134
+ watcher.maintain() # one upkeep pass (rebalance/manifest) for the one-shot
135
+ print(
136
+ f"watch: checked {result.sources_checked} sources, "
137
+ f"imported {result.items_imported} items ({result.events_created} events)",
138
+ flush=True,
139
+ )
140
+ for err in result.errors:
141
+ print(f" ! {err}", flush=True)
142
+ return 0
143
+
144
+ # Optionally cohost the read viewer in this always-on process (one process, one
145
+ # engine) so there's a persistent URL — WAL lets the web reader run concurrent
146
+ # with the watcher's writes (see store._base).
147
+ httpd = None
148
+ if args.web:
149
+ from ._web import serve_in_thread
150
+
151
+ httpd = serve_in_thread(host=args.web_host, port=args.web_port)
152
+ logging.getLogger("thread_archive._watcher").info(
153
+ "cohosting web viewer on http://%s:%s", args.web_host, args.web_port
154
+ )
155
+
156
+ available = [w.source_name for w in watcher.available()]
157
+ logging.getLogger("thread_archive._watcher").info(
158
+ "watching %d sources %s every %ss (maintenance every %.0fs)",
159
+ len(available), available, args.interval, watcher.maintenance_interval,
160
+ )
161
+ try:
162
+ watcher.run()
163
+ except KeyboardInterrupt:
164
+ print("\nstopped.", flush=True)
165
+ finally:
166
+ if httpd is not None:
167
+ httpd.server_close()
168
+ return 0
169
+
170
+
171
+ def cmd_daemon(args: argparse.Namespace) -> int:
172
+ from . import _launchd
173
+
174
+ if args.action == "install":
175
+ plist = _launchd.install_watcher(args.home, web=args.web, web_port=args.web_port)
176
+ print(f"installed {_launchd.WATCHER_LABEL} ({plist})")
177
+ print("the watcher is always-on (RunAtLoad); `archive daemon status` to check,")
178
+ print("`archive daemon restart` to apply a code edit.")
179
+ if args.web:
180
+ print(f"web viewer: http://127.0.0.1:{args.web_port}")
181
+ elif args.action == "uninstall":
182
+ _launchd.uninstall_watcher()
183
+ print(f"uninstalled {_launchd.WATCHER_LABEL}")
184
+ elif args.action == "restart":
185
+ _launchd.restart_watcher()
186
+ print(f"restarted {_launchd.WATCHER_LABEL}")
187
+ else: # status
188
+ print(_launchd.watcher_status())
189
+ return 0
190
+
191
+
192
+ def cmd_reindex(args: argparse.Namespace) -> int:
193
+ from . import _api as api
194
+
195
+ _self_throttle() # a rebuild is background work — don't bog the interactive machine
196
+ paths = resolve_paths(args.home)
197
+ print(f"reindexing {paths.index_path} from {paths.truth_dir} (vectors={args.vectors}, throttled)")
198
+ try:
199
+ counts = api.reindex(home=args.home, vectors=args.vectors, salvage=args.salvage)
200
+ except RuntimeError as e:
201
+ # A refused publication (corrupt truth lines, failed quick_check, no disk
202
+ # room) — operator guidance, not a stack trace. The old index is intact.
203
+ print(f"reindex refused: {e}", file=sys.stderr)
204
+ return 1
205
+ for name, n in counts.items():
206
+ print(f" {name:16} {n:>9}")
207
+ print("done")
208
+ return 0
209
+
210
+
211
+ def cmd_embed(args: argparse.Namespace) -> int:
212
+ from . import _api as api
213
+
214
+ print("embedding missing vectors (rebuild=%s)..." % args.rebuild, flush=True)
215
+ res = api.embed(home=args.home, rebuild=args.rebuild, max_events=args.limit,
216
+ newest_first=args.newest_first)
217
+ print(f" embedded {res['embedded']}")
218
+ return 0
219
+
220
+
221
+ def cmd_backup(args: argparse.Namespace) -> int:
222
+ from . import _api as api
223
+
224
+ res = api.backup(
225
+ args.dest, home=args.home,
226
+ allow_shrink=args.allow_shrink, verify_first=args.verify,
227
+ )
228
+ mb = res["bytes_copied"] / (1024 * 1024)
229
+ print(f"backed up {res['truth_dir']} → {res['dest']}: {res['files_copied']} files ({mb:.1f} MB copied)")
230
+ if res.get("generation_created"):
231
+ print(
232
+ f"generation: pre-run state preserved as .generations/{res['generation_created']} "
233
+ f"({res.get('generations_kept', '?')} kept, {res.get('generations_pruned', 0)} pruned)"
234
+ )
235
+ if res.get("generation_error"):
236
+ print(f"WARNING: generation snapshot failed ({res['generation_error']}) — "
237
+ "this run had no pre-overwrite recovery margin")
238
+ if res.get("same_device"):
239
+ print(
240
+ "WARNING: the backup destination is on the SAME filesystem as the "
241
+ "archive — one disk failure takes both copies (and every generation). "
242
+ "Point it at a different disk/machine, or add an off-machine leg."
243
+ )
244
+ if not res["verify_ok"]:
245
+ print(
246
+ "WARNING: pre-backup verify FAILED — the source truth has integrity "
247
+ "problems; mirror ran additively (no deletions). Run `archive verify`."
248
+ )
249
+ if res.get("rehomed_twins_deleted"):
250
+ print(
251
+ f"rebalance twins: {res['rehomed_twins_deleted']} superseded old-layout "
252
+ "copies removed from the backup"
253
+ )
254
+ if res["deletions_skipped"]:
255
+ print(
256
+ f"WARNING: {res['deletions_skipped']} stale destination files kept — "
257
+ "planned deletions exceeded the safety bound (gutted source?); mirror ran additively"
258
+ )
259
+ if res["shrinks_skipped"]:
260
+ print(
261
+ f"SHRINK GUARD: {res['shrinks_skipped']} append-only truth files are SMALLER at "
262
+ f"the source than in the backup — the source lost data; their backup copies were "
263
+ f"kept. Sample: {res['shrink_sample']}. Investigate before rerunning; "
264
+ "--allow-shrink overrides after a deliberate truth re-emit."
265
+ )
266
+ if not res["mirror_complete"]:
267
+ print(
268
+ f"MIRROR INCOMPLETE: {res['dest_missing_files']} source files missing at dest, "
269
+ f"{res['dest_divergent_files']} divergent"
270
+ )
271
+ return 1
272
+ # Skipped deletions fail the run too: the condition is either a gutted source
273
+ # (page-worthy) or a persistently additive backup accumulating stale records —
274
+ # both need eyes, and the scheduled wrapper only notifies on a nonzero exit.
275
+ return 0 if res["verify_ok"] and not res["deletions_skipped"] else 1
276
+
277
+
278
+ def cmd_verify(args: argparse.Namespace) -> int:
279
+ from . import _api as api
280
+
281
+ res = api.verify(home=args.home, deep=args.deep, hashes=args.hashes, backup=args.backup)
282
+ t = res["truth"]
283
+ print(
284
+ f"truth: threads={t['threads']} events={t['events']} "
285
+ f"effective={t['events_effective']} "
286
+ f"superseded={t['duplicate_id_lines'] + t['duplicate_content_lines']} "
287
+ f"parse_errors={t['parse_errors']}"
288
+ )
289
+ if t["parse_errors"]:
290
+ print(
291
+ f" torn tails={t['parse_errors_torn_tail']} "
292
+ f"interior={t['parse_errors_interior']} — `archive repair` quarantines "
293
+ "these and restores any committed content they shadow"
294
+ )
295
+ print(f" parse error sample: {t['parse_error_sample']}")
296
+ print(
297
+ f"index: threads={res['index']['threads']} events={res['index']['events']} "
298
+ f"kg_events={res['index']['kg_events']} "
299
+ f"{res['index'].get('check', 'quick_check')}={res['index']['quick_check']}"
300
+ )
301
+ print(
302
+ f"drift: threads={res['drift']['threads']:+d} events={res['drift']['events']:+d} "
303
+ f"kg_events={res['drift']['kg_events']:+d}"
304
+ )
305
+ fts = res["fts"]
306
+ if fts["shadow_rows"] != fts["fts5_rows"] or fts["orphan_rows"]:
307
+ print(
308
+ f"fts: shadow={fts['shadow_rows']} fts5={fts['fts5_rows']} "
309
+ f"orphans={fts['orphan_rows']} — `archive reindex` rebuilds the search surface"
310
+ )
311
+ if args.deep:
312
+ dp = res["deep"]
313
+ print(
314
+ f"deep: index_only={dp['events_index_only']} "
315
+ f"missing={dp['events_missing_from_index']} "
316
+ f"key_mismatch={dp['events_key_mismatch']} "
317
+ f"superseded_twins={dp['events_superseded_twins']} "
318
+ f"(watermark {dp['watermark']})"
319
+ )
320
+ if dp["thread_meta_mismatch"]:
321
+ print(
322
+ f" thread metadata drift (report-only): "
323
+ f"{dp['thread_meta_mismatch']} threads, sample {dp['thread_meta_sample']}"
324
+ )
325
+ print(
326
+ f" kg index_only={dp['kg']['index_only']} truth_only={dp['kg']['truth_only']} "
327
+ f"content_mismatch={dp['kg']['content_mismatch']}; "
328
+ f"dangling links={dp['dangling']['link_endpoints']} "
329
+ f"citations={dp['dangling']['citation_events']} "
330
+ f"citation_thread_mismatch={dp['dangling']['citation_thread_mismatch']} "
331
+ f"events={dp['dangling']['event_threads']}; "
332
+ f"dup_pairs={dp['duplicate_content_pairs_index']}"
333
+ )
334
+ f = dp["fts"]
335
+ print(
336
+ f" fts orphans={f['orphan_rows']} "
337
+ f"shadow={f['shadow_rows']} fts5={f['fts5_rows']} "
338
+ f"unindexed={f['unindexed_events']} "
339
+ f"empty_extract={f['empty_extract_events']}"
340
+ )
341
+ if f["unindexed_events"]:
342
+ print(f" unindexed sample: {f['unindexed_sample']}")
343
+ if dp["events_missing_from_index"]:
344
+ print(f" missing sample: {dp['missing_sample']}")
345
+ if dp["events_index_only"]:
346
+ print(f" index-only sample: {dp['index_only_sample']}")
347
+ if dp["events_key_mismatch"]:
348
+ print(f" key-mismatch sample: {dp['key_mismatch_sample']}")
349
+ if args.hashes:
350
+ h = res["hashes"]
351
+ for side in ("truth", "index"):
352
+ hs = h[side]
353
+ print(
354
+ f"hashes[{side}]: checked={hs['checked']} mismatched={hs['mismatched']} "
355
+ f"unhashed_keys={hs['unhashed_keys']} no_key={hs['no_key']}"
356
+ )
357
+ if hs["mismatched"]:
358
+ print(f" mismatch sample: {hs['mismatch_sample']}")
359
+ c = h["cross"]
360
+ print(f"hashes[cross]: compared={c['compared']} mismatched={c['mismatched']}")
361
+ if c["mismatched"]:
362
+ print(f" mismatch sample: {c['mismatch_sample']}")
363
+ if "delta" in h:
364
+ d = h["delta"]
365
+ print(
366
+ f"hashes delta vs {h['previous']['at']}: "
367
+ f"truth {d['truth_mismatched']:+d} index {d['index_mismatched']:+d} "
368
+ f"cross {d['cross_mismatched']:+d}"
369
+ )
370
+ if args.backup:
371
+ b = res["backup"]
372
+ if "error" in b:
373
+ print(f"backup[{b['dest']}]: {b['error']}")
374
+ else:
375
+ bs = b["scan"]
376
+ print(
377
+ f"backup[{b['dest']}]: threads={bs['threads']} "
378
+ f"effective={bs['events_effective']} parse_errors={bs['parse_errors']} "
379
+ f"coverage={b['coverage']:.4f}"
380
+ )
381
+ if "effective_drop" in b:
382
+ ed = b["effective_drop"]
383
+ print(
384
+ f" MIRROR SHRANK: {ed['previous']} → {ed['current']} effective "
385
+ f"events since {ed['previous_at']} — the backup lost content "
386
+ "between looks; check the mirror before the next run overwrites it"
387
+ )
388
+ if "hashes" in b:
389
+ bh = b["hashes"]
390
+ print(
391
+ f"backup hashes: checked={bh['checked']} mismatched={bh['mismatched']} "
392
+ f"unhashed_keys={bh['unhashed_keys']} no_key={bh['no_key']}"
393
+ )
394
+ if bh["mismatched"]:
395
+ print(f" mismatch sample: {bh['mismatch_sample']}")
396
+ if res["ok"]:
397
+ print("OK")
398
+ else:
399
+ print(f"FAILED: {', '.join(res['failed_components'])}")
400
+ if res.get("failure_log"):
401
+ print(f" full result appended to {res['failure_log']}")
402
+ return 0 if res["ok"] else 1
403
+
404
+
405
+ def cmd_restore_drill(args: argparse.Namespace) -> int:
406
+ from . import _api as api
407
+
408
+ print(f"restore drill: rebuilding an index from {args.dest} in a throwaway home...", flush=True)
409
+ res = api.restore_drill(args.dest, home=args.home, keep_home=args.keep_home)
410
+ if "error" in res:
411
+ print(f"FAILED: {res['error']}")
412
+ if "mirror" in res:
413
+ m = res["mirror"]
414
+ print(
415
+ f"mirror: threads={m['threads']} effective={m['events_effective']} "
416
+ f"parse_errors={m['parse_errors']}"
417
+ )
418
+ if "rebuilt" in res:
419
+ r = res["rebuilt"]
420
+ print(
421
+ f"rebuilt: threads={r['threads']} events={r['events']} fts={r.get('fts')} "
422
+ f"coverage={res.get('coverage', 0):.4f} of live"
423
+ )
424
+ if "smoke" in res:
425
+ sm = res["smoke"]
426
+ if sm.get("skipped"):
427
+ print(f"smoke: skipped ({sm['skipped']})")
428
+ else:
429
+ print(
430
+ f"smoke: read={'ok' if sm.get('read_ok') else 'FAILED'} "
431
+ f"search={'ok' if sm.get('search_ok') else 'FAILED'}"
432
+ + (f" (token {sm['token']!r})" if sm.get("token") else "")
433
+ + (f" error: {sm['error']}" if sm.get("error") else "")
434
+ )
435
+ if res.get("drill_home"):
436
+ print(f"drill home kept: {res['drill_home']}")
437
+ print(f"{'OK' if res.get('ok') else 'RESTORE DRILL FAILED'} ({res.get('seconds', '?')}s)")
438
+ return 0 if res.get("ok") else 1
439
+
440
+
441
+ def cmd_nightly(args: argparse.Namespace) -> int:
442
+ from . import _api as api
443
+
444
+ print(f"nightly pipeline: backup → verify → restore drill ({args.dest})", flush=True)
445
+ res = api.nightly(
446
+ args.dest, home=args.home, notify_url=args.notify_url,
447
+ allow_shrink=args.allow_shrink, drill=args.drill,
448
+ )
449
+ b = res["backup"]
450
+ if "error" in b:
451
+ print(f"backup: ERROR {b['error']}")
452
+ else:
453
+ mb = b["bytes_copied"] / (1024 * 1024)
454
+ print(f"backup: {b['files_copied']} files ({mb:.1f} MB copied)"
455
+ + (" [SAME DEVICE as archive]" if b.get("same_device") else ""))
456
+ esc = res["escalations"]
457
+ v = res["verify"]
458
+ flags = "+".join(k for k in ("deep", "hashes") if esc[k]) or "shallow"
459
+ if "error" in v:
460
+ print(f"verify [{flags}]: ERROR {v['error']}")
461
+ elif v["ok"]:
462
+ print(f"verify [{flags}]: ok "
463
+ f"drift={v['drift']['events']:+d} parse_errors={v['truth']['parse_errors']}")
464
+ else:
465
+ print(f"verify [{flags}]: FAILED ({', '.join(v['failed_components'])}) "
466
+ f"drift={v['drift']['events']:+d} parse_errors={v['truth']['parse_errors']}")
467
+ if v.get("failure_log"):
468
+ print(f" full result appended to {v['failure_log']}")
469
+ if "drill" in res:
470
+ d = res["drill"]
471
+ if "error" in d:
472
+ print(f"restore drill: ERROR {d['error']}")
473
+ else:
474
+ print(f"restore drill: {'ok' if d.get('ok') else 'FAILED'} "
475
+ f"coverage={d.get('coverage', 0):.4f} ({d.get('seconds', '?')}s)")
476
+ if res.get("notify_error"):
477
+ print(f"notify: could not deliver failure notification ({res['notify_error']})")
478
+ print("NIGHTLY OK" if res["ok"]
479
+ else f"NIGHTLY FAILED: {', '.join(res['failed_stages'])}")
480
+ return 0 if res["ok"] else 1
481
+
482
+
483
+ def cmd_repair(args: argparse.Namespace) -> int:
484
+ from . import _api as api
485
+
486
+ res = api.repair(home=args.home, dry_run=args.dry_run)
487
+ verb = "would quarantine" if res["dry_run"] else "quarantined"
488
+ print(
489
+ f"{verb} {res['fragments_quarantined']} unparseable line(s) "
490
+ f"across {res['files_damaged']} file(s)"
491
+ )
492
+ if res.get("damaged_sample"):
493
+ print(f" sample: {res['damaged_sample']}")
494
+ if res.get("quarantine_file"):
495
+ print(f" ledger: {res['quarantine_file']}")
496
+ verb = "would restore" if res["dry_run"] else "restored"
497
+ print(
498
+ f"{verb} from index: {res['events_restored_from_index']} event(s), "
499
+ f"{res['kg_events_restored']} kg event(s), "
500
+ f"{res['thread_records_restored']} thread record(s)"
501
+ )
502
+ if not res["dry_run"] and res["fragments_quarantined"]:
503
+ print("note: the repaired files shrank — the next `archive backup` may need --allow-shrink")
504
+ if not res["dry_run"]:
505
+ print("run `archive verify` to confirm the archive is clean")
506
+ return 0
507
+
508
+
509
+ def _age(iso: str) -> str:
510
+ from datetime import datetime, timezone
511
+
512
+ try:
513
+ dt = datetime.fromisoformat(iso)
514
+ except (TypeError, ValueError):
515
+ return "?"
516
+ if dt.tzinfo is None:
517
+ dt = dt.replace(tzinfo=timezone.utc)
518
+ hours = (datetime.now(timezone.utc) - dt).total_seconds() / 3600
519
+ return f"{hours / 24:.1f}d ago" if hours >= 48 else f"{hours:.1f}h ago"
520
+
521
+
522
+ def cmd_status(args: argparse.Namespace) -> int:
523
+ from . import _api as api
524
+
525
+ st = api.status(home=args.home)
526
+ print(f"home: {st['home']}")
527
+ print(f"truth: {st['truth_dir']}")
528
+ print(f"index: {st['index_path']}")
529
+ print(f"threads: {st['threads']}")
530
+ print(f"events: {st['events']}")
531
+ print(f"indexed: {st['fts_indexed']}")
532
+ v, b = st.get("last_verify"), st.get("last_backup")
533
+ d = st.get("last_restore_drill")
534
+ if v and v["ok"]:
535
+ print(f"verify: ok {v['at']} ({_age(v['at'])})")
536
+ elif v:
537
+ components = ", ".join(v.get("failed", [])) or "see verify-failures.jsonl"
538
+ print(f"verify: FAILED ({components}) {v['at']} ({_age(v['at'])})")
539
+ else:
540
+ print("verify: never recorded")
541
+ print(
542
+ f"backup: {'ok' if b['ok'] else 'FAILED'} → {b['dest']} {b['at']} ({_age(b['at'])})"
543
+ if b else "backup: never recorded"
544
+ )
545
+ if b and b.get("same_device"):
546
+ print(" WARNING: backup destination is on the same filesystem as the archive")
547
+ print(
548
+ f"drill: {'ok' if d['ok'] else 'FAILED'} coverage={d.get('coverage')} "
549
+ f"{d['at']} ({_age(d['at'])})"
550
+ if d else "drill: never recorded"
551
+ )
552
+ w = st.get("last_watch_errors")
553
+ if w:
554
+ print(
555
+ f"watch: poll errors seen, last at {w['at']} ({_age(w['at'])}), "
556
+ f"{w.get('count_since_start', '?')} since daemon start"
557
+ )
558
+ for err in w.get("errors", [])[:3]:
559
+ print(f" {err}")
560
+ return 0
561
+
562
+
563
+ def build_parser() -> argparse.ArgumentParser:
564
+ parser = argparse.ArgumentParser(
565
+ prog="archive",
566
+ description="Serverless-native local archive for AI conversations (JSONL truth + SQLite index).",
567
+ epilog="Retrieval has no CLI verbs by design: search/read are the archive-mcp "
568
+ "tools, and the web viewer is cohosted by `archive watch --web`.",
569
+ )
570
+ parser.add_argument("--version", action="version", version=f"thread-archive {__version__}")
571
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
572
+
573
+ from ._importers import PROVIDERS # registry is the single source of truth for choices
574
+
575
+ p_import = sub.add_parser("import", help="import a transcript or provider store")
576
+ _add_home_arg(p_import)
577
+ p_import.add_argument("path", help="transcript file (claude-code/codex/grok/antigravity/cloth) or DB (cursor/opencode)")
578
+ p_import.add_argument(
579
+ "--provider",
580
+ default=None,
581
+ choices=PROVIDERS,
582
+ help="source provider (default: claude-code)",
583
+ )
584
+ p_import.set_defaults(func=cmd_import)
585
+
586
+ p_import_export = sub.add_parser(
587
+ "import-export", help="import a downloaded claude.ai / xAI account export (ZIP or dir)"
588
+ )
589
+ _add_home_arg(p_import_export)
590
+ p_import_export.add_argument("path", help="export ZIP file or unzipped directory")
591
+ p_import_export.add_argument(
592
+ "--force", action="store_true", help="reimport conversations already present"
593
+ )
594
+ p_import_export.set_defaults(func=cmd_import_export)
595
+
596
+ p_watch = sub.add_parser("watch", help="watch local AI-tool stores and import incrementally")
597
+ _add_home_arg(p_watch)
598
+ p_watch.add_argument("--once", action="store_true", help="poll once and exit")
599
+ p_watch.add_argument("--interval", type=float, default=5.0, help="poll interval in seconds")
600
+ p_watch.add_argument("--web", action="store_true", help="cohost the web viewer (persistent URL)")
601
+ p_watch.add_argument("--web-host", default="127.0.0.1", help="cohosted viewer bind host")
602
+ p_watch.add_argument("--web-port", type=int, default=8787, help="cohosted viewer bind port")
603
+ p_watch.add_argument("--no-embed", dest="embed", action="store_false",
604
+ help="disable the live vector cohost (no semantic-index upkeep)")
605
+ p_watch.add_argument("--embed-interval", type=float, default=300.0,
606
+ help="seconds between vector-cohost passes (default 300)")
607
+ p_watch.add_argument("--embed-batch", type=int, default=512,
608
+ help="max events embedded per cohost pass (default 512)")
609
+ p_watch.set_defaults(func=cmd_watch)
610
+
611
+ p_reindex = sub.add_parser("reindex", help="rebuild index.db from the JSONL truth directory")
612
+ _add_home_arg(p_reindex)
613
+ p_reindex.add_argument("--vectors", action="store_true", help="also rebuild local vectors")
614
+ p_reindex.add_argument(
615
+ "--salvage", action="store_true",
616
+ help="publish the rebuild even if it loses committed records the current "
617
+ "index holds (the default refuses and keeps the old index)",
618
+ )
619
+ p_reindex.set_defaults(func=cmd_reindex)
620
+
621
+ p_embed = sub.add_parser("embed", help="embed user/text events missing a vector (incremental catch-up)")
622
+ _add_home_arg(p_embed)
623
+ p_embed.add_argument("--rebuild", action="store_true", help="re-embed everything, not just the gap")
624
+ p_embed.add_argument("--limit", type=int, default=None, help="cap events embedded this run")
625
+ p_embed.add_argument("--newest-first", action="store_true",
626
+ help="embed the freshest gap first (recent threads findable soonest)")
627
+ p_embed.set_defaults(func=cmd_embed)
628
+
629
+ p_status = sub.add_parser("status", help="archive health / paths / counts")
630
+ _add_home_arg(p_status)
631
+ p_status.set_defaults(func=cmd_status)
632
+
633
+ p_backup = sub.add_parser("backup", help="mirror the JSONL truth dir to a backup destination")
634
+ _add_home_arg(p_backup)
635
+ p_backup.add_argument("dest", help="backup destination dir (ideally a different disk/machine)")
636
+ p_backup.add_argument(
637
+ "--allow-shrink", action="store_true",
638
+ help="let a smaller source file overwrite its larger backup copy (only after "
639
+ "a deliberate truth re-emit; the default keeps the backup copy)",
640
+ )
641
+ p_backup.add_argument(
642
+ "--no-verify", dest="verify", action="store_false",
643
+ help="skip the pre-backup integrity verify of the source truth",
644
+ )
645
+ p_backup.set_defaults(func=cmd_backup)
646
+
647
+ p_verify = sub.add_parser("verify", help="integrity check: truth parses + matches the index")
648
+ _add_home_arg(p_verify)
649
+ p_verify.add_argument(
650
+ "--deep", action="store_true",
651
+ help="id-level truth↔index diff + dedup_key parity + knowledge-layer and "
652
+ "dangling-reference checks (slower)",
653
+ )
654
+ p_verify.add_argument(
655
+ "--hashes", action="store_true",
656
+ help="re-hash every payload against its dedup_key's content hash, both stores "
657
+ "(rot detection; report-only, CPU-heavy)",
658
+ )
659
+ p_verify.add_argument(
660
+ "--backup", default=None, metavar="DEST",
661
+ help="also parse-and-count a backup mirror at DEST and report its coverage "
662
+ "against the live truth",
663
+ )
664
+ p_verify.set_defaults(func=cmd_verify)
665
+
666
+ p_drill = sub.add_parser(
667
+ "restore-drill",
668
+ help="prove a backup restores: rebuild a full index from the mirror in "
669
+ "a throwaway home and compare counts",
670
+ )
671
+ _add_home_arg(p_drill)
672
+ p_drill.add_argument("dest", help="backup mirror to restore from")
673
+ p_drill.add_argument(
674
+ "--keep-home", action="store_true",
675
+ help="keep the throwaway home (inspect the restored index) instead of deleting it",
676
+ )
677
+ p_drill.set_defaults(func=cmd_restore_drill)
678
+
679
+ p_nightly = sub.add_parser(
680
+ "nightly",
681
+ help="scheduled pipeline: backup → verify (age-gated deep/hashes "
682
+ "escalation) → restore drill, with per-stage health records",
683
+ )
684
+ _add_home_arg(p_nightly)
685
+ p_nightly.add_argument("dest", help="backup destination dir (a different disk/machine)")
686
+ p_nightly.add_argument(
687
+ "--notify-url", default=None, metavar="URL",
688
+ help="POST {title, message} here when any stage fails "
689
+ "(lab's /api/notify shape); silence still needs a staleness watcher",
690
+ )
691
+ p_nightly.add_argument(
692
+ "--allow-shrink", action="store_true",
693
+ help="pass through to the backup stage (after a deliberate truth re-emit)",
694
+ )
695
+ p_nightly.add_argument(
696
+ "--no-drill", dest="drill", action="store_false",
697
+ help="skip the restore drill stage",
698
+ )
699
+ p_nightly.set_defaults(func=cmd_nightly)
700
+
701
+ p_repair = sub.add_parser(
702
+ "repair",
703
+ help="quarantine unparseable truth lines and restore committed content "
704
+ "the truth lacks from the index",
705
+ )
706
+ _add_home_arg(p_repair)
707
+ p_repair.add_argument(
708
+ "--dry-run", action="store_true",
709
+ help="report what would be quarantined/restored without touching anything",
710
+ )
711
+ p_repair.set_defaults(func=cmd_repair)
712
+
713
+ p_daemon = sub.add_parser(
714
+ "daemon",
715
+ help="manage the always-on watcher LaunchAgent (macOS): the upgrade "
716
+ "from lazy MCP-cohosted ingest to always-fresh",
717
+ )
718
+ _add_home_arg(p_daemon)
719
+ p_daemon.add_argument(
720
+ "action", choices=["install", "uninstall", "restart", "status"],
721
+ help="install writes the plist (pointing at this environment's `archive`) "
722
+ "and (re)loads the agent; restart applies a code edit to the running agent",
723
+ )
724
+ p_daemon.add_argument(
725
+ "--no-web", dest="web", action="store_false",
726
+ help="don't cohost the web viewer in the watcher process",
727
+ )
728
+ p_daemon.add_argument(
729
+ "--web-port", type=int, default=8787, help="cohosted viewer port (default 8787)"
730
+ )
731
+ p_daemon.set_defaults(func=cmd_daemon)
732
+
733
+ return parser
734
+
735
+
736
+ def main(argv: list[str] | None = None) -> int:
737
+ parser = build_parser()
738
+ args = parser.parse_args(argv)
739
+ if not getattr(args, "command", None):
740
+ parser.print_help()
741
+ return 0
742
+ return args.func(args)
743
+
744
+
745
+ if __name__ == "__main__":
746
+ sys.exit(main())