pi-hermes-memory 0.7.11 → 0.7.13
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 +5 -0
- package/package.json +1 -1
- package/src/handlers/auto-consolidate.ts +46 -15
- package/src/index.ts +8 -2
- package/src/store/db.ts +3 -0
- package/src/store/fts-query.ts +38 -0
- package/src/store/memory-lookup.ts +15 -0
- package/src/store/memory-store.ts +33 -34
- package/src/store/session-search.ts +14 -16
- package/src/store/sqlite-memory-store.ts +38 -30
package/README.md
CHANGED
|
@@ -278,6 +278,11 @@ By default, the extension indexes your Pi session history into a SQLite database
|
|
|
278
278
|
| `session_search` | Search past conversations — "what did we discuss about auth?" |
|
|
279
279
|
| `memory_search` | Search extended memory store — unlimited capacity, keyword-based |
|
|
280
280
|
|
|
281
|
+
Search behavior notes:
|
|
282
|
+
- Multi-word natural-language queries are supported for both `memory_search` and `session_search`.
|
|
283
|
+
- Exact phrases can be requested with quotes, for example `"memory search"`.
|
|
284
|
+
- Advanced FTS queries with operators like `OR` still work when you need them.
|
|
285
|
+
|
|
281
286
|
Session history is indexed automatically on session shutdown. To bulk-import existing sessions:
|
|
282
287
|
|
|
283
288
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hermes-memory",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.13",
|
|
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",
|
|
@@ -12,24 +12,38 @@ import { MemoryStore } from "../store/memory-store.js";
|
|
|
12
12
|
import { CONSOLIDATION_PROMPT, ENTRY_DELIMITER } from "../constants.js";
|
|
13
13
|
import type { ConsolidationResult } from "../types.js";
|
|
14
14
|
|
|
15
|
+
type MemoryTarget = "memory" | "user" | "failure";
|
|
16
|
+
type ToolMemoryTarget = MemoryTarget | "project";
|
|
17
|
+
|
|
18
|
+
function entriesForTarget(store: MemoryStore, target: MemoryTarget): string[] {
|
|
19
|
+
return target === "user" ? store.getUserEntries() : store.getMemoryEntries();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function labelForTarget(target: MemoryTarget, toolTarget: ToolMemoryTarget): string {
|
|
23
|
+
if (toolTarget === "project") return "Project Memory";
|
|
24
|
+
if (target === "user") return "User Profile";
|
|
25
|
+
if (target === "failure") return "Failure Memory";
|
|
26
|
+
return "Memory";
|
|
27
|
+
}
|
|
28
|
+
|
|
15
29
|
export async function triggerConsolidation(
|
|
16
30
|
pi: ExtensionAPI,
|
|
17
31
|
store: MemoryStore,
|
|
18
|
-
target:
|
|
32
|
+
target: MemoryTarget,
|
|
19
33
|
signal?: AbortSignal,
|
|
20
34
|
timeoutMs: number = 60000,
|
|
35
|
+
toolTarget: ToolMemoryTarget = target,
|
|
21
36
|
): Promise<ConsolidationResult> {
|
|
22
|
-
const entries =
|
|
23
|
-
target === "memory" ? store.getMemoryEntries() : store.getUserEntries();
|
|
37
|
+
const entries = entriesForTarget(store, target);
|
|
24
38
|
const currentContent = entries.join(ENTRY_DELIMITER);
|
|
25
39
|
|
|
26
40
|
const prompt = [
|
|
27
41
|
CONSOLIDATION_PROMPT,
|
|
28
42
|
"",
|
|
29
|
-
`--- Current ${target
|
|
43
|
+
`--- Current ${labelForTarget(target, toolTarget)} Entries ---`,
|
|
30
44
|
currentContent || "(empty)",
|
|
31
45
|
"",
|
|
32
|
-
`Use the memory tool to consolidate. Target: '${
|
|
46
|
+
`Use the memory tool to consolidate. Target: '${toolTarget}'`,
|
|
33
47
|
].join("\n");
|
|
34
48
|
|
|
35
49
|
try {
|
|
@@ -60,30 +74,47 @@ export function registerConsolidateCommand(
|
|
|
60
74
|
pi: ExtensionAPI,
|
|
61
75
|
store: MemoryStore,
|
|
62
76
|
timeoutMs: number = 60000,
|
|
77
|
+
projectStore: MemoryStore | null = null,
|
|
78
|
+
projectName?: string | null,
|
|
63
79
|
): void {
|
|
64
80
|
pi.registerCommand("memory-consolidate", {
|
|
65
81
|
description: "Manually trigger memory consolidation to free up space",
|
|
66
82
|
handler: async (_args, ctx) => {
|
|
67
83
|
const results: string[] = [];
|
|
84
|
+
const targets: Array<{
|
|
85
|
+
label: string;
|
|
86
|
+
store: MemoryStore;
|
|
87
|
+
target: MemoryTarget;
|
|
88
|
+
toolTarget: ToolMemoryTarget;
|
|
89
|
+
}> = [
|
|
90
|
+
{ label: "memory", store, target: "memory", toolTarget: "memory" },
|
|
91
|
+
{ label: "user", store, target: "user", toolTarget: "user" },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
if (projectStore) {
|
|
95
|
+
targets.push({
|
|
96
|
+
label: projectName ? `project:${projectName}` : "project",
|
|
97
|
+
store: projectStore,
|
|
98
|
+
target: "memory",
|
|
99
|
+
toolTarget: "project",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
68
102
|
|
|
69
|
-
for (const
|
|
70
|
-
const entries =
|
|
71
|
-
target === "memory"
|
|
72
|
-
? store.getMemoryEntries()
|
|
73
|
-
: store.getUserEntries();
|
|
103
|
+
for (const item of targets) {
|
|
104
|
+
const entries = entriesForTarget(item.store, item.target);
|
|
74
105
|
|
|
75
106
|
if (entries.length === 0) {
|
|
76
|
-
results.push(`${
|
|
107
|
+
results.push(`${item.label}: (empty, nothing to consolidate)`);
|
|
77
108
|
continue;
|
|
78
109
|
}
|
|
79
110
|
|
|
80
|
-
const result = await triggerConsolidation(pi, store, target, ctx.signal, timeoutMs);
|
|
111
|
+
const result = await triggerConsolidation(pi, item.store, item.target, ctx.signal, timeoutMs, item.toolTarget);
|
|
81
112
|
|
|
82
113
|
if (result.consolidated) {
|
|
83
|
-
await store.loadFromDisk();
|
|
84
|
-
results.push(`${
|
|
114
|
+
await item.store.loadFromDisk();
|
|
115
|
+
results.push(`${item.label}: ✅ consolidated`);
|
|
85
116
|
} else {
|
|
86
|
-
results.push(`${
|
|
117
|
+
results.push(`${item.label}: ❌ ${result.error}`);
|
|
87
118
|
}
|
|
88
119
|
}
|
|
89
120
|
|
package/src/index.ts
CHANGED
|
@@ -177,11 +177,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
177
177
|
// ── 6. Setup session-end flush ──
|
|
178
178
|
setupSessionFlush(pi, store, projectStore, config);
|
|
179
179
|
|
|
180
|
-
// ── 7. Setup auto-consolidation (inject consolidator into
|
|
180
|
+
// ── 7. Setup auto-consolidation (inject consolidator into stores) ──
|
|
181
181
|
store.setConsolidator(async (target, signal) => {
|
|
182
182
|
return triggerConsolidation(pi, store, target, signal, config.consolidationTimeoutMs);
|
|
183
183
|
});
|
|
184
|
-
|
|
184
|
+
if (projectStore) {
|
|
185
|
+
projectStore.setConsolidator(async (target, signal) => {
|
|
186
|
+
const toolTarget = target === "memory" ? "project" : target;
|
|
187
|
+
return triggerConsolidation(pi, projectStore, target, signal, config.consolidationTimeoutMs, toolTarget);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
registerConsolidateCommand(pi, store, config.consolidationTimeoutMs, projectStore, projectName);
|
|
185
191
|
|
|
186
192
|
// ── 8. Setup correction detection ──
|
|
187
193
|
setupCorrectionDetector(pi, store, projectStore, config, dbManager, projectName);
|
package/src/store/db.ts
CHANGED
|
@@ -106,6 +106,8 @@ export class DatabaseManager {
|
|
|
106
106
|
|
|
107
107
|
// Enable WAL mode + FK enforcement for each connection.
|
|
108
108
|
db.exec('PRAGMA journal_mode = WAL');
|
|
109
|
+
db.exec('PRAGMA wal_autocheckpoint = 100');
|
|
110
|
+
db.exec('PRAGMA journal_size_limit = 5242880');
|
|
109
111
|
db.exec('PRAGMA foreign_keys = ON');
|
|
110
112
|
|
|
111
113
|
// Create tables and triggers
|
|
@@ -251,6 +253,7 @@ export class DatabaseManager {
|
|
|
251
253
|
*/
|
|
252
254
|
close(): void {
|
|
253
255
|
if (this.db) {
|
|
256
|
+
try { this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* best effort */ }
|
|
254
257
|
this.db.close();
|
|
255
258
|
this.db = null;
|
|
256
259
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const FTS5_OPERATOR_PATTERN = /\b(OR|AND|NOT|NEAR)\b/;
|
|
2
|
+
const FTS5_TOKEN_PATTERN = /"([^"]*)"|(\S+)/g;
|
|
3
|
+
const NATURAL_LANGUAGE_CONNECTORS = new Set(['and', 'or', 'not', 'near']);
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalize natural-language search input into an FTS5 query.
|
|
7
|
+
* Plain terms become individually quoted for implicit AND matching.
|
|
8
|
+
* Explicit quoted phrases are preserved, connector stopwords are ignored in
|
|
9
|
+
* natural-language mode, and raw uppercase FTS5 operators pass through.
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeFts5Query(query: string): string {
|
|
12
|
+
const trimmed = query.trim();
|
|
13
|
+
if (trimmed.length === 0) return '';
|
|
14
|
+
|
|
15
|
+
if (FTS5_OPERATOR_PATTERN.test(trimmed)) {
|
|
16
|
+
return trimmed;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const normalizedTerms: string[] = [];
|
|
20
|
+
for (const match of trimmed.matchAll(FTS5_TOKEN_PATTERN)) {
|
|
21
|
+
const phrase = match[1];
|
|
22
|
+
const term = match[2];
|
|
23
|
+
if (phrase === undefined && term && NATURAL_LANGUAGE_CONNECTORS.has(term.toLowerCase())) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const rawValue = phrase ?? term ?? '';
|
|
28
|
+
normalizedTerms.push(`"${rawValue.replace(/"/g, '""')}"`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return normalizedTerms.join(' ');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isFts5QueryError(err: unknown): boolean {
|
|
35
|
+
if (!(err instanceof Error)) return false;
|
|
36
|
+
const msg = err.message.toLowerCase();
|
|
37
|
+
return msg.includes('fts5') || msg.includes('unterminated string');
|
|
38
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function normalizeMemoryLookupText(text: string): string {
|
|
2
|
+
let normalized = text.trim();
|
|
3
|
+
if (!normalized) return "";
|
|
4
|
+
|
|
5
|
+
const firstNonEmptyLine = normalized
|
|
6
|
+
.split(/\r?\n/)
|
|
7
|
+
.map((line) => line.trim())
|
|
8
|
+
.find((line) => line.length > 0);
|
|
9
|
+
if (firstNonEmptyLine) normalized = firstNonEmptyLine;
|
|
10
|
+
|
|
11
|
+
normalized = normalized.replace(/^\S+\s+\[[^\]]+\]\s+/u, "");
|
|
12
|
+
normalized = normalized.replace(/^(\[[^\]]+\])\s+\1(\s+|$)/, "$1 ");
|
|
13
|
+
|
|
14
|
+
return normalized.trim();
|
|
15
|
+
}
|
|
@@ -15,6 +15,7 @@ import * as fs from "node:fs/promises";
|
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import * as os from "node:os";
|
|
17
17
|
import { scanContent } from "./content-scanner.js";
|
|
18
|
+
import { normalizeMemoryLookupText } from "./memory-lookup.js";
|
|
18
19
|
import {
|
|
19
20
|
ENTRY_DELIMITER,
|
|
20
21
|
DEFAULT_MEMORY_CHAR_LIMIT,
|
|
@@ -117,32 +118,8 @@ export class MemoryStore {
|
|
|
117
118
|
correctedTo?: string;
|
|
118
119
|
project?: string;
|
|
119
120
|
}): Promise<MemoryResult> {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const scanError = scanContent(content);
|
|
124
|
-
if (scanError) return { success: false, error: scanError };
|
|
125
|
-
|
|
126
|
-
const categoryTag = "[" + options.category + "]";
|
|
127
|
-
const parts = [categoryTag + " " + content];
|
|
128
|
-
if (options.failureReason) parts.push("Failed: " + options.failureReason);
|
|
129
|
-
if (options.toolState) parts.push("Tool state: " + options.toolState);
|
|
130
|
-
if (options.correctedTo) parts.push("Corrected to: " + options.correctedTo);
|
|
131
|
-
if (options.project) parts.push("Project: " + options.project);
|
|
132
|
-
|
|
133
|
-
const failureText = parts.join(" — ");
|
|
134
|
-
const today = new Date().toISOString().split("T")[0];
|
|
135
|
-
const encoded = this.encodeEntry(failureText, today, today);
|
|
136
|
-
|
|
137
|
-
this.failureEntries.push(encoded);
|
|
138
|
-
await this.saveToDisk("failure");
|
|
139
|
-
|
|
140
|
-
return {
|
|
141
|
-
success: true,
|
|
142
|
-
target: "failure",
|
|
143
|
-
message: "Failure memory saved: " + options.category,
|
|
144
|
-
entry_count: this.failureEntries.length,
|
|
145
|
-
};
|
|
121
|
+
const failureText = this.buildFailureMemoryText(content, options);
|
|
122
|
+
return this._add("failure", failureText, undefined, 1, "Failure memory saved: " + options.category);
|
|
146
123
|
}
|
|
147
124
|
|
|
148
125
|
getFailureEntries(maxAgeDays = 7): string[] {
|
|
@@ -158,7 +135,13 @@ export class MemoryStore {
|
|
|
158
135
|
.map((entry) => this.stripMetadata(entry));
|
|
159
136
|
}
|
|
160
137
|
|
|
161
|
-
private async _add(
|
|
138
|
+
private async _add(
|
|
139
|
+
target: "memory" | "user" | "failure",
|
|
140
|
+
content: string,
|
|
141
|
+
signal?: AbortSignal,
|
|
142
|
+
_retriesLeft = 1,
|
|
143
|
+
addedMessage = "Entry added.",
|
|
144
|
+
): Promise<MemoryResult> {
|
|
162
145
|
content = content.trim();
|
|
163
146
|
if (!content) return { success: false, error: "Content cannot be empty." };
|
|
164
147
|
|
|
@@ -194,7 +177,7 @@ export class MemoryStore {
|
|
|
194
177
|
// CRITICAL: reload from disk — child process modified files, our arrays are stale
|
|
195
178
|
await this.loadFromDisk();
|
|
196
179
|
// Retry the add exactly once (retriesLeft = 0 means no more consolidation)
|
|
197
|
-
return this._add(target, content, signal, _retriesLeft - 1);
|
|
180
|
+
return this._add(target, content, signal, _retriesLeft - 1, addedMessage);
|
|
198
181
|
}
|
|
199
182
|
} catch {
|
|
200
183
|
// Consolidation failed — fall through to error
|
|
@@ -207,7 +190,7 @@ export class MemoryStore {
|
|
|
207
190
|
this.setEntries(target, entries);
|
|
208
191
|
await this.saveToDisk(target);
|
|
209
192
|
|
|
210
|
-
return this.successResponse(target,
|
|
193
|
+
return this.successResponse(target, addedMessage);
|
|
211
194
|
}
|
|
212
195
|
|
|
213
196
|
private async fifoEvictAndAdd(
|
|
@@ -253,7 +236,7 @@ export class MemoryStore {
|
|
|
253
236
|
}
|
|
254
237
|
|
|
255
238
|
async replace(target: "memory" | "user" | "failure", oldText: string, newContent: string): Promise<MemoryResult> {
|
|
256
|
-
oldText = oldText
|
|
239
|
+
oldText = normalizeMemoryLookupText(oldText);
|
|
257
240
|
newContent = newContent.trim();
|
|
258
241
|
if (!oldText) return { success: false, error: "old_text cannot be empty." };
|
|
259
242
|
if (!newContent) return { success: false, error: "new_content cannot be empty. Use 'remove' to delete entries." };
|
|
@@ -299,18 +282,18 @@ export class MemoryStore {
|
|
|
299
282
|
}
|
|
300
283
|
|
|
301
284
|
async remove(target: "memory" | "user" | "failure", oldText: string): Promise<MemoryResult> {
|
|
302
|
-
oldText = oldText
|
|
285
|
+
oldText = normalizeMemoryLookupText(oldText);
|
|
303
286
|
if (!oldText) return { success: false, error: "old_text cannot be empty." };
|
|
304
287
|
|
|
305
288
|
const entries = this.entriesFor(target);
|
|
306
|
-
const matches = entries.filter((e) => e.includes(oldText));
|
|
289
|
+
const matches = entries.filter((e) => this.stripMetadata(e).includes(oldText));
|
|
307
290
|
|
|
308
291
|
if (matches.length === 0) return { success: false, error: `No entry matched '${oldText}'.` };
|
|
309
292
|
if (matches.length > 1 && new Set(matches).size > 1) {
|
|
310
293
|
return {
|
|
311
294
|
success: false,
|
|
312
295
|
error: `Multiple entries matched '${oldText}'. Be more specific.`,
|
|
313
|
-
matches: matches.map((e) => e.slice(0, 80) + (e.length > 80 ? "..." : "")),
|
|
296
|
+
matches: matches.map((e) => this.stripMetadata(e).slice(0, 80) + (this.stripMetadata(e).length > 80 ? "..." : "")),
|
|
314
297
|
};
|
|
315
298
|
}
|
|
316
299
|
|
|
@@ -392,6 +375,23 @@ export class MemoryStore {
|
|
|
392
375
|
return this.decodeEntry(text).text;
|
|
393
376
|
}
|
|
394
377
|
|
|
378
|
+
private buildFailureMemoryText(content: string, options: {
|
|
379
|
+
category: MemoryCategory;
|
|
380
|
+
failureReason?: string;
|
|
381
|
+
toolState?: string;
|
|
382
|
+
correctedTo?: string;
|
|
383
|
+
project?: string;
|
|
384
|
+
}): string {
|
|
385
|
+
const trimmedContent = content.trim();
|
|
386
|
+
const categoryTag = "[" + options.category + "]";
|
|
387
|
+
const parts = [categoryTag + " " + trimmedContent];
|
|
388
|
+
if (options.failureReason) parts.push("Failed: " + options.failureReason);
|
|
389
|
+
if (options.toolState) parts.push("Tool state: " + options.toolState);
|
|
390
|
+
if (options.correctedTo) parts.push("Corrected to: " + options.correctedTo);
|
|
391
|
+
if (options.project) parts.push("Project: " + options.project);
|
|
392
|
+
return parts.join(" — ");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
395
|
private successResponse(target: "memory" | "user" | "failure", message?: string): MemoryResult {
|
|
396
396
|
const entries = this.entriesFor(target);
|
|
397
397
|
const current = this.charCount(target);
|
|
@@ -401,7 +401,6 @@ export class MemoryStore {
|
|
|
401
401
|
const resp: MemoryResult = {
|
|
402
402
|
success: true,
|
|
403
403
|
target,
|
|
404
|
-
entries,
|
|
405
404
|
usage: `${pct}% — ${current}/${limit} chars`,
|
|
406
405
|
entry_count: entries.length,
|
|
407
406
|
};
|
|
@@ -1,17 +1,5 @@
|
|
|
1
1
|
import { DatabaseManager } from './db.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Escape a string for FTS5 query syntax.
|
|
5
|
-
* Wraps the query in double quotes to treat it as a literal phrase.
|
|
6
|
-
*/
|
|
7
|
-
function escapeFts5Query(query: string): string {
|
|
8
|
-
// If the query already contains FTS5 operators (OR, AND, NOT, NEAR), leave it as-is
|
|
9
|
-
if (/\b(OR|AND|NOT|NEAR)\b/.test(query)) {
|
|
10
|
-
return query;
|
|
11
|
-
}
|
|
12
|
-
// Otherwise, wrap in double quotes to treat as literal phrase
|
|
13
|
-
return `"${query.replace(/"/g, '""')}"`;
|
|
14
|
-
}
|
|
2
|
+
import { isFts5QueryError, normalizeFts5Query } from './fts-query.js';
|
|
15
3
|
|
|
16
4
|
/**
|
|
17
5
|
* Search result from session history.
|
|
@@ -52,6 +40,10 @@ export function searchSessions(
|
|
|
52
40
|
query: string,
|
|
53
41
|
options: SessionSearchOptions = {}
|
|
54
42
|
): SessionSearchResult[] {
|
|
43
|
+
if (query.trim().length === 0) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
55
47
|
const db = dbManager.getDb();
|
|
56
48
|
const { limit = 10, project, role, since } = options;
|
|
57
49
|
|
|
@@ -60,8 +52,12 @@ export function searchSessions(
|
|
|
60
52
|
const params: unknown[] = [];
|
|
61
53
|
|
|
62
54
|
// FTS5 match condition — use subquery for reliable rowid matching
|
|
55
|
+
const normalizedQuery = normalizeFts5Query(query);
|
|
56
|
+
if (normalizedQuery.length === 0) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
63
59
|
conditions.push('m.rowid IN (SELECT rowid FROM message_fts WHERE message_fts MATCH ?)');
|
|
64
|
-
params.push(
|
|
60
|
+
params.push(normalizedQuery);
|
|
65
61
|
|
|
66
62
|
// Project filter
|
|
67
63
|
if (project) {
|
|
@@ -119,8 +115,10 @@ export function searchSessions(
|
|
|
119
115
|
snippet: row.snippet,
|
|
120
116
|
}));
|
|
121
117
|
} catch (err) {
|
|
122
|
-
|
|
123
|
-
|
|
118
|
+
if (isFts5QueryError(err)) {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
throw err;
|
|
124
122
|
}
|
|
125
123
|
}
|
|
126
124
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { DatabaseManager } from './db.js';
|
|
2
|
+
import { isFts5QueryError, normalizeFts5Query } from './fts-query.js';
|
|
3
|
+
import { normalizeMemoryLookupText } from './memory-lookup.js';
|
|
2
4
|
import type { MemoryCategory } from '../types.js';
|
|
3
5
|
|
|
4
6
|
const MEMORY_SELECT_COLUMNS = `
|
|
@@ -420,10 +422,12 @@ export function replaceSyncedMemories(
|
|
|
420
422
|
},
|
|
421
423
|
): SqliteMemoryUpdateResult {
|
|
422
424
|
const db = dbManager.getDb();
|
|
425
|
+
const normalizedOldText = normalizeMemoryLookupText(oldText);
|
|
426
|
+
if (!normalizedOldText) return { matched: 0, updated: 0, entries: [] };
|
|
423
427
|
const params: unknown[] = [];
|
|
424
428
|
const conditions = buildScopeConditions(params, updates.target, updates.project ?? undefined);
|
|
425
429
|
conditions.push(`content LIKE ? ESCAPE '\\'`);
|
|
426
|
-
params.push(`%${escapeLikePattern(
|
|
430
|
+
params.push(`%${escapeLikePattern(normalizedOldText)}%`);
|
|
427
431
|
|
|
428
432
|
const rows = db.prepare(`
|
|
429
433
|
SELECT ${MEMORY_SELECT_COLUMNS}
|
|
@@ -489,10 +493,12 @@ export function removeSyncedMemories(
|
|
|
489
493
|
options: SqliteMemoryRemoveOptions,
|
|
490
494
|
): SqliteMemoryRemoveResult {
|
|
491
495
|
const db = dbManager.getDb();
|
|
496
|
+
const normalizedOldText = normalizeMemoryLookupText(oldText);
|
|
497
|
+
if (!normalizedOldText) return { matched: 0, removed: 0 };
|
|
492
498
|
const params: unknown[] = [];
|
|
493
499
|
const conditions = buildScopeConditions(params, options.target, options.project ?? undefined);
|
|
494
500
|
conditions.push(`content LIKE ? ESCAPE '\\'`);
|
|
495
|
-
params.push(`%${escapeLikePattern(
|
|
501
|
+
params.push(`%${escapeLikePattern(normalizedOldText)}%`);
|
|
496
502
|
|
|
497
503
|
const matchingIds = db.prepare(`
|
|
498
504
|
SELECT id
|
|
@@ -550,19 +556,6 @@ export function removeExactSyncedMemories(
|
|
|
550
556
|
};
|
|
551
557
|
}
|
|
552
558
|
|
|
553
|
-
/**
|
|
554
|
-
* Escape a string for FTS5 query syntax.
|
|
555
|
-
* Wraps the query in double quotes to treat it as a literal phrase.
|
|
556
|
-
*/
|
|
557
|
-
function escapeFts5Query(query: string): string {
|
|
558
|
-
// If the query already contains FTS5 operators (OR, AND, NOT, NEAR), leave it as-is
|
|
559
|
-
if (/\b(OR|AND|NOT|NEAR)\b/.test(query)) {
|
|
560
|
-
return query;
|
|
561
|
-
}
|
|
562
|
-
// Otherwise, wrap in double quotes to treat as literal phrase
|
|
563
|
-
return `"${query.replace(/"/g, '""')}"`;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
559
|
/**
|
|
567
560
|
* Search memories using FTS5.
|
|
568
561
|
*/
|
|
@@ -571,6 +564,10 @@ export function searchMemories(
|
|
|
571
564
|
query: string,
|
|
572
565
|
options: { project?: string; target?: string; category?: MemoryCategory; limit?: number } = {}
|
|
573
566
|
): SqliteMemoryEntry[] {
|
|
567
|
+
if (query.trim().length === 0) {
|
|
568
|
+
return [];
|
|
569
|
+
}
|
|
570
|
+
|
|
574
571
|
const db = dbManager.getDb();
|
|
575
572
|
const { project, target, category, limit = 10 } = options;
|
|
576
573
|
|
|
@@ -578,8 +575,12 @@ export function searchMemories(
|
|
|
578
575
|
const params: unknown[] = [];
|
|
579
576
|
|
|
580
577
|
// FTS5 match via subquery with escaped query
|
|
578
|
+
const normalizedQuery = normalizeFts5Query(query);
|
|
579
|
+
if (normalizedQuery.length === 0) {
|
|
580
|
+
return [];
|
|
581
|
+
}
|
|
581
582
|
conditions.push('m.id IN (SELECT rowid FROM memory_fts WHERE memory_fts MATCH ?)');
|
|
582
|
-
params.push(
|
|
583
|
+
params.push(normalizedQuery);
|
|
583
584
|
|
|
584
585
|
if (project !== undefined) {
|
|
585
586
|
if (project === null) {
|
|
@@ -611,20 +612,27 @@ export function searchMemories(
|
|
|
611
612
|
`;
|
|
612
613
|
params.push(limit);
|
|
613
614
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
615
|
+
try {
|
|
616
|
+
const rows = db.prepare(sql).all(...params) as Array<{
|
|
617
|
+
id: number;
|
|
618
|
+
project: string | null;
|
|
619
|
+
target: string;
|
|
620
|
+
category: string | null;
|
|
621
|
+
content: string;
|
|
622
|
+
failure_reason: string | null;
|
|
623
|
+
tool_state: string | null;
|
|
624
|
+
corrected_to: string | null;
|
|
625
|
+
created: string;
|
|
626
|
+
last_referenced: string;
|
|
627
|
+
}>;
|
|
628
|
+
|
|
629
|
+
return rows.map(mapRow);
|
|
630
|
+
} catch (err) {
|
|
631
|
+
if (isFts5QueryError(err)) {
|
|
632
|
+
return [];
|
|
633
|
+
}
|
|
634
|
+
throw err;
|
|
635
|
+
}
|
|
628
636
|
}
|
|
629
637
|
|
|
630
638
|
/**
|