@henryqw/pi-session-recall 0.2.3 → 0.2.5
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/README.md +24 -4
- package/extensions/query.ts +378 -0
- package/extensions/search-core.ts +22 -394
- package/extensions/session-recall.ts +2 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# `@henryqw/pi-session-recall`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Find decisions and context in past Pi sessions through a local FTS5 index with no model calls.
|
|
4
4
|
|
|
5
|
-
The
|
|
5
|
+
The bundled `pi-session-pattern-miner` skill finds repeated work that may deserve automation.
|
|
6
6
|
|
|
7
7
|
## Why
|
|
8
8
|
|
|
9
|
-
- **Created for**:
|
|
10
|
-
- **Advantage**:
|
|
9
|
+
- **Created for**: Pi users who need earlier decisions without carrying every transcript in current context.
|
|
10
|
+
- **Advantage**: Search stays local and adds no standing prompt cost.
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
@@ -17,6 +17,26 @@ pi install npm:@henryqw/pi-session-recall
|
|
|
17
17
|
|
|
18
18
|
## Use
|
|
19
19
|
|
|
20
|
+
Start discovery with a distinctive query:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{ "query": "database migration rollback" }
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`session_search` returns ranked sessions. The top result includes nearby messages and session bookends.
|
|
27
|
+
|
|
28
|
+
Use IDs from that result to ask for more context:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"sessionId": "<returned sessionId>",
|
|
33
|
+
"aroundMessageId": "<returned message entryId>",
|
|
34
|
+
"window": 10
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The follow-up returns up to ten messages before and after that anchor on the selected branch.
|
|
39
|
+
|
|
20
40
|
| Surface | Type | Purpose |
|
|
21
41
|
| --- | --- | --- |
|
|
22
42
|
| `session_search` | tool | Search past sessions or inspect one. |
|
|
@@ -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. */
|
|
@@ -516,358 +515,6 @@ function getBacklog(db: DatabaseSync): number {
|
|
|
516
515
|
return row ? Number(row.value) : 0;
|
|
517
516
|
}
|
|
518
517
|
|
|
519
|
-
// --- Query sanitize ladder ---
|
|
520
|
-
|
|
521
|
-
const OPERATOR_RE = /\b(OR|AND|NOT|NEAR)\b/;
|
|
522
|
-
// FTS5 string syntax: `""` inside a quoted phrase is one literal quote.
|
|
523
|
-
const TOKEN_RE = /"((?:[^"]|"")*)"|(\S+)/g;
|
|
524
|
-
const unescapePhrase = (s: string): string => s.replaceAll('""', '"');
|
|
525
|
-
|
|
526
|
-
interface QueryTerm {
|
|
527
|
-
text: string;
|
|
528
|
-
operator: boolean;
|
|
529
|
-
nearDistance: boolean;
|
|
530
|
-
quoted: boolean;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
function collectQueryTerms(query: string): QueryTerm[] {
|
|
534
|
-
const terms: QueryTerm[] = [];
|
|
535
|
-
let depth = 0;
|
|
536
|
-
let pendingNear = false;
|
|
537
|
-
let nearDepth = 0;
|
|
538
|
-
let afterNearComma = false;
|
|
539
|
-
for (const match of spaceParensOutsideQuotes(query).matchAll(TOKEN_RE)) {
|
|
540
|
-
const phrase = match[1];
|
|
541
|
-
let raw = phrase === undefined ? match[2] ?? "" : unescapePhrase(phrase);
|
|
542
|
-
if (phrase === undefined && raw === "(") {
|
|
543
|
-
depth++;
|
|
544
|
-
if (pendingNear) {
|
|
545
|
-
nearDepth = depth;
|
|
546
|
-
pendingNear = false;
|
|
547
|
-
}
|
|
548
|
-
continue;
|
|
549
|
-
}
|
|
550
|
-
if (phrase === undefined && raw === ")") {
|
|
551
|
-
if (depth === nearDepth) {
|
|
552
|
-
nearDepth = 0;
|
|
553
|
-
afterNearComma = false;
|
|
554
|
-
}
|
|
555
|
-
depth = Math.max(0, depth - 1);
|
|
556
|
-
continue;
|
|
557
|
-
}
|
|
558
|
-
const hasNearComma = phrase === undefined && nearDepth > 0 && raw.endsWith(",");
|
|
559
|
-
// An attached distance like `NEAR(Go Rust,10)` has no standalone comma
|
|
560
|
-
// token; split it so the numeric tail is recognized as the distance.
|
|
561
|
-
let attachedDistance: string | undefined;
|
|
562
|
-
if (!hasNearComma && phrase === undefined && nearDepth > 0) {
|
|
563
|
-
const attached = /^(.*),(\d+)$/.exec(raw);
|
|
564
|
-
if (attached) {
|
|
565
|
-
raw = attached[1];
|
|
566
|
-
attachedDistance = attached[2];
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
// Unmatched quote delimiters are malformed syntax, not searchable text.
|
|
570
|
-
// Phrases arrive already unescaped via raw.
|
|
571
|
-
const text = phrase === undefined ? raw.replace(/^[.,!?;:()]+|[.,!?;:()]+$/g, "").replace(/"/g, "") : raw;
|
|
572
|
-
if (text) {
|
|
573
|
-
const operator = phrase === undefined && /^(?:OR|AND|NOT|NEAR)$/.test(text);
|
|
574
|
-
const nearDistance = nearDepth > 0 && afterNearComma && /^\d+$/.test(text);
|
|
575
|
-
terms.push({ text, operator, nearDistance, quoted: phrase !== undefined });
|
|
576
|
-
if (operator && text === "NEAR") pendingNear = true;
|
|
577
|
-
}
|
|
578
|
-
if (attachedDistance !== undefined) terms.push({ text: attachedDistance, operator: false, nearDistance: true, quoted: false });
|
|
579
|
-
if (hasNearComma) afterNearComma = true;
|
|
580
|
-
}
|
|
581
|
-
return terms;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
function quoteTerm(term: QueryTerm): string {
|
|
585
|
-
// Prefix expansion belongs only to an unquoted trailing star. An explicitly
|
|
586
|
-
// quoted `"deploy*"` searches for the literal asterisk.
|
|
587
|
-
if (!term.quoted && term.text.endsWith("*")) return `"${term.text.slice(0, -1).replace(/"/g, '""')}"*`;
|
|
588
|
-
return `"${term.text.replace(/"/g, '""')}"`;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
function quoteTerms(terms: QueryTerm[], sep: string): string {
|
|
592
|
-
return terms.map(quoteTerm).join(sep);
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
export interface FtsQueryPlan {
|
|
596
|
-
/** Candidate FTS5 MATCH expressions in try order. */
|
|
597
|
-
ftsCandidates: string[];
|
|
598
|
-
/** When true, fall back to SQL LIKE (also forced when every term < 3 chars). */
|
|
599
|
-
forceLike: boolean;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
/**
|
|
603
|
-
* Sanitize ladder: trim + 512 cap; implicit-AND quoting when no explicit
|
|
604
|
-
* FTS5 operator; raw pass-through otherwise; recovery candidates are the
|
|
605
|
-
* fully-quoted form, then OR-expansion; LIKE covers everything else.
|
|
606
|
-
*/
|
|
607
|
-
export function buildFtsQueryPlan(rawQuery: string): FtsQueryPlan {
|
|
608
|
-
let query = rawQuery.trim();
|
|
609
|
-
if (query.length > MAX_QUERY_CHARS) query = query.slice(0, MAX_QUERY_CHARS);
|
|
610
|
-
if (!query) return { ftsCandidates: [], forceLike: false };
|
|
611
|
-
|
|
612
|
-
const queryTerms = collectQueryTerms(query);
|
|
613
|
-
const hasOperator = OPERATOR_RE.test(query);
|
|
614
|
-
// Short terms vanish under trigram MATCH — for natural-language queries
|
|
615
|
-
// (AND semantics) that silently breaks the query. Explicit-operator queries
|
|
616
|
-
// route there too (`Go OR Rust` silently drops the Go side); the boolean
|
|
617
|
-
// LIKE fallback preserves their AND/OR/NOT semantics.
|
|
618
|
-
if (
|
|
619
|
-
queryTerms.some(
|
|
620
|
-
(term) => !term.operator && !term.nearDistance && [...term.text.replace(/["()*]/g, "")].length < 3,
|
|
621
|
-
)
|
|
622
|
-
) {
|
|
623
|
-
return { ftsCandidates: [], forceLike: true };
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
const operands = queryTerms.filter((term) => !term.operator && !term.nearDistance);
|
|
627
|
-
// Recovery operands exclude syntax operators so a malformed query can never
|
|
628
|
-
// broaden into matches on AND/OR/NOT/NEAR themselves.
|
|
629
|
-
const natural = quoteTerms(operands, " ");
|
|
630
|
-
const orExpanded = operands.length > 1 ? quoteTerms(operands, " OR ") : null;
|
|
631
|
-
|
|
632
|
-
if (!hasOperator) {
|
|
633
|
-
// Quoted form cannot fail to parse; OR-expand only as breadth fallback.
|
|
634
|
-
return { ftsCandidates: orExpanded ? [natural, orExpanded] : [natural], forceLike: false };
|
|
635
|
-
}
|
|
636
|
-
// Explicit operators: raw first, then recovery paths.
|
|
637
|
-
const candidates = [query, natural];
|
|
638
|
-
if (orExpanded) candidates.push(orExpanded);
|
|
639
|
-
return { ftsCandidates: candidates, forceLike: false };
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
/** Unicode-aware case fold for all LIKE comparisons (column values via ulower,
|
|
643
|
-
* bound operand patterns, snippet matching). toLowerCase alone is not an
|
|
644
|
-
* equivalence for Greek: word-final Σ lowercases to ς while a typed query uses
|
|
645
|
-
* σ. ponytail: normalizes final sigma only, not full Unicode CaseFolding.txt
|
|
646
|
-
* (e.g. ß→ss stays unmatched); add a fold table if that ever matters. */
|
|
647
|
-
function foldCase(s: string): string {
|
|
648
|
-
return s.toLowerCase().replaceAll("ς", "σ");
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
function normalizeLikeTerm(term: string, quoted = false): string {
|
|
652
|
-
// Only an unquoted trailing star is prefix syntax. Quoted stars stay literal.
|
|
653
|
-
return !quoted && term.endsWith("*") ? term.slice(0, -1) : term;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
function likePattern(term: string): string {
|
|
657
|
-
// Fold here too: escaping is unaffected because % _ \ have no case variants.
|
|
658
|
-
return `%${foldCase(term).replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// --- Boolean LIKE fallback ---
|
|
662
|
-
// Parameterized translation of simple AND/OR/NOT/NEAR queries to SQL LIKE so
|
|
663
|
-
// short operands (trigram floor) keep boolean semantics. User input only ever
|
|
664
|
-
// reaches SQL as bound data.
|
|
665
|
-
|
|
666
|
-
function likeClause(term: string, quoted = false): { clause: string; params: string[] } | null {
|
|
667
|
-
term = normalizeLikeTerm(term, quoted);
|
|
668
|
-
if (!term) return null;
|
|
669
|
-
const pattern = likePattern(term);
|
|
670
|
-
return {
|
|
671
|
-
clause: "(ulower(m.head) LIKE ? ESCAPE '\\' OR ulower(m.tail) LIKE ? ESCAPE '\\')",
|
|
672
|
-
params: [pattern, pattern],
|
|
673
|
-
};
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
interface LikeSql {
|
|
677
|
-
where: string;
|
|
678
|
-
params: SQLInputValue[];
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
/** Character-position analogue of trigram FTS5 NEAR for LIKE-only operands.
|
|
682
|
-
* Trigram positions make N allow at most N-2 characters between phrases. */
|
|
683
|
-
function nearLike(textValue: SQLOutputValue, termsValue: SQLOutputValue, distanceValue: SQLOutputValue): number {
|
|
684
|
-
if (typeof textValue !== "string" || typeof termsValue !== "string" || typeof distanceValue !== "number") return 0;
|
|
685
|
-
// Duplicate operands cannot affect the all-distinct-terms-present predicate,
|
|
686
|
-
// and the 512-char query cap otherwise admits hundreds of them.
|
|
687
|
-
const needles = [...new Set((JSON.parse(termsValue) as string[]).map(foldCase))];
|
|
688
|
-
const text = foldCase(textValue);
|
|
689
|
-
// Deduplication can leave one needle: presence satisfies any distance,
|
|
690
|
-
// while the multi-needle span math below demands an impossible negative gap.
|
|
691
|
-
if (needles.length === 1) return text.includes(needles[0]) ? 1 : 0;
|
|
692
|
-
const codePointAt = new Uint32Array(text.length + 1);
|
|
693
|
-
let point = 0;
|
|
694
|
-
for (let i = 0; i < text.length; point++) {
|
|
695
|
-
const width = (text.codePointAt(i) ?? 0) > 0xffff ? 2 : 1;
|
|
696
|
-
codePointAt[i] = point;
|
|
697
|
-
if (width === 2) codePointAt[i + 1] = point;
|
|
698
|
-
i += width;
|
|
699
|
-
}
|
|
700
|
-
codePointAt[text.length] = point;
|
|
701
|
-
|
|
702
|
-
const occurrences: { start: number; end: number; term: number }[] = [];
|
|
703
|
-
for (const [term, needle] of needles.entries()) {
|
|
704
|
-
for (let at = text.indexOf(needle); at >= 0; at = text.indexOf(needle, at + 1)) {
|
|
705
|
-
occurrences.push({ start: codePointAt[at], end: codePointAt[at + needle.length], term });
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
occurrences.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
709
|
-
|
|
710
|
-
const counts = new Uint16Array(needles.length);
|
|
711
|
-
let present = 0;
|
|
712
|
-
let left = 0;
|
|
713
|
-
for (let right = 0; right < occurrences.length; right++) {
|
|
714
|
-
if (counts[occurrences[right].term]++ === 0) present++;
|
|
715
|
-
while (present === needles.length) {
|
|
716
|
-
if (occurrences[right].start - occurrences[left].end + 2 <= distanceValue) return 1;
|
|
717
|
-
if (--counts[occurrences[left++].term] === 0) present--;
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
return 0;
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
interface LikeToken {
|
|
724
|
-
phrase?: string;
|
|
725
|
-
word?: string;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
function parseNearLikeSql(tokens: LikeToken[], start: number): { sql: LikeSql; end: number } | null {
|
|
729
|
-
if (tokens[start + 1]?.word !== "(") return null;
|
|
730
|
-
const operands: { text: string; quoted: boolean }[] = [];
|
|
731
|
-
let sawComma = false;
|
|
732
|
-
let distanceText: string | undefined;
|
|
733
|
-
|
|
734
|
-
for (let i = start + 2; i < tokens.length; i++) {
|
|
735
|
-
const token = tokens[i];
|
|
736
|
-
if (token.word === ")") {
|
|
737
|
-
if (operands.length < 2 || (sawComma && distanceText === undefined)) return null;
|
|
738
|
-
const distance = distanceText === undefined ? 10 : Number(distanceText);
|
|
739
|
-
if (distanceText !== undefined && (!/^\d+$/.test(distanceText) || !Number.isSafeInteger(distance))) return null;
|
|
740
|
-
const terms = operands.map((term) => normalizeLikeTerm(term.text, term.quoted));
|
|
741
|
-
if (terms.some((term) => !term)) return null;
|
|
742
|
-
const clauses = terms.map((term) => likeClause(term, true)!);
|
|
743
|
-
const encoded = JSON.stringify(terms);
|
|
744
|
-
return {
|
|
745
|
-
sql: {
|
|
746
|
-
where: `(${clauses.map((clause) => clause.clause).join(" AND ")} AND (unear(m.head, ?, ?) OR unear(m.tail, ?, ?)))`,
|
|
747
|
-
params: [...clauses.flatMap((clause) => clause.params), encoded, distance, encoded, distance],
|
|
748
|
-
},
|
|
749
|
-
end: i,
|
|
750
|
-
};
|
|
751
|
-
}
|
|
752
|
-
if (token.phrase !== undefined) {
|
|
753
|
-
if (sawComma) return null;
|
|
754
|
-
operands.push({ text: token.phrase, quoted: true });
|
|
755
|
-
continue;
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
const word = token.word ?? "";
|
|
759
|
-
if (!word || word === "(" || /^(?:AND|OR|NOT|NEAR)$/.test(word) || word.includes('"')) return null;
|
|
760
|
-
const comma = word.indexOf(",");
|
|
761
|
-
if (comma >= 0) {
|
|
762
|
-
if (sawComma || word.indexOf(",", comma + 1) >= 0) return null;
|
|
763
|
-
const before = word.slice(0, comma).replace(/^[.!?;:]+|[.!?;:]+$/g, "");
|
|
764
|
-
if (before) operands.push({ text: before, quoted: false });
|
|
765
|
-
sawComma = true;
|
|
766
|
-
distanceText = word.slice(comma + 1) || undefined;
|
|
767
|
-
} else if (sawComma) {
|
|
768
|
-
if (distanceText !== undefined) return null;
|
|
769
|
-
distanceText = word;
|
|
770
|
-
} else {
|
|
771
|
-
const text = word.replace(/^[.!?;:]+|[.!?;:]+$/g, "");
|
|
772
|
-
if (!text) return null;
|
|
773
|
-
operands.push({ text, quoted: false });
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
return null;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
/** Space out parens that act as grouping syntax while leaving quoted phrases
|
|
780
|
-
* like "C(ABI)" intact. */
|
|
781
|
-
function spaceParensOutsideQuotes(q: string): string {
|
|
782
|
-
let out = "";
|
|
783
|
-
let inQuote = false;
|
|
784
|
-
for (const c of q) {
|
|
785
|
-
if (c === '"') inQuote = !inQuote;
|
|
786
|
-
out += !inQuote && (c === "(" || c === ")") ? ` ${c} ` : c;
|
|
787
|
-
}
|
|
788
|
-
return out;
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
function buildBooleanLikeSql(rawQuery: string): LikeSql | null {
|
|
792
|
-
let query = rawQuery.trim();
|
|
793
|
-
if (!query) return null;
|
|
794
|
-
// Imbalance detection/recovery is quote-aware: parentheses inside quoted
|
|
795
|
-
// operands ("func(") are literals and must survive.
|
|
796
|
-
let depth = 0;
|
|
797
|
-
let inQuote = false;
|
|
798
|
-
for (const c of query) {
|
|
799
|
-
if (c === '"') inQuote = !inQuote;
|
|
800
|
-
else if (!inQuote && c === "(") depth++;
|
|
801
|
-
else if (!inQuote && c === ")" && --depth < 0) break;
|
|
802
|
-
}
|
|
803
|
-
if (depth !== 0) {
|
|
804
|
-
inQuote = false;
|
|
805
|
-
query = [...query].map((c) => {
|
|
806
|
-
if (c === '"') inQuote = !inQuote;
|
|
807
|
-
return !inQuote && (c === "(" || c === ")") ? " " : c;
|
|
808
|
-
}).join("");
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
const tokens: LikeToken[] = [...spaceParensOutsideQuotes(query).matchAll(TOKEN_RE)]
|
|
812
|
-
.map((m) => (m[1] !== undefined ? { phrase: unescapePhrase(m[1]) } : { word: m[2] ?? "" }))
|
|
813
|
-
.filter((t) => (t.phrase !== undefined ? t.phrase !== "" : t.word !== ""));
|
|
814
|
-
const fail = (): LikeSql | null => tokens.some((token) => token.word === "NEAR") ? { where: "0", params: [] } : null;
|
|
815
|
-
const sql: string[] = [];
|
|
816
|
-
const params: SQLInputValue[] = [];
|
|
817
|
-
let expectingOperand = true;
|
|
818
|
-
depth = 0;
|
|
819
|
-
|
|
820
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
821
|
-
const token = tokens[i];
|
|
822
|
-
const word = token.word;
|
|
823
|
-
if (word === "(") {
|
|
824
|
-
if (!expectingOperand) sql.push("AND");
|
|
825
|
-
sql.push("(");
|
|
826
|
-
depth++;
|
|
827
|
-
expectingOperand = true;
|
|
828
|
-
continue;
|
|
829
|
-
}
|
|
830
|
-
if (word === ")") {
|
|
831
|
-
if (expectingOperand || depth-- === 0) return fail();
|
|
832
|
-
sql.push(")");
|
|
833
|
-
expectingOperand = false;
|
|
834
|
-
continue;
|
|
835
|
-
}
|
|
836
|
-
if (word === "AND" || word === "OR") {
|
|
837
|
-
if (expectingOperand) return fail();
|
|
838
|
-
sql.push(word);
|
|
839
|
-
expectingOperand = true;
|
|
840
|
-
continue;
|
|
841
|
-
}
|
|
842
|
-
if (word === "NOT") {
|
|
843
|
-
if (!expectingOperand) sql.push("AND");
|
|
844
|
-
sql.push("NOT");
|
|
845
|
-
expectingOperand = true;
|
|
846
|
-
continue;
|
|
847
|
-
}
|
|
848
|
-
if (word === "NEAR") {
|
|
849
|
-
const near = parseNearLikeSql(tokens, i);
|
|
850
|
-
if (near === null) return fail();
|
|
851
|
-
if (!expectingOperand) sql.push("AND");
|
|
852
|
-
sql.push(near.sql.where);
|
|
853
|
-
params.push(...near.sql.params);
|
|
854
|
-
expectingOperand = false;
|
|
855
|
-
i = near.end;
|
|
856
|
-
continue;
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
const term = token.phrase ?? word?.replace(/^[.,!?;:]+|[.,!?;:]+$/g, "").replace(/"/g, "") ?? "";
|
|
860
|
-
const clause = likeClause(term, token.phrase !== undefined);
|
|
861
|
-
if (clause === null) return fail();
|
|
862
|
-
if (!expectingOperand) sql.push("AND");
|
|
863
|
-
sql.push(clause.clause);
|
|
864
|
-
params.push(...clause.params);
|
|
865
|
-
expectingOperand = false;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
return expectingOperand || depth !== 0 ? fail() : { where: sql.join(" "), params };
|
|
869
|
-
}
|
|
870
|
-
|
|
871
518
|
// --- Search ---
|
|
872
519
|
|
|
873
520
|
export interface SearchOptions {
|
|
@@ -885,6 +532,15 @@ export interface SearchOptions {
|
|
|
885
532
|
const LIVE_FILTER_SQL = `NOT EXISTS (
|
|
886
533
|
SELECT 1 FROM live_filter lf WHERE lf.path = m.path AND lf.entry_id = m.entry_id
|
|
887
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
|
+
)`;
|
|
888
544
|
const BASE_SELECT = `
|
|
889
545
|
WITH matches AS (
|
|
890
546
|
SELECT m.path, m.entry_id, m.role, m.timestamp, s.cwd, s.name, s.started_at,
|
|
@@ -901,15 +557,7 @@ WITH matches AS (
|
|
|
901
557
|
SELECT path, entry_id, role, timestamp, snip, cwd, name, started_at
|
|
902
558
|
FROM ranked r
|
|
903
559
|
WHERE rn <= ${ROWS_PER_FILE}
|
|
904
|
-
AND
|
|
905
|
-
SELECT 1 FROM ranked parent
|
|
906
|
-
WHERE parent.path = r.parent_session
|
|
907
|
-
AND parent.path <> r.path
|
|
908
|
-
AND parent.rn <= ${ROWS_PER_FILE}
|
|
909
|
-
-- Direct mutual cycle (A<->B) would suppress both sides; keep the
|
|
910
|
-
-- lexicographically smaller path deterministically.
|
|
911
|
-
AND NOT (parent.parent_session IS r.path AND r.path < parent.path)
|
|
912
|
-
)
|
|
560
|
+
AND ${LINEAGE_FILTER_SQL}
|
|
913
561
|
ORDER BY score, rid
|
|
914
562
|
LIMIT ${SCAN_LIMIT}`;
|
|
915
563
|
|
|
@@ -996,45 +644,25 @@ export function searchIndex(
|
|
|
996
644
|
}
|
|
997
645
|
|
|
998
646
|
if (usedLike) {
|
|
999
|
-
const
|
|
1000
|
-
|
|
1001
|
-
// distance; only unquoted syntax tokens are excluded.
|
|
1002
|
-
const operandTerms = collectQueryTerms(trimmed)
|
|
1003
|
-
.filter((term) => !term.operator && !term.nearDistance)
|
|
1004
|
-
.map((term) => normalizeLikeTerm(term.text, term.quoted))
|
|
1005
|
-
.filter(Boolean);
|
|
1006
|
-
if (operandTerms.length === 0) return { hits: [], backlogRemaining: getBacklog(db) };
|
|
1007
|
-
const terms = operandTerms;
|
|
1008
|
-
// Snippets anchor on operand terms only — operator words like OR would
|
|
1009
|
-
// otherwise match common substrings and hide the real match.
|
|
1010
|
-
const snippetTerms = terms;
|
|
1011
|
-
// Boolean LIKE preserves simple AND/OR/NOT; unsupported shapes degrade
|
|
1012
|
-
// to AND-of-terms. Both forms are fully parameterized.
|
|
1013
|
-
const bool = buildBooleanLikeSql(trimmed);
|
|
1014
|
-
const where = bool?.where ?? terms.map(() => "(ulower(m.head) LIKE ? ESCAPE '\\' OR ulower(m.tail) LIKE ? ESCAPE '\\')").join(" AND ");
|
|
1015
|
-
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) };
|
|
1016
649
|
rows = db.prepare(`WITH ranked AS (
|
|
1017
650
|
SELECT m.path, m.entry_id, m.role, m.timestamp, m.head, m.tail, s.cwd, s.name, s.started_at,
|
|
1018
651
|
s.parent_session,
|
|
1019
652
|
ROW_NUMBER() OVER (PARTITION BY m.path ORDER BY m.rowid DESC) AS rn,
|
|
1020
653
|
COUNT(*) OVER (PARTITION BY m.path) AS matches
|
|
1021
654
|
FROM messages m LEFT JOIN sessions s ON s.path = m.path
|
|
1022
|
-
WHERE ${where} AND ${LIVE_FILTER_SQL}
|
|
655
|
+
WHERE ${likePlan.where} AND ${LIVE_FILTER_SQL}
|
|
1023
656
|
)
|
|
1024
657
|
SELECT path, entry_id, role, timestamp, head, tail, cwd, name, started_at
|
|
1025
658
|
FROM ranked r
|
|
1026
659
|
WHERE rn <= ${ROWS_PER_FILE}
|
|
1027
|
-
AND
|
|
1028
|
-
SELECT 1 FROM ranked parent
|
|
1029
|
-
WHERE parent.path = r.parent_session
|
|
1030
|
-
AND parent.path <> r.path
|
|
1031
|
-
AND parent.rn <= ${ROWS_PER_FILE}
|
|
1032
|
-
-- mutual-cycle tie-break: keep the lexicographically smaller path
|
|
1033
|
-
AND NOT (parent.parent_session IS r.path AND r.path < parent.path)
|
|
1034
|
-
)
|
|
660
|
+
AND ${LINEAGE_FILTER_SQL}
|
|
1035
661
|
ORDER BY matches DESC, started_at DESC, path
|
|
1036
|
-
LIMIT ${SCAN_LIMIT}`).all(...params) as any;
|
|
1037
|
-
|
|
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);
|
|
1038
666
|
}
|
|
1039
667
|
|
|
1040
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.5",
|
|
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"
|