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 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.11",
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: "memory" | "user" | "failure",
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 === "user" ? "User Profile" : "Memory"} Entries ---`,
43
+ `--- Current ${labelForTarget(target, toolTarget)} Entries ---`,
30
44
  currentContent || "(empty)",
31
45
  "",
32
- `Use the memory tool to consolidate. Target: '${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 target of ["memory", "user"] as 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(`${target}: (empty, nothing to consolidate)`);
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(`${target}: ✅ consolidated`);
114
+ await item.store.loadFromDisk();
115
+ results.push(`${item.label}: ✅ consolidated`);
85
116
  } else {
86
- results.push(`${target}: ❌ ${result.error}`);
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 store) ──
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
- registerConsolidateCommand(pi, store, config.consolidationTimeoutMs);
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
- content = content.trim();
121
- if (!content) return { success: false, error: "Content cannot be empty." };
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(target: "memory" | "user" | "failure", content: string, signal?: AbortSignal, _retriesLeft = 1): Promise<MemoryResult> {
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, "Entry added.");
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.trim();
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.trim();
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(escapeFts5Query(query));
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
- // FTS5 can throw on malformed queries — return empty results
123
- return [];
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(oldText)}%`);
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(oldText)}%`);
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(escapeFts5Query(query));
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
- const rows = db.prepare(sql).all(...params) as Array<{
615
- id: number;
616
- project: string | null;
617
- target: string;
618
- category: string | null;
619
- content: string;
620
- failure_reason: string | null;
621
- tool_state: string | null;
622
- corrected_to: string | null;
623
- created: string;
624
- last_referenced: string;
625
- }>;
626
-
627
- return rows.map(mapRow);
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
  /**