@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.
@@ -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. */
@@ -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 NOT EXISTS (
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 trimmed = query.trim().slice(0, MAX_QUERY_CHARS);
992
- // Keep quoted operator words as operands and omit NEAR's optional numeric
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 NOT EXISTS (
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
- 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);
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 { 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.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-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"