pi-hermes-memory 0.7.17 → 0.7.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hermes-memory",
3
- "version": "0.7.17",
3
+ "version": "0.7.18",
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,135 @@
1
+ import type { DatabaseManager } from '../store/db.js';
2
+ import {
3
+ indexAllSessions,
4
+ needsBackfill,
5
+ touchBackfillTimestamp,
6
+ type BulkIndexResult,
7
+ } from '../store/session-indexer.js';
8
+
9
+ export const SESSION_BACKFILL_SHUTDOWN_TIMEOUT_MS = 5000;
10
+
11
+ type NotifyLevel = 'info' | 'warning' | 'error';
12
+ type NotifyFn = (message: string, level: NotifyLevel) => void;
13
+
14
+ type SetTimeoutFn = (callback: () => void, ms: number) => unknown;
15
+
16
+ export interface SessionBackfillState {
17
+ inProgress: boolean;
18
+ promise: Promise<void> | null;
19
+ }
20
+
21
+ export const sessionBackfillState: SessionBackfillState = {
22
+ inProgress: false,
23
+ promise: null,
24
+ };
25
+
26
+ export interface ScheduleSessionBackfillOptions {
27
+ notify?: NotifyFn;
28
+ state?: SessionBackfillState;
29
+ setTimeoutFn?: SetTimeoutFn;
30
+ needsBackfillFn?: typeof needsBackfill;
31
+ indexAllSessionsFn?: typeof indexAllSessions;
32
+ touchBackfillTimestampFn?: typeof touchBackfillTimestamp;
33
+ }
34
+
35
+ function formatBackfillResult(result: BulkIndexResult): string {
36
+ const errorSuffix = result.errors.length > 0 ? ` (${result.errors.length} file error${result.errors.length === 1 ? '' : 's'})` : '';
37
+ return `🧠 Session backfill complete: ${result.sessionsIndexed} indexed, ${result.sessionsSkipped} skipped, ${result.messagesIndexed} messages${errorSuffix}.`;
38
+ }
39
+
40
+ function notifyBestEffort(notify: NotifyFn | undefined, message: string, level: NotifyLevel): void {
41
+ try {
42
+ notify?.(message, level);
43
+ } catch {
44
+ // Notification failures must never affect backfill.
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Schedule a best-effort, non-blocking backfill of unindexed Pi sessions.
50
+ *
51
+ * The expensive indexAllSessions() pass is always deferred with setTimeout(0)
52
+ * so session_start can resolve before disk parsing/indexing begins. A shared
53
+ * state guard prevents concurrent backfills within this extension instance.
54
+ *
55
+ * @returns true when a backfill task was scheduled; false when it was skipped.
56
+ */
57
+ export function scheduleSessionBackfill(
58
+ dbManager: DatabaseManager,
59
+ sessionsDir: string,
60
+ options: ScheduleSessionBackfillOptions = {},
61
+ ): boolean {
62
+ const state = options.state ?? sessionBackfillState;
63
+ const setTimeoutFn = options.setTimeoutFn ?? setTimeout;
64
+ const needsBackfillFn = options.needsBackfillFn ?? needsBackfill;
65
+ const indexAllSessionsFn = options.indexAllSessionsFn ?? indexAllSessions;
66
+ const touchBackfillTimestampFn = options.touchBackfillTimestampFn ?? touchBackfillTimestamp;
67
+
68
+ if (state.inProgress) {
69
+ return false;
70
+ }
71
+
72
+ try {
73
+ if (!needsBackfillFn(dbManager, sessionsDir)) {
74
+ return false;
75
+ }
76
+ } catch (err) {
77
+ notifyBestEffort(
78
+ options.notify,
79
+ `⚠️ Session backfill check failed: ${err instanceof Error ? err.message : String(err)}`,
80
+ 'warning',
81
+ );
82
+ return false;
83
+ }
84
+
85
+ state.inProgress = true;
86
+ state.promise = new Promise<void>((resolve) => {
87
+ setTimeoutFn(() => {
88
+ try {
89
+ const result = indexAllSessionsFn(dbManager, sessionsDir);
90
+ touchBackfillTimestampFn(dbManager);
91
+ notifyBestEffort(options.notify, formatBackfillResult(result), result.errors.length > 0 ? 'warning' : 'info');
92
+ } catch (err) {
93
+ notifyBestEffort(
94
+ options.notify,
95
+ `⚠️ Session backfill failed: ${err instanceof Error ? err.message : String(err)}`,
96
+ 'warning',
97
+ );
98
+ } finally {
99
+ state.inProgress = false;
100
+ state.promise = null;
101
+ resolve();
102
+ }
103
+ }, 0);
104
+ });
105
+
106
+ return true;
107
+ }
108
+
109
+ /**
110
+ * Wait briefly for an in-progress backfill before shutdown closes SQLite.
111
+ *
112
+ * @returns true if no backfill was running or it completed before the timeout;
113
+ * false if the timeout elapsed first.
114
+ */
115
+ export async function waitForSessionBackfill(
116
+ timeoutMs = SESSION_BACKFILL_SHUTDOWN_TIMEOUT_MS,
117
+ state: SessionBackfillState = sessionBackfillState,
118
+ ): Promise<boolean> {
119
+ const promise = state.promise;
120
+ if (!state.inProgress || !promise) {
121
+ return true;
122
+ }
123
+
124
+ let timeout: ReturnType<typeof setTimeout> | undefined;
125
+ try {
126
+ return await Promise.race([
127
+ promise.then(() => true),
128
+ new Promise<boolean>((resolve) => {
129
+ timeout = setTimeout(() => resolve(false), timeoutMs);
130
+ }),
131
+ ]);
132
+ } finally {
133
+ if (timeout) clearTimeout(timeout);
134
+ }
135
+ }
@@ -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
@@ -28,6 +28,8 @@ 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
30
  import { indexSession } 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
@@ -229,6 +252,14 @@ export default function (pi: ExtensionAPI) {
229
252
  } catch {
230
253
  // Silent fail — don't block shutdown
231
254
  } finally {
255
+ try {
256
+ await Promise.all([
257
+ waitForSessionBackfill(SESSION_BACKFILL_SHUTDOWN_TIMEOUT_MS),
258
+ waitForLiveSessionIndex(SESSION_LIVE_INDEX_SHUTDOWN_TIMEOUT_MS),
259
+ ]);
260
+ } catch {
261
+ // Best effort only — shutdown should not be held up by indexing errors.
262
+ }
232
263
  try { dbManager.close(); } catch { /* best effort — never block shutdown */ }
233
264
  }
234
265
  });
@@ -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
 
@@ -10,6 +10,12 @@
10
10
  */
11
11
 
12
12
  export const SCHEMA_SQL = `
13
+ -- Extension key/value metadata
14
+ CREATE TABLE IF NOT EXISTS extension_metadata (
15
+ key TEXT PRIMARY KEY,
16
+ value TEXT NOT NULL
17
+ );
18
+
13
19
  -- Session metadata
14
20
  CREATE TABLE IF NOT EXISTS sessions (
15
21
  id TEXT PRIMARY KEY,
@@ -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
  /**
@@ -29,33 +33,39 @@ export interface BulkIndexResult {
29
33
  export function indexSession(dbManager: DatabaseManager, session: ParsedSession): IndexResult {
30
34
  const db = dbManager.getDb();
31
35
 
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
- }
36
+ const existingSession = db.prepare('SELECT id FROM sessions WHERE id = ?').get(session.id) as { id: string } | undefined;
37
+ const before = db.prepare('SELECT COUNT(*) as count FROM messages WHERE session_id = ?').get(session.id) as { count: number };
37
38
 
38
- // Insert session
39
- db.prepare(`
40
- INSERT INTO sessions (id, project, cwd, started_at, ended_at, message_count)
39
+ const insertSession = db.prepare(`
40
+ INSERT OR IGNORE INTO sessions (id, project, cwd, started_at, ended_at, message_count)
41
41
  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
42
+ `);
43
+
52
44
  const insertMsg = db.prepare(`
53
- INSERT INTO messages (id, session_id, role, content, timestamp, tool_calls)
45
+ INSERT OR IGNORE INTO messages (id, session_id, role, content, timestamp, tool_calls)
54
46
  VALUES (?, ?, ?, ?, ?, ?)
55
47
  `);
56
48
 
57
- const writeMessages = (messages: ParsedSession['messages']) => {
58
- for (const msg of messages) {
49
+ const updateSession = db.prepare(`
50
+ UPDATE sessions
51
+ SET project = ?,
52
+ cwd = ?,
53
+ ended_at = COALESCE(?, ended_at),
54
+ message_count = (SELECT COUNT(*) FROM messages WHERE session_id = ?)
55
+ WHERE id = ?
56
+ `);
57
+
58
+ const writeSession = () => {
59
+ insertSession.run(
60
+ session.id,
61
+ session.project,
62
+ session.cwd,
63
+ session.startedAt,
64
+ session.endedAt,
65
+ session.messages.length
66
+ );
67
+
68
+ for (const msg of session.messages) {
59
69
  insertMsg.run(
60
70
  msg.id,
61
71
  session.id,
@@ -65,16 +75,136 @@ export function indexSession(dbManager: DatabaseManager, session: ParsedSession)
65
75
  msg.toolCalls ? JSON.stringify(msg.toolCalls) : null
66
76
  );
67
77
  }
78
+
79
+ updateSession.run(session.project, session.cwd, session.endedAt, session.id, session.id);
68
80
  };
69
81
 
70
82
  if (db.transaction) {
71
- const insertMany = db.transaction(writeMessages);
72
- insertMany(session.messages);
83
+ const tx = db.transaction(writeSession);
84
+ tx();
73
85
  } else {
74
- writeMessages(session.messages);
86
+ writeSession();
87
+ }
88
+
89
+ const after = db.prepare('SELECT COUNT(*) as count FROM messages WHERE session_id = ?').get(session.id) as { count: number };
90
+ const messagesIndexed = after.count - before.count;
91
+
92
+ return { sessionId: session.id, messagesIndexed, skipped: Boolean(existingSession) && messagesIndexed === 0 };
93
+ }
94
+
95
+ type SessionManagerSnapshot = {
96
+ getHeader: () => { id: string; timestamp: string; cwd: string } | null;
97
+ getEntries: () => unknown[];
98
+ getSessionFile?: () => string | undefined;
99
+ };
100
+
101
+ type SessionMessageEntryLike = {
102
+ type?: unknown;
103
+ id?: unknown;
104
+ timestamp?: unknown;
105
+ message?: {
106
+ role?: unknown;
107
+ content?: unknown;
108
+ };
109
+ };
110
+
111
+ function extractTextContent(content: unknown): string {
112
+ if (typeof content === 'string') return content;
113
+ if (!Array.isArray(content)) return '';
114
+
115
+ const parts: string[] = [];
116
+ for (const block of content) {
117
+ if (!block || typeof block !== 'object') continue;
118
+ const b = block as Record<string, unknown>;
119
+
120
+ switch (b.type) {
121
+ case 'text':
122
+ if (typeof b.text === 'string') parts.push(b.text);
123
+ break;
124
+ case 'tool_result':
125
+ if (typeof b.content === 'string') {
126
+ parts.push(b.content);
127
+ } else if (Array.isArray(b.content)) {
128
+ for (const item of b.content) {
129
+ if (item && typeof item === 'object' && (item as Record<string, unknown>).type === 'text') {
130
+ const text = (item as Record<string, unknown>).text;
131
+ if (typeof text === 'string') parts.push(text);
132
+ }
133
+ }
134
+ }
135
+ break;
136
+ }
137
+ }
138
+
139
+ return parts.join('\n').trim();
140
+ }
141
+
142
+ function extractToolCalls(content: unknown): string[] | undefined {
143
+ if (!Array.isArray(content)) return undefined;
144
+
145
+ const toolNames: string[] = [];
146
+ for (const block of content) {
147
+ if (!block || typeof block !== 'object') continue;
148
+ const b = block as Record<string, unknown>;
149
+ if ((b.type === 'toolCall' || b.type === 'tool_use') && typeof b.name === 'string') {
150
+ toolNames.push(b.name);
151
+ }
152
+ }
153
+ return toolNames.length > 0 ? toolNames : undefined;
154
+ }
155
+
156
+ function parseMessageEntry(entry: unknown): ParsedSession['messages'][number] | null {
157
+ if (!entry || typeof entry !== 'object') return null;
158
+ const e = entry as SessionMessageEntryLike;
159
+ if (e.type !== 'message' || typeof e.id !== 'string' || typeof e.timestamp !== 'string' || !e.message) return null;
160
+
161
+ const role = e.message.role;
162
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') return null;
163
+
164
+ const content = extractTextContent(e.message.content);
165
+ if (!content) return null;
166
+
167
+ return {
168
+ id: e.id,
169
+ role,
170
+ content,
171
+ timestamp: e.timestamp,
172
+ toolCalls: role === 'assistant' ? extractToolCalls(e.message.content) : undefined,
173
+ };
174
+ }
175
+
176
+ export function parseSessionManagerSnapshot(sessionManager: SessionManagerSnapshot): ParsedSession | null {
177
+ const header = sessionManager.getHeader();
178
+ if (!header?.id || !header.cwd || !header.timestamp) return null;
179
+
180
+ const messages = sessionManager.getEntries()
181
+ .map(parseMessageEntry)
182
+ .filter((msg): msg is ParsedSession['messages'][number] => msg !== null);
183
+
184
+ return {
185
+ id: header.id,
186
+ project: header.cwd.split('/').pop() ?? header.cwd,
187
+ cwd: header.cwd,
188
+ startedAt: header.timestamp,
189
+ endedAt: null,
190
+ messages,
191
+ };
192
+ }
193
+
194
+ export function indexCurrentSession(dbManager: DatabaseManager, sessionManager: SessionManagerSnapshot): IndexResult | null {
195
+ const session = parseSessionManagerSnapshot(sessionManager);
196
+ if (!session) return null;
197
+ return indexSession(dbManager, session);
198
+ }
199
+
200
+ export function indexLiveSession(dbManager: DatabaseManager, sessionManager: SessionManagerSnapshot): IndexResult | null {
201
+ const sessionFile = sessionManager.getSessionFile?.();
202
+ if (sessionFile && fs.existsSync(sessionFile)) {
203
+ const session = parseSessionFile(sessionFile);
204
+ if (session) return indexSession(dbManager, session);
75
205
  }
76
206
 
77
- return { sessionId: session.id, messagesIndexed: session.messages.length, skipped: false };
207
+ return indexCurrentSession(dbManager, sessionManager);
78
208
  }
79
209
 
80
210
  /**
@@ -124,6 +254,58 @@ export function indexAllSessions(
124
254
  return result;
125
255
  }
126
256
 
257
+ /**
258
+ * Cheaply count session JSONL files in the same scope indexAllSessions scans.
259
+ */
260
+ export function countSessionFiles(sessionsDir: string): number {
261
+ return getSessionFiles(sessionsDir).length;
262
+ }
263
+
264
+ function getLastBackfillTimestamp(dbManager: DatabaseManager): string | null {
265
+ const db = dbManager.getDb();
266
+ const row = db.prepare('SELECT value FROM extension_metadata WHERE key = ?').get(LAST_SESSION_BACKFILL_KEY) as { value: string } | undefined;
267
+ return row?.value ?? null;
268
+ }
269
+
270
+ function isRecentBackfillTimestamp(value: string | null, nowMs: number): boolean {
271
+ if (!value) return false;
272
+ const parsed = Date.parse(value);
273
+ if (!Number.isFinite(parsed)) return false;
274
+ return nowMs - parsed < SESSION_BACKFILL_INTERVAL_MS;
275
+ }
276
+
277
+ /**
278
+ * Determine whether a background session backfill should run.
279
+ *
280
+ * A backfill is needed when the number of JSONL session files differs from
281
+ * the indexed session count, or when no successful backfill has completed in
282
+ * the last 24 hours. The count check catches crashed/abnormal sessions; the
283
+ * timestamp check periodically repairs parse errors or manual DB edits.
284
+ */
285
+ export function needsBackfill(dbManager: DatabaseManager, sessionsDir: string, now = new Date()): boolean {
286
+ const db = dbManager.getDb();
287
+ const fileCount = countSessionFiles(sessionsDir);
288
+ const indexed = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number };
289
+
290
+ if (fileCount !== indexed.count) {
291
+ return true;
292
+ }
293
+
294
+ return !isRecentBackfillTimestamp(getLastBackfillTimestamp(dbManager), now.getTime());
295
+ }
296
+
297
+ /**
298
+ * Record a successful session backfill completion timestamp.
299
+ */
300
+ export function touchBackfillTimestamp(dbManager: DatabaseManager, timestamp = new Date()): void {
301
+ const db = dbManager.getDb();
302
+ db.prepare(`
303
+ INSERT INTO extension_metadata (key, value)
304
+ VALUES (?, ?)
305
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
306
+ `).run(LAST_SESSION_BACKFILL_KEY, timestamp.toISOString());
307
+ }
308
+
127
309
  /**
128
310
  * Get statistics about indexed sessions.
129
311
  */
@@ -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
  /**