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
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Truth repair: quarantine unparseable lines, restore committed rows the truth lacks.
|
|
2
|
+
|
|
3
|
+
The sanctioned path from a red ``archive verify`` back to green. An unparseable
|
|
4
|
+
line in a truth file is one of two things: the torn final append of a crash
|
|
5
|
+
(never committed — the line's fsync never completed, so the COMMIT that follows
|
|
6
|
+
it in the write seam never ran), or a damaged formerly-good line. Either way the
|
|
7
|
+
line contributes nothing to a reindex (the loaders skip it), but it keeps
|
|
8
|
+
``verify`` failing on every run — and an integrity signal that can't be cleared
|
|
9
|
+
trains its operator to ignore it, which is worse than the damage.
|
|
10
|
+
|
|
11
|
+
:func:`repair_truth` makes the state actionable:
|
|
12
|
+
|
|
13
|
+
* **Quarantine, never delete.** Every unparseable line is appended — bytes
|
|
14
|
+
preserved — to the ledger ``truth/quarantine/fragments.jsonl`` (itself valid
|
|
15
|
+
JSONL: each record wraps the raw fragment as a string with its file, line
|
|
16
|
+
number, and timestamp), fsynced durable *before* the source file is atomically
|
|
17
|
+
rewritten without it. The ledger lives inside the truth directory, so backups
|
|
18
|
+
mirror it; no byte is ever discarded.
|
|
19
|
+
* **Containment restore.** After the excision, every committed row the truth
|
|
20
|
+
lacks is re-emitted from the live index: an ``events`` row with no truth line
|
|
21
|
+
(by id, per thread), a ``kg_events`` row with no log line, and a thread with
|
|
22
|
+
no ``type:thread`` metadata record. This is the one operation where writing
|
|
23
|
+
truth *from* the projection is exactly right — the index provably holds what
|
|
24
|
+
the truth lost, and the alternative is a reindex regression gate that
|
|
25
|
+
(correctly) refuses to publish. A fragment that was never committed has no
|
|
26
|
+
index row and restores nothing. Restored event payloads are self-validated
|
|
27
|
+
against the content hash in their own ``dedup_key``: a failing payload is
|
|
28
|
+
still restored (the index copy is the only copy left) but counted and logged
|
|
29
|
+
(``restored_hash_mismatches``), and the next ``verify --hashes`` reports it.
|
|
30
|
+
|
|
31
|
+
Runs under the exclusive reindex lock — every writer holds it shared around its
|
|
32
|
+
truth append + commit, so the tree is quiescent — and resolves any crashed
|
|
33
|
+
drain's intent first, so a torn tail the drain rollback would remove isn't
|
|
34
|
+
quarantined as damage. Idempotent: a clean truth repairs to zero actions.
|
|
35
|
+
|
|
36
|
+
After a repair that excised fragments, the next ``archive backup`` may need
|
|
37
|
+
``--allow-shrink``: the rewrite shrinks the repaired file, and the mirror's
|
|
38
|
+
shrink guard (correctly) flags shrinking truth files.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import logging
|
|
45
|
+
import os
|
|
46
|
+
from datetime import datetime, timezone
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
|
|
49
|
+
from sqlalchemy import select
|
|
50
|
+
|
|
51
|
+
from .._store import Event, KgEvent, Thread, get_session
|
|
52
|
+
from .jsonl_log import (
|
|
53
|
+
KG_EVENTS_FILE,
|
|
54
|
+
THREADS_SUBDIR,
|
|
55
|
+
_fsync_dir,
|
|
56
|
+
_hash_key_check,
|
|
57
|
+
_hold_reindex_lock,
|
|
58
|
+
_json_default,
|
|
59
|
+
_row_dict,
|
|
60
|
+
_shard_depth,
|
|
61
|
+
_thread_file,
|
|
62
|
+
_truth_write_lock,
|
|
63
|
+
log_dir,
|
|
64
|
+
reset_handles,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
logger = logging.getLogger(__name__)
|
|
68
|
+
|
|
69
|
+
QUARANTINE_SUBDIR = "quarantine"
|
|
70
|
+
FRAGMENTS_FILE = "fragments.jsonl"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _damaged_lines(path: Path) -> list[tuple[int, bytes]]:
|
|
74
|
+
"""``(lineno, raw)`` of every unparseable non-empty line. Decodes with
|
|
75
|
+
``errors="replace"`` before parsing — the exact tolerance every truth reader
|
|
76
|
+
(scan, reindex loader) applies — so repair never excises a line the readers
|
|
77
|
+
accept."""
|
|
78
|
+
bad: list[tuple[int, bytes]] = []
|
|
79
|
+
with open(path, "rb") as fh:
|
|
80
|
+
for lineno, raw in enumerate(fh, 1):
|
|
81
|
+
line = raw.strip()
|
|
82
|
+
if not line:
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
json.loads(line.decode("utf-8", "replace"))
|
|
86
|
+
except ValueError:
|
|
87
|
+
bad.append((lineno, raw.rstrip(b"\n")))
|
|
88
|
+
return bad
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _quarantine(d: Path, records: list[dict]) -> None:
|
|
92
|
+
"""Append fragment records to the ledger, fsynced durable — the bytes must
|
|
93
|
+
survive a crash before the rewrite discards them from the source file."""
|
|
94
|
+
qdir = d / QUARANTINE_SUBDIR
|
|
95
|
+
qdir.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
qpath = qdir / FRAGMENTS_FILE
|
|
97
|
+
existed = qpath.exists()
|
|
98
|
+
with open(qpath, "a", encoding="utf-8") as fh:
|
|
99
|
+
for rec in records:
|
|
100
|
+
fh.write(json.dumps(rec, ensure_ascii=False, default=_json_default))
|
|
101
|
+
fh.write("\n")
|
|
102
|
+
fh.flush()
|
|
103
|
+
os.fsync(fh.fileno())
|
|
104
|
+
if not existed:
|
|
105
|
+
_fsync_dir(qdir)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _rewrite_without(path: Path, bad_linenos: set[int]) -> None:
|
|
109
|
+
"""Atomically rewrite ``path`` minus the damaged lines, byte-exact for every
|
|
110
|
+
surviving line (an unterminated final good line gains its newline)."""
|
|
111
|
+
tmp = path.with_name(path.name + ".repair")
|
|
112
|
+
with open(path, "rb") as src, open(tmp, "wb") as dst:
|
|
113
|
+
for lineno, raw in enumerate(src, 1):
|
|
114
|
+
if lineno in bad_linenos:
|
|
115
|
+
continue
|
|
116
|
+
dst.write(raw if raw.endswith(b"\n") else raw + b"\n")
|
|
117
|
+
dst.flush()
|
|
118
|
+
os.fsync(dst.fileno())
|
|
119
|
+
os.replace(tmp, path)
|
|
120
|
+
_fsync_dir(path.parent)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _append_records(path: Path, records: list[dict]) -> None:
|
|
124
|
+
"""Append restored records to a truth file, fsynced (dir too when created)."""
|
|
125
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
is_new = not path.exists()
|
|
127
|
+
with open(path, "a", encoding="utf-8") as fh:
|
|
128
|
+
for rec in records:
|
|
129
|
+
fh.write(json.dumps(rec, default=_json_default, ensure_ascii=False))
|
|
130
|
+
fh.write("\n")
|
|
131
|
+
fh.flush()
|
|
132
|
+
os.fsync(fh.fileno())
|
|
133
|
+
if is_new:
|
|
134
|
+
_fsync_dir(path.parent)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def repair_truth(*, dry_run: bool = False) -> dict:
|
|
138
|
+
"""Quarantine unparseable truth lines and restore committed rows from the index.
|
|
139
|
+
|
|
140
|
+
``dry_run=True`` reports what would happen without touching anything.
|
|
141
|
+
Returns counts: files damaged, fragments quarantined, events / kg-events /
|
|
142
|
+
thread records restored from the index.
|
|
143
|
+
"""
|
|
144
|
+
d = log_dir()
|
|
145
|
+
with _hold_reindex_lock():
|
|
146
|
+
# Resolve any crashed drain first — its rollback removes a partial batch
|
|
147
|
+
# (torn tail included) that this scan would otherwise quarantine as damage.
|
|
148
|
+
with _truth_write_lock():
|
|
149
|
+
pass
|
|
150
|
+
return _repair_locked(d, dry_run=dry_run)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _repair_locked(d: Path, *, dry_run: bool) -> dict:
|
|
154
|
+
threads_dir = d / THREADS_SUBDIR
|
|
155
|
+
targets: list[Path] = sorted(threads_dir.rglob("*.jsonl")) if threads_dir.exists() else []
|
|
156
|
+
if (d / KG_EVENTS_FILE).exists():
|
|
157
|
+
targets.append(d / KG_EVENTS_FILE)
|
|
158
|
+
|
|
159
|
+
damaged: dict[Path, list[tuple[int, bytes]]] = {}
|
|
160
|
+
for path in targets:
|
|
161
|
+
bad = _damaged_lines(path)
|
|
162
|
+
if bad:
|
|
163
|
+
damaged[path] = bad
|
|
164
|
+
|
|
165
|
+
result: dict = {
|
|
166
|
+
"dry_run": dry_run,
|
|
167
|
+
"files_damaged": len(damaged),
|
|
168
|
+
"fragments_quarantined": sum(len(v) for v in damaged.values()),
|
|
169
|
+
"events_restored_from_index": 0,
|
|
170
|
+
"kg_events_restored": 0,
|
|
171
|
+
"thread_records_restored": 0,
|
|
172
|
+
}
|
|
173
|
+
if damaged:
|
|
174
|
+
result["quarantine_file"] = str(d / QUARANTINE_SUBDIR / FRAGMENTS_FILE)
|
|
175
|
+
result["damaged_sample"] = [
|
|
176
|
+
f"{path.relative_to(d)}:{lineno}"
|
|
177
|
+
for path, bad in list(damaged.items())[:10]
|
|
178
|
+
for lineno, _ in bad[:2]
|
|
179
|
+
][:10]
|
|
180
|
+
|
|
181
|
+
if not dry_run and damaged:
|
|
182
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
183
|
+
_quarantine(d, [
|
|
184
|
+
{
|
|
185
|
+
"file": str(path.relative_to(d)),
|
|
186
|
+
"lineno": lineno,
|
|
187
|
+
"raw": raw.decode("utf-8", "backslashreplace"),
|
|
188
|
+
"quarantined_at": now,
|
|
189
|
+
}
|
|
190
|
+
for path, bad in damaged.items()
|
|
191
|
+
for lineno, raw in bad
|
|
192
|
+
])
|
|
193
|
+
reset_handles() # cached appenders must not span the rewrites
|
|
194
|
+
for path, bad in damaged.items():
|
|
195
|
+
_rewrite_without(path, {lineno for lineno, _ in bad})
|
|
196
|
+
logger.warning("repair: quarantined %d unparseable line(s) from %s", len(bad), path)
|
|
197
|
+
|
|
198
|
+
# Containment pass over the whole archive (not just the files touched above):
|
|
199
|
+
# a repair killed between its rewrite and this restore, or any other source of
|
|
200
|
+
# index ⊃ truth drift, is healed by the next run — repair is the inverse of
|
|
201
|
+
# verify, so it must converge on verify-green regardless of how the drift arose.
|
|
202
|
+
truth_event_ids: dict[int, set[int]] = {}
|
|
203
|
+
tids_with_meta: set[int] = set()
|
|
204
|
+
for path in (sorted(threads_dir.rglob("*.jsonl")) if threads_dir.exists() else []):
|
|
205
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
206
|
+
for line in fh:
|
|
207
|
+
line = line.strip()
|
|
208
|
+
if not line:
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
rec = json.loads(line)
|
|
212
|
+
except ValueError:
|
|
213
|
+
continue # freshly-quarantined already; dry-run tolerates
|
|
214
|
+
kind = rec.get("type", "event")
|
|
215
|
+
if kind == "thread":
|
|
216
|
+
if rec.get("id") is not None:
|
|
217
|
+
tids_with_meta.add(int(rec["id"]))
|
|
218
|
+
elif kind == "event":
|
|
219
|
+
ev_id, tid = rec.get("id"), rec.get("thread_id")
|
|
220
|
+
if ev_id is not None and tid is not None:
|
|
221
|
+
truth_event_ids.setdefault(int(tid), set()).add(int(ev_id))
|
|
222
|
+
kg_line_ids: set[int] = set()
|
|
223
|
+
if (d / KG_EVENTS_FILE).exists():
|
|
224
|
+
with open(d / KG_EVENTS_FILE, encoding="utf-8", errors="replace") as fh:
|
|
225
|
+
for line in fh:
|
|
226
|
+
line = line.strip()
|
|
227
|
+
if not line:
|
|
228
|
+
continue
|
|
229
|
+
try:
|
|
230
|
+
rec = json.loads(line)
|
|
231
|
+
except ValueError:
|
|
232
|
+
continue
|
|
233
|
+
if rec.get("id") is not None:
|
|
234
|
+
kg_line_ids.add(int(rec["id"]))
|
|
235
|
+
|
|
236
|
+
depth = _shard_depth(d)
|
|
237
|
+
empty: set[int] = set()
|
|
238
|
+
with get_session() as s:
|
|
239
|
+
conn = s.connection().connection # raw sqlite3 — stream, don't materialize
|
|
240
|
+
missing_by_tid: dict[int, list[int]] = {}
|
|
241
|
+
for ev_id, tid in conn.execute("SELECT id, thread_id FROM events"):
|
|
242
|
+
if int(ev_id) not in truth_event_ids.get(int(tid), empty):
|
|
243
|
+
missing_by_tid.setdefault(int(tid), []).append(int(ev_id))
|
|
244
|
+
missing_kg = sorted(
|
|
245
|
+
int(r[0]) for r in conn.execute("SELECT id FROM kg_events")
|
|
246
|
+
if int(r[0]) not in kg_line_ids
|
|
247
|
+
)
|
|
248
|
+
# A thread the index holds whose truth carries no metadata record — the
|
|
249
|
+
# damaged-thread-line case (reindex would synthesize a stub otherwise).
|
|
250
|
+
# Only threads that have (or are about to get) a truth file qualify.
|
|
251
|
+
meta_missing = {
|
|
252
|
+
int(r[0]) for r in conn.execute("SELECT id FROM threads")
|
|
253
|
+
if int(r[0]) not in tids_with_meta
|
|
254
|
+
and (int(r[0]) in truth_event_ids or int(r[0]) in missing_by_tid)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
result["events_restored_from_index"] = sum(len(v) for v in missing_by_tid.values())
|
|
258
|
+
result["kg_events_restored"] = len(missing_kg)
|
|
259
|
+
result["thread_records_restored"] = len(meta_missing)
|
|
260
|
+
if dry_run:
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
# Restored payloads are self-validated against the content hash in their
|
|
264
|
+
# own dedup_key. A failing row is still restored — the index copy is the
|
|
265
|
+
# only copy left, and quarantining data is not this tool's job — but the
|
|
266
|
+
# count and sample make the suspect content *seen*: the next
|
|
267
|
+
# ``verify --hashes`` will go red on it as a new truth-side mismatch.
|
|
268
|
+
hash_mismatched = 0
|
|
269
|
+
mismatch_sample: list[int] = []
|
|
270
|
+
|
|
271
|
+
def _validated(ev) -> dict:
|
|
272
|
+
nonlocal hash_mismatched
|
|
273
|
+
rec = _row_dict(ev)
|
|
274
|
+
key = rec.get("dedup_key")
|
|
275
|
+
if key and _hash_key_check(rec.get("payload"), key) is False:
|
|
276
|
+
hash_mismatched += 1
|
|
277
|
+
if len(mismatch_sample) < 10:
|
|
278
|
+
mismatch_sample.append(int(rec["id"]))
|
|
279
|
+
return rec
|
|
280
|
+
|
|
281
|
+
for tid in sorted(set(missing_by_tid) | meta_missing):
|
|
282
|
+
records: list[dict] = []
|
|
283
|
+
if tid in meta_missing:
|
|
284
|
+
t = s.get(Thread, tid)
|
|
285
|
+
if t is not None:
|
|
286
|
+
records.append({"type": "thread", **_row_dict(t)})
|
|
287
|
+
ids = missing_by_tid.get(tid, [])
|
|
288
|
+
for start in range(0, len(ids), 500):
|
|
289
|
+
rows = s.execute(
|
|
290
|
+
select(Event).where(Event.id.in_(ids[start:start + 500])).order_by(Event.id)
|
|
291
|
+
).scalars()
|
|
292
|
+
records.extend({"type": "event", **_validated(ev)} for ev in rows)
|
|
293
|
+
if records:
|
|
294
|
+
_append_records(_thread_file(d, tid, depth), records)
|
|
295
|
+
logger.warning(
|
|
296
|
+
"repair: restored %d record(s) to thread %d from the index", len(records), tid
|
|
297
|
+
)
|
|
298
|
+
if missing_kg:
|
|
299
|
+
records = []
|
|
300
|
+
for start in range(0, len(missing_kg), 500):
|
|
301
|
+
rows = s.execute(
|
|
302
|
+
select(KgEvent)
|
|
303
|
+
.where(KgEvent.id.in_(missing_kg[start:start + 500]))
|
|
304
|
+
.order_by(KgEvent.id)
|
|
305
|
+
).scalars()
|
|
306
|
+
records.extend({"type": "kg_event", **_row_dict(ev)} for ev in rows)
|
|
307
|
+
_append_records(d / KG_EVENTS_FILE, records)
|
|
308
|
+
logger.warning("repair: restored %d kg event(s) from the index", len(records))
|
|
309
|
+
|
|
310
|
+
result["restored_hash_mismatches"] = hash_mismatched
|
|
311
|
+
if hash_mismatched:
|
|
312
|
+
result["restored_hash_mismatch_sample"] = mismatch_sample
|
|
313
|
+
logger.warning(
|
|
314
|
+
"repair: %d restored payload(s) fail their own dedup-key content hash "
|
|
315
|
+
"(sample: %s) — restored anyway (the index copy is the only copy); "
|
|
316
|
+
"the next `verify --hashes` will report them",
|
|
317
|
+
hash_mismatched, mismatch_sample,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
if result["files_damaged"] or result["events_restored_from_index"] or result["kg_events_restored"]:
|
|
321
|
+
logger.warning("repair: %s", result)
|
|
322
|
+
return result
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Local-source watcher: poll AI-tool stores and import incrementally.
|
|
2
|
+
|
|
3
|
+
Product-owned (no ops, no HTTP) — each source watcher calls the local importer
|
|
4
|
+
directly. Covers the providers with importers — Claude Code, Codex, Grok, Antigravity,
|
|
5
|
+
cloth, Cursor, OpenCode, Cowork, Claude Science — plus a drop-folder watcher that
|
|
6
|
+
auto-imports claude.ai / xAI account exports dropped into ``<home>/dumps/``. Every
|
|
7
|
+
watcher self-gates on
|
|
8
|
+
``is_available()``, so all of them ride the one ``archive watch`` loop and an absent
|
|
9
|
+
store simply costs nothing.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .base import SourceDiscovery, SourceWatcher, WatchResult
|
|
15
|
+
from .daemon import Watcher
|
|
16
|
+
from .export_drop import ExportDropWatcher
|
|
17
|
+
from .exthost import ExthostWatcher
|
|
18
|
+
from .lazy import catch_up_once, try_ingest_owner_lock
|
|
19
|
+
from .sources import (
|
|
20
|
+
ClaudeCodeWatcher,
|
|
21
|
+
ClaudeScienceWatcher,
|
|
22
|
+
antigravity_watcher,
|
|
23
|
+
cloth_watcher,
|
|
24
|
+
codex_watcher,
|
|
25
|
+
cursor_watcher,
|
|
26
|
+
default_watchers,
|
|
27
|
+
discover_claude_dirs,
|
|
28
|
+
discover_claude_science_dbs,
|
|
29
|
+
enabled_watchers,
|
|
30
|
+
grok_watcher,
|
|
31
|
+
opencode_watcher,
|
|
32
|
+
provider_watchers,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"Watcher",
|
|
37
|
+
"SourceDiscovery",
|
|
38
|
+
"SourceWatcher",
|
|
39
|
+
"WatchResult",
|
|
40
|
+
"catch_up_once",
|
|
41
|
+
"try_ingest_owner_lock",
|
|
42
|
+
"default_watchers",
|
|
43
|
+
"enabled_watchers",
|
|
44
|
+
"provider_watchers",
|
|
45
|
+
"discover_claude_dirs",
|
|
46
|
+
"discover_claude_science_dbs",
|
|
47
|
+
"ClaudeCodeWatcher",
|
|
48
|
+
"ClaudeScienceWatcher",
|
|
49
|
+
"codex_watcher",
|
|
50
|
+
"grok_watcher",
|
|
51
|
+
"antigravity_watcher",
|
|
52
|
+
"cloth_watcher",
|
|
53
|
+
"cursor_watcher",
|
|
54
|
+
"opencode_watcher",
|
|
55
|
+
"ExportDropWatcher",
|
|
56
|
+
"ExthostWatcher",
|
|
57
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Source-watcher interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class WatchResult:
|
|
12
|
+
"""Result of a single poll iteration."""
|
|
13
|
+
|
|
14
|
+
sources_checked: int = 0
|
|
15
|
+
items_imported: int = 0
|
|
16
|
+
events_created: int = 0
|
|
17
|
+
errors: list[str] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
def __add__(self, other: "WatchResult") -> "WatchResult":
|
|
20
|
+
return WatchResult(
|
|
21
|
+
sources_checked=self.sources_checked + other.sources_checked,
|
|
22
|
+
items_imported=self.items_imported + other.items_imported,
|
|
23
|
+
events_created=self.events_created + other.events_created,
|
|
24
|
+
errors=self.errors + other.errors,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class SourceDiscovery:
|
|
30
|
+
"""What a dry-run look at one source's store found — stat-only, no content
|
|
31
|
+
parse, no import. ``items`` is a session/conversation count where the store
|
|
32
|
+
exposes one cheaply (per-session-file sources), else ``None``; timestamps
|
|
33
|
+
are file mtimes (unix seconds), the honest cheap proxy for activity range."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
available: bool
|
|
37
|
+
items: Optional[int] = None
|
|
38
|
+
bytes: int = 0
|
|
39
|
+
earliest: Optional[float] = None
|
|
40
|
+
latest: Optional[float] = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SourceWatcher(ABC):
|
|
44
|
+
"""Watches one kind of local AI-tool store and imports new content."""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def source_name(self) -> str:
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def poll(self) -> WatchResult:
|
|
53
|
+
"""Poll for changes and import new content (catching per-item errors)."""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def is_available(self) -> bool:
|
|
58
|
+
"""True if this source's paths exist on this system."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
def discover(self) -> SourceDiscovery:
|
|
62
|
+
"""Cheap dry-run report of what this source's store holds (see
|
|
63
|
+
:class:`SourceDiscovery`). Base form: availability only; watchers that
|
|
64
|
+
can stat their stores cheaply override with counts/sizes/ranges."""
|
|
65
|
+
return SourceDiscovery(name=self.source_name, available=self.is_available())
|