pi-hermes-memory 0.7.17 → 0.7.19

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 CHANGED
@@ -283,7 +283,7 @@ Search behavior notes:
283
283
  - Exact phrases can be requested with quotes, for example `"memory search"`.
284
284
  - Advanced FTS queries with operators like `OR` still work when you need them.
285
285
 
286
- Session history is indexed automatically on session shutdown. To bulk-import existing sessions:
286
+ Session history is indexed automatically during the active session and on session shutdown. Startup also runs a bounded incremental backfill for missed sessions: it compares stored file metadata and only parses files without matching metadata, capped per startup. To bulk-import existing sessions manually:
287
287
 
288
288
  ```
289
289
  /memory-index-sessions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hermes-memory",
3
- "version": "0.7.17",
3
+ "version": "0.7.19",
4
4
  "description": "🧠 Persistent memory + 🔍 session search + 🛡️ secret scanning for Pi. Token-aware policy-only memory by default, SQLite FTS5 search, auto-consolidation, procedural skills. 368 tests. Ported from Hermes agent.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,3 +1,6 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
1
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
5
  import type { MemoryConfig, ThinkingLevel } from "../types.js";
3
6
 
@@ -15,9 +18,32 @@ interface ExecChildPromptOptions {
15
18
  retryWithoutOverrides?: boolean;
16
19
  }
17
20
 
21
+ export interface ChildPiInvocation {
22
+ command: string;
23
+ args: string[];
24
+ }
25
+
26
+ interface ResolveChildPiInvocationOptions {
27
+ platform?: NodeJS.Platform;
28
+ execPath?: string;
29
+ argv?: string[];
30
+ piCliPath?: string | null;
31
+ }
32
+
18
33
  const OVERRIDE_FAILURE_SUBJECT = /\b(model|provider|thinking)\b/i;
19
34
  const OVERRIDE_FAILURE_REASON = /\b(not found|unknown|invalid|unsupported|unavailable|unrecognized|no match|no matches|cannot resolve|failed to resolve)\b/i;
20
35
 
36
+ // Resolve the path to pi-hermes-memory's own extension entry point.
37
+ // Used to pass -e <path> to child subprocesses so they only load this
38
+ // extension instead of all plugins from settings.json.
39
+ const OWN_EXTENSION_PATH: string = (() => {
40
+ try {
41
+ return resolve(dirname(fileURLToPath(import.meta.url)), "../index.ts");
42
+ } catch {
43
+ return "";
44
+ }
45
+ })();
46
+
21
47
  function normalizedModelOverride(config: ChildLlmConfig): string | undefined {
22
48
  const trimmed = config.llmModelOverride?.trim();
23
49
  return trimmed ? trimmed : undefined;
@@ -31,6 +57,7 @@ export function hasChildLlmOverrides(config: ChildLlmConfig): boolean {
31
57
  return normalizedModelOverride(config) !== undefined || effectiveThinkingOverride(config) !== undefined;
32
58
  }
33
59
 
60
+ /** @deprecated No longer called after PR #78 — kept for API backward compat. */
34
61
  export function inheritedExtensionArgs(argv: string[] = process.argv.slice(2)): string[] {
35
62
  const args: string[] = [];
36
63
 
@@ -53,22 +80,86 @@ export function inheritedExtensionArgs(argv: string[] = process.argv.slice(2)):
53
80
  return args;
54
81
  }
55
82
 
56
- export function buildChildPiPromptArgs(prompt: string, config: ChildLlmConfig, argv: string[] = process.argv.slice(2)): string[] {
83
+ function appendOwnExtensionArgs(args: string[]): void {
84
+ // Skip all packages from settings.json (--no-extensions) — the subprocess
85
+ // only needs pi-hermes-memory to access the memory tool. Loading every
86
+ // plugin (context-mode, pi-lens, pi-web-access, pi-review, …) wastes
87
+ // prompt tokens and startup CPU for simple one-shot memory tasks.
88
+ if (OWN_EXTENSION_PATH) {
89
+ args.push("--no-extensions", "-e", OWN_EXTENSION_PATH);
90
+ }
91
+ }
92
+
93
+ export function buildChildPiPromptArgs(prompt: string, config: ChildLlmConfig, _argv?: string[]): string[] {
57
94
  const args = ["-p", "--no-session"];
58
95
  const model = normalizedModelOverride(config);
59
96
  const thinking = effectiveThinkingOverride(config);
60
- const inheritedExtensions = inheritedExtensionArgs(argv);
61
97
 
62
98
  if (model) args.push("--model", model);
63
99
  if (thinking) args.push("--thinking", thinking);
64
- args.push(...inheritedExtensions);
100
+ appendOwnExtensionArgs(args);
65
101
  args.push(prompt);
66
102
 
67
103
  return args;
68
104
  }
69
105
 
70
106
  function basePromptArgs(prompt: string): string[] {
71
- return ["-p", "--no-session", prompt];
107
+ // Always use --no-extensions + own path so the retry also avoids loading
108
+ // all settings.json packages — matching the primary code path.
109
+ const args = ["-p", "--no-session"];
110
+ appendOwnExtensionArgs(args);
111
+ args.push(prompt);
112
+ return args;
113
+ }
114
+
115
+ function isCliJsPath(value: string | undefined): value is string {
116
+ if (!value) return false;
117
+ return value.replace(/\\/g, "/").toLowerCase().endsWith("/cli.js");
118
+ }
119
+
120
+ function resolvedInstalledPiCliPath(): string | undefined {
121
+ try {
122
+ const packageEntry = import.meta.resolve("@earendil-works/pi-coding-agent");
123
+ const entryPath = fileURLToPath(packageEntry);
124
+ const cliPath = join(dirname(entryPath), "cli.js");
125
+ return existsSync(cliPath) ? cliPath : undefined;
126
+ } catch {
127
+ return undefined;
128
+ }
129
+ }
130
+
131
+ function resolvedPiCliPath(options: ResolveChildPiInvocationOptions): string | undefined {
132
+ if (options.piCliPath !== undefined) {
133
+ return options.piCliPath ?? undefined;
134
+ }
135
+
136
+ const argv = options.argv ?? process.argv;
137
+ const currentCli = argv[1];
138
+ if (isCliJsPath(currentCli) && existsSync(currentCli)) {
139
+ return currentCli;
140
+ }
141
+
142
+ return resolvedInstalledPiCliPath();
143
+ }
144
+
145
+ export function resolveChildPiInvocation(
146
+ args: string[],
147
+ options: ResolveChildPiInvocationOptions = {},
148
+ ): ChildPiInvocation {
149
+ const platform = options.platform ?? process.platform;
150
+ if (platform !== "win32") {
151
+ return { command: "pi", args };
152
+ }
153
+
154
+ const piCliPath = resolvedPiCliPath(options);
155
+ if (!piCliPath) {
156
+ return { command: "pi", args };
157
+ }
158
+
159
+ return {
160
+ command: options.execPath ?? process.execPath,
161
+ args: [piCliPath, ...args],
162
+ };
72
163
  }
73
164
 
74
165
  function shouldRetryWithoutOverridesFromText(text: string | undefined): boolean {
@@ -96,7 +187,8 @@ export async function execChildPrompt(
96
187
  };
97
188
 
98
189
  try {
99
- const result = await pi.exec("pi", buildChildPiPromptArgs(prompt, config), execOptions) as PiExecResult;
190
+ const invocation = resolveChildPiInvocation(buildChildPiPromptArgs(prompt, config));
191
+ const result = await pi.exec(invocation.command, invocation.args, execOptions) as PiExecResult;
100
192
  if (
101
193
  result.code === 0 ||
102
194
  !options.retryWithoutOverrides ||
@@ -115,5 +207,6 @@ export async function execChildPrompt(
115
207
  }
116
208
  }
117
209
 
118
- return pi.exec("pi", basePromptArgs(prompt), execOptions) as Promise<PiExecResult>;
210
+ const retryInvocation = resolveChildPiInvocation(basePromptArgs(prompt));
211
+ return pi.exec(retryInvocation.command, retryInvocation.args, execOptions) as Promise<PiExecResult>;
119
212
  }
@@ -0,0 +1,139 @@
1
+ import type { DatabaseManager } from '../store/db.js';
2
+ import {
3
+ indexChangedSessions,
4
+ needsBackfill,
5
+ touchBackfillTimestamp,
6
+ type BulkIndexResult,
7
+ } from '../store/session-indexer.js';
8
+
9
+ export const SESSION_BACKFILL_SHUTDOWN_TIMEOUT_MS = 5000;
10
+ export const SESSION_BACKFILL_MAX_FILES = 50;
11
+
12
+ type NotifyLevel = 'info' | 'warning' | 'error';
13
+ type NotifyFn = (message: string, level: NotifyLevel) => void;
14
+
15
+ type SetTimeoutFn = (callback: () => void, ms: number) => unknown;
16
+
17
+ export interface SessionBackfillState {
18
+ inProgress: boolean;
19
+ promise: Promise<void> | null;
20
+ }
21
+
22
+ export const sessionBackfillState: SessionBackfillState = {
23
+ inProgress: false,
24
+ promise: null,
25
+ };
26
+
27
+ export interface ScheduleSessionBackfillOptions {
28
+ notify?: NotifyFn;
29
+ state?: SessionBackfillState;
30
+ setTimeoutFn?: SetTimeoutFn;
31
+ needsBackfillFn?: typeof needsBackfill;
32
+ indexSessionsFn?: typeof indexChangedSessions;
33
+ maxFilesToIndex?: number;
34
+ touchBackfillTimestampFn?: typeof touchBackfillTimestamp;
35
+ }
36
+
37
+ function formatBackfillResult(result: BulkIndexResult): string {
38
+ const errorSuffix = result.errors.length > 0 ? ` (${result.errors.length} file error${result.errors.length === 1 ? '' : 's'})` : '';
39
+ const limitSuffix = result.reachedLimit ? ' (startup limit reached)' : '';
40
+ return `🧠 Session backfill complete: ${result.sessionsIndexed} indexed, ${result.sessionsSkipped} skipped, ${result.messagesIndexed} messages${errorSuffix}${limitSuffix}.`;
41
+ }
42
+
43
+ function notifyBestEffort(notify: NotifyFn | undefined, message: string, level: NotifyLevel): void {
44
+ try {
45
+ notify?.(message, level);
46
+ } catch {
47
+ // Notification failures must never affect backfill.
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Schedule a best-effort, bounded incremental backfill of unindexed Pi sessions.
53
+ *
54
+ * The JSONL parsing work is deferred with setTimeout(0) so session_start can
55
+ * resolve first. The scheduled pass only parses files without matching stored
56
+ * metadata and caps the number of files parsed per startup.
57
+ *
58
+ * @returns true when a backfill task was scheduled; false when it was skipped.
59
+ */
60
+ export function scheduleSessionBackfill(
61
+ dbManager: DatabaseManager,
62
+ sessionsDir: string,
63
+ options: ScheduleSessionBackfillOptions = {},
64
+ ): boolean {
65
+ const state = options.state ?? sessionBackfillState;
66
+ const setTimeoutFn = options.setTimeoutFn ?? setTimeout;
67
+ const needsBackfillFn = options.needsBackfillFn ?? needsBackfill;
68
+ const indexSessionsFn = options.indexSessionsFn ?? indexChangedSessions;
69
+ const maxFilesToIndex = options.maxFilesToIndex ?? SESSION_BACKFILL_MAX_FILES;
70
+ const touchBackfillTimestampFn = options.touchBackfillTimestampFn ?? touchBackfillTimestamp;
71
+
72
+ if (state.inProgress) {
73
+ return false;
74
+ }
75
+
76
+ try {
77
+ if (!needsBackfillFn(dbManager, sessionsDir)) {
78
+ return false;
79
+ }
80
+ } catch (err) {
81
+ notifyBestEffort(
82
+ options.notify,
83
+ `⚠️ Session backfill check failed: ${err instanceof Error ? err.message : String(err)}`,
84
+ 'warning',
85
+ );
86
+ return false;
87
+ }
88
+
89
+ state.inProgress = true;
90
+ state.promise = new Promise<void>((resolve) => {
91
+ setTimeoutFn(() => {
92
+ try {
93
+ const result = indexSessionsFn(dbManager, sessionsDir, { maxFilesToIndex });
94
+ if (!result.reachedLimit) touchBackfillTimestampFn(dbManager);
95
+ notifyBestEffort(options.notify, formatBackfillResult(result), result.errors.length > 0 || result.reachedLimit ? 'warning' : 'info');
96
+ } catch (err) {
97
+ notifyBestEffort(
98
+ options.notify,
99
+ `⚠️ Session backfill failed: ${err instanceof Error ? err.message : String(err)}`,
100
+ 'warning',
101
+ );
102
+ } finally {
103
+ state.inProgress = false;
104
+ state.promise = null;
105
+ resolve();
106
+ }
107
+ }, 0);
108
+ });
109
+
110
+ return true;
111
+ }
112
+
113
+ /**
114
+ * Wait briefly for an in-progress backfill before shutdown closes SQLite.
115
+ *
116
+ * @returns true if no backfill was running or it completed before the timeout;
117
+ * false if the timeout elapsed first.
118
+ */
119
+ export async function waitForSessionBackfill(
120
+ timeoutMs = SESSION_BACKFILL_SHUTDOWN_TIMEOUT_MS,
121
+ state: SessionBackfillState = sessionBackfillState,
122
+ ): Promise<boolean> {
123
+ const promise = state.promise;
124
+ if (!state.inProgress || !promise) {
125
+ return true;
126
+ }
127
+
128
+ let timeout: ReturnType<typeof setTimeout> | undefined;
129
+ try {
130
+ return await Promise.race([
131
+ promise.then(() => true),
132
+ new Promise<boolean>((resolve) => {
133
+ timeout = setTimeout(() => resolve(false), timeoutMs);
134
+ }),
135
+ ]);
136
+ } finally {
137
+ if (timeout) clearTimeout(timeout);
138
+ }
139
+ }
@@ -0,0 +1,89 @@
1
+ import type { DatabaseManager } from '../store/db.js';
2
+ import { indexLiveSession } from '../store/session-indexer.js';
3
+
4
+ export const SESSION_LIVE_INDEX_DELAY_MS = 50;
5
+ export const SESSION_LIVE_INDEX_SHUTDOWN_TIMEOUT_MS = 5000;
6
+
7
+ type SetTimeoutFn = (callback: () => void, ms: number) => unknown;
8
+
9
+ type SessionManagerSnapshot = Parameters<typeof indexLiveSession>[1];
10
+
11
+ export interface SessionLiveIndexState {
12
+ inProgress: boolean;
13
+ promise: Promise<void> | null;
14
+ }
15
+
16
+ export const sessionLiveIndexState: SessionLiveIndexState = {
17
+ inProgress: false,
18
+ promise: null,
19
+ };
20
+
21
+ export interface ScheduleLiveSessionIndexOptions {
22
+ state?: SessionLiveIndexState;
23
+ setTimeoutFn?: SetTimeoutFn;
24
+ indexLiveSessionFn?: typeof indexLiveSession;
25
+ delayMs?: number;
26
+ onError?: (error: unknown) => void;
27
+ }
28
+
29
+ /**
30
+ * Schedule non-blocking indexing of the current live session.
31
+ *
32
+ * Pi emits message_end before it appends the finalized message to the JSONL
33
+ * session file/session manager. Deferring briefly lets Pi persist the entry
34
+ * first, then we index any message ids not already present in SQLite. Multiple
35
+ * message_end events in the same window coalesce into one all-missing sync.
36
+ */
37
+ export function scheduleLiveSessionIndex(
38
+ dbManager: DatabaseManager,
39
+ sessionManager: SessionManagerSnapshot,
40
+ options: ScheduleLiveSessionIndexOptions = {},
41
+ ): boolean {
42
+ const state = options.state ?? sessionLiveIndexState;
43
+ if (state.inProgress) {
44
+ return false;
45
+ }
46
+
47
+ const setTimeoutFn = options.setTimeoutFn ?? setTimeout;
48
+ const indexLiveSessionFn = options.indexLiveSessionFn ?? indexLiveSession;
49
+ const delayMs = options.delayMs ?? SESSION_LIVE_INDEX_DELAY_MS;
50
+
51
+ state.inProgress = true;
52
+ state.promise = new Promise<void>((resolve) => {
53
+ setTimeoutFn(() => {
54
+ try {
55
+ indexLiveSessionFn(dbManager, sessionManager);
56
+ } catch (err) {
57
+ try { options.onError?.(err); } catch { /* best effort */ }
58
+ } finally {
59
+ state.inProgress = false;
60
+ state.promise = null;
61
+ resolve();
62
+ }
63
+ }, delayMs);
64
+ });
65
+
66
+ return true;
67
+ }
68
+
69
+ export async function waitForLiveSessionIndex(
70
+ timeoutMs = SESSION_LIVE_INDEX_SHUTDOWN_TIMEOUT_MS,
71
+ state: SessionLiveIndexState = sessionLiveIndexState,
72
+ ): Promise<boolean> {
73
+ const promise = state.promise;
74
+ if (!state.inProgress || !promise) {
75
+ return true;
76
+ }
77
+
78
+ let timeout: ReturnType<typeof setTimeout> | undefined;
79
+ try {
80
+ return await Promise.race([
81
+ promise.then(() => true),
82
+ new Promise<boolean>((resolve) => {
83
+ timeout = setTimeout(() => resolve(false), timeoutMs);
84
+ }),
85
+ ]);
86
+ } finally {
87
+ if (timeout) clearTimeout(timeout);
88
+ }
89
+ }
package/src/index.ts CHANGED
@@ -27,7 +27,9 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
27
  import { MemoryStore } from "./store/memory-store.js";
28
28
  import { SkillStore } from "./store/skill-store.js";
29
29
  import { DatabaseManager } from "./store/db.js";
30
- import { indexSession } from "./store/session-indexer.js";
30
+ import { indexSession, upsertSessionFileMetadata } from "./store/session-indexer.js";
31
+ import { scheduleSessionBackfill, waitForSessionBackfill, SESSION_BACKFILL_SHUTDOWN_TIMEOUT_MS } from "./handlers/session-backfill.js";
32
+ import { scheduleLiveSessionIndex, waitForLiveSessionIndex, SESSION_LIVE_INDEX_SHUTDOWN_TIMEOUT_MS } from "./handlers/session-live-index.js";
31
33
  import { parseSessionFile } from "./store/session-parser.js";
32
34
  import { registerMemoryTool } from "./tools/memory-tool.js";
33
35
  import { registerSkillTool } from "./tools/skill-tool.js";
@@ -107,6 +109,7 @@ export default function (pi: ExtensionAPI) {
107
109
  migrationSentinelPath: path.join(globalDir, ".skills-migrated-to-extension-storage"),
108
110
  });
109
111
  const dbManager = new DatabaseManager(globalDir);
112
+ const sessionsDir = path.join(agentRoot, "sessions");
110
113
 
111
114
  const refreshSkillProjectContext = (cwd?: string) => {
112
115
  const resource = resolveProjectSkillDiscovery(skillStore, config.projectsMemoryDir, cwd);
@@ -150,6 +153,19 @@ export default function (pi: ExtensionAPI) {
150
153
  await skillStore.ensureDiscoveredRoots();
151
154
  await store.loadFromDisk();
152
155
  if (projectStore) await projectStore.loadFromDisk();
156
+
157
+ scheduleSessionBackfill(dbManager, sessionsDir, {
158
+ notify: (message, level) => {
159
+ const ui = (ctx as { ui?: { notify?: (message: string, level?: string) => void } }).ui;
160
+ if (ui?.notify) {
161
+ ui.notify(message, level);
162
+ } else if (level === "error" || level === "warning") {
163
+ console.warn(message);
164
+ } else {
165
+ console.info(message);
166
+ }
167
+ },
168
+ });
153
169
  });
154
170
 
155
171
  registerProjectSkillDiscoveryHandler(pi, skillStore, config.projectsMemoryDir);
@@ -201,12 +217,19 @@ export default function (pi: ExtensionAPI) {
201
217
  registerSyncMarkdownMemoriesCommand(pi, dbManager, globalDir, config.projectsMemoryDir, agentRoot);
202
218
  registerPreviewContextCommand(pi, store, projectStore, projectName, config);
203
219
 
204
- // ── 10. SQLite session search + extended memory ──
220
+ // ── 10. Live session indexing ──
221
+ pi.on("message_end", async (_event, ctx) => {
222
+ scheduleLiveSessionIndex(dbManager, ctx.sessionManager, {
223
+ onError: (err) => console.warn(`⚠️ Live session indexing failed: ${err instanceof Error ? err.message : String(err)}`),
224
+ });
225
+ });
226
+
227
+ // ── 11. SQLite session search + extended memory ──
205
228
  registerSessionSearchTool(pi, dbManager, config.sessionSearch ?? { variant: "legacy" });
206
229
  registerMemorySearchTool(pi, dbManager);
207
230
  registerIndexSessionsCommand(pi);
208
231
 
209
- // ── 11. Auto-index session on shutdown ──
232
+ // ── 12. Auto-index session on shutdown ──
210
233
  // Registered last, so this runs after the session-flush shutdown handler and
211
234
  // is the final DB activity. Closing here truncates the WAL via
212
235
  // PRAGMA wal_checkpoint(TRUNCATE); without it the WAL only grows to its
@@ -224,11 +247,24 @@ export default function (pi: ExtensionAPI) {
224
247
  const sessionData = parseSessionFile(sessionFile);
225
248
  if (sessionData) {
226
249
  indexSession(dbManager, sessionData);
250
+ // Keep session_files metadata in sync with the final on-disk state.
251
+ // Pi appends the closing session entry on shutdown after the last
252
+ // message_end, so without this upsert the stored size/mtime would be
253
+ // stale and the next startup would re-parse this file unnecessarily.
254
+ upsertSessionFileMetadata(dbManager, sessionFile, sessionData.id);
227
255
  }
228
256
  }
229
257
  } catch {
230
258
  // Silent fail — don't block shutdown
231
259
  } finally {
260
+ try {
261
+ await Promise.all([
262
+ waitForSessionBackfill(SESSION_BACKFILL_SHUTDOWN_TIMEOUT_MS),
263
+ waitForLiveSessionIndex(SESSION_LIVE_INDEX_SHUTDOWN_TIMEOUT_MS),
264
+ ]);
265
+ } catch {
266
+ // Best effort only — shutdown should not be held up by indexing errors.
267
+ }
232
268
  try { dbManager.close(); } catch { /* best effort — never block shutdown */ }
233
269
  }
234
270
  });
@@ -2,6 +2,10 @@ const FTS5_OPERATOR_PATTERN = /\b(OR|AND|NOT|NEAR)\b/;
2
2
  const FTS5_TOKEN_PATTERN = /"([^"]*)"|(\S+)/g;
3
3
  const NATURAL_LANGUAGE_CONNECTORS = new Set(['and', 'or', 'not', 'near']);
4
4
 
5
+ export function hasExplicitFts5Operator(query: string): boolean {
6
+ return FTS5_OPERATOR_PATTERN.test(query.trim());
7
+ }
8
+
5
9
  function collectNaturalLanguageTerms(query: string): string[] {
6
10
  const terms: string[] = [];
7
11
 
@@ -29,7 +33,7 @@ export function normalizeFts5Query(query: string): string {
29
33
  const trimmed = query.trim();
30
34
  if (trimmed.length === 0) return '';
31
35
 
32
- if (FTS5_OPERATOR_PATTERN.test(trimmed)) {
36
+ if (hasExplicitFts5Operator(trimmed)) {
33
37
  return trimmed;
34
38
  }
35
39
 
@@ -45,7 +49,7 @@ export function normalizeFts5Query(query: string): string {
45
49
  */
46
50
  export function buildFallbackFts5Query(query: string): string | null {
47
51
  const trimmed = query.trim();
48
- if (trimmed.length === 0 || FTS5_OPERATOR_PATTERN.test(trimmed)) {
52
+ if (trimmed.length === 0 || hasExplicitFts5Operator(trimmed)) {
49
53
  return null;
50
54
  }
51
55
 
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Tables:
5
5
  * - sessions — Pi session metadata
6
+ * - session_files — indexed JSONL metadata for incremental backfill
6
7
  * - messages — all conversation messages
7
8
  * - message_fts — FTS5 index for full-text search across messages
8
9
  * - memories — extended memory entries (unlimited, searchable)
@@ -10,6 +11,12 @@
10
11
  */
11
12
 
12
13
  export const SCHEMA_SQL = `
14
+ -- Extension key/value metadata
15
+ CREATE TABLE IF NOT EXISTS extension_metadata (
16
+ key TEXT PRIMARY KEY,
17
+ value TEXT NOT NULL
18
+ );
19
+
13
20
  -- Session metadata
14
21
  CREATE TABLE IF NOT EXISTS sessions (
15
22
  id TEXT PRIMARY KEY,
@@ -20,6 +27,15 @@ export const SCHEMA_SQL = `
20
27
  message_count INTEGER DEFAULT 0
21
28
  );
22
29
 
30
+ -- Indexed session file metadata for cheap incremental backfill
31
+ CREATE TABLE IF NOT EXISTS session_files (
32
+ path TEXT PRIMARY KEY,
33
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
34
+ size INTEGER NOT NULL,
35
+ mtime_ms INTEGER NOT NULL,
36
+ indexed_at TEXT NOT NULL
37
+ );
38
+
23
39
  -- All messages from all sessions
24
40
  CREATE TABLE IF NOT EXISTS messages (
25
41
  id TEXT PRIMARY KEY,
@@ -96,4 +112,5 @@ export const SCHEMA_SQL = `
96
112
  CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category);
97
113
  CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project);
98
114
  CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);
115
+ CREATE INDEX IF NOT EXISTS idx_session_files_session_id ON session_files(session_id);
99
116
  `;
@@ -1,13 +1,17 @@
1
+ import fs from 'node:fs';
1
2
  import { DatabaseManager } from './db.js';
2
3
  import { parseSessionFile, getSessionFiles, type ParsedSession } from './session-parser.js';
3
4
 
5
+ export const LAST_SESSION_BACKFILL_KEY = 'last_session_backfill';
6
+ export const SESSION_BACKFILL_INTERVAL_MS = 24 * 60 * 60 * 1000;
7
+
4
8
  /**
5
9
  * Index result for a single session.
6
10
  */
7
11
  export interface IndexResult {
8
12
  sessionId: string;
9
13
  messagesIndexed: number;
10
- skipped: boolean; // true if already indexed
14
+ skipped: boolean; // true if the session already existed and no new messages were indexed
11
15
  }
12
16
 
13
17
  /**
@@ -19,6 +23,18 @@ export interface BulkIndexResult {
19
23
  sessionsSkipped: number;
20
24
  messagesIndexed: number;
21
25
  errors: string[];
26
+ reachedLimit?: boolean;
27
+ }
28
+
29
+ interface SessionFileMetadata {
30
+ path: string;
31
+ size: number;
32
+ mtimeMs: number;
33
+ }
34
+
35
+ export interface IncrementalIndexOptions {
36
+ projectDir?: string;
37
+ maxFilesToIndex?: number;
22
38
  }
23
39
 
24
40
  /**
@@ -29,33 +45,39 @@ export interface BulkIndexResult {
29
45
  export function indexSession(dbManager: DatabaseManager, session: ParsedSession): IndexResult {
30
46
  const db = dbManager.getDb();
31
47
 
32
- // Check if already indexed
33
- const existing = db.prepare('SELECT id FROM sessions WHERE id = ?').get(session.id) as { id: string } | undefined;
34
- if (existing) {
35
- return { sessionId: session.id, messagesIndexed: 0, skipped: true };
36
- }
48
+ const existingSession = db.prepare('SELECT id FROM sessions WHERE id = ?').get(session.id) as { id: string } | undefined;
49
+ const before = db.prepare('SELECT COUNT(*) as count FROM messages WHERE session_id = ?').get(session.id) as { count: number };
37
50
 
38
- // Insert session
39
- db.prepare(`
40
- INSERT INTO sessions (id, project, cwd, started_at, ended_at, message_count)
51
+ const insertSession = db.prepare(`
52
+ INSERT OR IGNORE INTO sessions (id, project, cwd, started_at, ended_at, message_count)
41
53
  VALUES (?, ?, ?, ?, ?, ?)
42
- `).run(
43
- session.id,
44
- session.project,
45
- session.cwd,
46
- session.startedAt,
47
- session.endedAt,
48
- session.messages.length
49
- );
50
-
51
- // Insert messages in a transaction for performance
54
+ `);
55
+
52
56
  const insertMsg = db.prepare(`
53
- INSERT INTO messages (id, session_id, role, content, timestamp, tool_calls)
57
+ INSERT OR IGNORE INTO messages (id, session_id, role, content, timestamp, tool_calls)
54
58
  VALUES (?, ?, ?, ?, ?, ?)
55
59
  `);
56
60
 
57
- const writeMessages = (messages: ParsedSession['messages']) => {
58
- for (const msg of messages) {
61
+ const updateSession = db.prepare(`
62
+ UPDATE sessions
63
+ SET project = ?,
64
+ cwd = ?,
65
+ ended_at = COALESCE(?, ended_at),
66
+ message_count = (SELECT COUNT(*) FROM messages WHERE session_id = ?)
67
+ WHERE id = ?
68
+ `);
69
+
70
+ const writeSession = () => {
71
+ insertSession.run(
72
+ session.id,
73
+ session.project,
74
+ session.cwd,
75
+ session.startedAt,
76
+ session.endedAt,
77
+ session.messages.length
78
+ );
79
+
80
+ for (const msg of session.messages) {
59
81
  insertMsg.run(
60
82
  msg.id,
61
83
  session.id,
@@ -65,16 +87,202 @@ export function indexSession(dbManager: DatabaseManager, session: ParsedSession)
65
87
  msg.toolCalls ? JSON.stringify(msg.toolCalls) : null
66
88
  );
67
89
  }
90
+
91
+ updateSession.run(session.project, session.cwd, session.endedAt, session.id, session.id);
68
92
  };
69
93
 
70
94
  if (db.transaction) {
71
- const insertMany = db.transaction(writeMessages);
72
- insertMany(session.messages);
95
+ const tx = db.transaction(writeSession);
96
+ tx();
73
97
  } else {
74
- writeMessages(session.messages);
98
+ writeSession();
99
+ }
100
+
101
+ const after = db.prepare('SELECT COUNT(*) as count FROM messages WHERE session_id = ?').get(session.id) as { count: number };
102
+ const messagesIndexed = after.count - before.count;
103
+
104
+ return { sessionId: session.id, messagesIndexed, skipped: Boolean(existingSession) && messagesIndexed === 0 };
105
+ }
106
+
107
+ type SessionManagerSnapshot = {
108
+ getHeader: () => { id: string; timestamp: string; cwd: string } | null;
109
+ getEntries: () => unknown[];
110
+ getSessionFile?: () => string | undefined;
111
+ };
112
+
113
+ type SessionMessageEntryLike = {
114
+ type?: unknown;
115
+ id?: unknown;
116
+ timestamp?: unknown;
117
+ message?: {
118
+ role?: unknown;
119
+ content?: unknown;
120
+ };
121
+ };
122
+
123
+ function extractTextContent(content: unknown): string {
124
+ if (typeof content === 'string') return content;
125
+ if (!Array.isArray(content)) return '';
126
+
127
+ const parts: string[] = [];
128
+ for (const block of content) {
129
+ if (!block || typeof block !== 'object') continue;
130
+ const b = block as Record<string, unknown>;
131
+
132
+ switch (b.type) {
133
+ case 'text':
134
+ if (typeof b.text === 'string') parts.push(b.text);
135
+ break;
136
+ case 'tool_result':
137
+ if (typeof b.content === 'string') {
138
+ parts.push(b.content);
139
+ } else if (Array.isArray(b.content)) {
140
+ for (const item of b.content) {
141
+ if (item && typeof item === 'object' && (item as Record<string, unknown>).type === 'text') {
142
+ const text = (item as Record<string, unknown>).text;
143
+ if (typeof text === 'string') parts.push(text);
144
+ }
145
+ }
146
+ }
147
+ break;
148
+ }
149
+ }
150
+
151
+ return parts.join('\n').trim();
152
+ }
153
+
154
+ function extractToolCalls(content: unknown): string[] | undefined {
155
+ if (!Array.isArray(content)) return undefined;
156
+
157
+ const toolNames: string[] = [];
158
+ for (const block of content) {
159
+ if (!block || typeof block !== 'object') continue;
160
+ const b = block as Record<string, unknown>;
161
+ if ((b.type === 'toolCall' || b.type === 'tool_use') && typeof b.name === 'string') {
162
+ toolNames.push(b.name);
163
+ }
164
+ }
165
+ return toolNames.length > 0 ? toolNames : undefined;
166
+ }
167
+
168
+ function parseMessageEntry(entry: unknown): ParsedSession['messages'][number] | null {
169
+ if (!entry || typeof entry !== 'object') return null;
170
+ const e = entry as SessionMessageEntryLike;
171
+ if (e.type !== 'message' || typeof e.id !== 'string' || typeof e.timestamp !== 'string' || !e.message) return null;
172
+
173
+ const role = e.message.role;
174
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') return null;
175
+
176
+ const content = extractTextContent(e.message.content);
177
+ if (!content) return null;
178
+
179
+ return {
180
+ id: e.id,
181
+ role,
182
+ content,
183
+ timestamp: e.timestamp,
184
+ toolCalls: role === 'assistant' ? extractToolCalls(e.message.content) : undefined,
185
+ };
186
+ }
187
+
188
+ export function parseSessionManagerSnapshot(sessionManager: SessionManagerSnapshot): ParsedSession | null {
189
+ const header = sessionManager.getHeader();
190
+ if (!header?.id || !header.cwd || !header.timestamp) return null;
191
+
192
+ const messages = sessionManager.getEntries()
193
+ .map(parseMessageEntry)
194
+ .filter((msg): msg is ParsedSession['messages'][number] => msg !== null);
195
+
196
+ return {
197
+ id: header.id,
198
+ project: header.cwd.split('/').pop() ?? header.cwd,
199
+ cwd: header.cwd,
200
+ startedAt: header.timestamp,
201
+ endedAt: null,
202
+ messages,
203
+ };
204
+ }
205
+
206
+ export function indexCurrentSession(dbManager: DatabaseManager, sessionManager: SessionManagerSnapshot): IndexResult | null {
207
+ const session = parseSessionManagerSnapshot(sessionManager);
208
+ if (!session) return null;
209
+ return indexSession(dbManager, session);
210
+ }
211
+
212
+ export function indexLiveSession(dbManager: DatabaseManager, sessionManager: SessionManagerSnapshot): IndexResult | null {
213
+ const sessionFile = sessionManager.getSessionFile?.();
214
+ if (sessionFile && fs.existsSync(sessionFile)) {
215
+ const session = parseSessionFile(sessionFile);
216
+ if (session) {
217
+ const result = indexSession(dbManager, session);
218
+ upsertSessionFileMetadata(dbManager, sessionFile, session.id);
219
+ return result;
220
+ }
221
+ }
222
+
223
+ return indexCurrentSession(dbManager, sessionManager);
224
+ }
225
+
226
+ function getSessionFileMetadata(filePath: string): SessionFileMetadata {
227
+ const stat = fs.statSync(filePath);
228
+ return { path: filePath, size: stat.size, mtimeMs: Math.trunc(stat.mtimeMs) };
229
+ }
230
+
231
+ function getStoredSessionFileMetadata(dbManager: DatabaseManager, filePath: string): { size: number; mtime_ms: number } | undefined {
232
+ return dbManager.getDb().prepare('SELECT size, mtime_ms FROM session_files WHERE path = ?').get(filePath) as { size: number; mtime_ms: number } | undefined;
233
+ }
234
+
235
+ function storedSessionFileMatches(dbManager: DatabaseManager, metadata: SessionFileMetadata): boolean {
236
+ const row = getStoredSessionFileMetadata(dbManager, metadata.path);
237
+ return Boolean(row && row.size === metadata.size && row.mtime_ms === metadata.mtimeMs);
238
+ }
239
+
240
+ export function upsertSessionFileMetadata(
241
+ dbManager: DatabaseManager,
242
+ filePath: string,
243
+ sessionId: string,
244
+ metadata = getSessionFileMetadata(filePath),
245
+ indexedAt = new Date(),
246
+ ): void {
247
+ const db = dbManager.getDb();
248
+ db.prepare(`
249
+ INSERT INTO session_files (path, session_id, size, mtime_ms, indexed_at)
250
+ VALUES (?, ?, ?, ?, ?)
251
+ ON CONFLICT(path) DO UPDATE SET
252
+ session_id = excluded.session_id,
253
+ size = excluded.size,
254
+ mtime_ms = excluded.mtime_ms,
255
+ indexed_at = excluded.indexed_at
256
+ `).run(metadata.path, sessionId, metadata.size, metadata.mtimeMs, indexedAt.toISOString());
257
+ }
258
+
259
+ function emptyBulkIndexResult(): BulkIndexResult {
260
+ return {
261
+ sessionsProcessed: 0,
262
+ sessionsIndexed: 0,
263
+ sessionsSkipped: 0,
264
+ messagesIndexed: 0,
265
+ errors: [],
266
+ };
267
+ }
268
+
269
+ function indexSessionFile(dbManager: DatabaseManager, file: string, result: BulkIndexResult): void {
270
+ result.sessionsProcessed++;
271
+
272
+ const session = parseSessionFile(file);
273
+ if (!session) {
274
+ result.errors.push(`Failed to parse: ${file}`);
275
+ return;
75
276
  }
76
277
 
77
- return { sessionId: session.id, messagesIndexed: session.messages.length, skipped: false };
278
+ const indexResult = indexSession(dbManager, session);
279
+ upsertSessionFileMetadata(dbManager, file, session.id);
280
+ if (indexResult.skipped) {
281
+ result.sessionsSkipped++;
282
+ } else {
283
+ result.sessionsIndexed++;
284
+ result.messagesIndexed += indexResult.messagesIndexed;
285
+ }
78
286
  }
79
287
 
80
288
  /**
@@ -91,39 +299,132 @@ export function indexAllSessions(
91
299
  projectDir?: string
92
300
  ): BulkIndexResult {
93
301
  const files = getSessionFiles(sessionsDir, projectDir);
94
- const result: BulkIndexResult = {
95
- sessionsProcessed: 0,
96
- sessionsIndexed: 0,
97
- sessionsSkipped: 0,
98
- messagesIndexed: 0,
99
- errors: [],
100
- };
302
+ const result = emptyBulkIndexResult();
101
303
 
102
304
  for (const file of files) {
103
- result.sessionsProcessed++;
104
-
105
305
  try {
106
- const session = parseSessionFile(file);
107
- if (!session) {
108
- result.errors.push(`Failed to parse: ${file}`);
109
- continue;
110
- }
306
+ indexSessionFile(dbManager, file, result);
307
+ } catch (err) {
308
+ result.errors.push(`Error indexing ${file}: ${err instanceof Error ? err.message : String(err)}`);
309
+ }
310
+ }
311
+
312
+ return result;
313
+ }
111
314
 
112
- const indexResult = indexSession(dbManager, session);
113
- if (indexResult.skipped) {
315
+ /**
316
+ * Incrementally index session JSONL files without matching stored metadata.
317
+ *
318
+ * This is intentionally cheaper than indexAllSessions() for startup backfill:
319
+ * files with matching stored size/mtime metadata are skipped, and all other
320
+ * files are parsed under the startup cap.
321
+ */
322
+ export function indexChangedSessions(
323
+ dbManager: DatabaseManager,
324
+ sessionsDir: string,
325
+ options: IncrementalIndexOptions = {},
326
+ ): BulkIndexResult {
327
+ const files = getSessionFiles(sessionsDir, options.projectDir);
328
+ const maxFilesToIndex = options.maxFilesToIndex ?? 50;
329
+ const result = emptyBulkIndexResult();
330
+
331
+ // Gather the changed set first, then sort newest-first before applying the
332
+ // cap. Crash recovery is the primary value of startup backfill (the live
333
+ // message_end path missed the session's final state), and crashed sessions
334
+ // are the most recently modified files. Sorting newest-first ensures they
335
+ // are indexed on the very next startup instead of waiting behind old
336
+ // historical files that fill the per-startup cap in filesystem order.
337
+ const changed: SessionFileMetadata[] = [];
338
+ for (const file of files) {
339
+ try {
340
+ const metadata = getSessionFileMetadata(file);
341
+ if (storedSessionFileMatches(dbManager, metadata)) {
114
342
  result.sessionsSkipped++;
115
- } else {
116
- result.sessionsIndexed++;
117
- result.messagesIndexed += indexResult.messagesIndexed;
343
+ continue;
118
344
  }
345
+ changed.push(metadata);
119
346
  } catch (err) {
120
347
  result.errors.push(`Error indexing ${file}: ${err instanceof Error ? err.message : String(err)}`);
121
348
  }
122
349
  }
123
350
 
351
+ changed.sort((a, b) => b.mtimeMs - a.mtimeMs);
352
+
353
+ for (const metadata of changed) {
354
+ if (result.sessionsProcessed >= maxFilesToIndex) {
355
+ result.reachedLimit = true;
356
+ break;
357
+ }
358
+ try {
359
+ indexSessionFile(dbManager, metadata.path, result);
360
+ } catch (err) {
361
+ result.errors.push(`Error indexing ${metadata.path}: ${err instanceof Error ? err.message : String(err)}`);
362
+ }
363
+ }
364
+
124
365
  return result;
125
366
  }
126
367
 
368
+ /**
369
+ * Cheaply count session JSONL files in the same scope indexAllSessions scans.
370
+ */
371
+ export function countSessionFiles(sessionsDir: string): number {
372
+ return getSessionFiles(sessionsDir).length;
373
+ }
374
+
375
+ function getLastBackfillTimestamp(dbManager: DatabaseManager): string | null {
376
+ const db = dbManager.getDb();
377
+ const row = db.prepare('SELECT value FROM extension_metadata WHERE key = ?').get(LAST_SESSION_BACKFILL_KEY) as { value: string } | undefined;
378
+ return row?.value ?? null;
379
+ }
380
+
381
+ function isRecentBackfillTimestamp(value: string | null, nowMs: number): boolean {
382
+ if (!value) return false;
383
+ const parsed = Date.parse(value);
384
+ if (!Number.isFinite(parsed)) return false;
385
+ return nowMs - parsed < SESSION_BACKFILL_INTERVAL_MS;
386
+ }
387
+
388
+ /**
389
+ * Determine whether a background session backfill should run.
390
+ *
391
+ * The check stays cheap: it compares file counts and stored file size/mtime
392
+ * metadata. Full JSONL parsing is left to the scheduled incremental backfill.
393
+ */
394
+ export function needsBackfill(dbManager: DatabaseManager, sessionsDir: string, now = new Date()): boolean {
395
+ const db = dbManager.getDb();
396
+ const files = getSessionFiles(sessionsDir);
397
+ const indexed = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number };
398
+
399
+ if (files.length > indexed.count) {
400
+ return true;
401
+ }
402
+
403
+ for (const file of files) {
404
+ try {
405
+ const metadata = getSessionFileMetadata(file);
406
+ if (storedSessionFileMatches(dbManager, metadata)) continue;
407
+ return true;
408
+ } catch {
409
+ return true;
410
+ }
411
+ }
412
+
413
+ return !isRecentBackfillTimestamp(getLastBackfillTimestamp(dbManager), now.getTime());
414
+ }
415
+
416
+ /**
417
+ * Record a successful session backfill completion timestamp.
418
+ */
419
+ export function touchBackfillTimestamp(dbManager: DatabaseManager, timestamp = new Date()): void {
420
+ const db = dbManager.getDb();
421
+ db.prepare(`
422
+ INSERT INTO extension_metadata (key, value)
423
+ VALUES (?, ?)
424
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
425
+ `).run(LAST_SESSION_BACKFILL_KEY, timestamp.toISOString());
426
+ }
427
+
127
428
  /**
128
429
  * Get statistics about indexed sessions.
129
430
  */
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
 
3
4
  /**
4
5
  * Parsed session data from a JSONL file.
@@ -90,7 +91,7 @@ function extractToolCalls(content: unknown): string[] | undefined {
90
91
  for (const block of content) {
91
92
  if (!block || typeof block !== 'object') continue;
92
93
  const b = block as Record<string, unknown>;
93
- if (b.type === 'tool_use' && typeof b.name === 'string') {
94
+ if ((b.type === 'tool_use' || b.type === 'toolCall') && typeof b.name === 'string') {
94
95
  toolNames.push(b.name);
95
96
  }
96
97
  }
@@ -179,23 +180,29 @@ export function parseSessionFile(filePath: string): ParsedSession | null {
179
180
  */
180
181
  export function getSessionFiles(sessionsDir: string, projectDir?: string): string[] {
181
182
  if (projectDir) {
182
- const dir = `${sessionsDir}/${projectDir}`;
183
+ const dir = path.join(sessionsDir, projectDir);
183
184
  if (!fs.existsSync(dir)) return [];
184
185
  return fs.readdirSync(dir)
185
186
  .filter(f => f.endsWith('.jsonl'))
186
- .map(f => `${dir}/${f}`);
187
+ .map(f => path.join(dir, f));
187
188
  }
188
189
 
189
190
  // All projects
190
191
  if (!fs.existsSync(sessionsDir)) return [];
191
192
  const files: string[] = [];
192
- for (const dir of fs.readdirSync(sessionsDir)) {
193
- const dirPath = `${sessionsDir}/${dir}`;
194
- if (!fs.statSync(dirPath).isDirectory()) continue;
195
- for (const f of fs.readdirSync(dirPath)) {
196
- if (f.endsWith('.jsonl')) {
197
- files.push(`${dirPath}/${f}`);
193
+ for (const entry of fs.readdirSync(sessionsDir)) {
194
+ const entryPath = path.join(sessionsDir, entry);
195
+ const stat = fs.statSync(entryPath);
196
+ if (stat.isDirectory()) {
197
+ // Scan .jsonl files inside project subdirectories
198
+ for (const f of fs.readdirSync(entryPath)) {
199
+ if (f.endsWith('.jsonl')) {
200
+ files.push(path.join(entryPath, f));
201
+ }
198
202
  }
203
+ } else if (stat.isFile() && entry.endsWith('.jsonl')) {
204
+ // Also pick up root-level .jsonl files
205
+ files.push(entryPath);
199
206
  }
200
207
  }
201
208
  return files;
@@ -1,5 +1,5 @@
1
1
  import { DatabaseManager } from './db.js';
2
- import { isFts5QueryError, normalizeFts5Query } from './fts-query.js';
2
+ import { buildFallbackFts5Query, hasExplicitFts5Operator, isFts5QueryError, normalizeFts5Query } from './fts-query.js';
3
3
 
4
4
  /**
5
5
  * Search result from session history.
@@ -27,6 +27,52 @@ export interface SessionSearchOptions {
27
27
  since?: string;
28
28
  }
29
29
 
30
+ type SearchMatch =
31
+ | { type: 'fts'; query: string }
32
+ | { type: 'like'; terms: string[] };
33
+
34
+ const QUERY_TOKEN_PATTERN = /"([^"]*)"|(\S+)/g;
35
+ const NATURAL_LANGUAGE_CONNECTORS = new Set(['and', 'or', 'not', 'near']);
36
+
37
+ function escapeLikePattern(text: string): string {
38
+ return text.replace(/[\\%_]/g, '\\$&');
39
+ }
40
+
41
+ function collectLikeTerms(query: string): string[] {
42
+ const terms: string[] = [];
43
+
44
+ for (const match of query.matchAll(QUERY_TOKEN_PATTERN)) {
45
+ const phrase = match[1];
46
+ const term = match[2];
47
+ if (phrase === undefined && term && NATURAL_LANGUAGE_CONNECTORS.has(term.toLowerCase())) {
48
+ continue;
49
+ }
50
+
51
+ const rawValue = phrase ?? term ?? '';
52
+ if (rawValue.length > 0) terms.push(rawValue);
53
+ }
54
+
55
+ return terms;
56
+ }
57
+
58
+ function mapRows(rows: Array<{
59
+ session_id: string;
60
+ project: string;
61
+ role: string;
62
+ content: string;
63
+ timestamp: string;
64
+ snippet: string;
65
+ }>): SessionSearchResult[] {
66
+ return rows.map(row => ({
67
+ sessionId: row.session_id,
68
+ project: row.project,
69
+ role: row.role,
70
+ content: row.content,
71
+ timestamp: row.timestamp,
72
+ snippet: row.snippet,
73
+ }));
74
+ }
75
+
30
76
  /**
31
77
  * Search across indexed session messages using FTS5.
32
78
  *
@@ -47,79 +93,104 @@ export function searchSessions(
47
93
  const db = dbManager.getDb();
48
94
  const { limit = 10, project, role, since } = options;
49
95
 
50
- // Build the query dynamically based on filters
51
- const conditions: string[] = [];
52
- const params: unknown[] = [];
96
+ const executeSearch = (match: SearchMatch): SessionSearchResult[] => {
97
+ const conditions: string[] = [];
98
+ const params: unknown[] = [];
99
+
100
+ if (match.type === 'fts') {
101
+ // FTS5 match condition — use subquery for reliable rowid matching
102
+ conditions.push('m.rowid IN (SELECT rowid FROM message_fts WHERE message_fts MATCH ?)');
103
+ params.push(match.query);
104
+ } else {
105
+ if (match.terms.length === 0) {
106
+ return [];
107
+ }
108
+ const likeConditions = match.terms.map(() => `m.content LIKE ? ESCAPE '\\'`);
109
+ conditions.push(`(${likeConditions.join(' OR ')})`);
110
+ for (const term of match.terms) {
111
+ params.push(`%${escapeLikePattern(term)}%`);
112
+ }
113
+ }
114
+
115
+ // Project filter
116
+ if (project) {
117
+ conditions.push('s.project = ?');
118
+ params.push(project);
119
+ }
120
+
121
+ // Role filter
122
+ if (role) {
123
+ conditions.push('m.role = ?');
124
+ params.push(role);
125
+ }
126
+
127
+ // Date filter
128
+ if (since) {
129
+ conditions.push('m.timestamp >= ?');
130
+ params.push(since);
131
+ }
132
+
133
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
134
+
135
+ const sql = `
136
+ SELECT
137
+ m.session_id,
138
+ s.project,
139
+ m.role,
140
+ m.content,
141
+ m.timestamp,
142
+ m.content as snippet
143
+ FROM messages m
144
+ JOIN sessions s ON s.id = m.session_id
145
+ ${whereClause}
146
+ ORDER BY m.timestamp DESC
147
+ LIMIT ?
148
+ `;
149
+
150
+ try {
151
+ const rows = db.prepare(sql).all(...params, limit) as Array<{
152
+ session_id: string;
153
+ project: string;
154
+ role: string;
155
+ content: string;
156
+ timestamp: string;
157
+ snippet: string;
158
+ }>;
159
+
160
+ return mapRows(rows);
161
+ } catch (err) {
162
+ if (match.type === 'fts' && isFts5QueryError(err)) {
163
+ return [];
164
+ }
165
+ throw err;
166
+ }
167
+ };
53
168
 
54
- // FTS5 match condition — use subquery for reliable rowid matching
55
169
  const normalizedQuery = normalizeFts5Query(query);
56
170
  if (normalizedQuery.length === 0) {
57
171
  return [];
58
172
  }
59
- conditions.push('m.rowid IN (SELECT rowid FROM message_fts WHERE message_fts MATCH ?)');
60
- params.push(normalizedQuery);
61
-
62
- // Project filter
63
- if (project) {
64
- conditions.push('s.project = ?');
65
- params.push(project);
66
- }
67
173
 
68
- // Role filter
69
- if (role) {
70
- conditions.push('m.role = ?');
71
- params.push(role);
174
+ const exactResults = executeSearch({ type: 'fts', query: normalizedQuery });
175
+ if (exactResults.length > 0) {
176
+ return exactResults;
72
177
  }
73
178
 
74
- // Date filter
75
- if (since) {
76
- conditions.push('m.timestamp >= ?');
77
- params.push(since);
179
+ const explicitOperatorQuery = hasExplicitFts5Operator(query);
180
+ if (explicitOperatorQuery) {
181
+ return exactResults;
78
182
  }
79
183
 
80
- const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
81
-
82
- const sql = `
83
- SELECT
84
- m.session_id,
85
- s.project,
86
- m.role,
87
- m.content,
88
- m.timestamp,
89
- m.content as snippet
90
- FROM messages m
91
- JOIN sessions s ON s.id = m.session_id
92
- ${whereClause}
93
- ORDER BY m.timestamp DESC
94
- LIMIT ?
95
- `;
96
- params.push(limit);
97
-
98
- try {
99
- const rows = db.prepare(sql).all(...params) as Array<{
100
- session_id: string;
101
- project: string;
102
- role: string;
103
- content: string;
104
- timestamp: string;
105
- snippet: string;
106
- }>;
107
-
108
- // Map snake_case column names to camelCase
109
- return rows.map(row => ({
110
- sessionId: row.session_id,
111
- project: row.project,
112
- role: row.role,
113
- content: row.content,
114
- timestamp: row.timestamp,
115
- snippet: row.snippet,
116
- }));
117
- } catch (err) {
118
- if (isFts5QueryError(err)) {
119
- return [];
184
+ const fallbackQuery = buildFallbackFts5Query(query);
185
+ if (fallbackQuery && fallbackQuery !== normalizedQuery) {
186
+ const fallbackResults = executeSearch({ type: 'fts', query: fallbackQuery });
187
+ if (fallbackResults.length > 0) {
188
+ return fallbackResults;
120
189
  }
121
- throw err;
122
190
  }
191
+
192
+ const likeTerms = collectLikeTerms(query);
193
+ return executeSearch({ type: 'like', terms: likeTerms });
123
194
  }
124
195
 
125
196
  /**