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
@@ -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())