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,438 @@
1
+ """The curatorial write API — the librarian's hands on the topic graph.
2
+
3
+ Thin, typed functions over the event-sourced knowledge layer. Each *mutating* call
4
+ does the same three things atomically: append a ``KgEvent`` to the truth log, fold it
5
+ into the projection (:func:`thread_archive._knowledge.materialize.apply_event`), and —
6
+ when it owns the session — commit and drop the graph cache. One call = a durable log
7
+ line + an updated projection. The conversation archive stays untouched; this only
8
+ writes the knowledge overlay (topics, links, citations) that sits beside it.
9
+
10
+ Topics are threads (``thread_type='topic'``), so :func:`create_topic` writes a real
11
+ per-thread truth file and the ``topic.created`` event is the audit record; links and
12
+ evidence have no owning conversation file, so the ``kg_events.jsonl`` log *is* their
13
+ truth. Read helpers (:func:`review_queue`, :func:`topic_search`,
14
+ :func:`thread_user_messages`) are the curation-read surface the librarian needs to
15
+ decide *what* to write — kept here so the MCP and the skill share one library.
16
+
17
+ All writes are idempotent at the projection layer (upsert + tombstone), so re-running
18
+ the librarian never double-links or double-cites — a partial pass is simply redone.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import functools
24
+ import json
25
+ import os
26
+ from datetime import datetime, timezone
27
+ from typing import Optional
28
+
29
+ from sqlalchemy import or_, select
30
+ from sqlalchemy.orm import Session
31
+
32
+ from .._store import Event, KgEvent, Thread, ThreadLink, TopicMessage, use_session
33
+ from .._truth.jsonl_log import append_kg_event, record_thread, shared_ingest_lock
34
+ from .graph import reset_cache
35
+ from .materialize import apply_event
36
+
37
+
38
+ def _now() -> datetime:
39
+ return datetime.now(timezone.utc)
40
+
41
+
42
+ def _locked_write(fn):
43
+ """Run an own-session mutation under the shared reindex lock.
44
+
45
+ Every mutating function here appends truth (a ``KgEvent``, sometimes a thread
46
+ record) and commits; racing a reindex unlocked, that write can land after the
47
+ rebuild's read point and commit into the database inode the swap replaces.
48
+ When the caller passes its own ``session`` it owns the transaction boundary —
49
+ and must hold the lock around it itself."""
50
+
51
+ @functools.wraps(fn)
52
+ def wrapper(*args, session: Optional[Session] = None, **kwargs):
53
+ if session is not None:
54
+ return fn(*args, session=session, **kwargs)
55
+ with shared_ingest_lock():
56
+ return fn(*args, session=None, **kwargs)
57
+
58
+ return wrapper
59
+
60
+
61
+ def _topic_name(title: str) -> str:
62
+ """The unique ``threads.name`` for a topic. Topics share the threads table with
63
+ conversations, so they need a namespaced unique name distinct from a conversation's
64
+ ``"{source}:{source_id}"``."""
65
+ return f"topic:{title}"
66
+
67
+
68
+ def _emit(
69
+ session: Session,
70
+ *,
71
+ event_type: str,
72
+ entity_type: str,
73
+ entity_id,
74
+ payload: dict,
75
+ actor: str,
76
+ actor_thread_id: Optional[int],
77
+ ) -> KgEvent:
78
+ """Append a ``KgEvent`` and fold it into the projection, in the caller's session.
79
+
80
+ Flush first so ``id`` / ``recorded_at`` are populated before the row is staged to
81
+ truth and applied; the caller owns the commit (the before-commit drain writes the
82
+ log line). The single seam every mutating function below goes through."""
83
+ ev = KgEvent(
84
+ event_type=event_type,
85
+ entity_type=entity_type,
86
+ entity_id=str(entity_id) if entity_id is not None else None,
87
+ payload=payload,
88
+ actor=actor,
89
+ actor_thread_id=actor_thread_id,
90
+ occurred_at=_now(),
91
+ )
92
+ session.add(ev)
93
+ session.flush()
94
+ append_kg_event(session, ev)
95
+ apply_event(session, ev)
96
+ return ev
97
+
98
+
99
+ # ── topics ─────────────────────────────────────────────────────────────────────
100
+ @_locked_write
101
+ def create_topic(
102
+ title: str,
103
+ description: Optional[str] = None,
104
+ *,
105
+ topic_kind: Optional[str] = None,
106
+ actor: str = "librarian",
107
+ actor_thread_id: Optional[int] = None,
108
+ session: Optional[Session] = None,
109
+ ) -> dict:
110
+ """Create a topic (a ``thread_type='topic'`` thread) and return ``{topic_id, ...}``.
111
+
112
+ Raises ``ValueError`` if a topic with that title already exists — search first
113
+ (:func:`topic_search`) and link to the existing one rather than making a duplicate.
114
+ """
115
+ own = session is None
116
+ with use_session(session) as s:
117
+ existing = s.execute(select(Thread).where(Thread.name == _topic_name(title))).scalars().first()
118
+ if existing is not None:
119
+ raise ValueError(f"a topic named {title!r} already exists (id={existing.id}); link to it instead")
120
+ topic = Thread(
121
+ name=_topic_name(title), title=title, description=description,
122
+ thread_type="topic", topic_kind=topic_kind,
123
+ )
124
+ s.add(topic)
125
+ s.flush()
126
+ record_thread(s, topic) # the topic's own per-thread truth file opens with its record
127
+ ev = _emit(
128
+ s, event_type="topic.created", entity_type="topic", entity_id=topic.id,
129
+ payload={"title": title, "description": description, "thread_id": topic.id},
130
+ actor=actor, actor_thread_id=actor_thread_id,
131
+ )
132
+ result = {"topic_id": topic.id, "title": title, "event_id": ev.id}
133
+ if own:
134
+ s.commit()
135
+ reset_cache()
136
+ return result
137
+
138
+
139
+ @_locked_write
140
+ def rename_topic(
141
+ topic_id: int, title: str, *, description: Optional[str] = None,
142
+ actor: str = "librarian", actor_thread_id: Optional[int] = None,
143
+ session: Optional[Session] = None,
144
+ ) -> dict:
145
+ """Rename (and optionally re-describe) a topic."""
146
+ own = session is None
147
+ with use_session(session) as s:
148
+ topic = _require_topic(s, topic_id)
149
+ topic.title = title
150
+ if description is not None:
151
+ topic.description = description
152
+ topic.updated_at = _now()
153
+ record_thread(s, topic) # latest-wins metadata in the topic's per-thread file
154
+ payload: dict = {"title": title}
155
+ if description is not None:
156
+ payload["description"] = description
157
+ ev = _emit(s, event_type="topic.renamed", entity_type="topic", entity_id=topic_id,
158
+ payload=payload, actor=actor, actor_thread_id=actor_thread_id)
159
+ result = {"topic_id": int(topic_id), "title": title, "event_id": ev.id}
160
+ if own:
161
+ s.commit()
162
+ reset_cache()
163
+ return result
164
+
165
+
166
+ @_locked_write
167
+ def archive_topic(
168
+ topic_id: int, *, actor: str = "librarian", actor_thread_id: Optional[int] = None,
169
+ session: Optional[Session] = None,
170
+ ) -> dict:
171
+ """Archive a topic (drops it from the live graph; the thread stays reachable)."""
172
+ own = session is None
173
+ with use_session(session) as s:
174
+ topic = _require_topic(s, topic_id)
175
+ topic.archived = True
176
+ topic.updated_at = _now()
177
+ record_thread(s, topic)
178
+ ev = _emit(s, event_type="topic.archived", entity_type="topic", entity_id=topic_id,
179
+ payload={}, actor=actor, actor_thread_id=actor_thread_id)
180
+ result = {"topic_id": int(topic_id), "archived": True, "event_id": ev.id}
181
+ if own:
182
+ s.commit()
183
+ reset_cache()
184
+ return result
185
+
186
+
187
+ @_locked_write
188
+ def merge_topics(
189
+ from_id: int, into_id: int, *, actor: str = "librarian",
190
+ actor_thread_id: Optional[int] = None, session: Optional[Session] = None,
191
+ ) -> dict:
192
+ """Merge ``from_id`` into ``into_id``: repoint its links + citations, archive it."""
193
+ own = session is None
194
+ with use_session(session) as s:
195
+ from_topic = _require_topic(s, from_id)
196
+ _require_topic(s, into_id)
197
+ ev = _emit(s, event_type="topic.merged", entity_type="topic", entity_id=into_id,
198
+ payload={"from_id": int(from_id), "into_id": int(into_id)},
199
+ actor=actor, actor_thread_id=actor_thread_id)
200
+ # Persist the merged-away topic's archival to its per-thread file too (the fold
201
+ # archived it in the table; this records it in the truth the file carries).
202
+ from_topic.archived = True
203
+ from_topic.updated_at = ev.occurred_at
204
+ record_thread(s, from_topic)
205
+ result = {"from_id": int(from_id), "into_id": int(into_id), "event_id": ev.id}
206
+ if own:
207
+ s.commit()
208
+ reset_cache()
209
+ return result
210
+
211
+
212
+ # ── links ──────────────────────────────────────────────────────────────────────
213
+ @_locked_write
214
+ def link_threads(
215
+ source_id: int, target_id: int, link_type: str = "related", *,
216
+ strength: float = 1.0, evidence: Optional[str] = None, actor: str = "librarian",
217
+ actor_thread_id: Optional[int] = None, session: Optional[Session] = None,
218
+ ) -> dict:
219
+ """Create (or update) a directed link between two threads/topics. Idempotent on
220
+ ``(source, target, link_type)``. Both endpoints must exist — the kg log is
221
+ truth, and a link to a nonexistent thread would dangle on every replay."""
222
+ own = session is None
223
+ with use_session(session) as s:
224
+ for tid in (source_id, target_id):
225
+ if s.get(Thread, int(tid)) is None:
226
+ raise ValueError(f"no thread with id {tid} — link endpoints must exist")
227
+ ev = _emit(
228
+ s, event_type="link.created", entity_type="link",
229
+ entity_id=f"{int(source_id)}:{int(target_id)}:{link_type}",
230
+ payload={
231
+ "source_thread_id": int(source_id), "target_thread_id": int(target_id),
232
+ "link_type": link_type, "strength": float(strength),
233
+ "evidence": evidence, "created_by": actor,
234
+ },
235
+ actor=actor, actor_thread_id=actor_thread_id,
236
+ )
237
+ result = {
238
+ "source_thread_id": int(source_id), "target_thread_id": int(target_id),
239
+ "link_type": link_type, "event_id": ev.id,
240
+ }
241
+ if own:
242
+ s.commit()
243
+ reset_cache()
244
+ return result
245
+
246
+
247
+ @_locked_write
248
+ def unlink_threads(
249
+ source_id: int, target_id: int, link_type: str = "related", *,
250
+ actor: str = "librarian", actor_thread_id: Optional[int] = None,
251
+ session: Optional[Session] = None,
252
+ ) -> dict:
253
+ """Remove a link (a tombstone event — the operation is recorded, not erased)."""
254
+ own = session is None
255
+ with use_session(session) as s:
256
+ ev = _emit(
257
+ s, event_type="link.deleted", entity_type="link",
258
+ entity_id=f"{int(source_id)}:{int(target_id)}:{link_type}",
259
+ payload={"source_thread_id": int(source_id), "target_thread_id": int(target_id),
260
+ "link_type": link_type},
261
+ actor=actor, actor_thread_id=actor_thread_id,
262
+ )
263
+ result = {"source_thread_id": int(source_id), "target_thread_id": int(target_id),
264
+ "link_type": link_type, "event_id": ev.id}
265
+ if own:
266
+ s.commit()
267
+ reset_cache()
268
+ return result
269
+
270
+
271
+ # ── evidence (message → topic citations) ─────────────────────────────────────────
272
+ @_locked_write
273
+ def add_topic_evidence(
274
+ topic_id: int, event_id: int, thread_id: int, quote: str, *,
275
+ actor: str = "librarian", actor_thread_id: Optional[int] = None,
276
+ session: Optional[Session] = None,
277
+ ) -> dict:
278
+ """Cite a conversation message as evidence for a topic. Idempotent on
279
+ ``(topic_id, event_id)``.
280
+
281
+ Validated before anything is emitted: the topic must exist (and be a topic),
282
+ the event must exist, and ``thread_id`` must be the cited event's actual
283
+ thread — a citation is a claim about a real message, and the kg log is truth,
284
+ so a bad reference must be rejected here rather than replayed forever."""
285
+ own = session is None
286
+ with use_session(session) as s:
287
+ _require_topic(s, topic_id)
288
+ cited = s.get(Event, int(event_id))
289
+ if cited is None:
290
+ raise ValueError(
291
+ f"no event with id {event_id} — citations must reference a real archived message"
292
+ )
293
+ if int(cited.thread_id) != int(thread_id):
294
+ raise ValueError(
295
+ f"event {event_id} belongs to thread {cited.thread_id}, not {thread_id} — "
296
+ "check which message you meant to cite"
297
+ )
298
+ ev = _emit(
299
+ s, event_type="evidence.added", entity_type="topic_message",
300
+ entity_id=f"{int(topic_id)}:{int(event_id)}",
301
+ payload={"topic_id": int(topic_id), "event_id": int(event_id),
302
+ "thread_id": int(thread_id), "quote": quote, "actor": actor},
303
+ actor=actor, actor_thread_id=actor_thread_id,
304
+ )
305
+ result = {"topic_id": int(topic_id), "event_id": int(event_id), "kg_event_id": ev.id}
306
+ if own:
307
+ s.commit()
308
+ return result
309
+
310
+
311
+ @_locked_write
312
+ def archive_topic_evidence(
313
+ topic_id: int, event_id: int, *, actor: str = "librarian",
314
+ actor_thread_id: Optional[int] = None, session: Optional[Session] = None,
315
+ ) -> dict:
316
+ """Archive a citation (tombstone — sets ``archived_at``, keeps the row + history)."""
317
+ own = session is None
318
+ with use_session(session) as s:
319
+ ev = _emit(
320
+ s, event_type="evidence.archived", entity_type="topic_message",
321
+ entity_id=f"{int(topic_id)}:{int(event_id)}",
322
+ payload={"topic_id": int(topic_id), "event_id": int(event_id)},
323
+ actor=actor, actor_thread_id=actor_thread_id,
324
+ )
325
+ result = {"topic_id": int(topic_id), "event_id": int(event_id), "kg_event_id": ev.id}
326
+ if own:
327
+ s.commit()
328
+ return result
329
+
330
+
331
+ # ── curation-read surface ────────────────────────────────────────────────────────
332
+ def review_queue(
333
+ limit: int = 20, *, exclude_source_id: Optional[str] = None,
334
+ exclude_ids: Optional[list[int]] = None, session: Optional[Session] = None,
335
+ ) -> list[dict]:
336
+ """Unreviewed conversation threads — the librarian's backlog.
337
+
338
+ Event-bearing conversation threads the librarian hasn't curated yet, newest first.
339
+ Pass ``exclude_source_id`` to drop the caller's own live session (its still-growing
340
+ transcript sits at the top of its own queue otherwise).
341
+
342
+ **State is the data, not a ledger.** 'Reviewed' is *derived from the curation a thread
343
+ produced* — a thread leaves the queue the instant it gains its first live topic
344
+ citation (or a link touching it); there is no summary column or processed-list to keep
345
+ in sync. That makes the queue idempotent (a half-done thread simply reappears) and safe
346
+ to drain even if two workers briefly overlap. Corollary: a thread genuinely read but
347
+ yielding nothing worth citing stays in the queue — acceptable, and in practice the
348
+ librarian-gate forces ≥1 citation per processed thread, so reviewed ⇒ cited.
349
+
350
+ **Parallel backfill via lease-claims.** When ``$THREAD_ARCHIVE_LIBRARIAN_WORKER`` is
351
+ set (the backfill driver sets a distinct id per instance), this call *claims* the batch
352
+ it returns — recording a timestamped lease in ``<home>/.librarian-claims.json`` so other
353
+ workers skip it, and reclaiming any lease older than an hour (a dead worker's). Launch N
354
+ instances with distinct worker ids and they self-balance with no central coordinator.
355
+ Interactive use (no worker id) is a plain read. ``exclude_ids`` filters explicitly and
356
+ bypasses the claim path (it's how the claim layer asks for the un-held remainder)."""
357
+ worker = os.environ.get("THREAD_ARCHIVE_LIBRARIAN_WORKER")
358
+ if worker and session is None and exclude_ids is None:
359
+ from ._claims import claim_review_batch
360
+
361
+ return claim_review_batch(worker, batch=limit, exclude_source_id=exclude_source_id)
362
+
363
+ has_events = select(Event.id).where(Event.thread_id == Thread.id).exists()
364
+ # 'Curated' = the librarian drew something from this thread: a live (non-archived)
365
+ # topic citation sourced from it, or a link touching it. Review state is
366
+ # derived from the curation the thread produced, never from a summary
367
+ # column (there is none).
368
+ is_curated = or_(
369
+ select(TopicMessage.id)
370
+ .where(TopicMessage.thread_id == Thread.id, TopicMessage.archived_at.is_(None))
371
+ .exists(),
372
+ select(ThreadLink.id)
373
+ .where(
374
+ or_(
375
+ ThreadLink.source_thread_id == Thread.id,
376
+ ThreadLink.target_thread_id == Thread.id,
377
+ )
378
+ )
379
+ .exists(),
380
+ )
381
+ conds = [
382
+ Thread.thread_type == "conversation",
383
+ ~is_curated,
384
+ or_(Thread.archived.is_(False), Thread.archived.is_(None)),
385
+ has_events,
386
+ ]
387
+ if exclude_source_id:
388
+ conds.append(or_(Thread.source_id.is_(None), Thread.source_id != exclude_source_id))
389
+ if exclude_ids:
390
+ conds.append(Thread.id.notin_(exclude_ids))
391
+ with use_session(session) as s:
392
+ rows = s.execute(
393
+ select(Thread.id, Thread.title, Thread.source_id)
394
+ .where(*conds).order_by(Thread.id.desc()).limit(limit)
395
+ ).all()
396
+ return [{"id": r.id, "title": r.title, "source_id": r.source_id} for r in rows]
397
+
398
+
399
+ def topic_search(query: str, limit: int = 10, *, session: Optional[Session] = None) -> list[dict]:
400
+ """Find existing live topics by title substring — search before creating duplicates."""
401
+ like = f"%{query}%"
402
+ with use_session(session) as s:
403
+ rows = s.execute(
404
+ select(Thread.id, Thread.title).where(
405
+ Thread.thread_type == "topic",
406
+ or_(Thread.archived.is_(False), Thread.archived.is_(None)),
407
+ Thread.title.ilike(like),
408
+ ).order_by(Thread.id).limit(limit)
409
+ ).all()
410
+ return [{"topic_id": r.id, "title": r.title} for r in rows]
411
+
412
+
413
+ def thread_user_messages(
414
+ thread_id: int, *, limit: Optional[int] = None, session: Optional[Session] = None,
415
+ ) -> list[dict]:
416
+ """A thread's user messages as ``[{event_id, text}]`` — the cheap, high-signal read
417
+ the librarian cites from (citations anchor on these ``event_id`` values)."""
418
+ q = select(Event.id, Event.payload).where(
419
+ Event.thread_id == int(thread_id),
420
+ Event.event_type.in_(("user_message_sent", "thread_message_sent")),
421
+ ).order_by(Event.id)
422
+ if limit:
423
+ q = q.limit(limit)
424
+ out: list[dict] = []
425
+ with use_session(session) as s:
426
+ for eid, payload in s.execute(q):
427
+ p = payload if isinstance(payload, dict) else json.loads(payload)
428
+ content = (p.get("content") or "").strip()
429
+ if content:
430
+ out.append({"event_id": eid, "text": content})
431
+ return out
432
+
433
+
434
+ def _require_topic(session: Session, topic_id: int) -> Thread:
435
+ topic = session.get(Thread, int(topic_id))
436
+ if topic is None or topic.thread_type != "topic":
437
+ raise ValueError(f"no topic with id {topic_id}")
438
+ return topic
@@ -0,0 +1,167 @@
1
+ """LaunchAgent management for the always-on watcher — private machinery.
2
+
3
+ ``archive daemon install`` materializes the watcher's LaunchAgent plist from
4
+ inside the package — pointing at the installed ``archive`` console script,
5
+ wherever this Python environment put it — and loads it. No repo checkout, no
6
+ Makefile, no sed: this is the "upgrade to always-fresh" step after a plain
7
+ ``pip install thread-archive``, and it is what ``host/Makefile install-agent``
8
+ delegates to for the operator flow (which adds the thread-family manifest on
9
+ top; the manifest writer stays in ``host/``, repo-only by design).
10
+
11
+ macOS only, deliberately (launchd is the product's process manager). The
12
+ plist mirrors what the watcher needs and nothing else: run at login in the
13
+ Aqua session (the watched stores live in the user's home), restart on crash,
14
+ logs under ``<archive home>/logs``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import plistlib
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ import time
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+ WATCHER_LABEL = "com.thread-archive.watcher"
28
+
29
+
30
+ def _require_darwin() -> None:
31
+ if sys.platform != "darwin":
32
+ raise SystemExit("archive daemon: launchd management is macOS-only")
33
+
34
+
35
+ def _plist_path() -> Path:
36
+ return Path.home() / "Library" / "LaunchAgents" / f"{WATCHER_LABEL}.plist"
37
+
38
+
39
+ def _entry_path() -> Path:
40
+ """The ``archive`` console script this environment installed — the thing
41
+ the plist must point at (launchd won't inherit a venv)."""
42
+ candidate = Path(sys.executable).with_name("archive")
43
+ if candidate.is_file():
44
+ return candidate
45
+ found = shutil.which("archive")
46
+ if found:
47
+ return Path(found)
48
+ raise SystemExit(
49
+ "archive daemon: cannot find the `archive` console script next to "
50
+ f"{sys.executable} or on PATH — is the package installed in this environment?"
51
+ )
52
+
53
+
54
+ def watcher_plist(
55
+ entry: Path,
56
+ log_dir: Path,
57
+ *,
58
+ home: Optional[str] = None,
59
+ web: bool = True,
60
+ web_port: int = 8787,
61
+ ) -> dict:
62
+ """The watcher LaunchAgent as a plist dict (pure — no filesystem, no launchctl)."""
63
+ args = [str(entry), "watch"]
64
+ if web:
65
+ # Cohost the read-only viewer in the watcher's own process so it has a
66
+ # persistent URL — one process, one engine; WAL makes the concurrent
67
+ # reads safe (see _store/_base.py).
68
+ args += ["--web", "--web-port", str(web_port)]
69
+ env = {
70
+ # The entry's own bin dir first so `archive` resolves its interpreter.
71
+ "PATH": f"{entry.parent}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
72
+ }
73
+ if home:
74
+ env["THREAD_ARCHIVE_HOME"] = home
75
+ return {
76
+ "Label": WATCHER_LABEL,
77
+ "ProgramArguments": args,
78
+ "RunAtLoad": True,
79
+ # The user's GUI login session — that's where the watched home-dir
80
+ # stores (~/.claude, ~/.codex, Cursor, …) live.
81
+ "LimitLoadToSessionType": "Aqua",
82
+ "KeepAlive": {"Crashed": True},
83
+ "ThrottleInterval": 5,
84
+ # A live working-memory indexer: don't let App Nap stall the poll cadence.
85
+ "ProcessType": "Standard",
86
+ "WorkingDirectory": str(Path.home()),
87
+ "StandardOutPath": str(log_dir / "watcher-stdout.log"),
88
+ "StandardErrorPath": str(log_dir / "watcher-stderr.log"),
89
+ "EnvironmentVariables": env,
90
+ }
91
+
92
+
93
+ def _uid() -> int:
94
+ import os
95
+
96
+ return os.getuid()
97
+
98
+
99
+ def _launchctl(*args: str, check: bool = False) -> subprocess.CompletedProcess:
100
+ return subprocess.run(
101
+ ["launchctl", *args], check=check, capture_output=True, text=True
102
+ )
103
+
104
+
105
+ def install_watcher(
106
+ home: Optional[str] = None, *, web: bool = True, web_port: int = 8787
107
+ ) -> Path:
108
+ """Write the plist and (re)load the agent. Returns the plist path.
109
+
110
+ Idempotent: an existing agent is booted out first, so re-running after an
111
+ upgrade or a config change is the supported way to apply it.
112
+ """
113
+ _require_darwin()
114
+ from ._config import resolve_paths
115
+
116
+ entry = _entry_path()
117
+ log_dir = resolve_paths(home).home / "logs"
118
+ # launchd won't mkdir StandardOutPath's parent — a missing dir fails the
119
+ # load silently.
120
+ log_dir.mkdir(parents=True, exist_ok=True)
121
+ plist = _plist_path()
122
+ plist.parent.mkdir(parents=True, exist_ok=True)
123
+ plist.write_bytes(
124
+ plistlib.dumps(watcher_plist(entry, log_dir, home=home, web=web, web_port=web_port))
125
+ )
126
+ domain = f"gui/{_uid()}"
127
+ if _launchctl("bootout", f"{domain}/{WATCHER_LABEL}").returncode == 0:
128
+ # Let bootout settle before bootstrap (avoids 'Bootstrap failed: 5:
129
+ # Input/output error').
130
+ time.sleep(3)
131
+ result = _launchctl("bootstrap", domain, str(plist))
132
+ if result.returncode != 0:
133
+ raise SystemExit(
134
+ f"archive daemon: launchctl bootstrap failed: {result.stderr.strip()}"
135
+ )
136
+ return plist
137
+
138
+
139
+ def uninstall_watcher() -> None:
140
+ _require_darwin()
141
+ _launchctl("bootout", f"gui/{_uid()}/{WATCHER_LABEL}")
142
+ _plist_path().unlink(missing_ok=True)
143
+
144
+
145
+ def restart_watcher() -> None:
146
+ """Apply a code edit: kick the running agent (the plist itself is only
147
+ re-read on install)."""
148
+ _require_darwin()
149
+ result = _launchctl("kickstart", "-k", f"gui/{_uid()}/{WATCHER_LABEL}")
150
+ if result.returncode != 0:
151
+ raise SystemExit(
152
+ f"archive daemon: launchctl kickstart failed: {result.stderr.strip()}"
153
+ )
154
+
155
+
156
+ def watcher_status() -> str:
157
+ """A short human-readable status: state/pid/program, or 'not loaded'."""
158
+ _require_darwin()
159
+ result = _launchctl("print", f"gui/{_uid()}/{WATCHER_LABEL}")
160
+ if result.returncode != 0:
161
+ return f"{WATCHER_LABEL}: not loaded"
162
+ lines = [
163
+ ln.strip()
164
+ for ln in result.stdout.splitlines()
165
+ if any(k in ln for k in ("state =", "pid =", "program ="))
166
+ ]
167
+ return "\n".join([WATCHER_LABEL, *lines])
@@ -0,0 +1 @@
1
+ """The MCP agent surface: ``thread_search`` + ``thread_read`` over the local archive."""