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.
- thread_archive/__init__.py +34 -0
- thread_archive/_api.py +2235 -0
- thread_archive/_config.py +126 -0
- thread_archive/_importers/__init__.py +81 -0
- thread_archive/_importers/_continuation.py +136 -0
- thread_archive/_importers/_cursor.py +103 -0
- thread_archive/_importers/_events.py +244 -0
- thread_archive/_importers/_line_stream.py +193 -0
- thread_archive/_importers/_read.py +82 -0
- thread_archive/_importers/_result.py +17 -0
- thread_archive/_importers/_sidecar.py +113 -0
- thread_archive/_importers/_state.py +240 -0
- thread_archive/_importers/_titles.py +179 -0
- thread_archive/_importers/antigravity.py +254 -0
- thread_archive/_importers/claude_code.py +293 -0
- thread_archive/_importers/claude_science.py +330 -0
- thread_archive/_importers/cloth.py +20 -0
- thread_archive/_importers/codex.py +324 -0
- thread_archive/_importers/cowork.py +107 -0
- thread_archive/_importers/cursor.py +432 -0
- thread_archive/_importers/exports.py +389 -0
- thread_archive/_importers/grok.py +600 -0
- thread_archive/_importers/opencode.py +538 -0
- thread_archive/_knowledge/__init__.py +67 -0
- thread_archive/_knowledge/_claims.py +116 -0
- thread_archive/_knowledge/_community.py +75 -0
- thread_archive/_knowledge/graph.py +208 -0
- thread_archive/_knowledge/materialize.py +212 -0
- thread_archive/_knowledge/write.py +438 -0
- thread_archive/_launchd.py +167 -0
- thread_archive/_mcp/__init__.py +1 -0
- thread_archive/_mcp/librarian.py +151 -0
- thread_archive/_mcp/server.py +307 -0
- thread_archive/_retrieval/__init__.py +280 -0
- thread_archive/_retrieval/_classify.py +80 -0
- thread_archive/_retrieval/_codex.py +157 -0
- thread_archive/_retrieval/_context.py +123 -0
- thread_archive/_retrieval/_extract.py +184 -0
- thread_archive/_retrieval/embed.py +186 -0
- thread_archive/_retrieval/format.py +149 -0
- thread_archive/_retrieval/fts.py +538 -0
- thread_archive/_retrieval/rank.py +228 -0
- thread_archive/_retrieval/read.py +908 -0
- thread_archive/_retrieval/rerank.py +143 -0
- thread_archive/_retrieval/vectors.py +611 -0
- thread_archive/_scripts/__init__.py +1 -0
- thread_archive/_scripts/backfill_recompute.py +328 -0
- thread_archive/_scripts/backfill_reconcile.py +296 -0
- thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
- thread_archive/_scripts/recover_dropped_events.py +190 -0
- thread_archive/_scripts/repair_grok_tool_names.py +118 -0
- thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
- thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
- thread_archive/_setup/__init__.py +12 -0
- thread_archive/_setup/clients.py +89 -0
- thread_archive/_setup/wizard.py +474 -0
- thread_archive/_store/__init__.py +45 -0
- thread_archive/_store/_base.py +173 -0
- thread_archive/_store/_defaults.py +57 -0
- thread_archive/_store/_types.py +38 -0
- thread_archive/_store/models.py +370 -0
- thread_archive/_store/schema.py +84 -0
- thread_archive/_thread_import/__init__.py +40 -0
- thread_archive/_thread_import/api.py +78 -0
- thread_archive/_thread_import/event_builder.py +810 -0
- thread_archive/_thread_import/exporters/__init__.py +9 -0
- thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
- thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
- thread_archive/_thread_import/exporters/cursor.py +582 -0
- thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
- thread_archive/_thread_import/parsers/__init__.py +191 -0
- thread_archive/_thread_import/parsers/base.py +698 -0
- thread_archive/_thread_import/parsers/chatgpt.py +722 -0
- thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
- thread_archive/_thread_import/parsers/claude.py +443 -0
- thread_archive/_thread_import/parsers/claude_code.py +1035 -0
- thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
- thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
- thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
- thread_archive/_thread_import/parsers/config/__init__.py +26 -0
- thread_archive/_thread_import/parsers/config/base.py +220 -0
- thread_archive/_thread_import/parsers/cursor.py +538 -0
- thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
- thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
- thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
- thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
- thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
- thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
- thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
- thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
- thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
- thread_archive/_thread_import/parsers/types/__init__.py +56 -0
- thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
- thread_archive/_thread_import/parsers/types/claude.py +120 -0
- thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
- thread_archive/_thread_import/parsers/types/cursor.py +109 -0
- thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
- thread_archive/_thread_import/parsers/validators/base.py +168 -0
- thread_archive/_thread_import/parsers/validators/content.py +80 -0
- thread_archive/_thread_import/parsers/validators/referential.py +57 -0
- thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
- thread_archive/_thread_import/parsers/validators/types.py +87 -0
- thread_archive/_thread_import/schemas/__init__.py +231 -0
- thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
- thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
- thread_archive/_thread_import/schemas/claude/v1.json +188 -0
- thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
- thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
- thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
- thread_archive/_thread_import/timestamps.py +57 -0
- thread_archive/_thread_import/tool_names.py +48 -0
- thread_archive/_truth/__init__.py +40 -0
- thread_archive/_truth/jsonl_log.py +2340 -0
- thread_archive/_truth/repair.py +322 -0
- thread_archive/_watcher/__init__.py +57 -0
- thread_archive/_watcher/base.py +65 -0
- thread_archive/_watcher/daemon.py +289 -0
- thread_archive/_watcher/export_drop.py +203 -0
- thread_archive/_watcher/exthost.py +316 -0
- thread_archive/_watcher/lazy.py +117 -0
- thread_archive/_watcher/sources.py +616 -0
- thread_archive/_web/__init__.py +15 -0
- thread_archive/_web/server.py +299 -0
- thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
- thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
- thread_archive/_web/static/index.html +13 -0
- thread_archive/cli.py +746 -0
- thread_archive-0.0.2.dist-info/METADATA +296 -0
- thread_archive-0.0.2.dist-info/RECORD +132 -0
- thread_archive-0.0.2.dist-info/WHEEL +4 -0
- thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
- 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())
|