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,616 @@
|
|
|
1
|
+
"""The source watchers — one per supported provider.
|
|
2
|
+
|
|
3
|
+
Two properties worth noting: (1) each watcher calls the **local importer**
|
|
4
|
+
directly rather than POSTing to an HTTP ingest route, so it needs no backend
|
|
5
|
+
running, and (2) the JSONL file watchers share a single ``(mtime_ns, size)``
|
|
6
|
+
fingerprint skip, so an unchanged file is a no-op without a server round-trip.
|
|
7
|
+
|
|
8
|
+
Out of scope here: any non-conversation data source, and any peer/status server —
|
|
9
|
+
neither belongs in a serverless conversation archive.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import platform
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Callable, Iterator, Optional
|
|
18
|
+
|
|
19
|
+
from .._importers import (
|
|
20
|
+
import_antigravity_session_incremental,
|
|
21
|
+
import_claude_science_db,
|
|
22
|
+
import_cloth_session_incremental,
|
|
23
|
+
import_codex_session_incremental,
|
|
24
|
+
import_cowork_session_incremental,
|
|
25
|
+
import_cursor_db,
|
|
26
|
+
import_grok_session_incremental,
|
|
27
|
+
import_opencode_db,
|
|
28
|
+
import_session_incremental,
|
|
29
|
+
)
|
|
30
|
+
from .base import SourceDiscovery, SourceWatcher, WatchResult
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _stat_discovery(name: str, paths: Iterator[Path]) -> SourceDiscovery:
|
|
36
|
+
"""Fold a stream of store files into a :class:`SourceDiscovery` — stat only."""
|
|
37
|
+
items = 0
|
|
38
|
+
total = 0
|
|
39
|
+
earliest: Optional[float] = None
|
|
40
|
+
latest: Optional[float] = None
|
|
41
|
+
for p in paths:
|
|
42
|
+
try:
|
|
43
|
+
st = p.stat()
|
|
44
|
+
except OSError:
|
|
45
|
+
continue
|
|
46
|
+
if st.st_size == 0:
|
|
47
|
+
continue
|
|
48
|
+
items += 1
|
|
49
|
+
total += st.st_size
|
|
50
|
+
earliest = st.st_mtime if earliest is None else min(earliest, st.st_mtime)
|
|
51
|
+
latest = st.st_mtime if latest is None else max(latest, st.st_mtime)
|
|
52
|
+
return SourceDiscovery(
|
|
53
|
+
name=name, available=items > 0, items=items, bytes=total,
|
|
54
|
+
earliest=earliest, latest=latest,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def discover_claude_dirs() -> list[Path]:
|
|
59
|
+
"""All ``~/.claude*/projects/`` directories (handles .claude → .claude1 renames)."""
|
|
60
|
+
home = Path.home()
|
|
61
|
+
dirs = sorted(p for p in home.glob(".claude*/projects") if p.is_dir())
|
|
62
|
+
return dirs or [home / ".claude" / "projects"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── file (per-session JSONL) watchers ───────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class FileSessionWatcher(SourceWatcher):
|
|
69
|
+
"""Watches a tree of per-session JSONL transcripts, fingerprint-skipping
|
|
70
|
+
unchanged files and importing changed ones via ``self._import``.
|
|
71
|
+
|
|
72
|
+
Subclasses provide ``source_name``, ``is_available``, ``_iter_files`` (yields
|
|
73
|
+
``(path, source_id)``), and ``_import(path, source_id) -> result`` (a result
|
|
74
|
+
with ``.events_created`` / ``.thread_id`` / ``.is_new_thread``).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self) -> None:
|
|
78
|
+
# Per-file (mtime_ns, size) fingerprints, pruned each poll to files on disk.
|
|
79
|
+
self._seen: dict[str, tuple[int, int]] = {}
|
|
80
|
+
|
|
81
|
+
def _iter_files(self) -> Iterator[tuple[Path, str]]:
|
|
82
|
+
raise NotImplementedError
|
|
83
|
+
|
|
84
|
+
def _import(self, path: Path, source_id: str):
|
|
85
|
+
raise NotImplementedError
|
|
86
|
+
|
|
87
|
+
def discover(self) -> SourceDiscovery:
|
|
88
|
+
return _stat_discovery(self.source_name, (p for p, _ in self._iter_files()))
|
|
89
|
+
|
|
90
|
+
def poll(self) -> WatchResult:
|
|
91
|
+
result = WatchResult()
|
|
92
|
+
seen_this_poll: set[str] = set()
|
|
93
|
+
|
|
94
|
+
for session_file, source_id in self._iter_files():
|
|
95
|
+
try:
|
|
96
|
+
st = session_file.stat()
|
|
97
|
+
except OSError:
|
|
98
|
+
continue
|
|
99
|
+
if st.st_size == 0:
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
resolved = str(session_file.resolve())
|
|
103
|
+
fingerprint = (st.st_mtime_ns, st.st_size)
|
|
104
|
+
seen_this_poll.add(resolved)
|
|
105
|
+
if self._seen.get(resolved) == fingerprint:
|
|
106
|
+
result = result + WatchResult(sources_checked=1)
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
imp = self._import(session_file, source_id)
|
|
111
|
+
# Fingerprint only after a successful import, so a failure retries.
|
|
112
|
+
self._seen[resolved] = fingerprint
|
|
113
|
+
result = result + WatchResult(
|
|
114
|
+
sources_checked=1,
|
|
115
|
+
items_imported=1 if imp.events_created > 0 else 0,
|
|
116
|
+
events_created=imp.events_created,
|
|
117
|
+
)
|
|
118
|
+
except Exception as e: # noqa: BLE001 — one bad file must not stop the poll
|
|
119
|
+
msg = f"{self.source_name} import error for {source_id}: {e}"
|
|
120
|
+
logger.warning(msg)
|
|
121
|
+
result = result + WatchResult(sources_checked=1, errors=[msg])
|
|
122
|
+
|
|
123
|
+
# Prune fingerprints for files no longer present.
|
|
124
|
+
if seen_this_poll:
|
|
125
|
+
self._seen = {k: v for k, v in self._seen.items() if k in seen_this_poll}
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ClaudeCodeWatcher(FileSessionWatcher):
|
|
130
|
+
def __init__(self, projects_dirs: Optional[list[Path]] = None) -> None:
|
|
131
|
+
super().__init__()
|
|
132
|
+
self._projects_dirs = projects_dirs
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def source_name(self) -> str:
|
|
136
|
+
return "claude-code"
|
|
137
|
+
|
|
138
|
+
def _dirs(self) -> list[Path]:
|
|
139
|
+
if self._projects_dirs is not None:
|
|
140
|
+
return self._projects_dirs
|
|
141
|
+
return discover_claude_dirs()
|
|
142
|
+
|
|
143
|
+
def is_available(self) -> bool:
|
|
144
|
+
return any(d.exists() for d in self._dirs())
|
|
145
|
+
|
|
146
|
+
def _iter_files(self) -> Iterator[tuple[Path, str]]:
|
|
147
|
+
pairs: list[tuple[Path, str]] = []
|
|
148
|
+
for projects_dir in self._dirs():
|
|
149
|
+
if not projects_dir.exists():
|
|
150
|
+
continue
|
|
151
|
+
for project_dir in projects_dir.iterdir():
|
|
152
|
+
if not project_dir.is_dir():
|
|
153
|
+
continue
|
|
154
|
+
for session_file in project_dir.glob("*.jsonl"):
|
|
155
|
+
pairs.append((session_file, f"{project_dir.name}:{session_file.stem}"))
|
|
156
|
+
for agent_file in project_dir.glob("*/subagents/*.jsonl"):
|
|
157
|
+
pairs.append((agent_file, f"{project_dir.name}:{agent_file.stem}"))
|
|
158
|
+
|
|
159
|
+
# Import oldest-first so a continuation's parent thread already exists when
|
|
160
|
+
# the continuation is processed (continuation detection resolves to an
|
|
161
|
+
# existing thread). On the live path parents land in earlier polls anyway;
|
|
162
|
+
# this makes a cold start / rebuild-from-source merge correctly too.
|
|
163
|
+
def _mtime(path: Path) -> float:
|
|
164
|
+
try:
|
|
165
|
+
return path.stat().st_mtime
|
|
166
|
+
except OSError:
|
|
167
|
+
return 0.0
|
|
168
|
+
|
|
169
|
+
pairs.sort(key=lambda ps: _mtime(ps[0]))
|
|
170
|
+
yield from pairs
|
|
171
|
+
|
|
172
|
+
def _import(self, path: Path, source_id: str):
|
|
173
|
+
return import_session_incremental(path, source_id)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _RglobWatcher(FileSessionWatcher):
|
|
177
|
+
"""A FileSessionWatcher over ``root.rglob(glob)`` with provider-specific id +
|
|
178
|
+
importer."""
|
|
179
|
+
|
|
180
|
+
glob: str
|
|
181
|
+
_name: str
|
|
182
|
+
|
|
183
|
+
def __init__(self, root: Path, importer: Callable, source_id_of: Callable[[Path], str]) -> None:
|
|
184
|
+
super().__init__()
|
|
185
|
+
self.root = root
|
|
186
|
+
self._importer = importer
|
|
187
|
+
self._source_id_of = source_id_of
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def source_name(self) -> str:
|
|
191
|
+
return self._name
|
|
192
|
+
|
|
193
|
+
def is_available(self) -> bool:
|
|
194
|
+
return self.root.exists()
|
|
195
|
+
|
|
196
|
+
def _iter_files(self) -> Iterator[tuple[Path, str]]:
|
|
197
|
+
if not self.root.exists():
|
|
198
|
+
return
|
|
199
|
+
for f in sorted(self.root.rglob(self.glob)):
|
|
200
|
+
if f.is_file():
|
|
201
|
+
yield f, self._source_id_of(f)
|
|
202
|
+
|
|
203
|
+
def _import(self, path: Path, source_id: str):
|
|
204
|
+
return self._importer(path, source_id)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def codex_watcher(sessions_dir: Optional[Path] = None) -> _RglobWatcher:
|
|
208
|
+
w = _RglobWatcher(
|
|
209
|
+
sessions_dir or (Path.home() / ".codex" / "sessions"),
|
|
210
|
+
import_codex_session_incremental,
|
|
211
|
+
lambda p: p.stem,
|
|
212
|
+
)
|
|
213
|
+
w.glob, w._name = "*.jsonl", "codex"
|
|
214
|
+
return w
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def grok_watcher(sessions_dir: Optional[Path] = None) -> _RglobWatcher:
|
|
218
|
+
# Each session lives in its own UUID dir; that UUID is the stable id.
|
|
219
|
+
w = _RglobWatcher(
|
|
220
|
+
sessions_dir or (Path.home() / ".grok" / "sessions"),
|
|
221
|
+
import_grok_session_incremental,
|
|
222
|
+
lambda p: p.parent.name,
|
|
223
|
+
)
|
|
224
|
+
w.glob, w._name = "chat_history.jsonl", "grok"
|
|
225
|
+
return w
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def antigravity_watcher(brain_dir: Optional[Path] = None) -> _RglobWatcher:
|
|
229
|
+
# brain/<id>/.system_generated/logs/transcript.jsonl → <id> is the session UUID.
|
|
230
|
+
w = _RglobWatcher(
|
|
231
|
+
brain_dir or (Path.home() / ".gemini" / "antigravity-cli" / "brain"),
|
|
232
|
+
import_antigravity_session_incremental,
|
|
233
|
+
lambda p: p.parents[2].name,
|
|
234
|
+
)
|
|
235
|
+
w.glob, w._name = "transcript.jsonl", "antigravity"
|
|
236
|
+
return w
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def cloth_watcher(threads_dir: Optional[Path] = None) -> _RglobWatcher:
|
|
240
|
+
# cloth writes Claude-Code-shaped JSONL to ~/.thread/cloth/threads/<stem>.jsonl — one file
|
|
241
|
+
# per CLI session, ``source_id = "<stem>"``. Modern cloth names files by session
|
|
242
|
+
# uuid (already globally unique), so the source_id is the bare stem — no prefix.
|
|
243
|
+
# (Legacy numeric stems like ``1``/``22`` land bare too; they namespace under
|
|
244
|
+
# source='cloth' for import, and only collide with an integer thread PK on *read*,
|
|
245
|
+
# which is an accepted tradeoff for the near-dead numeric era.) CLOTH_HOME relocates
|
|
246
|
+
# the store. This is the sole live cloth store the standalone archive ingests.
|
|
247
|
+
import os
|
|
248
|
+
|
|
249
|
+
root = threads_dir or (Path(os.environ.get("CLOTH_HOME") or Path.home() / ".thread" / "cloth").expanduser() / "threads")
|
|
250
|
+
w = _RglobWatcher(root, import_cloth_session_incremental, lambda p: p.stem)
|
|
251
|
+
w.glob, w._name = "*.jsonl", "cloth"
|
|
252
|
+
return w
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ── DB (single live SQLite) watchers ────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _scan_errors(source_name: str, scan) -> list[str]:
|
|
259
|
+
"""A DB scan's per-item failures, tagged with the source, for the poll's errors.
|
|
260
|
+
|
|
261
|
+
The scanners catch per-item failures so one bad conversation can't abort the scan —
|
|
262
|
+
but a caught failure that only reaches the log is a conversation the archive doesn't
|
|
263
|
+
have and doesn't know it doesn't have. Carrying them here puts them in the daemon's
|
|
264
|
+
``health.json`` (``watch_errors_last``), so a failing item shows up in ``archive
|
|
265
|
+
status`` instead of reading as "nothing new"."""
|
|
266
|
+
return [f"{source_name}: {e}" for e in (getattr(scan, "errors", None) or [])]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class _DbScanWatcher(SourceWatcher):
|
|
270
|
+
"""Detects mtime changes on a single live SQLite DB and runs a scan importer."""
|
|
271
|
+
|
|
272
|
+
def __init__(self, db_path: Optional[Path], name: str, scanner: Callable, *, watch_wal: bool = False) -> None:
|
|
273
|
+
self.db_path = db_path
|
|
274
|
+
self._name = name
|
|
275
|
+
self._scanner = scanner
|
|
276
|
+
self._watch_wal = watch_wal
|
|
277
|
+
self._last_mtime: Optional[float] = None
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def source_name(self) -> str:
|
|
281
|
+
return self._name
|
|
282
|
+
|
|
283
|
+
def is_available(self) -> bool:
|
|
284
|
+
return self.db_path is not None and self.db_path.exists()
|
|
285
|
+
|
|
286
|
+
def _current_mtime(self) -> Optional[float]:
|
|
287
|
+
if not self.db_path or not self.db_path.exists():
|
|
288
|
+
return None
|
|
289
|
+
mtimes = [self.db_path.stat().st_mtime]
|
|
290
|
+
if self._watch_wal:
|
|
291
|
+
wal = self.db_path.with_name(self.db_path.name + "-wal")
|
|
292
|
+
if wal.exists():
|
|
293
|
+
mtimes.append(wal.stat().st_mtime)
|
|
294
|
+
return max(mtimes)
|
|
295
|
+
|
|
296
|
+
def discover(self) -> SourceDiscovery:
|
|
297
|
+
# One live SQLite DB: size + activity mtime. No conversation count —
|
|
298
|
+
# counting means opening and understanding the provider's schema, and
|
|
299
|
+
# the discovery pass is stat-only by contract.
|
|
300
|
+
if not self.is_available():
|
|
301
|
+
return SourceDiscovery(name=self._name, available=False)
|
|
302
|
+
try:
|
|
303
|
+
st = self.db_path.stat()
|
|
304
|
+
mtime = self._current_mtime() or st.st_mtime
|
|
305
|
+
return SourceDiscovery(
|
|
306
|
+
name=self._name, available=True, items=None, bytes=st.st_size,
|
|
307
|
+
earliest=None, latest=mtime,
|
|
308
|
+
)
|
|
309
|
+
except OSError:
|
|
310
|
+
return SourceDiscovery(name=self._name, available=False)
|
|
311
|
+
|
|
312
|
+
def poll(self) -> WatchResult:
|
|
313
|
+
try:
|
|
314
|
+
current = self._current_mtime()
|
|
315
|
+
except OSError as e:
|
|
316
|
+
return WatchResult(errors=[f"{self._name}: cannot stat db: {e}"])
|
|
317
|
+
if current is None:
|
|
318
|
+
return WatchResult(errors=[f"{self._name}: database not found"])
|
|
319
|
+
if self._last_mtime == current:
|
|
320
|
+
return WatchResult()
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
scan = self._scanner(self.db_path)
|
|
324
|
+
except Exception as e: # noqa: BLE001
|
|
325
|
+
return WatchResult(errors=[f"{self._name}: scan failed: {e}"])
|
|
326
|
+
# Fingerprint only after a successful scan, so a failure retries. Per-item
|
|
327
|
+
# failures inside the scan don't hold the fingerprint back — a permanently
|
|
328
|
+
# broken row would then re-scan the whole DB every poll forever — but they do
|
|
329
|
+
# ride out as errors, and the item itself retries on the DB's next change
|
|
330
|
+
# (its own watermark never advanced).
|
|
331
|
+
self._last_mtime = current
|
|
332
|
+
|
|
333
|
+
fields = vars(scan)
|
|
334
|
+
processed = next((v for k, v in fields.items() if k.endswith("_processed")), 0)
|
|
335
|
+
imported = next((v for k, v in fields.items() if k.endswith("_imported")), 0)
|
|
336
|
+
return WatchResult(
|
|
337
|
+
sources_checked=processed,
|
|
338
|
+
items_imported=imported,
|
|
339
|
+
events_created=scan.events_created,
|
|
340
|
+
errors=_scan_errors(self._name, scan),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def cursor_watcher(db_path: Optional[Path] = None) -> _DbScanWatcher:
|
|
345
|
+
return _DbScanWatcher(db_path or _cursor_default_db(), "cursor", import_cursor_db)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def opencode_watcher(db_path: Optional[Path] = None) -> _DbScanWatcher:
|
|
349
|
+
return _DbScanWatcher(
|
|
350
|
+
db_path or _opencode_default_db(), "opencode", import_opencode_db, watch_wal=True
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _cursor_default_db() -> Optional[Path]:
|
|
355
|
+
system = platform.system()
|
|
356
|
+
home = Path.home()
|
|
357
|
+
if system == "Darwin":
|
|
358
|
+
path = home / "Library" / "Application Support" / "Cursor" / "User" / "globalStorage" / "state.vscdb"
|
|
359
|
+
elif system == "Linux":
|
|
360
|
+
path = home / ".config" / "Cursor" / "User" / "globalStorage" / "state.vscdb"
|
|
361
|
+
elif system == "Windows":
|
|
362
|
+
import os
|
|
363
|
+
|
|
364
|
+
appdata = os.environ.get("APPDATA", "")
|
|
365
|
+
if not appdata:
|
|
366
|
+
return None
|
|
367
|
+
path = Path(appdata) / "Cursor" / "User" / "globalStorage" / "state.vscdb"
|
|
368
|
+
else:
|
|
369
|
+
return None
|
|
370
|
+
return path if path.exists() else None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _opencode_default_db() -> Optional[Path]:
|
|
374
|
+
path = Path.home() / ".local" / "share" / "opencode" / "opencode.db"
|
|
375
|
+
return path if path.exists() else None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ── Cowork (nested local-agent-mode sessions) ───────────────────────────────
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def discover_cowork_session_dirs() -> list[Path]:
|
|
382
|
+
"""Org-scoped dirs under ``~/Library/Application Support/Claude/
|
|
383
|
+
local-agent-mode-sessions/<user>/<org>/`` (one level above the ``local_<id>/``
|
|
384
|
+
session folders), so the caller derives ``user_uuid`` from ``parent.name`` and
|
|
385
|
+
``org_uuid`` from ``dir.name``."""
|
|
386
|
+
base = Path.home() / "Library" / "Application Support" / "Claude" / "local-agent-mode-sessions"
|
|
387
|
+
if not base.is_dir():
|
|
388
|
+
return []
|
|
389
|
+
dirs: list[Path] = []
|
|
390
|
+
for user_dir in base.iterdir():
|
|
391
|
+
if not user_dir.is_dir() or user_dir.name == "skills-plugin":
|
|
392
|
+
continue
|
|
393
|
+
for org_dir in user_dir.iterdir():
|
|
394
|
+
if org_dir.is_dir():
|
|
395
|
+
dirs.append(org_dir)
|
|
396
|
+
return dirs
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class CoworkWatcher(SourceWatcher):
|
|
400
|
+
"""Watches Cowork ``audit.jsonl`` logs across all org dirs and imports the grown
|
|
401
|
+
ones directly. source_id is ``{user_uuid}:{org_uuid}:{session_id}``; the human
|
|
402
|
+
title comes from the sibling ``local_{id}.json``."""
|
|
403
|
+
|
|
404
|
+
def __init__(self) -> None:
|
|
405
|
+
self._seen: dict[str, tuple[int, int]] = {}
|
|
406
|
+
|
|
407
|
+
@property
|
|
408
|
+
def source_name(self) -> str:
|
|
409
|
+
return "cowork"
|
|
410
|
+
|
|
411
|
+
def is_available(self) -> bool:
|
|
412
|
+
return bool(discover_cowork_session_dirs())
|
|
413
|
+
|
|
414
|
+
def discover(self) -> SourceDiscovery:
|
|
415
|
+
return _stat_discovery(
|
|
416
|
+
self.source_name, (audit for audit, _, _ in self._iter_sessions())
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
def _iter_sessions(self) -> Iterator[tuple[Path, str, Optional[Path]]]:
|
|
420
|
+
for org_dir in discover_cowork_session_dirs():
|
|
421
|
+
user_uuid = org_dir.parent.name
|
|
422
|
+
org_uuid = org_dir.name
|
|
423
|
+
for session_dir in org_dir.iterdir():
|
|
424
|
+
if not session_dir.is_dir() or not session_dir.name.startswith("local_"):
|
|
425
|
+
continue
|
|
426
|
+
audit_path = session_dir / "audit.jsonl"
|
|
427
|
+
if not audit_path.exists():
|
|
428
|
+
continue
|
|
429
|
+
session_id = session_dir.name[len("local_"):]
|
|
430
|
+
source_id = f"{user_uuid}:{org_uuid}:{session_id}"
|
|
431
|
+
metadata_path = org_dir / f"{session_dir.name}.json"
|
|
432
|
+
yield audit_path, source_id, (metadata_path if metadata_path.exists() else None)
|
|
433
|
+
|
|
434
|
+
def poll(self) -> WatchResult:
|
|
435
|
+
result = WatchResult()
|
|
436
|
+
seen_this_poll: set[str] = set()
|
|
437
|
+
for audit_path, source_id, metadata_path in self._iter_sessions():
|
|
438
|
+
try:
|
|
439
|
+
st = audit_path.stat()
|
|
440
|
+
except OSError:
|
|
441
|
+
continue
|
|
442
|
+
if st.st_size == 0:
|
|
443
|
+
continue
|
|
444
|
+
resolved = str(audit_path.resolve())
|
|
445
|
+
fingerprint = (st.st_mtime_ns, st.st_size)
|
|
446
|
+
seen_this_poll.add(resolved)
|
|
447
|
+
if self._seen.get(resolved) == fingerprint:
|
|
448
|
+
result = result + WatchResult(sources_checked=1)
|
|
449
|
+
continue
|
|
450
|
+
try:
|
|
451
|
+
imp = import_cowork_session_incremental(audit_path, source_id, metadata_path)
|
|
452
|
+
self._seen[resolved] = fingerprint
|
|
453
|
+
result = result + WatchResult(
|
|
454
|
+
sources_checked=1,
|
|
455
|
+
items_imported=1 if imp.events_created > 0 else 0,
|
|
456
|
+
events_created=imp.events_created,
|
|
457
|
+
)
|
|
458
|
+
except Exception as e: # noqa: BLE001 — one bad session must not stop the poll
|
|
459
|
+
msg = f"cowork import error for {source_id}: {e}"
|
|
460
|
+
logger.warning(msg)
|
|
461
|
+
result = result + WatchResult(sources_checked=1, errors=[msg])
|
|
462
|
+
if seen_this_poll:
|
|
463
|
+
self._seen = {k: v for k, v in self._seen.items() if k in seen_this_poll}
|
|
464
|
+
return result
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
# ── Claude Science (per-org SQLite DB of conversation frames) ────────────────
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def discover_claude_science_dbs(base: Optional[Path] = None) -> list[tuple[Path, str]]:
|
|
471
|
+
"""``(operon-cli.db path, org_uuid)`` for each org under ``~/.claude-science/orgs/``.
|
|
472
|
+
|
|
473
|
+
Claude Science (the AI Workbench app) keeps one live SQLite DB per org at
|
|
474
|
+
``orgs/<org_uuid>/operon-cli.db``; the org_uuid is the dir name, used to scope the
|
|
475
|
+
thread source_id so two orgs' frames never collide."""
|
|
476
|
+
base = base or (Path.home() / ".claude-science" / "orgs")
|
|
477
|
+
if not base.is_dir():
|
|
478
|
+
return []
|
|
479
|
+
out: list[tuple[Path, str]] = []
|
|
480
|
+
for org_dir in sorted(base.iterdir()):
|
|
481
|
+
if not org_dir.is_dir():
|
|
482
|
+
continue
|
|
483
|
+
db = org_dir / "operon-cli.db"
|
|
484
|
+
if db.exists():
|
|
485
|
+
out.append((db, org_dir.name))
|
|
486
|
+
return out
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class ClaudeScienceWatcher(SourceWatcher):
|
|
490
|
+
"""Watches every org's ``operon-cli.db`` and imports its conversation frames.
|
|
491
|
+
|
|
492
|
+
Like the Cursor/OpenCode DB watchers it mtime-gates a live SQLite DB wholesale
|
|
493
|
+
(DB **and** its ``-wal``, since writes can land in the WAL with the main file's
|
|
494
|
+
mtime untouched), but it discovers DBs dynamically each poll — as cowork does for
|
|
495
|
+
its org dirs — so an org created after startup is picked up without a restart."""
|
|
496
|
+
|
|
497
|
+
def __init__(self, base: Optional[Path] = None) -> None:
|
|
498
|
+
self._base = base
|
|
499
|
+
self._last_mtime: dict[str, float] = {}
|
|
500
|
+
|
|
501
|
+
@property
|
|
502
|
+
def source_name(self) -> str:
|
|
503
|
+
return "claude-science"
|
|
504
|
+
|
|
505
|
+
def is_available(self) -> bool:
|
|
506
|
+
return bool(discover_claude_science_dbs(self._base))
|
|
507
|
+
|
|
508
|
+
def discover(self) -> SourceDiscovery:
|
|
509
|
+
# Per-org live DBs: sizes + activity mtimes; org count isn't a
|
|
510
|
+
# conversation count, so items stays None (stat-only contract).
|
|
511
|
+
dbs = discover_claude_science_dbs(self._base)
|
|
512
|
+
report = _stat_discovery(self.source_name, (db for db, _ in dbs))
|
|
513
|
+
report.items = None
|
|
514
|
+
return report
|
|
515
|
+
|
|
516
|
+
@staticmethod
|
|
517
|
+
def _current_mtime(db_path: Path) -> Optional[float]:
|
|
518
|
+
try:
|
|
519
|
+
mtimes = [db_path.stat().st_mtime]
|
|
520
|
+
except OSError:
|
|
521
|
+
return None
|
|
522
|
+
wal = db_path.with_name(db_path.name + "-wal")
|
|
523
|
+
if wal.exists():
|
|
524
|
+
mtimes.append(wal.stat().st_mtime)
|
|
525
|
+
return max(mtimes)
|
|
526
|
+
|
|
527
|
+
def poll(self) -> WatchResult:
|
|
528
|
+
result = WatchResult()
|
|
529
|
+
seen_this_poll: set[str] = set()
|
|
530
|
+
for db_path, org_uuid in discover_claude_science_dbs(self._base):
|
|
531
|
+
key = str(db_path.resolve())
|
|
532
|
+
seen_this_poll.add(key)
|
|
533
|
+
mtime = self._current_mtime(db_path)
|
|
534
|
+
if mtime is None:
|
|
535
|
+
continue
|
|
536
|
+
if self._last_mtime.get(key) == mtime:
|
|
537
|
+
result = result + WatchResult(sources_checked=1)
|
|
538
|
+
continue
|
|
539
|
+
try:
|
|
540
|
+
scan = import_claude_science_db(db_path, org_uuid)
|
|
541
|
+
# Fingerprint only after a successful scan, so a failure retries.
|
|
542
|
+
self._last_mtime[key] = mtime
|
|
543
|
+
result = result + WatchResult(
|
|
544
|
+
sources_checked=scan.frames_processed,
|
|
545
|
+
items_imported=scan.frames_imported,
|
|
546
|
+
events_created=scan.events_created,
|
|
547
|
+
errors=_scan_errors(f"claude-science {org_uuid[:8]}", scan),
|
|
548
|
+
)
|
|
549
|
+
except Exception as e: # noqa: BLE001 — one bad org DB must not stop the poll
|
|
550
|
+
msg = f"claude-science scan failed for {org_uuid}: {e}"
|
|
551
|
+
logger.warning(msg)
|
|
552
|
+
result = result + WatchResult(sources_checked=1, errors=[msg])
|
|
553
|
+
# Prune fingerprints for org DBs no longer present.
|
|
554
|
+
if seen_this_poll:
|
|
555
|
+
self._last_mtime = {k: v for k, v in self._last_mtime.items() if k in seen_this_poll}
|
|
556
|
+
return result
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def default_watchers() -> list[SourceWatcher]:
|
|
560
|
+
"""One watcher per provider, default system paths.
|
|
561
|
+
|
|
562
|
+
Each is **self-gating** — ``poll_once`` skips any whose ``is_available()`` is
|
|
563
|
+
false — so a provider whose store is absent (no Cursor installed, no cloth store)
|
|
564
|
+
costs nothing and adds no process. cloth is a provider like the rest, riding this
|
|
565
|
+
one loop; there is no separate cloth daemon.
|
|
566
|
+
|
|
567
|
+
The exthost watcher runs last: the JSONL sources import each session's persisted
|
|
568
|
+
messages first (establishing their dedup_keys), so the exthost pass only has the
|
|
569
|
+
genuinely-lost steering messages left to write.
|
|
570
|
+
|
|
571
|
+
The export-drop watcher rides the same loop: it imports any claude.ai / xAI account
|
|
572
|
+
export dropped into ``<home>/dumps/`` (a human-driven drop zone, not a live store),
|
|
573
|
+
so a one-time bulk export needs no separate command."""
|
|
574
|
+
from .export_drop import ExportDropWatcher
|
|
575
|
+
from .exthost import ExthostWatcher
|
|
576
|
+
|
|
577
|
+
return [
|
|
578
|
+
ClaudeCodeWatcher(),
|
|
579
|
+
codex_watcher(),
|
|
580
|
+
grok_watcher(),
|
|
581
|
+
antigravity_watcher(),
|
|
582
|
+
cloth_watcher(),
|
|
583
|
+
cursor_watcher(),
|
|
584
|
+
opencode_watcher(),
|
|
585
|
+
CoworkWatcher(),
|
|
586
|
+
ClaudeScienceWatcher(),
|
|
587
|
+
ExportDropWatcher(),
|
|
588
|
+
ExthostWatcher(),
|
|
589
|
+
]
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# The mechanism watchers, as distinct from provider *sources*: export-drop only
|
|
593
|
+
# reads the archive's own <home>/dumps drop zone (consented by construction),
|
|
594
|
+
# and cc-exthost recovers claude-code steering messages — it follows the
|
|
595
|
+
# claude-code source rather than being a store of its own. Setup presents
|
|
596
|
+
# provider sources only; these two ride along.
|
|
597
|
+
_MECHANISM_SOURCES = frozenset({"export-drop", "cc-exthost"})
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def provider_watchers() -> list[SourceWatcher]:
|
|
601
|
+
"""The consumer-facing provider sources (default set minus the mechanisms)."""
|
|
602
|
+
return [w for w in default_watchers() if w.source_name not in _MECHANISM_SOURCES]
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def enabled_watchers(home=None) -> list[SourceWatcher]:
|
|
606
|
+
"""``default_watchers()`` minus sources disabled in ``<home>/config.json``.
|
|
607
|
+
|
|
608
|
+
The single choke point where operator source opt-outs (see
|
|
609
|
+
:func:`.._config.source_enabled`) reach every ingest path — the daemon, the
|
|
610
|
+
lazy MCP catch-up, ``archive watch`` — all of which construct their watcher
|
|
611
|
+
set here. No config file means the full default set.
|
|
612
|
+
"""
|
|
613
|
+
from .._config import load_config, source_enabled
|
|
614
|
+
|
|
615
|
+
cfg = load_config(home)
|
|
616
|
+
return [w for w in default_watchers() if source_enabled(cfg, w.source_name)]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The read-only local viewer, cohosted by the watcher (``archive watch --web``).
|
|
2
|
+
|
|
3
|
+
Datasette-shaped: a tiny stdlib HTTP server bound to localhost, serving a single
|
|
4
|
+
self-contained page (search + reader) plus a few JSON endpoints that call
|
|
5
|
+
straight into :mod:`thread_archive._api`. It runs on a daemon thread inside the
|
|
6
|
+
always-on watcher process — no second daemon, no standalone CLI verb — and pulls
|
|
7
|
+
in no new dependency (stdlib only), so the package stays serverless and
|
|
8
|
+
Python-only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .server import resolve_archive_link, route, serve_in_thread
|
|
14
|
+
|
|
15
|
+
__all__ = ["route", "serve_in_thread", "resolve_archive_link"]
|