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