@henryqw/pi-session-recall 0.2.3 → 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.
@@ -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 from Pi session JSONL files, query sanitize ladder,
4
- * discovery search with one-hop lineage suppression. No pi runtime imports —
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 { SQLInputValue, SQLOutputValue } from "node:sqlite";
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 NOT EXISTS (
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 trimmed = query.trim().slice(0, MAX_QUERY_CHARS);
1000
- // Keep quoted operator words as operands and omit NEAR's optional numeric
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 NOT EXISTS (
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
- for (const r of rows as any[]) r.snip = likeSnippet((r as any).head ?? "", (r as any).tail ?? "", snippetTerms.length > 0 ? snippetTerms : terms);
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 { MAX_QUERY_CHARS, getSessionRows, searchIndex, syncSessions } from "./search-core.ts";
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",
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-packages.git",
37
- "directory": "packages/pi-session-recall"
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-packages/issues"
40
+ "url": "https://github.com/HenryQW/pi-harness/issues"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public"