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 +1 -1
- package/src/handlers/pi-child-process.ts +99 -6
- package/src/handlers/session-backfill.ts +135 -0
- package/src/handlers/session-live-index.ts +89 -0
- package/src/index.ts +33 -2
- package/src/store/fts-query.ts +6 -2
- package/src/store/schema.ts +6 -0
- package/src/store/session-indexer.ts +208 -26
- package/src/store/session-parser.ts +16 -9
- package/src/store/session-search.ts +133 -62
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hermes-memory",
|
|
3
|
-
"version": "0.7.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
// ──
|
|
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
|
});
|
package/src/store/fts-query.ts
CHANGED
|
@@ -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 (
|
|
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 ||
|
|
52
|
+
if (trimmed.length === 0 || hasExplicitFts5Operator(trimmed)) {
|
|
49
53
|
return null;
|
|
50
54
|
}
|
|
51
55
|
|
package/src/store/schema.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
const
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
`)
|
|
43
|
-
|
|
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
|
|
58
|
-
|
|
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
|
|
72
|
-
|
|
83
|
+
const tx = db.transaction(writeSession);
|
|
84
|
+
tx();
|
|
73
85
|
} else {
|
|
74
|
-
|
|
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
|
|
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 =
|
|
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 =>
|
|
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
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
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
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
params.push(since);
|
|
179
|
+
const explicitOperatorQuery = hasExplicitFts5Operator(query);
|
|
180
|
+
if (explicitOperatorQuery) {
|
|
181
|
+
return exactResults;
|
|
78
182
|
}
|
|
79
183
|
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
/**
|