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,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."""
|