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