@henryqw/pi-session-recall 0.2.2 → 0.2.4
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.
- package/extensions/query.ts +378 -0
- package/extensions/search-core.ts +30 -394
- package/extensions/session-recall.ts +2 -1
- package/package.json +4 -4
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure query sanitization and SQL planning for session search.
|
|
3
|
+
*/
|
|
4
|
+
import type { SQLInputValue, SQLOutputValue } from "node:sqlite";
|
|
5
|
+
|
|
6
|
+
export const MAX_QUERY_CHARS = 512;
|
|
7
|
+
|
|
8
|
+
const OPERATOR_RE = /\b(OR|AND|NOT|NEAR)\b/;
|
|
9
|
+
// FTS5 string syntax: `""` inside a quoted phrase is one literal quote.
|
|
10
|
+
const TOKEN_RE = /"((?:[^"]|"")*)"|(\S+)/g;
|
|
11
|
+
const unescapePhrase = (s: string): string => s.replaceAll('""', '"');
|
|
12
|
+
|
|
13
|
+
interface QueryTerm {
|
|
14
|
+
text: string;
|
|
15
|
+
operator: boolean;
|
|
16
|
+
nearDistance: boolean;
|
|
17
|
+
quoted: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function collectQueryTerms(query: string): QueryTerm[] {
|
|
21
|
+
const terms: QueryTerm[] = [];
|
|
22
|
+
let depth = 0;
|
|
23
|
+
let pendingNear = false;
|
|
24
|
+
let nearDepth = 0;
|
|
25
|
+
let afterNearComma = false;
|
|
26
|
+
for (const match of spaceParensOutsideQuotes(query).matchAll(TOKEN_RE)) {
|
|
27
|
+
const phrase = match[1];
|
|
28
|
+
let raw = phrase === undefined ? match[2] ?? "" : unescapePhrase(phrase);
|
|
29
|
+
if (phrase === undefined && raw === "(") {
|
|
30
|
+
depth++;
|
|
31
|
+
if (pendingNear) {
|
|
32
|
+
nearDepth = depth;
|
|
33
|
+
pendingNear = false;
|
|
34
|
+
}
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (phrase === undefined && raw === ")") {
|
|
38
|
+
if (depth === nearDepth) {
|
|
39
|
+
nearDepth = 0;
|
|
40
|
+
afterNearComma = false;
|
|
41
|
+
}
|
|
42
|
+
depth = Math.max(0, depth - 1);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const hasNearComma = phrase === undefined && nearDepth > 0 && raw.endsWith(",");
|
|
46
|
+
// An attached distance like `NEAR(Go Rust,10)` has no standalone comma
|
|
47
|
+
// token; split it so the numeric tail is recognized as the distance.
|
|
48
|
+
let attachedDistance: string | undefined;
|
|
49
|
+
if (!hasNearComma && phrase === undefined && nearDepth > 0) {
|
|
50
|
+
const attached = /^(.*),(\d+)$/.exec(raw);
|
|
51
|
+
if (attached) {
|
|
52
|
+
raw = attached[1];
|
|
53
|
+
attachedDistance = attached[2];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Unmatched quote delimiters are malformed syntax, not searchable text.
|
|
57
|
+
// Phrases arrive already unescaped via raw.
|
|
58
|
+
const text = phrase === undefined ? raw.replace(/^[.,!?;:()]+|[.,!?;:()]+$/g, "").replace(/"/g, "") : raw;
|
|
59
|
+
if (text) {
|
|
60
|
+
const operator = phrase === undefined && /^(?:OR|AND|NOT|NEAR)$/.test(text);
|
|
61
|
+
const nearDistance = nearDepth > 0 && afterNearComma && /^\d+$/.test(text);
|
|
62
|
+
terms.push({ text, operator, nearDistance, quoted: phrase !== undefined });
|
|
63
|
+
if (operator && text === "NEAR") pendingNear = true;
|
|
64
|
+
}
|
|
65
|
+
if (attachedDistance !== undefined) terms.push({ text: attachedDistance, operator: false, nearDistance: true, quoted: false });
|
|
66
|
+
if (hasNearComma) afterNearComma = true;
|
|
67
|
+
}
|
|
68
|
+
return terms;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function quoteTerm(term: QueryTerm): string {
|
|
72
|
+
// Prefix expansion belongs only to an unquoted trailing star. An explicitly
|
|
73
|
+
// quoted `"deploy*"` searches for the literal asterisk.
|
|
74
|
+
if (!term.quoted && term.text.endsWith("*")) return `"${term.text.slice(0, -1).replace(/"/g, '""')}"*`;
|
|
75
|
+
return `"${term.text.replace(/"/g, '""')}"`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function quoteTerms(terms: QueryTerm[], sep: string): string {
|
|
79
|
+
return terms.map(quoteTerm).join(sep);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface FtsQueryPlan {
|
|
83
|
+
/** Candidate FTS5 MATCH expressions in try order. */
|
|
84
|
+
ftsCandidates: string[];
|
|
85
|
+
/** When true, fall back to SQL LIKE (also forced when every term < 3 chars). */
|
|
86
|
+
forceLike: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Sanitize ladder: trim + 512 cap; implicit-AND quoting when no explicit
|
|
91
|
+
* FTS5 operator; raw pass-through otherwise; recovery candidates are the
|
|
92
|
+
* fully-quoted form, then OR-expansion; LIKE covers everything else.
|
|
93
|
+
*/
|
|
94
|
+
export function buildFtsQueryPlan(rawQuery: string): FtsQueryPlan {
|
|
95
|
+
let query = rawQuery.trim();
|
|
96
|
+
if (query.length > MAX_QUERY_CHARS) query = query.slice(0, MAX_QUERY_CHARS);
|
|
97
|
+
if (!query) return { ftsCandidates: [], forceLike: false };
|
|
98
|
+
|
|
99
|
+
const queryTerms = collectQueryTerms(query);
|
|
100
|
+
const hasOperator = OPERATOR_RE.test(query);
|
|
101
|
+
// Short terms vanish under trigram MATCH — for natural-language queries
|
|
102
|
+
// (AND semantics) that silently breaks the query. Explicit-operator queries
|
|
103
|
+
// route there too (`Go OR Rust` silently drops the Go side); the boolean
|
|
104
|
+
// LIKE fallback preserves their AND/OR/NOT semantics.
|
|
105
|
+
if (
|
|
106
|
+
queryTerms.some(
|
|
107
|
+
(term) => !term.operator && !term.nearDistance && [...term.text.replace(/["()*]/g, "")].length < 3,
|
|
108
|
+
)
|
|
109
|
+
) {
|
|
110
|
+
return { ftsCandidates: [], forceLike: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const operands = queryTerms.filter((term) => !term.operator && !term.nearDistance);
|
|
114
|
+
// Recovery operands exclude syntax operators so a malformed query can never
|
|
115
|
+
// broaden into matches on AND/OR/NOT/NEAR themselves.
|
|
116
|
+
const natural = quoteTerms(operands, " ");
|
|
117
|
+
const orExpanded = operands.length > 1 ? quoteTerms(operands, " OR ") : null;
|
|
118
|
+
|
|
119
|
+
if (!hasOperator) {
|
|
120
|
+
// Quoted form cannot fail to parse; OR-expand only as breadth fallback.
|
|
121
|
+
return { ftsCandidates: orExpanded ? [natural, orExpanded] : [natural], forceLike: false };
|
|
122
|
+
}
|
|
123
|
+
// Explicit operators: raw first, then recovery paths.
|
|
124
|
+
const candidates = [query, natural];
|
|
125
|
+
if (orExpanded) candidates.push(orExpanded);
|
|
126
|
+
return { ftsCandidates: candidates, forceLike: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Unicode-aware case fold for all LIKE comparisons (column values via ulower,
|
|
130
|
+
* bound operand patterns, snippet matching). toLowerCase alone is not an
|
|
131
|
+
* equivalence for Greek: word-final Σ lowercases to ς while a typed query uses
|
|
132
|
+
* σ. ponytail: normalizes final sigma only, not full Unicode CaseFolding.txt
|
|
133
|
+
* (e.g. ß→ss stays unmatched); add a fold table if that ever matters. */
|
|
134
|
+
export function foldCase(s: string): string {
|
|
135
|
+
return s.toLowerCase().replaceAll("ς", "σ");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function normalizeLikeTerm(term: string, quoted = false): string {
|
|
139
|
+
// Only an unquoted trailing star is prefix syntax. Quoted stars stay literal.
|
|
140
|
+
return !quoted && term.endsWith("*") ? term.slice(0, -1) : term;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function likePattern(term: string): string {
|
|
144
|
+
// Fold here too: escaping is unaffected because % _ \ have no case variants.
|
|
145
|
+
return `%${foldCase(term).replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Parameterized translation of simple AND/OR/NOT/NEAR queries to SQL LIKE so
|
|
149
|
+
// short operands (trigram floor) keep boolean semantics. User input only ever
|
|
150
|
+
// reaches SQL as bound data.
|
|
151
|
+
function likeClause(term: string, quoted = false): { clause: string; params: string[] } | null {
|
|
152
|
+
term = normalizeLikeTerm(term, quoted);
|
|
153
|
+
if (!term) return null;
|
|
154
|
+
const pattern = likePattern(term);
|
|
155
|
+
return {
|
|
156
|
+
clause: "(ulower(m.head) LIKE ? ESCAPE '\\' OR ulower(m.tail) LIKE ? ESCAPE '\\')",
|
|
157
|
+
params: [pattern, pattern],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface LikeSql {
|
|
162
|
+
where: string;
|
|
163
|
+
params: SQLInputValue[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Character-position analogue of trigram FTS5 NEAR for LIKE-only operands.
|
|
167
|
+
* Trigram positions make N allow at most N-2 characters between phrases. */
|
|
168
|
+
export function nearLike(textValue: SQLOutputValue, termsValue: SQLOutputValue, distanceValue: SQLOutputValue): number {
|
|
169
|
+
if (typeof textValue !== "string" || typeof termsValue !== "string" || typeof distanceValue !== "number") return 0;
|
|
170
|
+
// Duplicate operands cannot affect the all-distinct-terms-present predicate,
|
|
171
|
+
// and the 512-char query cap otherwise admits hundreds of them.
|
|
172
|
+
const needles = [...new Set((JSON.parse(termsValue) as string[]).map(foldCase))];
|
|
173
|
+
const text = foldCase(textValue);
|
|
174
|
+
// Deduplication can leave one needle: presence satisfies any distance,
|
|
175
|
+
// while the multi-needle span math below demands an impossible negative gap.
|
|
176
|
+
if (needles.length === 1) return text.includes(needles[0]) ? 1 : 0;
|
|
177
|
+
const codePointAt = new Uint32Array(text.length + 1);
|
|
178
|
+
let point = 0;
|
|
179
|
+
for (let i = 0; i < text.length; point++) {
|
|
180
|
+
const width = (text.codePointAt(i) ?? 0) > 0xffff ? 2 : 1;
|
|
181
|
+
codePointAt[i] = point;
|
|
182
|
+
if (width === 2) codePointAt[i + 1] = point;
|
|
183
|
+
i += width;
|
|
184
|
+
}
|
|
185
|
+
codePointAt[text.length] = point;
|
|
186
|
+
|
|
187
|
+
const occurrences: { start: number; end: number; term: number }[] = [];
|
|
188
|
+
for (const [term, needle] of needles.entries()) {
|
|
189
|
+
for (let at = text.indexOf(needle); at >= 0; at = text.indexOf(needle, at + 1)) {
|
|
190
|
+
occurrences.push({ start: codePointAt[at], end: codePointAt[at + needle.length], term });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
occurrences.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
194
|
+
|
|
195
|
+
const counts = new Uint16Array(needles.length);
|
|
196
|
+
let present = 0;
|
|
197
|
+
let left = 0;
|
|
198
|
+
for (let right = 0; right < occurrences.length; right++) {
|
|
199
|
+
if (counts[occurrences[right].term]++ === 0) present++;
|
|
200
|
+
while (present === needles.length) {
|
|
201
|
+
if (occurrences[right].start - occurrences[left].end + 2 <= distanceValue) return 1;
|
|
202
|
+
if (--counts[occurrences[left++].term] === 0) present--;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface LikeToken {
|
|
209
|
+
phrase?: string;
|
|
210
|
+
word?: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseNearLikeSql(tokens: LikeToken[], start: number): { sql: LikeSql; end: number } | null {
|
|
214
|
+
if (tokens[start + 1]?.word !== "(") return null;
|
|
215
|
+
const operands: { text: string; quoted: boolean }[] = [];
|
|
216
|
+
let sawComma = false;
|
|
217
|
+
let distanceText: string | undefined;
|
|
218
|
+
|
|
219
|
+
for (let i = start + 2; i < tokens.length; i++) {
|
|
220
|
+
const token = tokens[i];
|
|
221
|
+
if (token.word === ")") {
|
|
222
|
+
if (operands.length < 2 || (sawComma && distanceText === undefined)) return null;
|
|
223
|
+
const distance = distanceText === undefined ? 10 : Number(distanceText);
|
|
224
|
+
if (distanceText !== undefined && (!/^\d+$/.test(distanceText) || !Number.isSafeInteger(distance))) return null;
|
|
225
|
+
const terms = operands.map((term) => normalizeLikeTerm(term.text, term.quoted));
|
|
226
|
+
if (terms.some((term) => !term)) return null;
|
|
227
|
+
const clauses = terms.map((term) => likeClause(term, true)!);
|
|
228
|
+
const encoded = JSON.stringify(terms);
|
|
229
|
+
return {
|
|
230
|
+
sql: {
|
|
231
|
+
where: `(${clauses.map((clause) => clause.clause).join(" AND ")} AND (unear(m.head, ?, ?) OR unear(m.tail, ?, ?)))`,
|
|
232
|
+
params: [...clauses.flatMap((clause) => clause.params), encoded, distance, encoded, distance],
|
|
233
|
+
},
|
|
234
|
+
end: i,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (token.phrase !== undefined) {
|
|
238
|
+
if (sawComma) return null;
|
|
239
|
+
operands.push({ text: token.phrase, quoted: true });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const word = token.word ?? "";
|
|
244
|
+
if (!word || word === "(" || /^(?:AND|OR|NOT|NEAR)$/.test(word) || word.includes('"')) return null;
|
|
245
|
+
const comma = word.indexOf(",");
|
|
246
|
+
if (comma >= 0) {
|
|
247
|
+
if (sawComma || word.indexOf(",", comma + 1) >= 0) return null;
|
|
248
|
+
const before = word.slice(0, comma).replace(/^[.!?;:]+|[.!?;:]+$/g, "");
|
|
249
|
+
if (before) operands.push({ text: before, quoted: false });
|
|
250
|
+
sawComma = true;
|
|
251
|
+
distanceText = word.slice(comma + 1) || undefined;
|
|
252
|
+
} else if (sawComma) {
|
|
253
|
+
if (distanceText !== undefined) return null;
|
|
254
|
+
distanceText = word;
|
|
255
|
+
} else {
|
|
256
|
+
const text = word.replace(/^[.!?;:]+|[.!?;:]+$/g, "");
|
|
257
|
+
if (!text) return null;
|
|
258
|
+
operands.push({ text, quoted: false });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Space out parens that act as grouping syntax while leaving quoted phrases
|
|
265
|
+
* like "C(ABI)" intact. */
|
|
266
|
+
function spaceParensOutsideQuotes(q: string): string {
|
|
267
|
+
let out = "";
|
|
268
|
+
let inQuote = false;
|
|
269
|
+
for (const c of q) {
|
|
270
|
+
if (c === '"') inQuote = !inQuote;
|
|
271
|
+
out += !inQuote && (c === "(" || c === ")") ? ` ${c} ` : c;
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function buildBooleanLikeSql(rawQuery: string): LikeSql | null {
|
|
277
|
+
let query = rawQuery.trim();
|
|
278
|
+
if (!query) return null;
|
|
279
|
+
// Imbalance detection/recovery is quote-aware: parentheses inside quoted
|
|
280
|
+
// operands ("func(") are literals and must survive.
|
|
281
|
+
let depth = 0;
|
|
282
|
+
let inQuote = false;
|
|
283
|
+
for (const c of query) {
|
|
284
|
+
if (c === '"') inQuote = !inQuote;
|
|
285
|
+
else if (!inQuote && c === "(") depth++;
|
|
286
|
+
else if (!inQuote && c === ")" && --depth < 0) break;
|
|
287
|
+
}
|
|
288
|
+
if (depth !== 0) {
|
|
289
|
+
inQuote = false;
|
|
290
|
+
query = [...query].map((c) => {
|
|
291
|
+
if (c === '"') inQuote = !inQuote;
|
|
292
|
+
return !inQuote && (c === "(" || c === ")") ? " " : c;
|
|
293
|
+
}).join("");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const tokens: LikeToken[] = [...spaceParensOutsideQuotes(query).matchAll(TOKEN_RE)]
|
|
297
|
+
.map((m) => (m[1] !== undefined ? { phrase: unescapePhrase(m[1]) } : { word: m[2] ?? "" }))
|
|
298
|
+
.filter((t) => (t.phrase !== undefined ? t.phrase !== "" : t.word !== ""));
|
|
299
|
+
const fail = (): LikeSql | null => tokens.some((token) => token.word === "NEAR") ? { where: "0", params: [] } : null;
|
|
300
|
+
const sql: string[] = [];
|
|
301
|
+
const params: SQLInputValue[] = [];
|
|
302
|
+
let expectingOperand = true;
|
|
303
|
+
depth = 0;
|
|
304
|
+
|
|
305
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
306
|
+
const token = tokens[i];
|
|
307
|
+
const word = token.word;
|
|
308
|
+
if (word === "(") {
|
|
309
|
+
if (!expectingOperand) sql.push("AND");
|
|
310
|
+
sql.push("(");
|
|
311
|
+
depth++;
|
|
312
|
+
expectingOperand = true;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (word === ")") {
|
|
316
|
+
if (expectingOperand || depth-- === 0) return fail();
|
|
317
|
+
sql.push(")");
|
|
318
|
+
expectingOperand = false;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (word === "AND" || word === "OR") {
|
|
322
|
+
if (expectingOperand) return fail();
|
|
323
|
+
sql.push(word);
|
|
324
|
+
expectingOperand = true;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (word === "NOT") {
|
|
328
|
+
if (!expectingOperand) sql.push("AND");
|
|
329
|
+
sql.push("NOT");
|
|
330
|
+
expectingOperand = true;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (word === "NEAR") {
|
|
334
|
+
const near = parseNearLikeSql(tokens, i);
|
|
335
|
+
if (near === null) return fail();
|
|
336
|
+
if (!expectingOperand) sql.push("AND");
|
|
337
|
+
sql.push(near.sql.where);
|
|
338
|
+
params.push(...near.sql.params);
|
|
339
|
+
expectingOperand = false;
|
|
340
|
+
i = near.end;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const term = token.phrase ?? word?.replace(/^[.,!?;:]+|[.,!?;:]+$/g, "").replace(/"/g, "") ?? "";
|
|
345
|
+
const clause = likeClause(term, token.phrase !== undefined);
|
|
346
|
+
if (clause === null) return fail();
|
|
347
|
+
if (!expectingOperand) sql.push("AND");
|
|
348
|
+
sql.push(clause.clause);
|
|
349
|
+
params.push(...clause.params);
|
|
350
|
+
expectingOperand = false;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return expectingOperand || depth !== 0 ? fail() : { where: sql.join(" "), params };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
interface LikeQueryPlan extends LikeSql {
|
|
357
|
+
terms: string[];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function buildLikeQueryPlan(rawQuery: string): LikeQueryPlan | null {
|
|
361
|
+
const query = rawQuery.trim().slice(0, MAX_QUERY_CHARS);
|
|
362
|
+
// Keep quoted operator words as operands and omit NEAR's optional numeric
|
|
363
|
+
// distance; only unquoted syntax tokens are excluded.
|
|
364
|
+
const terms = collectQueryTerms(query)
|
|
365
|
+
.filter((term) => !term.operator && !term.nearDistance)
|
|
366
|
+
.map((term) => normalizeLikeTerm(term.text, term.quoted))
|
|
367
|
+
.filter(Boolean);
|
|
368
|
+
if (terms.length === 0) return null;
|
|
369
|
+
|
|
370
|
+
// Boolean LIKE preserves simple AND/OR/NOT; unsupported shapes degrade
|
|
371
|
+
// to AND-of-terms. Both forms are fully parameterized.
|
|
372
|
+
const bool = buildBooleanLikeSql(query);
|
|
373
|
+
return {
|
|
374
|
+
terms,
|
|
375
|
+
where: bool?.where ?? terms.map(() => "(ulower(m.head) LIKE ? ESCAPE '\\' OR ulower(m.tail) LIKE ? ESCAPE '\\')").join(" AND "),
|
|
376
|
+
params: bool?.params ?? terms.flatMap((term) => [likePattern(term), likePattern(term)]),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Index engine: SQLite schema (WAL, external-content trigram FTS5), capped
|
|
3
|
-
* incremental sync
|
|
4
|
-
*
|
|
5
|
-
* pure Node + node:sqlite so it is testable headless.
|
|
3
|
+
* incremental sync, and discovery search with one-hop lineage suppression.
|
|
4
|
+
* No pi runtime imports — pure Node + node:sqlite so it is testable headless.
|
|
6
5
|
*/
|
|
7
6
|
import { DatabaseSync } from "node:sqlite";
|
|
8
|
-
import type {
|
|
7
|
+
import type { SQLOutputValue } from "node:sqlite";
|
|
9
8
|
import fs from "node:fs";
|
|
10
9
|
import path from "node:path";
|
|
10
|
+
import { buildFtsQueryPlan, buildLikeQueryPlan, foldCase, nearLike } from "./query.ts";
|
|
11
11
|
import { MAX_SESSION_FILE_BYTES, readTranscriptEntries } from "./transcript.ts";
|
|
12
12
|
import type { SearchHit, SessionRow, SyncResult } from "./types.ts";
|
|
13
13
|
export const DEFAULT_SYNC_CAP = 50;
|
|
14
14
|
/** Hard ceiling for the internal/test `opts.cap` work bound of syncSessions. */
|
|
15
15
|
const MAX_SYNC_CAP = DEFAULT_SYNC_CAP * 10;
|
|
16
|
-
export const MAX_QUERY_CHARS = 512;
|
|
17
16
|
const MAX_TEXT_CHARS = 20000;
|
|
18
17
|
const SCAN_LIMIT = 300;
|
|
19
18
|
/** Best-ranked candidate retained per session after live-entry filtering. */
|
|
@@ -157,6 +156,14 @@ function openDb(dbPath: string): DatabaseSync {
|
|
|
157
156
|
// both sides case-fold through the same foldCase helper.
|
|
158
157
|
db.function("ulower", (s: SQLOutputValue): string => typeof s === "string" ? foldCase(s) : "");
|
|
159
158
|
db.function("unear", nearLike);
|
|
159
|
+
// Creating a missing external-content FTS table after messages and their
|
|
160
|
+
// watermarks persist leaves it blank: unchanged files never replay inserts.
|
|
161
|
+
// The disposable index must be deleted and rebuilt instead of repaired here.
|
|
162
|
+
const hasFts = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'session_fts'").get() !== undefined;
|
|
163
|
+
const hasPersistedSchema = db.prepare("SELECT 1 FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' LIMIT 1").get() !== undefined;
|
|
164
|
+
if (!hasFts && hasPersistedSchema) {
|
|
165
|
+
throw new Error("index schema incompatible: session_fts missing — delete the index to rebuild");
|
|
166
|
+
}
|
|
160
167
|
db.exec(SCHEMA_SQL);
|
|
161
168
|
// CREATE TABLE IF NOT EXISTS cannot repair a predecessor index whose
|
|
162
169
|
// tables exist but lack current watermark columns (e.g. no ctime_ms): its
|
|
@@ -508,358 +515,6 @@ function getBacklog(db: DatabaseSync): number {
|
|
|
508
515
|
return row ? Number(row.value) : 0;
|
|
509
516
|
}
|
|
510
517
|
|
|
511
|
-
// --- Query sanitize ladder ---
|
|
512
|
-
|
|
513
|
-
const OPERATOR_RE = /\b(OR|AND|NOT|NEAR)\b/;
|
|
514
|
-
// FTS5 string syntax: `""` inside a quoted phrase is one literal quote.
|
|
515
|
-
const TOKEN_RE = /"((?:[^"]|"")*)"|(\S+)/g;
|
|
516
|
-
const unescapePhrase = (s: string): string => s.replaceAll('""', '"');
|
|
517
|
-
|
|
518
|
-
interface QueryTerm {
|
|
519
|
-
text: string;
|
|
520
|
-
operator: boolean;
|
|
521
|
-
nearDistance: boolean;
|
|
522
|
-
quoted: boolean;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
function collectQueryTerms(query: string): QueryTerm[] {
|
|
526
|
-
const terms: QueryTerm[] = [];
|
|
527
|
-
let depth = 0;
|
|
528
|
-
let pendingNear = false;
|
|
529
|
-
let nearDepth = 0;
|
|
530
|
-
let afterNearComma = false;
|
|
531
|
-
for (const match of spaceParensOutsideQuotes(query).matchAll(TOKEN_RE)) {
|
|
532
|
-
const phrase = match[1];
|
|
533
|
-
let raw = phrase === undefined ? match[2] ?? "" : unescapePhrase(phrase);
|
|
534
|
-
if (phrase === undefined && raw === "(") {
|
|
535
|
-
depth++;
|
|
536
|
-
if (pendingNear) {
|
|
537
|
-
nearDepth = depth;
|
|
538
|
-
pendingNear = false;
|
|
539
|
-
}
|
|
540
|
-
continue;
|
|
541
|
-
}
|
|
542
|
-
if (phrase === undefined && raw === ")") {
|
|
543
|
-
if (depth === nearDepth) {
|
|
544
|
-
nearDepth = 0;
|
|
545
|
-
afterNearComma = false;
|
|
546
|
-
}
|
|
547
|
-
depth = Math.max(0, depth - 1);
|
|
548
|
-
continue;
|
|
549
|
-
}
|
|
550
|
-
const hasNearComma = phrase === undefined && nearDepth > 0 && raw.endsWith(",");
|
|
551
|
-
// An attached distance like `NEAR(Go Rust,10)` has no standalone comma
|
|
552
|
-
// token; split it so the numeric tail is recognized as the distance.
|
|
553
|
-
let attachedDistance: string | undefined;
|
|
554
|
-
if (!hasNearComma && phrase === undefined && nearDepth > 0) {
|
|
555
|
-
const attached = /^(.*),(\d+)$/.exec(raw);
|
|
556
|
-
if (attached) {
|
|
557
|
-
raw = attached[1];
|
|
558
|
-
attachedDistance = attached[2];
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
// Unmatched quote delimiters are malformed syntax, not searchable text.
|
|
562
|
-
// Phrases arrive already unescaped via raw.
|
|
563
|
-
const text = phrase === undefined ? raw.replace(/^[.,!?;:()]+|[.,!?;:()]+$/g, "").replace(/"/g, "") : raw;
|
|
564
|
-
if (text) {
|
|
565
|
-
const operator = phrase === undefined && /^(?:OR|AND|NOT|NEAR)$/.test(text);
|
|
566
|
-
const nearDistance = nearDepth > 0 && afterNearComma && /^\d+$/.test(text);
|
|
567
|
-
terms.push({ text, operator, nearDistance, quoted: phrase !== undefined });
|
|
568
|
-
if (operator && text === "NEAR") pendingNear = true;
|
|
569
|
-
}
|
|
570
|
-
if (attachedDistance !== undefined) terms.push({ text: attachedDistance, operator: false, nearDistance: true, quoted: false });
|
|
571
|
-
if (hasNearComma) afterNearComma = true;
|
|
572
|
-
}
|
|
573
|
-
return terms;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
function quoteTerm(term: QueryTerm): string {
|
|
577
|
-
// Prefix expansion belongs only to an unquoted trailing star. An explicitly
|
|
578
|
-
// quoted `"deploy*"` searches for the literal asterisk.
|
|
579
|
-
if (!term.quoted && term.text.endsWith("*")) return `"${term.text.slice(0, -1).replace(/"/g, '""')}"*`;
|
|
580
|
-
return `"${term.text.replace(/"/g, '""')}"`;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
function quoteTerms(terms: QueryTerm[], sep: string): string {
|
|
584
|
-
return terms.map(quoteTerm).join(sep);
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
export interface FtsQueryPlan {
|
|
588
|
-
/** Candidate FTS5 MATCH expressions in try order. */
|
|
589
|
-
ftsCandidates: string[];
|
|
590
|
-
/** When true, fall back to SQL LIKE (also forced when every term < 3 chars). */
|
|
591
|
-
forceLike: boolean;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
/**
|
|
595
|
-
* Sanitize ladder: trim + 512 cap; implicit-AND quoting when no explicit
|
|
596
|
-
* FTS5 operator; raw pass-through otherwise; recovery candidates are the
|
|
597
|
-
* fully-quoted form, then OR-expansion; LIKE covers everything else.
|
|
598
|
-
*/
|
|
599
|
-
export function buildFtsQueryPlan(rawQuery: string): FtsQueryPlan {
|
|
600
|
-
let query = rawQuery.trim();
|
|
601
|
-
if (query.length > MAX_QUERY_CHARS) query = query.slice(0, MAX_QUERY_CHARS);
|
|
602
|
-
if (!query) return { ftsCandidates: [], forceLike: false };
|
|
603
|
-
|
|
604
|
-
const queryTerms = collectQueryTerms(query);
|
|
605
|
-
const hasOperator = OPERATOR_RE.test(query);
|
|
606
|
-
// Short terms vanish under trigram MATCH — for natural-language queries
|
|
607
|
-
// (AND semantics) that silently breaks the query. Explicit-operator queries
|
|
608
|
-
// route there too (`Go OR Rust` silently drops the Go side); the boolean
|
|
609
|
-
// LIKE fallback preserves their AND/OR/NOT semantics.
|
|
610
|
-
if (
|
|
611
|
-
queryTerms.some(
|
|
612
|
-
(term) => !term.operator && !term.nearDistance && [...term.text.replace(/["()*]/g, "")].length < 3,
|
|
613
|
-
)
|
|
614
|
-
) {
|
|
615
|
-
return { ftsCandidates: [], forceLike: true };
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
const operands = queryTerms.filter((term) => !term.operator && !term.nearDistance);
|
|
619
|
-
// Recovery operands exclude syntax operators so a malformed query can never
|
|
620
|
-
// broaden into matches on AND/OR/NOT/NEAR themselves.
|
|
621
|
-
const natural = quoteTerms(operands, " ");
|
|
622
|
-
const orExpanded = operands.length > 1 ? quoteTerms(operands, " OR ") : null;
|
|
623
|
-
|
|
624
|
-
if (!hasOperator) {
|
|
625
|
-
// Quoted form cannot fail to parse; OR-expand only as breadth fallback.
|
|
626
|
-
return { ftsCandidates: orExpanded ? [natural, orExpanded] : [natural], forceLike: false };
|
|
627
|
-
}
|
|
628
|
-
// Explicit operators: raw first, then recovery paths.
|
|
629
|
-
const candidates = [query, natural];
|
|
630
|
-
if (orExpanded) candidates.push(orExpanded);
|
|
631
|
-
return { ftsCandidates: candidates, forceLike: false };
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
/** Unicode-aware case fold for all LIKE comparisons (column values via ulower,
|
|
635
|
-
* bound operand patterns, snippet matching). toLowerCase alone is not an
|
|
636
|
-
* equivalence for Greek: word-final Σ lowercases to ς while a typed query uses
|
|
637
|
-
* σ. ponytail: normalizes final sigma only, not full Unicode CaseFolding.txt
|
|
638
|
-
* (e.g. ß→ss stays unmatched); add a fold table if that ever matters. */
|
|
639
|
-
function foldCase(s: string): string {
|
|
640
|
-
return s.toLowerCase().replaceAll("ς", "σ");
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
function normalizeLikeTerm(term: string, quoted = false): string {
|
|
644
|
-
// Only an unquoted trailing star is prefix syntax. Quoted stars stay literal.
|
|
645
|
-
return !quoted && term.endsWith("*") ? term.slice(0, -1) : term;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
function likePattern(term: string): string {
|
|
649
|
-
// Fold here too: escaping is unaffected because % _ \ have no case variants.
|
|
650
|
-
return `%${foldCase(term).replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
// --- Boolean LIKE fallback ---
|
|
654
|
-
// Parameterized translation of simple AND/OR/NOT/NEAR queries to SQL LIKE so
|
|
655
|
-
// short operands (trigram floor) keep boolean semantics. User input only ever
|
|
656
|
-
// reaches SQL as bound data.
|
|
657
|
-
|
|
658
|
-
function likeClause(term: string, quoted = false): { clause: string; params: string[] } | null {
|
|
659
|
-
term = normalizeLikeTerm(term, quoted);
|
|
660
|
-
if (!term) return null;
|
|
661
|
-
const pattern = likePattern(term);
|
|
662
|
-
return {
|
|
663
|
-
clause: "(ulower(m.head) LIKE ? ESCAPE '\\' OR ulower(m.tail) LIKE ? ESCAPE '\\')",
|
|
664
|
-
params: [pattern, pattern],
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
interface LikeSql {
|
|
669
|
-
where: string;
|
|
670
|
-
params: SQLInputValue[];
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
/** Character-position analogue of trigram FTS5 NEAR for LIKE-only operands.
|
|
674
|
-
* Trigram positions make N allow at most N-2 characters between phrases. */
|
|
675
|
-
function nearLike(textValue: SQLOutputValue, termsValue: SQLOutputValue, distanceValue: SQLOutputValue): number {
|
|
676
|
-
if (typeof textValue !== "string" || typeof termsValue !== "string" || typeof distanceValue !== "number") return 0;
|
|
677
|
-
// Duplicate operands cannot affect the all-distinct-terms-present predicate,
|
|
678
|
-
// and the 512-char query cap otherwise admits hundreds of them.
|
|
679
|
-
const needles = [...new Set((JSON.parse(termsValue) as string[]).map(foldCase))];
|
|
680
|
-
const text = foldCase(textValue);
|
|
681
|
-
// Deduplication can leave one needle: presence satisfies any distance,
|
|
682
|
-
// while the multi-needle span math below demands an impossible negative gap.
|
|
683
|
-
if (needles.length === 1) return text.includes(needles[0]) ? 1 : 0;
|
|
684
|
-
const codePointAt = new Uint32Array(text.length + 1);
|
|
685
|
-
let point = 0;
|
|
686
|
-
for (let i = 0; i < text.length; point++) {
|
|
687
|
-
const width = (text.codePointAt(i) ?? 0) > 0xffff ? 2 : 1;
|
|
688
|
-
codePointAt[i] = point;
|
|
689
|
-
if (width === 2) codePointAt[i + 1] = point;
|
|
690
|
-
i += width;
|
|
691
|
-
}
|
|
692
|
-
codePointAt[text.length] = point;
|
|
693
|
-
|
|
694
|
-
const occurrences: { start: number; end: number; term: number }[] = [];
|
|
695
|
-
for (const [term, needle] of needles.entries()) {
|
|
696
|
-
for (let at = text.indexOf(needle); at >= 0; at = text.indexOf(needle, at + 1)) {
|
|
697
|
-
occurrences.push({ start: codePointAt[at], end: codePointAt[at + needle.length], term });
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
occurrences.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
701
|
-
|
|
702
|
-
const counts = new Uint16Array(needles.length);
|
|
703
|
-
let present = 0;
|
|
704
|
-
let left = 0;
|
|
705
|
-
for (let right = 0; right < occurrences.length; right++) {
|
|
706
|
-
if (counts[occurrences[right].term]++ === 0) present++;
|
|
707
|
-
while (present === needles.length) {
|
|
708
|
-
if (occurrences[right].start - occurrences[left].end + 2 <= distanceValue) return 1;
|
|
709
|
-
if (--counts[occurrences[left++].term] === 0) present--;
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
return 0;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
interface LikeToken {
|
|
716
|
-
phrase?: string;
|
|
717
|
-
word?: string;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
function parseNearLikeSql(tokens: LikeToken[], start: number): { sql: LikeSql; end: number } | null {
|
|
721
|
-
if (tokens[start + 1]?.word !== "(") return null;
|
|
722
|
-
const operands: { text: string; quoted: boolean }[] = [];
|
|
723
|
-
let sawComma = false;
|
|
724
|
-
let distanceText: string | undefined;
|
|
725
|
-
|
|
726
|
-
for (let i = start + 2; i < tokens.length; i++) {
|
|
727
|
-
const token = tokens[i];
|
|
728
|
-
if (token.word === ")") {
|
|
729
|
-
if (operands.length < 2 || (sawComma && distanceText === undefined)) return null;
|
|
730
|
-
const distance = distanceText === undefined ? 10 : Number(distanceText);
|
|
731
|
-
if (distanceText !== undefined && (!/^\d+$/.test(distanceText) || !Number.isSafeInteger(distance))) return null;
|
|
732
|
-
const terms = operands.map((term) => normalizeLikeTerm(term.text, term.quoted));
|
|
733
|
-
if (terms.some((term) => !term)) return null;
|
|
734
|
-
const clauses = terms.map((term) => likeClause(term, true)!);
|
|
735
|
-
const encoded = JSON.stringify(terms);
|
|
736
|
-
return {
|
|
737
|
-
sql: {
|
|
738
|
-
where: `(${clauses.map((clause) => clause.clause).join(" AND ")} AND (unear(m.head, ?, ?) OR unear(m.tail, ?, ?)))`,
|
|
739
|
-
params: [...clauses.flatMap((clause) => clause.params), encoded, distance, encoded, distance],
|
|
740
|
-
},
|
|
741
|
-
end: i,
|
|
742
|
-
};
|
|
743
|
-
}
|
|
744
|
-
if (token.phrase !== undefined) {
|
|
745
|
-
if (sawComma) return null;
|
|
746
|
-
operands.push({ text: token.phrase, quoted: true });
|
|
747
|
-
continue;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
const word = token.word ?? "";
|
|
751
|
-
if (!word || word === "(" || /^(?:AND|OR|NOT|NEAR)$/.test(word) || word.includes('"')) return null;
|
|
752
|
-
const comma = word.indexOf(",");
|
|
753
|
-
if (comma >= 0) {
|
|
754
|
-
if (sawComma || word.indexOf(",", comma + 1) >= 0) return null;
|
|
755
|
-
const before = word.slice(0, comma).replace(/^[.!?;:]+|[.!?;:]+$/g, "");
|
|
756
|
-
if (before) operands.push({ text: before, quoted: false });
|
|
757
|
-
sawComma = true;
|
|
758
|
-
distanceText = word.slice(comma + 1) || undefined;
|
|
759
|
-
} else if (sawComma) {
|
|
760
|
-
if (distanceText !== undefined) return null;
|
|
761
|
-
distanceText = word;
|
|
762
|
-
} else {
|
|
763
|
-
const text = word.replace(/^[.!?;:]+|[.!?;:]+$/g, "");
|
|
764
|
-
if (!text) return null;
|
|
765
|
-
operands.push({ text, quoted: false });
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
return null;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
/** Space out parens that act as grouping syntax while leaving quoted phrases
|
|
772
|
-
* like "C(ABI)" intact. */
|
|
773
|
-
function spaceParensOutsideQuotes(q: string): string {
|
|
774
|
-
let out = "";
|
|
775
|
-
let inQuote = false;
|
|
776
|
-
for (const c of q) {
|
|
777
|
-
if (c === '"') inQuote = !inQuote;
|
|
778
|
-
out += !inQuote && (c === "(" || c === ")") ? ` ${c} ` : c;
|
|
779
|
-
}
|
|
780
|
-
return out;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
function buildBooleanLikeSql(rawQuery: string): LikeSql | null {
|
|
784
|
-
let query = rawQuery.trim();
|
|
785
|
-
if (!query) return null;
|
|
786
|
-
// Imbalance detection/recovery is quote-aware: parentheses inside quoted
|
|
787
|
-
// operands ("func(") are literals and must survive.
|
|
788
|
-
let depth = 0;
|
|
789
|
-
let inQuote = false;
|
|
790
|
-
for (const c of query) {
|
|
791
|
-
if (c === '"') inQuote = !inQuote;
|
|
792
|
-
else if (!inQuote && c === "(") depth++;
|
|
793
|
-
else if (!inQuote && c === ")" && --depth < 0) break;
|
|
794
|
-
}
|
|
795
|
-
if (depth !== 0) {
|
|
796
|
-
inQuote = false;
|
|
797
|
-
query = [...query].map((c) => {
|
|
798
|
-
if (c === '"') inQuote = !inQuote;
|
|
799
|
-
return !inQuote && (c === "(" || c === ")") ? " " : c;
|
|
800
|
-
}).join("");
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
const tokens: LikeToken[] = [...spaceParensOutsideQuotes(query).matchAll(TOKEN_RE)]
|
|
804
|
-
.map((m) => (m[1] !== undefined ? { phrase: unescapePhrase(m[1]) } : { word: m[2] ?? "" }))
|
|
805
|
-
.filter((t) => (t.phrase !== undefined ? t.phrase !== "" : t.word !== ""));
|
|
806
|
-
const fail = (): LikeSql | null => tokens.some((token) => token.word === "NEAR") ? { where: "0", params: [] } : null;
|
|
807
|
-
const sql: string[] = [];
|
|
808
|
-
const params: SQLInputValue[] = [];
|
|
809
|
-
let expectingOperand = true;
|
|
810
|
-
depth = 0;
|
|
811
|
-
|
|
812
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
813
|
-
const token = tokens[i];
|
|
814
|
-
const word = token.word;
|
|
815
|
-
if (word === "(") {
|
|
816
|
-
if (!expectingOperand) sql.push("AND");
|
|
817
|
-
sql.push("(");
|
|
818
|
-
depth++;
|
|
819
|
-
expectingOperand = true;
|
|
820
|
-
continue;
|
|
821
|
-
}
|
|
822
|
-
if (word === ")") {
|
|
823
|
-
if (expectingOperand || depth-- === 0) return fail();
|
|
824
|
-
sql.push(")");
|
|
825
|
-
expectingOperand = false;
|
|
826
|
-
continue;
|
|
827
|
-
}
|
|
828
|
-
if (word === "AND" || word === "OR") {
|
|
829
|
-
if (expectingOperand) return fail();
|
|
830
|
-
sql.push(word);
|
|
831
|
-
expectingOperand = true;
|
|
832
|
-
continue;
|
|
833
|
-
}
|
|
834
|
-
if (word === "NOT") {
|
|
835
|
-
if (!expectingOperand) sql.push("AND");
|
|
836
|
-
sql.push("NOT");
|
|
837
|
-
expectingOperand = true;
|
|
838
|
-
continue;
|
|
839
|
-
}
|
|
840
|
-
if (word === "NEAR") {
|
|
841
|
-
const near = parseNearLikeSql(tokens, i);
|
|
842
|
-
if (near === null) return fail();
|
|
843
|
-
if (!expectingOperand) sql.push("AND");
|
|
844
|
-
sql.push(near.sql.where);
|
|
845
|
-
params.push(...near.sql.params);
|
|
846
|
-
expectingOperand = false;
|
|
847
|
-
i = near.end;
|
|
848
|
-
continue;
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
const term = token.phrase ?? word?.replace(/^[.,!?;:]+|[.,!?;:]+$/g, "").replace(/"/g, "") ?? "";
|
|
852
|
-
const clause = likeClause(term, token.phrase !== undefined);
|
|
853
|
-
if (clause === null) return fail();
|
|
854
|
-
if (!expectingOperand) sql.push("AND");
|
|
855
|
-
sql.push(clause.clause);
|
|
856
|
-
params.push(...clause.params);
|
|
857
|
-
expectingOperand = false;
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
return expectingOperand || depth !== 0 ? fail() : { where: sql.join(" "), params };
|
|
861
|
-
}
|
|
862
|
-
|
|
863
518
|
// --- Search ---
|
|
864
519
|
|
|
865
520
|
export interface SearchOptions {
|
|
@@ -877,6 +532,15 @@ export interface SearchOptions {
|
|
|
877
532
|
const LIVE_FILTER_SQL = `NOT EXISTS (
|
|
878
533
|
SELECT 1 FROM live_filter lf WHERE lf.path = m.path AND lf.entry_id = m.entry_id
|
|
879
534
|
)`;
|
|
535
|
+
const LINEAGE_FILTER_SQL = `NOT EXISTS (
|
|
536
|
+
SELECT 1 FROM ranked parent
|
|
537
|
+
WHERE parent.path = r.parent_session
|
|
538
|
+
AND parent.path <> r.path
|
|
539
|
+
AND parent.rn <= ${ROWS_PER_FILE}
|
|
540
|
+
-- Direct mutual cycle (A<->B) would suppress both sides; keep the
|
|
541
|
+
-- lexicographically smaller path deterministically.
|
|
542
|
+
AND NOT (parent.parent_session IS r.path AND r.path < parent.path)
|
|
543
|
+
)`;
|
|
880
544
|
const BASE_SELECT = `
|
|
881
545
|
WITH matches AS (
|
|
882
546
|
SELECT m.path, m.entry_id, m.role, m.timestamp, s.cwd, s.name, s.started_at,
|
|
@@ -893,15 +557,7 @@ WITH matches AS (
|
|
|
893
557
|
SELECT path, entry_id, role, timestamp, snip, cwd, name, started_at
|
|
894
558
|
FROM ranked r
|
|
895
559
|
WHERE rn <= ${ROWS_PER_FILE}
|
|
896
|
-
AND
|
|
897
|
-
SELECT 1 FROM ranked parent
|
|
898
|
-
WHERE parent.path = r.parent_session
|
|
899
|
-
AND parent.path <> r.path
|
|
900
|
-
AND parent.rn <= ${ROWS_PER_FILE}
|
|
901
|
-
-- Direct mutual cycle (A<->B) would suppress both sides; keep the
|
|
902
|
-
-- lexicographically smaller path deterministically.
|
|
903
|
-
AND NOT (parent.parent_session IS r.path AND r.path < parent.path)
|
|
904
|
-
)
|
|
560
|
+
AND ${LINEAGE_FILTER_SQL}
|
|
905
561
|
ORDER BY score, rid
|
|
906
562
|
LIMIT ${SCAN_LIMIT}`;
|
|
907
563
|
|
|
@@ -988,45 +644,25 @@ export function searchIndex(
|
|
|
988
644
|
}
|
|
989
645
|
|
|
990
646
|
if (usedLike) {
|
|
991
|
-
const
|
|
992
|
-
|
|
993
|
-
// distance; only unquoted syntax tokens are excluded.
|
|
994
|
-
const operandTerms = collectQueryTerms(trimmed)
|
|
995
|
-
.filter((term) => !term.operator && !term.nearDistance)
|
|
996
|
-
.map((term) => normalizeLikeTerm(term.text, term.quoted))
|
|
997
|
-
.filter(Boolean);
|
|
998
|
-
if (operandTerms.length === 0) return { hits: [], backlogRemaining: getBacklog(db) };
|
|
999
|
-
const terms = operandTerms;
|
|
1000
|
-
// Snippets anchor on operand terms only — operator words like OR would
|
|
1001
|
-
// otherwise match common substrings and hide the real match.
|
|
1002
|
-
const snippetTerms = terms;
|
|
1003
|
-
// Boolean LIKE preserves simple AND/OR/NOT; unsupported shapes degrade
|
|
1004
|
-
// to AND-of-terms. Both forms are fully parameterized.
|
|
1005
|
-
const bool = buildBooleanLikeSql(trimmed);
|
|
1006
|
-
const where = bool?.where ?? terms.map(() => "(ulower(m.head) LIKE ? ESCAPE '\\' OR ulower(m.tail) LIKE ? ESCAPE '\\')").join(" AND ");
|
|
1007
|
-
const params = bool?.params ?? terms.flatMap((t) => [likePattern(t), likePattern(t)]);
|
|
647
|
+
const likePlan = buildLikeQueryPlan(query);
|
|
648
|
+
if (likePlan === null) return { hits: [], backlogRemaining: getBacklog(db) };
|
|
1008
649
|
rows = db.prepare(`WITH ranked AS (
|
|
1009
650
|
SELECT m.path, m.entry_id, m.role, m.timestamp, m.head, m.tail, s.cwd, s.name, s.started_at,
|
|
1010
651
|
s.parent_session,
|
|
1011
652
|
ROW_NUMBER() OVER (PARTITION BY m.path ORDER BY m.rowid DESC) AS rn,
|
|
1012
653
|
COUNT(*) OVER (PARTITION BY m.path) AS matches
|
|
1013
654
|
FROM messages m LEFT JOIN sessions s ON s.path = m.path
|
|
1014
|
-
WHERE ${where} AND ${LIVE_FILTER_SQL}
|
|
655
|
+
WHERE ${likePlan.where} AND ${LIVE_FILTER_SQL}
|
|
1015
656
|
)
|
|
1016
657
|
SELECT path, entry_id, role, timestamp, head, tail, cwd, name, started_at
|
|
1017
658
|
FROM ranked r
|
|
1018
659
|
WHERE rn <= ${ROWS_PER_FILE}
|
|
1019
|
-
AND
|
|
1020
|
-
SELECT 1 FROM ranked parent
|
|
1021
|
-
WHERE parent.path = r.parent_session
|
|
1022
|
-
AND parent.path <> r.path
|
|
1023
|
-
AND parent.rn <= ${ROWS_PER_FILE}
|
|
1024
|
-
-- mutual-cycle tie-break: keep the lexicographically smaller path
|
|
1025
|
-
AND NOT (parent.parent_session IS r.path AND r.path < parent.path)
|
|
1026
|
-
)
|
|
660
|
+
AND ${LINEAGE_FILTER_SQL}
|
|
1027
661
|
ORDER BY matches DESC, started_at DESC, path
|
|
1028
|
-
LIMIT ${SCAN_LIMIT}`).all(...params) as any;
|
|
1029
|
-
|
|
662
|
+
LIMIT ${SCAN_LIMIT}`).all(...likePlan.params) as any;
|
|
663
|
+
// Snippets anchor on operand terms only — operator words like OR would
|
|
664
|
+
// otherwise match common substrings and hide the real match.
|
|
665
|
+
for (const r of rows as any[]) r.snip = likeSnippet((r as any).head ?? "", (r as any).tail ?? "", likePlan.terms);
|
|
1030
666
|
}
|
|
1031
667
|
|
|
1032
668
|
// Live-entry and one-hop lineage suppression already happened in SQL,
|
|
@@ -9,8 +9,9 @@ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
|
9
9
|
import { Type } from "typebox";
|
|
10
10
|
import { realpathSync } from "node:fs";
|
|
11
11
|
import { join, sep } from "node:path";
|
|
12
|
-
import {
|
|
12
|
+
import { getSessionRows, searchIndex, syncSessions } from "./search-core.ts";
|
|
13
13
|
import { getWindow, readSession } from "./hydrate.ts";
|
|
14
|
+
import { MAX_QUERY_CHARS } from "./query.ts";
|
|
14
15
|
import type { WindowMessage } from "./types.ts";
|
|
15
16
|
|
|
16
17
|
const dbPath = () => join(extensionConfigDir("pi-session-recall"), "index.db");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryqw/pi-session-recall",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Local FTS5 search over past Pi sessions plus a skill for turning recurring work into deterministic automation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -33,11 +33,11 @@
|
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|
|
36
|
-
"url": "git+https://github.com/HenryQW/pi-
|
|
37
|
-
"directory": "
|
|
36
|
+
"url": "git+https://github.com/HenryQW/pi-harness.git",
|
|
37
|
+
"directory": "extensions/pi-session-recall"
|
|
38
38
|
},
|
|
39
39
|
"bugs": {
|
|
40
|
-
"url": "https://github.com/HenryQW/pi-
|
|
40
|
+
"url": "https://github.com/HenryQW/pi-harness/issues"
|
|
41
41
|
},
|
|
42
42
|
"publishConfig": {
|
|
43
43
|
"access": "public"
|