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,538 @@
1
+ """SQLite FTS5 lexical search.
2
+
3
+ The embedded FTS arm, SQLite-native throughout. One in-DB FTS5 virtual table
4
+ (``event_search``) over event content, derived from the ``events_fts`` shadow,
5
+ which is in turn derived from the events. ``rebuild_fts`` does both derivations
6
+ and is the FTS half of ``reindex``.
7
+
8
+ SQL is composed with literal table names + bound params (never f-strings) — the
9
+ no-f-string-SQL convention.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import re
17
+ from datetime import datetime
18
+ from typing import Optional
19
+
20
+ from sqlalchemy import delete, insert, select
21
+ from sqlalchemy import text as sa_text
22
+ from sqlalchemy.orm import Session
23
+
24
+ from .._store import Event, EventFts, use_session
25
+ from ._classify import canonical_time_bound, classify_query
26
+ from ._extract import INDEXABLE_EVENT_TYPES, extract_fts_content
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Content is indexed; everything else is UNINDEXED so it can still be filtered in
31
+ # WHERE (thread/type/tool/time) without bloating the index.
32
+ _CREATE_FTS = (
33
+ "CREATE VIRTUAL TABLE event_search USING fts5("
34
+ "content, event_id UNINDEXED, thread_id UNINDEXED, event_type UNINDEXED, "
35
+ "content_type UNINDEXED, tool_name UNINDEXED, occurred_at UNINDEXED, "
36
+ "tokenize = 'porter unicode61')"
37
+ )
38
+
39
+ # Plain FTS5 bm25 (more-negative = better).
40
+ _RANK_EXPR = "bm25(event_search)"
41
+
42
+
43
+ def build_event_hit(
44
+ *,
45
+ event_id: int,
46
+ thread_id: int,
47
+ event_type: str,
48
+ content_type: Optional[str],
49
+ snippet: str,
50
+ full_content: str,
51
+ occurred_at_ts: int,
52
+ ) -> dict:
53
+ """One event search hit in the canonical shape. ``thread_title`` is enriched
54
+ by the caller."""
55
+ return {
56
+ "event_id": event_id,
57
+ "thread_id": thread_id,
58
+ "thread_title": None,
59
+ "event_type": event_type,
60
+ "content_type": content_type,
61
+ "snippet": snippet,
62
+ "full_content": full_content,
63
+ "occurred_at": datetime.fromtimestamp(occurred_at_ts) if occurred_at_ts else None,
64
+ }
65
+
66
+
67
+ def ensure_fts(session: Optional[Session] = None) -> None:
68
+ """Create the FTS5 virtual table if absent. Idempotent."""
69
+ with use_session(session) as s:
70
+ exists = s.execute(
71
+ sa_text("SELECT 1 FROM sqlite_master WHERE name = :n"), {"n": "event_search"}
72
+ ).scalar()
73
+ if not exists:
74
+ s.execute(sa_text(_CREATE_FTS))
75
+ if session is None:
76
+ s.commit()
77
+
78
+
79
+ def _to_match_query(query: str) -> str:
80
+ """Translate a natural-language / boolean / quoted-phrase query into a safe
81
+ FTS5 MATCH expression. AND/OR/NOT and "quoted phrases" pass through; every
82
+ other token is emitted quoted so stray punctuation can't raise a syntax error."""
83
+ out: list[str] = []
84
+ for tok in re.findall(r'"[^"]*"|\S+', query or ""):
85
+ if tok in ("AND", "OR", "NOT") or tok.startswith('"'):
86
+ out.append(tok)
87
+ else:
88
+ out.append('"' + tok.replace('"', "") + '"')
89
+ return " ".join(out) or '""'
90
+
91
+
92
+ def _clean_query_text(query: str) -> str:
93
+ """Strip quotes + boolean keywords and collapse whitespace — the substring a
94
+ code-identifier / pipe-OR query matches."""
95
+ clean = re.sub(r'["\']', "", query or "")
96
+ clean = re.sub(r"\b(AND|OR|NOT)\b", " ", clean)
97
+ return re.sub(r"\s+", " ", clean).strip()
98
+
99
+
100
+ def _like_prefix(prefix: str) -> str:
101
+ """Escape LIKE wildcards (``\\`` ``%`` ``_``) so ``startswith`` matches a literal
102
+ prefix; pair with ``ESCAPE '\\'`` in the SQL."""
103
+ escaped = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
104
+ return escaped + "%"
105
+
106
+
107
+ def _in_clause(column: str, values: list, prefix: str, params: dict, negate: bool) -> str:
108
+ names = []
109
+ for i, v in enumerate(values):
110
+ key = prefix + str(i)
111
+ params[key] = v
112
+ names.append(":" + key)
113
+ op = " NOT IN (" if negate else " IN ("
114
+ return column + op + ",".join(names) + ")"
115
+
116
+
117
+ def _quote_phrase(text_: str) -> str:
118
+ """A cleaned text span as one FTS5 phrase term."""
119
+ return '"' + text_.replace('"', "") + '"'
120
+
121
+
122
+ def search_events(
123
+ query: str,
124
+ thread_id: Optional[int] = None,
125
+ content_types: Optional[list[str]] = None,
126
+ limit: int = 50,
127
+ since: Optional[str] = None,
128
+ until: Optional[str] = None,
129
+ tool_name: Optional[str] = None,
130
+ exclude_content_types: Optional[list[str]] = None,
131
+ source: Optional[list[str]] = None,
132
+ startswith: Optional[str] = None,
133
+ *,
134
+ oldest_first: bool = False,
135
+ or_fallback: bool = True,
136
+ session: Optional[Session] = None,
137
+ ) -> list[dict]:
138
+ """Lexical search over the FTS5 index → canonical event-hit dicts.
139
+
140
+ Query mode (shared classifier): natural-language / boolean / quoted-phrase
141
+ queries run FTS5 MATCH (bm25-ranked). Pipe-OR and code-identifier shapes run
142
+ TWO passes, merged: a phrase MATCH (the tokenizer splits ``get_session`` into
143
+ ``get session``, so the quoted phrase rides the index, bm25-ranked over the
144
+ whole corpus) plus a substring LIKE over the most recent matches (catches
145
+ within-token substrings MATCH can't see). The MATCH pass is what keeps *old*
146
+ hits reachable for common identifiers — a single recency-ordered LIKE pass
147
+ caps out on the newest ``limit`` matches. ``startswith`` overrides the query
148
+ mode entirely with a structural prefix scan (content LIKE 'prefix%',
149
+ recency-ordered) — the query text is not matched, only the structural filters.
150
+
151
+ A plain natural-language query (no operators, no quotes) is implicitly
152
+ conjunctive — FTS5 MATCH requires *every* token, stopwords included, so
153
+ "how did we fix the auth bug" needs all seven words in one document. When
154
+ the strict pass leaves the pool short, a fallback OR pass over the
155
+ meaningful (non-stopword) terms tops it up, bm25-ranked, appended *after*
156
+ the strict hits so full matches keep their rank priority. That's what saves
157
+ lexical-only installs from hard zero-recall on conversational queries.
158
+ ``or_fallback=False`` disables the tier — a count tally must stay strict, or
159
+ partial matches inflate it.
160
+
161
+ ``oldest_first`` orders every pass by ``occurred_at ASC`` instead of its
162
+ ranking order, so the candidate pool holds the *earliest* matching rows —
163
+ without it, "when was this first discussed" sorts only whatever bm25's
164
+ top-N happened to keep, which for a frequent term is recency-biased.
165
+ """
166
+ ensure_fts(session)
167
+ mode, is_boolean = classify_query(query)
168
+
169
+ # Each pass is (match_where, match_params, order, use_match); shared filters
170
+ # are appended to every pass.
171
+ passes: list[tuple[str, dict, str, bool]] = []
172
+ if startswith is not None:
173
+ # Structural prefix scan — wildcards in the prefix are escaped so it matches
174
+ # a literal prefix (the reference left them unescaped; this hardens it).
175
+ passes.append(("content LIKE :sw ESCAPE '\\'", {"sw": _like_prefix(startswith)},
176
+ "occurred_at DESC", False))
177
+ elif mode == "or":
178
+ terms = [_clean_query_text(t) for t in (query or "").split("|")]
179
+ terms = [t for t in terms if t]
180
+ if not terms:
181
+ return []
182
+ match_q = " OR ".join(_quote_phrase(t) for t in terms)
183
+ passes.append(("event_search MATCH :q", {"q": match_q}, _RANK_EXPR, True))
184
+ like_params: dict = {}
185
+ ors = []
186
+ for i, term in enumerate(terms):
187
+ like_params["or" + str(i)] = "%" + term + "%"
188
+ ors.append("content LIKE :or" + str(i))
189
+ passes.append(("(" + " OR ".join(ors) + ")", like_params, "occurred_at DESC", False))
190
+ elif mode == "code":
191
+ clean = _clean_query_text(query)
192
+ passes.append(("event_search MATCH :q", {"q": _quote_phrase(clean)}, _RANK_EXPR, True))
193
+ passes.append(("content LIKE :codepat", {"codepat": "%" + clean + "%"},
194
+ "occurred_at DESC", False))
195
+ else:
196
+ passes.append(("event_search MATCH :q", {"q": _to_match_query(query)}, _RANK_EXPR, True))
197
+
198
+ shared: list[str] = []
199
+ shared_params: dict = {"lim": limit}
200
+ if thread_id is not None:
201
+ shared.append("thread_id = :tid")
202
+ shared_params["tid"] = thread_id
203
+ else:
204
+ # Honor the per-thread search blacklist (threads.exclude_from_search).
205
+ # An explicit thread_id scope is deliberate and bypasses it.
206
+ shared.append("thread_id NOT IN (SELECT id FROM threads WHERE exclude_from_search)")
207
+ if tool_name:
208
+ shared.append("tool_name = :tool")
209
+ shared_params["tool"] = tool_name
210
+ if content_types:
211
+ shared.append(_in_clause("content_type", content_types, "ct", shared_params, negate=False))
212
+ if exclude_content_types:
213
+ shared.append(_in_clause("content_type", exclude_content_types, "xct", shared_params, negate=True))
214
+ if source:
215
+ # event_search carries thread_id but not source; constrain to threads of
216
+ # the named provider(s) via an indexed subquery (idx_threads_source). An
217
+ # empty match yields no rows rather than invalid SQL.
218
+ shared.append(
219
+ "thread_id IN (SELECT id FROM threads WHERE "
220
+ + _in_clause("source", source, "src", shared_params, negate=False) + ")"
221
+ )
222
+ if since:
223
+ shared.append("occurred_at >= :since")
224
+ shared_params["since"] = since
225
+ if until:
226
+ shared.append("occurred_at <= :until")
227
+ shared_params["until"] = until
228
+
229
+ hits: list[dict] = []
230
+ seen: set[tuple[int, Optional[str]]] = set()
231
+ with use_session(session) as s:
232
+ def run_pass(match_where: str, match_params: dict, order: str, use_match: bool) -> None:
233
+ if oldest_first:
234
+ order = "occurred_at ASC"
235
+ snippet_expr = (
236
+ "snippet(event_search, 0, '', '', ' … ', 12)" if use_match
237
+ else "substr(content, 1, 300)"
238
+ )
239
+ sql = sa_text(
240
+ "SELECT event_id, thread_id, event_type, content_type, occurred_at, "
241
+ + snippet_expr + " AS snippet, content AS full_content "
242
+ "FROM event_search WHERE " + " AND ".join([match_where] + shared) +
243
+ " ORDER BY " + order + " LIMIT :lim"
244
+ )
245
+ rows = s.execute(sql, {**shared_params, **match_params}).mappings().all()
246
+ for r in rows:
247
+ key = (r["event_id"], r["content_type"])
248
+ if key in seen:
249
+ continue
250
+ seen.add(key)
251
+ ts = 0
252
+ oa = r["occurred_at"]
253
+ if oa:
254
+ try:
255
+ ts = int(datetime.fromisoformat(str(oa)).timestamp())
256
+ except ValueError:
257
+ ts = 0
258
+ hits.append(build_event_hit(
259
+ event_id=r["event_id"],
260
+ thread_id=r["thread_id"],
261
+ event_type=r["event_type"],
262
+ content_type=r["content_type"],
263
+ snippet=r["snippet"] or "",
264
+ full_content=r["full_content"] or "",
265
+ occurred_at_ts=ts,
266
+ ))
267
+
268
+ for p in passes:
269
+ run_pass(*p)
270
+
271
+ # Fallback OR tier for plain natural-language queries (see the docstring):
272
+ # only when the strict all-terms pass left the pool short, and only over
273
+ # the meaningful terms (stopwords carry no retrieval signal). Explicit
274
+ # boolean / quoted / pipe-OR / identifier queries asked for their own
275
+ # semantics and are left alone.
276
+ if (or_fallback and startswith is None and mode == "tsquery" and not is_boolean
277
+ and '"' not in (query or "") and len(hits) < limit):
278
+ from .rank import search_terms
279
+
280
+ terms = [t for t in search_terms(query) if " " not in t]
281
+ or_q = " OR ".join(_quote_phrase(t) for t in terms)
282
+ # Skip when the tier would be the strict pass verbatim (single term,
283
+ # nothing dropped) — same MATCH, nothing new to add.
284
+ if terms and or_q.lower() != _to_match_query(query).lower():
285
+ run_pass("event_search MATCH :orq", {"orq": or_q}, _RANK_EXPR, True)
286
+ return hits
287
+
288
+
289
+ _INSERT_SEARCH = sa_text(
290
+ "INSERT INTO event_search "
291
+ "(content, event_id, thread_id, event_type, content_type, tool_name, occurred_at) "
292
+ "VALUES (:content, :event_id, :thread_id, :event_type, :content_type, :tool_name, :occurred_at)"
293
+ )
294
+
295
+ # Thread-meta docs: the thread's title and short summary, indexed as searchable
296
+ # docs so "find the thread about X" works when X never appears verbatim in a
297
+ # message. Rows carry event_type='thread_meta' and content_type 'title'/'summary',
298
+ # anchored to the thread's first indexed event so every hit keeps a real
299
+ # [thread/event] anchor (reading from it lands at the thread's opening).
300
+ THREAD_META_EVENT_TYPE = "thread_meta"
301
+ THREAD_META_CONTENT_TYPES = ("title", "summary")
302
+
303
+
304
+ def _thread_meta_desired(s: Session, thread_ids: Optional[list[int]]) -> dict[tuple[int, str], str]:
305
+ """The meta docs that *should* exist: ``(thread_id, content_type) → content``.
306
+ Conversations only (topics have their own librarian search surface), search-
307
+ excluded threads omitted, empty title/summary omitted."""
308
+ where = "t.thread_type = 'conversation' AND NOT t.exclude_from_search"
309
+ params: dict = {}
310
+ if thread_ids is not None:
311
+ if not thread_ids:
312
+ return {}
313
+ where += " AND " + _in_clause("t.id", list(thread_ids), "tid", params, negate=False)
314
+ rows = s.execute(
315
+ sa_text("SELECT t.id, t.title, t.summary FROM threads t WHERE " + where), params
316
+ ).all()
317
+ desired: dict[tuple[int, str], str] = {}
318
+ for tid, title, summary in rows:
319
+ if title and title.strip():
320
+ desired[(tid, "title")] = title.strip()
321
+ if summary and summary.strip():
322
+ desired[(tid, "summary")] = summary.strip()
323
+ return desired
324
+
325
+
326
+ def index_thread_meta(session: Optional[Session] = None, thread_ids: Optional[list[int]] = None) -> int:
327
+ """Sync thread titles + short summaries into the FTS surface (shadow + FTS5)
328
+ as thread-meta docs. Diff-based: an unchanged thread writes nothing, a changed
329
+ title/summary replaces its rows (and drops its stale vector so the embed cohost
330
+ re-embeds it), a vanished one is deleted. ``thread_ids=None`` syncs every
331
+ thread — cheap enough for the watcher's maintenance cadence. Returns the
332
+ number of rows written."""
333
+ ensure_fts(session)
334
+ with use_session(session) as s:
335
+ desired = _thread_meta_desired(s, thread_ids)
336
+
337
+ params: dict = {"met": THREAD_META_EVENT_TYPE}
338
+ scope = ""
339
+ if thread_ids is not None:
340
+ if not thread_ids:
341
+ return 0
342
+ scope = " AND " + _in_clause("thread_id", list(thread_ids), "tid", params, negate=False)
343
+ existing = {
344
+ (r.thread_id, r.content_type): (r.id, r.event_id, r.content)
345
+ for r in s.execute(
346
+ sa_text(
347
+ "SELECT id, event_id, thread_id, content_type, content FROM events_fts "
348
+ "WHERE event_type = :met" + scope
349
+ ),
350
+ params,
351
+ )
352
+ }
353
+
354
+ stale = [k for k, (_, _, content) in existing.items() if desired.get(k) != content]
355
+ fresh = [k for k, content in desired.items() if existing.get(k, (None, None, None))[2] != content]
356
+ if not stale and not fresh:
357
+ return 0
358
+
359
+ # Anchor each thread at its first indexed event; a thread with no indexed
360
+ # events gets no meta docs (nothing to anchor a read to).
361
+ need_anchor = sorted({tid for tid, _ in fresh})
362
+ anchors: dict[int, tuple[int, Optional[str]]] = {}
363
+ for chunk_start in range(0, len(need_anchor), 500):
364
+ chunk = need_anchor[chunk_start:chunk_start + 500]
365
+ aparams: dict = {"met": THREAD_META_EVENT_TYPE}
366
+ rows = s.execute(
367
+ sa_text(
368
+ "SELECT f.thread_id, MIN(f.event_id) FROM events_fts f "
369
+ "WHERE f.event_type != :met AND "
370
+ + _in_clause("f.thread_id", chunk, "tid", aparams, negate=False)
371
+ + " GROUP BY f.thread_id"
372
+ ),
373
+ aparams,
374
+ ).all()
375
+ eids = [eid for _, eid in rows]
376
+ oparams: dict = {}
377
+ occurred = dict(
378
+ s.execute(
379
+ sa_text(
380
+ "SELECT id, occurred_at FROM events WHERE "
381
+ + _in_clause("id", eids, "eid", oparams, negate=False)
382
+ ),
383
+ oparams,
384
+ ).all()
385
+ ) if eids else {}
386
+ for tid, eid in rows:
387
+ anchors[tid] = (eid, str(occurred[eid]) if occurred.get(eid) else None)
388
+
389
+ # Probe once instead of catching mid-transaction: a failed statement would
390
+ # poison the session (lexical-only installs have no vector table).
391
+ have_vectors = bool(s.execute(
392
+ sa_text("SELECT 1 FROM sqlite_master WHERE name = :n"), {"n": "event_vectors"}
393
+ ).scalar())
394
+
395
+ written = 0
396
+ for key in stale:
397
+ tid, ct = key
398
+ rid, old_eid, _ = existing[key]
399
+ s.execute(sa_text("DELETE FROM events_fts WHERE id = :rid"), {"rid": rid})
400
+ s.execute(
401
+ sa_text(
402
+ "DELETE FROM event_search WHERE event_type = :met "
403
+ "AND thread_id = :tid AND content_type = :ct"
404
+ ),
405
+ {"met": THREAD_META_EVENT_TYPE, "tid": tid, "ct": ct},
406
+ )
407
+ # Drop the doc's vector so the embed cohost's missing-vector anti-join
408
+ # re-embeds the replacement (or forgets a removed doc).
409
+ if have_vectors:
410
+ s.execute(
411
+ sa_text("DELETE FROM event_vectors WHERE event_id = :eid AND content_type = :ct"),
412
+ {"eid": old_eid, "ct": ct},
413
+ )
414
+ for key in fresh:
415
+ tid, ct = key
416
+ if tid not in anchors:
417
+ continue
418
+ eid, oa = anchors[tid]
419
+ content = desired[key]
420
+ s.add(EventFts(
421
+ event_id=eid, thread_id=tid, event_type=THREAD_META_EVENT_TYPE,
422
+ content=content, content_type=ct, tool_name=None,
423
+ ))
424
+ s.execute(_INSERT_SEARCH, {
425
+ "content": content, "event_id": eid, "thread_id": tid,
426
+ "event_type": THREAD_META_EVENT_TYPE, "content_type": ct,
427
+ "tool_name": None, "occurred_at": oa,
428
+ })
429
+ written += 1
430
+ if session is None:
431
+ s.commit()
432
+ if written or stale:
433
+ logger.info("index_thread_meta: wrote %d meta docs (%d removed)", written, len(stale))
434
+ return written
435
+
436
+
437
+ def index_events(session: Session, events: list) -> int:
438
+ """Index a batch of just-written events into the FTS surface (shadow + FTS5).
439
+
440
+ Called from the import write seam so FTS stays current without a full rebuild,
441
+ in the same transaction as the event writes. Events are already deduped, so an
442
+ event is indexed exactly once.
443
+ """
444
+ ensure_fts(session)
445
+ indexed = 0
446
+ for ev in events:
447
+ if ev.event_type not in INDEXABLE_EVENT_TYPES:
448
+ continue
449
+ payload = ev.payload if isinstance(ev.payload, dict) else json.loads(ev.payload)
450
+ # Canonical form (naive-UTC, space-separated) so incremental rows collate
451
+ # with rebuilt rows — rebuild_fts copies events.occurred_at as SQLite
452
+ # rendered it, and the since/until bounds are resolved to the same form.
453
+ oa = canonical_time_bound(ev.occurred_at) if ev.occurred_at else None
454
+ for content, content_type, tool_name in extract_fts_content(ev.event_type, payload):
455
+ tn = tool_name[:200] if tool_name else None
456
+ session.add(EventFts(
457
+ event_id=ev.id, thread_id=ev.thread_id, event_type=ev.event_type,
458
+ content=content, content_type=content_type, tool_name=tn,
459
+ ))
460
+ session.execute(_INSERT_SEARCH, {
461
+ "content": content, "event_id": ev.id, "thread_id": ev.thread_id,
462
+ "event_type": ev.event_type, "content_type": content_type,
463
+ "tool_name": tn, "occurred_at": oa,
464
+ })
465
+ indexed += 1
466
+ return indexed
467
+
468
+
469
+ def rebuild_fts(session: Optional[Session] = None) -> int:
470
+ """Rebuild the FTS surface from events: derive the ``events_fts`` shadow from
471
+ the indexable events, then (re)populate the ``event_search`` FTS5 table from it.
472
+
473
+ The derived index is rebuilt from scratch (clear-and-refill) — the simplest
474
+ correct reindex. Returns the number of indexed documents.
475
+ """
476
+ ensure_fts(session)
477
+ own = session is None
478
+ with use_session(session) as s:
479
+ # 1. Derive the events_fts shadow from the events, in id-keyset batches so a
480
+ # multi-million-event corpus never materializes at once. Each batch's
481
+ # SELECT is fully read before its Core insert, so there's no open
482
+ # read-cursor during the writes.
483
+ s.execute(delete(EventFts))
484
+ s.flush()
485
+ fts_table = EventFts.__table__
486
+ conn = s.connection()
487
+ last_id = 0
488
+ while True:
489
+ rows = s.execute(
490
+ select(Event.id, Event.thread_id, Event.event_type, Event.payload)
491
+ .where(Event.event_type.in_(INDEXABLE_EVENT_TYPES), Event.id > last_id)
492
+ .order_by(Event.id)
493
+ .limit(5000)
494
+ ).all()
495
+ if not rows:
496
+ break
497
+ batch = []
498
+ for eid, tid, etype, payload in rows:
499
+ last_id = eid
500
+ p = payload if isinstance(payload, dict) else json.loads(payload)
501
+ for content, content_type, tool_name in extract_fts_content(etype, p):
502
+ batch.append({
503
+ "event_id": eid, "thread_id": tid, "event_type": etype,
504
+ "content": content, "content_type": content_type,
505
+ "tool_name": tool_name[:200] if tool_name else None,
506
+ })
507
+ if batch:
508
+ conn.execute(insert(fts_table), batch)
509
+
510
+ # 2. (Re)build the FTS5 table from the shadow.
511
+ s.execute(sa_text("DELETE FROM event_search"))
512
+ s.execute(sa_text(
513
+ "INSERT INTO event_search "
514
+ "(content, event_id, thread_id, event_type, content_type, tool_name, occurred_at) "
515
+ "SELECT f.content, f.event_id, f.thread_id, f.event_type, f.content_type, "
516
+ "f.tool_name, e.occurred_at "
517
+ "FROM events_fts f JOIN events e ON e.id = f.event_id "
518
+ "WHERE f.content IS NOT NULL AND f.content != ''"
519
+ ))
520
+
521
+ # 3. Derive the thread-meta docs (titles + summaries). The shadow refill
522
+ # above dropped them, so the sync sees a clean slate and writes them all.
523
+ index_thread_meta(s)
524
+
525
+ count = s.execute(sa_text("SELECT count(*) FROM event_search")).scalar() or 0
526
+ if own:
527
+ s.commit()
528
+ logger.info("rebuild_fts: indexed %d documents", count)
529
+ return int(count)
530
+
531
+
532
+ def fts_status(session: Optional[Session] = None) -> dict:
533
+ with use_session(session) as s:
534
+ exists = s.execute(
535
+ sa_text("SELECT 1 FROM sqlite_master WHERE name = :n"), {"n": "event_search"}
536
+ ).scalar()
537
+ count = s.execute(sa_text("SELECT count(*) FROM event_search")).scalar() if exists else 0
538
+ return {"indexed": int(count or 0), "table": "event_search"}