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,611 @@
1
+ """SQLite embedded semantic search.
2
+
3
+ One in-DB ``event_vectors`` BLOB table holding 768-d nomic vectors, searched by an
4
+ in-process brute-force cosine KNN (numpy). No server, no native extension, no HNSW:
5
+ the vectors live in the archive SQLite DB beside the data. Vectors are float32 and
6
+ unit-normalized, so cosine = dot product; at single-user scale the matrix loads once
7
+ and a query is a single BLAS matvec (~ms).
8
+
9
+ Long documents are **chunked**: a doc is embedded as one vector per
10
+ :data:`CHUNK_CHARS`-char slice (up to :data:`MAX_CHUNKS`), keyed
11
+ ``(event_id, content_type, chunk)``, and the KNN max-pools per document — the
12
+ best-matching chunk speaks for the doc. Without this, everything past the embed
13
+ cap of a long message is semantically invisible (the embed provider truncates),
14
+ and the vocab-mismatch queries the vector arm exists for are exactly the ones
15
+ that can't fall back to keywords.
16
+
17
+ Populated from the ``events_fts`` shadow's ``user`` / ``text`` / ``title`` /
18
+ ``summary`` pools via the ``[embeddings]`` provider (:mod:`.embed`). Cached durably in a ``vectors.sqlite``
19
+ sidecar so the hours-long embed survives ``rm index.db && reindex``. Degrades to
20
+ lexical-only when the extra isn't installed or nothing's indexed.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from datetime import datetime
27
+ from typing import Optional
28
+
29
+ import numpy as np
30
+ from sqlalchemy import text as sa_text
31
+
32
+ from .._store import get_engine, get_session
33
+ from .embed import EMBEDDING_CHAR_CAP
34
+ from .fts import build_event_hit
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ _DIM = 768
39
+ _CREATE_VEC = (
40
+ "CREATE TABLE event_vectors ("
41
+ "event_id INTEGER NOT NULL, content_type TEXT NOT NULL, "
42
+ "chunk INTEGER NOT NULL DEFAULT 0, dim INTEGER NOT NULL, "
43
+ "vec BLOB NOT NULL, PRIMARY KEY (event_id, content_type, chunk))"
44
+ )
45
+
46
+ # Document chunking: one vector per CHUNK_CHARS-char slice, at most MAX_CHUNKS per
47
+ # doc (16k chars — beyond that is tool-dump territory the embedded pools exclude
48
+ # anyway). CHUNK_CHARS equals the embed provider's input cap so a chunk is
49
+ # embedded whole, never re-truncated. The expected-chunk-count formula
50
+ # ``min(MAX_CHUNKS, ceil(len/CHUNK_CHARS))`` is duplicated in SQL by the drain's
51
+ # anti-join (:func:`index_events_local`) — the two MUST stay identical or the
52
+ # cohost re-embeds the same docs forever.
53
+ CHUNK_CHARS = EMBEDDING_CHAR_CAP
54
+ MAX_CHUNKS = 8
55
+
56
+
57
+ def _chunk(content: str) -> list[str]:
58
+ """Split ``content`` into the embed slices (non-overlapping, CHUNK_CHARS each,
59
+ capped at MAX_CHUNKS). Always at least one chunk for non-empty content."""
60
+ return [
61
+ content[i:i + CHUNK_CHARS]
62
+ for i in range(0, min(len(content), MAX_CHUNKS * CHUNK_CHARS), CHUNK_CHARS)
63
+ ] or [content]
64
+
65
+ # Embedded content-type pools: user → default pool; text → scoped assistant pool;
66
+ # title/summary → the thread-meta docs (thread-level aboutness).
67
+ _USER_CONTENT_TYPES = ("user",)
68
+ _ASSISTANT_CONTENT_TYPES = ("text",)
69
+ _META_CONTENT_TYPES = ("title", "summary")
70
+
71
+ # Process-local matrix cache: {(engine_id, cts): (validity_token, ids, ctypes, mat,
72
+ # doc_inverse, doc_rep)}. Keys are canonicalized (sorted cts tuple) so equivalent
73
+ # scopes in different orders share one entry, and the cache is bounded
74
+ # (:data:`_MATRIX_CACHE_MAX`) — each entry is a full float32 matrix, so unbounded
75
+ # scope proliferation would pin hundreds of MB per extra scope.
76
+ # The token includes store-derived counters (row count + max rowid), not just the
77
+ # process-local write version: the embed cohost lives in the *watcher* process, so a
78
+ # long-lived search process (the MCP server) must notice out-of-process vector writes
79
+ # or its semantic arm freezes at whatever was embedded when its matrix first loaded.
80
+ _MATRIX_CACHE: dict = {}
81
+ _MATRIX_CACHE_MAX = 4
82
+ _write_version = 0
83
+
84
+
85
+ def is_available() -> bool:
86
+ try:
87
+ return get_engine().dialect.name == "sqlite"
88
+ except Exception:
89
+ return False
90
+
91
+
92
+ def ensure_index() -> bool:
93
+ """Create the ``event_vectors`` table if absent; migrate a pre-chunking table
94
+ (no ``chunk`` column) in place, existing vectors becoming chunk 0. Idempotent."""
95
+ if not is_available():
96
+ return False
97
+ with get_session() as s:
98
+ exists = s.execute(
99
+ sa_text("SELECT 1 FROM sqlite_master WHERE name = :n"), {"n": "event_vectors"}
100
+ ).scalar()
101
+ if not exists:
102
+ s.execute(sa_text(_CREATE_VEC))
103
+ s.commit()
104
+ return True
105
+ cols = {r[1] for r in s.execute(sa_text("PRAGMA table_info(event_vectors)"))}
106
+ if "chunk" not in cols:
107
+ # SQLite can't extend a PRIMARY KEY in place: rebuild the table around
108
+ # the chunked key, carrying every existing vector over as chunk 0.
109
+ s.execute(sa_text("ALTER TABLE event_vectors RENAME TO event_vectors_prechunk"))
110
+ s.execute(sa_text(_CREATE_VEC))
111
+ s.execute(sa_text(
112
+ "INSERT INTO event_vectors (event_id, content_type, chunk, dim, vec) "
113
+ "SELECT event_id, content_type, 0, dim, vec FROM event_vectors_prechunk"
114
+ ))
115
+ s.execute(sa_text("DROP TABLE event_vectors_prechunk"))
116
+ s.commit()
117
+ _bump_version()
118
+ logger.info("vectors: migrated event_vectors to the chunked schema")
119
+ return True
120
+
121
+
122
+ def _normalize(v) -> "np.ndarray":
123
+ v = np.asarray(v, dtype=np.float32)
124
+ n = float(np.linalg.norm(v))
125
+ return v / n if n > 0.0 else v
126
+
127
+
128
+ def _scope_content_types(content_types: Optional[list[str]]) -> Optional[list[str]]:
129
+ embedded = _USER_CONTENT_TYPES + _ASSISTANT_CONTENT_TYPES + _META_CONTENT_TYPES
130
+ if content_types:
131
+ cts = [c for c in content_types if c in embedded]
132
+ else:
133
+ cts = list(embedded)
134
+ return cts or None
135
+
136
+
137
+ def index_vectors(records) -> int:
138
+ """Upsert vector rows into ``event_vectors``. Each record is
139
+ ``(event_id, content_type, vector)`` (chunk 0) or
140
+ ``(event_id, content_type, chunk, vector)``."""
141
+ if not is_available():
142
+ return 0
143
+ ensure_index()
144
+ rows = []
145
+ for rec in records:
146
+ event_id, content_type, chunk, vec = rec if len(rec) == 4 else (rec[0], rec[1], 0, rec[2])
147
+ arr = _normalize(np.asarray(vec, dtype=np.float32))
148
+ if arr.shape[0] != _DIM:
149
+ logger.warning("vectors: skipping event %s — dim %d != %d", event_id, arr.shape[0], _DIM)
150
+ continue
151
+ rows.append({"eid": int(event_id), "ct": str(content_type), "chunk": int(chunk),
152
+ "dim": int(arr.shape[0]), "vec": arr.tobytes()})
153
+ if not rows:
154
+ return 0
155
+ with get_session() as s:
156
+ s.execute(sa_text(
157
+ "INSERT INTO event_vectors (event_id, content_type, chunk, dim, vec) "
158
+ "VALUES (:eid, :ct, :chunk, :dim, :vec) "
159
+ "ON CONFLICT (event_id, content_type, chunk) "
160
+ "DO UPDATE SET dim = excluded.dim, vec = excluded.vec"
161
+ ), rows)
162
+ s.commit()
163
+ _bump_version()
164
+ return len(rows)
165
+
166
+
167
+ def _write_doc_vectors(docs: list[tuple[int, str, list]]) -> None:
168
+ """Replace each doc's vector set: delete every chunk row for the
169
+ ``(event_id, content_type)``, insert the new chunk vectors. One transaction —
170
+ a doc is never left half-chunked."""
171
+ with get_session() as s:
172
+ for eid, ct, vecs in docs:
173
+ s.execute(
174
+ sa_text("DELETE FROM event_vectors WHERE event_id = :eid AND content_type = :ct"),
175
+ {"eid": int(eid), "ct": str(ct)},
176
+ )
177
+ for chunk_idx, vec in enumerate(vecs):
178
+ arr = _normalize(np.asarray(vec, dtype=np.float32))
179
+ if arr.shape[0] != _DIM:
180
+ logger.warning("vectors: skipping event %s — dim %d != %d",
181
+ eid, arr.shape[0], _DIM)
182
+ continue
183
+ s.execute(sa_text(
184
+ "INSERT INTO event_vectors (event_id, content_type, chunk, dim, vec) "
185
+ "VALUES (:eid, :ct, :chunk, :dim, :vec)"
186
+ ), {"eid": int(eid), "ct": str(ct), "chunk": chunk_idx,
187
+ "dim": int(arr.shape[0]), "vec": arr.tobytes()})
188
+ s.commit()
189
+ _bump_version()
190
+
191
+
192
+ def index_events_local(
193
+ batch_size: int = 64,
194
+ rebuild: bool = False,
195
+ max_events: int | None = None,
196
+ newest_first: bool = False,
197
+ ) -> int:
198
+ """Compute event vectors in-process from the FTS shadow (user/text/title/summary
199
+ pools), one vector per :data:`CHUNK_CHARS` chunk (long docs get several).
200
+
201
+ ``rebuild=False`` only embeds docs with fewer vectors than their content needs
202
+ (anti-join on the chunk count — safe to re-run, and it picks up formerly
203
+ truncation-embedded long docs as pending). ``max_events`` caps how many *docs*
204
+ a single call embeds — the live cohost bounds each pass so a backlog drains
205
+ over cycles without stalling ingest; ``newest_first`` drains the freshest gap
206
+ first, which is what keeps recent-thread *semantic* recall current (the lexical
207
+ arm already covers fresh threads). Returns docs embedded. No-op (0) when the
208
+ store isn't SQLite or the embed backend is absent.
209
+ """
210
+ if not is_available():
211
+ return 0
212
+ from .embed import embed_documents
213
+ from .embed import is_available as embed_available
214
+
215
+ if not embed_available():
216
+ logger.info("vectors.index_events_local: embed backend unavailable — skipping")
217
+ return 0
218
+ ensure_index()
219
+
220
+ # Pending = docs whose stored vector count is short of what their length needs.
221
+ # The SQL mirrors _chunk()'s count — min(MAX_CHUNKS, ceil(len/CHUNK_CHARS)) —
222
+ # over the same concatenated content; if the two formulas diverge the cohost
223
+ # loops on the same docs forever, so change them together.
224
+ missing = ("" if rebuild else
225
+ " HAVING coalesce(v.nv, 0) < min(:mx, "
226
+ "(length(group_concat(f.content, ' ')) + :cc - 1) / :cc)")
227
+ order = "DESC" if newest_first else "ASC"
228
+ limit = " LIMIT :cap" if max_events else ""
229
+ sql = sa_text(
230
+ "SELECT f.event_id AS eid, f.content_type AS ct, group_concat(f.content, ' ') AS content "
231
+ "FROM events_fts f "
232
+ "LEFT JOIN (SELECT event_id, content_type, count(*) AS nv FROM event_vectors "
233
+ " GROUP BY event_id, content_type) v "
234
+ " ON v.event_id = f.event_id AND v.content_type = f.content_type "
235
+ "WHERE f.content_type IN ('user', 'text', 'title', 'summary') "
236
+ "AND f.content IS NOT NULL AND f.content != ''"
237
+ f" GROUP BY f.event_id, f.content_type{missing}"
238
+ f" ORDER BY f.event_id {order}" + limit
239
+ )
240
+ params: dict = {} if rebuild else {"mx": MAX_CHUNKS, "cc": CHUNK_CHARS}
241
+ if max_events:
242
+ params["cap"] = int(max_events)
243
+ with get_session() as s:
244
+ pending = [(r.eid, r.ct, r.content) for r in s.execute(sql, params)]
245
+
246
+ total = 0
247
+ batch: list[tuple[int, str, list[str]]] = []
248
+ batch_chunks = 0
249
+
250
+ def _flush() -> bool:
251
+ nonlocal total, batch, batch_chunks
252
+ if not batch:
253
+ return True
254
+ texts = [t for _, _, chunks in batch for t in chunks]
255
+ vecs = embed_documents(texts)
256
+ if not vecs:
257
+ logger.warning("vectors.index_events_local: embed returned None — stopping at %d", total)
258
+ return False
259
+ docs, pos = [], 0
260
+ for eid, ct, chunks in batch:
261
+ docs.append((eid, ct, vecs[pos:pos + len(chunks)]))
262
+ pos += len(chunks)
263
+ _write_doc_vectors(docs)
264
+ total += len(docs)
265
+ batch, batch_chunks = [], 0
266
+ return True
267
+
268
+ for eid, ct, content in pending:
269
+ chunks = _chunk(content)
270
+ batch.append((eid, ct, chunks))
271
+ batch_chunks += len(chunks)
272
+ if batch_chunks >= batch_size and not _flush():
273
+ return total
274
+ _flush()
275
+ return total
276
+
277
+
278
+ def save_vectors_sidecar(truth_dir, space_key: Optional[str] = None) -> int:
279
+ """Persist ``event_vectors`` to ``<truth_dir>/vectors.sqlite`` — a durable cache so
280
+ the expensive embed survives ``rm index.db && reindex``. Tagged with the embedding
281
+ space_key so a model change invalidates it.
282
+
283
+ A no-op (0) when the live store has no vectors at all: an empty save must never
284
+ replace a populated sidecar — the cache exists precisely to survive the states
285
+ (fresh index, vectors not yet restored) that would otherwise gut it."""
286
+ if not is_available():
287
+ return 0
288
+ import os
289
+ from pathlib import Path
290
+
291
+ from .embed import space_key as _sk
292
+ with get_session() as s:
293
+ exists = s.execute(
294
+ sa_text("SELECT 1 FROM sqlite_master WHERE name = :n"), {"n": "event_vectors"}
295
+ ).scalar()
296
+ live = s.execute(sa_text("SELECT count(*) FROM event_vectors")).scalar() if exists else 0
297
+ if not live:
298
+ return 0
299
+ sk = space_key or _sk()
300
+ path = Path(truth_dir) / "vectors.sqlite"
301
+ # Build into a temp sidecar and rename over the live one: a crash mid-save must
302
+ # not leave a gutted cache (the embed it protects takes hours to redo).
303
+ tmp = path.with_name(path.name + ".tmp")
304
+ tmp.unlink(missing_ok=True)
305
+ with get_engine().connect() as conn:
306
+ conn = conn.execution_options(isolation_level="AUTOCOMMIT")
307
+ conn.exec_driver_sql("ATTACH DATABASE ? AS side", (str(tmp),))
308
+ try:
309
+ conn.exec_driver_sql("CREATE TABLE side.event_vectors AS SELECT * FROM event_vectors")
310
+ conn.exec_driver_sql("CREATE TABLE side.vector_meta (space_key TEXT)")
311
+ conn.exec_driver_sql("INSERT INTO side.vector_meta (space_key) VALUES (?)", (sk,))
312
+ n = conn.exec_driver_sql("SELECT count(*) FROM side.event_vectors").scalar()
313
+ finally:
314
+ conn.exec_driver_sql("DETACH DATABASE side")
315
+ # Same durability bar as the truth writers: the sidecar protects an embed that
316
+ # takes hours to redo, so it must be on the platter — not just in the page
317
+ # cache — before the rename publishes it, and the rename itself must survive
318
+ # power loss (dir fsync).
319
+ from .._truth.jsonl_log import _fsync_dir
320
+
321
+ fd = os.open(tmp, os.O_RDONLY)
322
+ try:
323
+ os.fsync(fd)
324
+ finally:
325
+ os.close(fd)
326
+ os.replace(tmp, path)
327
+ _fsync_dir(Path(truth_dir))
328
+ logger.info("vectors: saved %s vectors to sidecar (space=%s)", n, sk)
329
+ return int(n or 0)
330
+
331
+
332
+ def load_vectors_sidecar(truth_dir, space_key: Optional[str] = None) -> int:
333
+ """Restore cached vectors from ``<truth_dir>/vectors.sqlite`` iff its space_key
334
+ matches the current embedding space. Returns rows after restore (0 if none usable)."""
335
+ if not is_available():
336
+ return 0
337
+ from pathlib import Path
338
+
339
+ from .embed import space_key as _sk
340
+ path = Path(truth_dir) / "vectors.sqlite"
341
+ if not path.exists():
342
+ return 0
343
+ ensure_index()
344
+ want = space_key or _sk()
345
+ with get_engine().connect() as conn:
346
+ conn = conn.execution_options(isolation_level="AUTOCOMMIT")
347
+ conn.exec_driver_sql("ATTACH DATABASE ? AS side", (str(path),))
348
+ try:
349
+ have = conn.exec_driver_sql("SELECT space_key FROM side.vector_meta LIMIT 1").scalar()
350
+ if have != want:
351
+ logger.info("vector sidecar space %r != current %r — re-embedding", have, want)
352
+ return 0
353
+ # A pre-chunking sidecar has no ``chunk`` column; its rows restore as
354
+ # chunk 0 (the cohost then tops up long docs' remaining chunks).
355
+ side_cols = {r[1] for r in conn.exec_driver_sql("PRAGMA side.table_info(event_vectors)")}
356
+ chunk_col = "chunk" if "chunk" in side_cols else "0"
357
+ conn.exec_driver_sql(
358
+ "INSERT OR IGNORE INTO event_vectors (event_id, content_type, chunk, dim, vec) "
359
+ f"SELECT event_id, content_type, {chunk_col}, dim, vec FROM side.event_vectors"
360
+ )
361
+ n = conn.exec_driver_sql("SELECT count(*) FROM event_vectors").scalar()
362
+ finally:
363
+ conn.exec_driver_sql("DETACH DATABASE side")
364
+ _bump_version()
365
+ logger.info("vectors: restored %s vectors from sidecar", n)
366
+ return int(n or 0)
367
+
368
+
369
+ def _bump_version() -> None:
370
+ global _write_version
371
+ _write_version += 1
372
+
373
+
374
+ def _validity_token(s) -> tuple:
375
+ """Cheap cross-process staleness probe for the matrix cache. Catches inserts
376
+ (max rowid grows) and deletes (count shrinks); an in-place upsert of an existing
377
+ row is invisible, which the in-process ``_write_version`` covers."""
378
+ row = s.execute(
379
+ sa_text("SELECT count(*), coalesce(max(rowid), 0) FROM event_vectors")
380
+ ).one()
381
+ return (_write_version, int(row[0]), int(row[1]))
382
+
383
+
384
+ def _load_matrix(cts: tuple[str, ...]):
385
+ eng = get_engine()
386
+ key = (id(eng), tuple(sorted(cts)))
387
+ with get_session() as s:
388
+ token = _validity_token(s)
389
+ cached = _MATRIX_CACHE.get(key)
390
+ if cached is not None and cached[0] == token:
391
+ return cached[1:]
392
+
393
+ where = "content_type IN (" + ",".join(":c" + str(i) for i in range(len(cts))) + ")"
394
+ params = {"c" + str(i): c for i, c in enumerate(cts)}
395
+ ids: list[int] = []
396
+ ctypes: list[str] = []
397
+ vecs: list[np.ndarray] = []
398
+ result = s.execute(
399
+ sa_text("SELECT event_id, content_type, vec FROM event_vectors WHERE " + where), params,
400
+ )
401
+ for r in result:
402
+ ids.append(int(r[0]))
403
+ ctypes.append(str(r[1]))
404
+ vecs.append(np.frombuffer(r[2], dtype=np.float32))
405
+ mat = np.vstack(vecs) if vecs else np.empty((0, _DIM), dtype=np.float32)
406
+ ids_arr = np.asarray(ids, dtype=np.int64)
407
+ ct_arr = np.asarray(ctypes, dtype=object)
408
+ # Per-document grouping for the KNN's chunk max-pool: rows sharing an
409
+ # (event_id, content_type) are one document. ``doc_inverse`` maps each row to
410
+ # its doc index; ``doc_rep`` gives one representative row per doc (to recover
411
+ # the id/ctype). Precomputed here so the per-query pool is a single
412
+ # ``np.maximum.at`` over the sims, not a python-level group-by.
413
+ ct_codes = {c: i for i, c in enumerate(sorted(set(ctypes)))}
414
+ if len(ids_arr):
415
+ group_key = ids_arr * len(ct_codes) + np.asarray([ct_codes[c] for c in ctypes], dtype=np.int64)
416
+ _, doc_rep, doc_inverse = np.unique(group_key, return_index=True, return_inverse=True)
417
+ else:
418
+ doc_rep = np.empty(0, dtype=np.int64)
419
+ doc_inverse = np.empty(0, dtype=np.int64)
420
+ if key not in _MATRIX_CACHE:
421
+ while len(_MATRIX_CACHE) >= _MATRIX_CACHE_MAX:
422
+ _MATRIX_CACHE.pop(next(iter(_MATRIX_CACHE)))
423
+ _MATRIX_CACHE[key] = (token, ids_arr, ct_arr, mat, doc_inverse, doc_rep)
424
+ return ids_arr, ct_arr, mat, doc_inverse, doc_rep
425
+
426
+
427
+ def _knn(qvec, cts: tuple[str, ...], cand: int, allowed_ids=None) -> list[tuple[int, str, float]]:
428
+ """Brute-force cosine KNN over the cached matrix, **max-pooled per document
429
+ before the top-k cut**: the matrix holds one row per chunk, and a doc's score
430
+ is its best chunk's, so a long message matches on whichever slice is relevant.
431
+ Pooling happens before the candidate cutoff — several strong chunks of one
432
+ long doc must count as ONE candidate, not eat ``cand`` slots that should hold
433
+ distinct documents. ``allowed_ids`` (an int64 ndarray) restricts candidates to
434
+ those event ids *before* the top-k cut, so a scoped search (one thread, a time
435
+ window) ranks within its scope instead of hoping the scope survives a
436
+ corpus-wide top-k."""
437
+ ids, ct_arr, mat, doc_inverse, doc_rep = _load_matrix(cts)
438
+ n = len(ids)
439
+ if n == 0:
440
+ return []
441
+ q = _normalize(qvec)
442
+ sims = mat @ q
443
+ rows = np.arange(n)
444
+ if allowed_ids is not None:
445
+ rows = np.nonzero(np.isin(ids, allowed_ids))[0]
446
+ if len(rows) == 0:
447
+ return []
448
+ # Max-pool chunk sims into per-doc scores (out-of-scope docs stay at -inf).
449
+ doc_scores = np.full(len(doc_rep), -np.inf, dtype=np.float32)
450
+ np.maximum.at(doc_scores, doc_inverse[rows], sims[rows].astype(np.float32))
451
+ live = np.nonzero(doc_scores > -np.inf)[0]
452
+ k = min(cand, len(live))
453
+ if k < len(live):
454
+ part = np.argpartition(-doc_scores[live], k - 1)[:k]
455
+ pool = live[part]
456
+ else:
457
+ pool = live
458
+ order = sorted(pool, key=lambda d: (-float(doc_scores[d]), int(ids[doc_rep[d]])))
459
+ return [(int(ids[doc_rep[d]]), str(ct_arr[doc_rep[d]]), float(doc_scores[d])) for d in order]
460
+
461
+
462
+ def _in_clause(column: str, values: list, prefix: str, params: dict, negate: bool) -> str:
463
+ names = []
464
+ for i, v in enumerate(values):
465
+ key = prefix + str(i)
466
+ params[key] = v
467
+ names.append(":" + key)
468
+ op = " NOT IN (" if negate else " IN ("
469
+ return column + op + ",".join(names) + ")"
470
+
471
+
472
+ def search(
473
+ query: str,
474
+ thread_id: Optional[int] = None,
475
+ content_types: Optional[list[str]] = None,
476
+ limit: int = 20,
477
+ since: Optional[str] = None,
478
+ until: Optional[str] = None,
479
+ tool_name: Optional[str] = None,
480
+ exclude_content_types: Optional[list[str]] = None,
481
+ source: Optional[list[str]] = None,
482
+ ) -> Optional[list[dict]]:
483
+ """Embedded semantic search: embed the query, brute-force cosine KNN, hydrate.
484
+
485
+ Returns None when this isn't SQLite, the scope has no embedded pool, nothing's
486
+ indexed, or the embed fails — so search degrades to the lexical arm.
487
+ """
488
+ if not is_available() or not query or not query.strip():
489
+ return None
490
+ ensure_index()
491
+ cts = _scope_content_types(content_types)
492
+ # Excluded types come out of the KNN scope itself, not just the hydration
493
+ # WHERE: dropping candidates after the top-k would waste slots on hits the
494
+ # filter then discards, shrinking the effective pool.
495
+ if cts and exclude_content_types:
496
+ cts = [c for c in cts if c not in set(exclude_content_types)]
497
+ if not cts:
498
+ return None
499
+
500
+ with get_session() as s:
501
+ total = s.execute(sa_text("SELECT count(*) FROM event_vectors")).scalar() or 0
502
+ if not total:
503
+ return None
504
+
505
+ try:
506
+ from .embed import embed_query
507
+ qvec = embed_query(query)
508
+ except Exception as e: # noqa: BLE001
509
+ logger.debug("vectors: query embed failed: %s", e)
510
+ return None
511
+ if not qvec:
512
+ return None
513
+
514
+ # A scoped search (one thread / time window / source) pre-masks the KNN to the
515
+ # in-scope event ids, so ranking happens *within* the scope — a corpus-wide
516
+ # top-k could miss the scope entirely. Unscoped searches skip the id query.
517
+ selective = thread_id is not None or since is not None or until is not None or bool(source)
518
+ allowed_ids = None
519
+ if selective:
520
+ awhere = []
521
+ aparams: dict = {}
522
+ ajoin = "FROM events e"
523
+ if thread_id is not None:
524
+ awhere.append("e.thread_id = :tid")
525
+ aparams["tid"] = thread_id
526
+ if since:
527
+ awhere.append("e.occurred_at >= :since")
528
+ aparams["since"] = since
529
+ if until:
530
+ awhere.append("e.occurred_at <= :until")
531
+ aparams["until"] = until
532
+ if source:
533
+ ajoin += " JOIN threads t ON t.id = e.thread_id"
534
+ awhere.append(_in_clause("t.source", source, "src", aparams, negate=False))
535
+ with get_session() as s:
536
+ allowed = [int(r[0]) for r in s.execute(
537
+ sa_text("SELECT e.id " + ajoin + " WHERE " + " AND ".join(awhere)), aparams,
538
+ )]
539
+ if not allowed:
540
+ return []
541
+ allowed_ids = np.asarray(allowed, dtype=np.int64)
542
+ cand = max(limit * 3, 100)
543
+ candidates = _knn(qvec, tuple(cts), cand, allowed_ids=allowed_ids)
544
+ if not candidates:
545
+ return []
546
+ sim_by: dict[tuple[int, str], float] = {(eid, ct): sim for eid, ct, sim in candidates}
547
+ event_ids = sorted({eid for eid, _, _ in candidates})
548
+
549
+ where = [_in_clause("f.event_id", event_ids, "e", {}, negate=False)]
550
+ params: dict = {"e" + str(i): v for i, v in enumerate(event_ids)}
551
+ if thread_id is not None:
552
+ where.append("f.thread_id = :tid")
553
+ params["tid"] = thread_id
554
+ else:
555
+ # Honor the per-thread search blacklist; an explicit thread scope bypasses it.
556
+ where.append("f.thread_id NOT IN (SELECT id FROM threads WHERE exclude_from_search)")
557
+ if exclude_content_types:
558
+ where.append(_in_clause("f.content_type", exclude_content_types, "xct", params, negate=True))
559
+ if since:
560
+ where.append("e.occurred_at >= :since")
561
+ params["since"] = since
562
+ if until:
563
+ where.append("e.occurred_at <= :until")
564
+ params["until"] = until
565
+ join = "FROM events_fts f JOIN events e ON e.id = f.event_id"
566
+ if source:
567
+ join += " JOIN threads t ON t.id = f.thread_id"
568
+ where.append(_in_clause("t.source", source, "src", params, negate=False))
569
+
570
+ sql = sa_text(
571
+ "SELECT f.event_id, f.thread_id, f.event_type, f.content_type, "
572
+ "f.content AS full_content, e.occurred_at " + join +
573
+ " WHERE " + " AND ".join(where)
574
+ )
575
+ with get_session() as s:
576
+ rows = s.execute(sql, params).mappings().all()
577
+
578
+ hydrated: list[tuple[float, dict]] = []
579
+ for r in rows:
580
+ sim = sim_by.get((r["event_id"], r["content_type"]))
581
+ if sim is None:
582
+ continue
583
+ ts = 0
584
+ oa = r["occurred_at"]
585
+ if oa:
586
+ try:
587
+ ts = int(datetime.fromisoformat(str(oa)).timestamp())
588
+ except ValueError:
589
+ ts = 0
590
+ content = r["full_content"] or ""
591
+ hit = build_event_hit(
592
+ event_id=r["event_id"], thread_id=r["thread_id"], event_type=r["event_type"],
593
+ content_type=r["content_type"], snippet=content[:300], full_content=content,
594
+ occurred_at_ts=ts,
595
+ )
596
+ hit["_semantic"] = round(float(sim), 4)
597
+ hydrated.append((sim, hit))
598
+
599
+ hydrated.sort(key=lambda t: (-t[0], t[1]["event_id"]))
600
+ return [h for _, h in hydrated[:limit]]
601
+
602
+
603
+ def get_status() -> dict:
604
+ if not is_available():
605
+ return {"available": False}
606
+ with get_session() as s:
607
+ exists = s.execute(
608
+ sa_text("SELECT 1 FROM sqlite_master WHERE name = :n"), {"n": "event_vectors"}
609
+ ).scalar()
610
+ count = s.execute(sa_text("SELECT count(*) FROM event_vectors")).scalar() if exists else 0
611
+ return {"available": True, "indexed": int(count or 0), "table": "event_vectors", "dim": _DIM}
@@ -0,0 +1 @@
1
+ """Archive operational scripts (owned by the archive slot)."""