pi-hermes-memory 0.7.12 → 0.7.14

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
@@ -424,6 +424,8 @@ Create `~/.pi/agent/hermes-memory-config.json`:
424
424
  "memoryDir": "~/.pi/agent/pi-hermes-memory",
425
425
  "projectsMemoryDir": "projects-memory",
426
426
  "sessionSearch": { "variant": "legacy" },
427
+ "llmModelOverride": "openrouter/deepseek/deepseek-v4-flash",
428
+ "llmThinkingOverride": "off",
427
429
  "nudgeInterval": 10,
428
430
  "nudgeToolCalls": 15,
429
431
  "reviewRecentMessages": 0,
@@ -453,6 +455,8 @@ Create `~/.pi/agent/hermes-memory-config.json`:
453
455
  | `memoryDir` | `~/.pi/agent/pi-hermes-memory` | Custom directory for extension storage files |
454
456
  | `projectsMemoryDir` | `projects-memory` | Subdirectory under `~/.pi/agent/` for project-scoped memory |
455
457
  | `sessionSearch` | `{ "variant": "legacy" }` | Session search implementation: `legacy` keeps the existing SQLite/FTS snippet search; `anchors` uses the opt-in Markdown request surface and returns compact JSONL line-range anchors from `~/.pi/agent/sessions/` |
458
+ | `llmModelOverride` | unset | Optional model override for child `pi -p` subprocess calls used by background review, correction save, session flush, and consolidation |
459
+ | `llmThinkingOverride` | unset | Optional thinking override for those child subprocess calls; valid values are `off`, `minimal`, `low`, `medium`, `high`, and `xhigh`. If `llmModelOverride` is set and this is omitted, child calls default to `off` |
456
460
  | `nudgeInterval` | `10` | Turns between auto-reviews |
457
461
  | `nudgeToolCalls` | `15` | Tool calls between auto-reviews (OR with turns) |
458
462
  | `reviewRecentMessages` | `0` | Recent messages included in background review (`0` = all) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hermes-memory",
3
- "version": "0.7.12",
3
+ "version": "0.7.14",
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",
package/src/config.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import * as os from "node:os";
4
- import type { MemoryConfig, MemoryOverflowStrategy, SessionSearchVariant } from "./types.js";
4
+ import type { MemoryConfig, MemoryOverflowStrategy, SessionSearchVariant, ThinkingLevel } from "./types.js";
5
5
  import {
6
6
  DEFAULT_MEMORY_CHAR_LIMIT,
7
7
  DEFAULT_USER_CHAR_LIMIT,
@@ -20,6 +20,7 @@ import { normalizeConfiguredMemoryDir, normalizeProjectsMemoryDir } from "./path
20
20
 
21
21
  const MEMORY_OVERFLOW_STRATEGIES: readonly MemoryOverflowStrategy[] = ["auto-consolidate", "reject", "fifo-evict"];
22
22
  const SESSION_SEARCH_VARIANTS: readonly SessionSearchVariant[] = ["legacy", "anchors"];
23
+ const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
23
24
 
24
25
  function isMemoryOverflowStrategy(value: unknown): value is MemoryOverflowStrategy {
25
26
  return typeof value === "string" && MEMORY_OVERFLOW_STRATEGIES.includes(value as MemoryOverflowStrategy);
@@ -29,6 +30,10 @@ function isSessionSearchVariant(value: unknown): value is SessionSearchVariant {
29
30
  return typeof value === "string" && SESSION_SEARCH_VARIANTS.includes(value as SessionSearchVariant);
30
31
  }
31
32
 
33
+ function isThinkingLevel(value: unknown): value is ThinkingLevel {
34
+ return typeof value === "string" && THINKING_LEVELS.includes(value as ThinkingLevel);
35
+ }
36
+
32
37
  const DEFAULT_CONFIG: MemoryConfig = {
33
38
  memoryMode: "policy-only",
34
39
  memoryPolicyStyle: "full",
@@ -127,6 +132,11 @@ export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig {
127
132
  ) {
128
133
  config.sessionSearch = { variant: parsed.sessionSearch.variant };
129
134
  }
135
+ if (typeof parsed.llmModelOverride === "string") {
136
+ const trimmed = parsed.llmModelOverride.trim();
137
+ if (trimmed.length > 0) config.llmModelOverride = trimmed;
138
+ }
139
+ if (isThinkingLevel(parsed.llmThinkingOverride)) config.llmThinkingOverride = parsed.llmThinkingOverride;
130
140
  if (hasMemoryOverflowStrategy) {
131
141
  config.autoConsolidate = config.memoryOverflowStrategy === "auto-consolidate";
132
142
  } else if (hasLegacyAutoConsolidate) {
@@ -10,7 +10,8 @@
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
  import { MemoryStore } from "../store/memory-store.js";
12
12
  import { CONSOLIDATION_PROMPT, ENTRY_DELIMITER } from "../constants.js";
13
- import type { ConsolidationResult } from "../types.js";
13
+ import type { ConsolidationResult, MemoryConfig } from "../types.js";
14
+ import { execChildPrompt } from "./pi-child-process.js";
14
15
 
15
16
  type MemoryTarget = "memory" | "user" | "failure";
16
17
  type ToolMemoryTarget = MemoryTarget | "project";
@@ -26,6 +27,20 @@ function labelForTarget(target: MemoryTarget, toolTarget: ToolMemoryTarget): str
26
27
  return "Memory";
27
28
  }
28
29
 
30
+ function describeConsolidationFailure(
31
+ result: { code: number; stderr?: string; killed?: boolean },
32
+ timeoutMs: number,
33
+ ): string {
34
+ const stderr = result.stderr?.trim();
35
+ const terminated = result.killed || result.code === 143;
36
+
37
+ if (terminated) {
38
+ return `Consolidation subprocess was terminated (likely timeout or cancellation). Timeout: ${timeoutMs}ms. Consider increasing consolidationTimeoutMs if this is a manual run.`;
39
+ }
40
+
41
+ return `Consolidation process exited with code ${result.code}: ${stderr?.slice(0, 200) || "unknown error"}`;
42
+ }
43
+
29
44
  export async function triggerConsolidation(
30
45
  pi: ExtensionAPI,
31
46
  store: MemoryStore,
@@ -33,6 +48,7 @@ export async function triggerConsolidation(
33
48
  signal?: AbortSignal,
34
49
  timeoutMs: number = 60000,
35
50
  toolTarget: ToolMemoryTarget = target,
51
+ llmConfig: Pick<MemoryConfig, "llmModelOverride" | "llmThinkingOverride"> = {},
36
52
  ): Promise<ConsolidationResult> {
37
53
  const entries = entriesForTarget(store, target);
38
54
  const currentContent = entries.join(ENTRY_DELIMITER);
@@ -47,17 +63,18 @@ export async function triggerConsolidation(
47
63
  ].join("\n");
48
64
 
49
65
  try {
50
- const result = await pi.exec("pi", ["-p", "--no-session", prompt], {
66
+ const result = await execChildPrompt(pi, prompt, llmConfig, {
51
67
  signal,
52
- timeout: timeoutMs,
53
- });
68
+ timeoutMs,
69
+ retryWithoutOverrides: true,
70
+ }) as { code: number; stdout?: string; stderr?: string; killed?: boolean };
54
71
 
55
72
  if (result.code === 0) {
56
73
  return { consolidated: true };
57
74
  }
58
75
  return {
59
76
  consolidated: false,
60
- error: `Consolidation process exited with code ${result.code}: ${result.stderr?.slice(0, 200) || "unknown error"}`,
77
+ error: describeConsolidationFailure(result, timeoutMs),
61
78
  };
62
79
  } catch (err) {
63
80
  return {
@@ -76,10 +93,12 @@ export function registerConsolidateCommand(
76
93
  timeoutMs: number = 60000,
77
94
  projectStore: MemoryStore | null = null,
78
95
  projectName?: string | null,
96
+ llmConfig: Pick<MemoryConfig, "llmModelOverride" | "llmThinkingOverride"> = {},
79
97
  ): void {
80
98
  pi.registerCommand("memory-consolidate", {
81
99
  description: "Manually trigger memory consolidation to free up space",
82
100
  handler: async (_args, ctx) => {
101
+ const manualTimeoutMs = Math.max(timeoutMs, 180000);
83
102
  const results: string[] = [];
84
103
  const targets: Array<{
85
104
  label: string;
@@ -100,6 +119,16 @@ export function registerConsolidateCommand(
100
119
  });
101
120
  }
102
121
 
122
+ try {
123
+ ctx.ui.notify(
124
+ `🔄 Starting memory consolidation for ${targets.length} target${targets.length === 1 ? "" : "s"}...`,
125
+ "info",
126
+ );
127
+ } catch {
128
+ // Best-effort only. If the command context is already stale, continue
129
+ // with the consolidation work rather than failing before it starts.
130
+ }
131
+
103
132
  for (const item of targets) {
104
133
  const entries = entriesForTarget(item.store, item.target);
105
134
 
@@ -108,7 +137,24 @@ export function registerConsolidateCommand(
108
137
  continue;
109
138
  }
110
139
 
111
- const result = await triggerConsolidation(pi, item.store, item.target, ctx.signal, timeoutMs, item.toolTarget);
140
+ try {
141
+ ctx.ui.notify(
142
+ `⏳ Consolidating ${item.label}...`,
143
+ "info",
144
+ );
145
+ } catch {
146
+ // Best-effort progress feedback only.
147
+ }
148
+
149
+ const result = await triggerConsolidation(
150
+ pi,
151
+ item.store,
152
+ item.target,
153
+ ctx.signal,
154
+ manualTimeoutMs,
155
+ item.toolTarget,
156
+ llmConfig,
157
+ );
112
158
 
113
159
  if (result.consolidated) {
114
160
  await item.store.loadFromDisk();
@@ -118,10 +164,16 @@ export function registerConsolidateCommand(
118
164
  }
119
165
  }
120
166
 
121
- ctx.ui.notify(
122
- `\n 🔄 Memory Consolidation\n ${"─".repeat(30)}\n${results.map((r) => ` ${r}`).join("\n")}`,
123
- "info",
124
- );
167
+ const summary = `\n 🔄 Memory Consolidation\n ${"─".repeat(30)}\n${results.map((r) => ` ${r}`).join("\n")}`;
168
+
169
+ try {
170
+ ctx.ui.notify(summary, "info");
171
+ } catch {
172
+ // Child consolidation can indirectly trigger a runtime reload/session
173
+ // replacement. If that happens, the original command ctx is stale by
174
+ // the time we reach the final summary, so the command should exit
175
+ // quietly instead of surfacing a stale-ctx error.
176
+ }
125
177
  },
126
178
  });
127
179
  }
@@ -12,6 +12,7 @@ import { MemoryStore } from "../store/memory-store.js";
12
12
  import { COMBINED_REVIEW_PROMPT } from "../constants.js";
13
13
  import type { MemoryConfig } from "../types.js";
14
14
  import { applyRecentMessageLimit, collectMessageParts } from "./message-parts.js";
15
+ import { execChildPrompt } from "./pi-child-process.js";
15
16
 
16
17
  export function setupBackgroundReview(
17
18
  pi: ExtensionAPI,
@@ -116,9 +117,9 @@ export function setupBackgroundReview(
116
117
  // We intentionally omit ctx.signal — the signal is tied to the turn
117
118
  // lifetime and would abort the subprocess before it finishes now that
118
119
  // we're not awaiting. The timeout (120s) provides its own safety net.
119
- const reviewPromise = pi.exec("pi", ["-p", "--no-session", reviewPrompt.join("\n")], {
120
+ const reviewPromise = execChildPrompt(pi, reviewPrompt.join("\n"), config, {
120
121
  signal: undefined,
121
- timeout: 120000,
122
+ timeoutMs: 120000,
122
123
  });
123
124
 
124
125
  reviewPromise
@@ -25,6 +25,7 @@ import {
25
25
  } from "../constants.js";
26
26
  import type { MemoryConfig } from "../types.js";
27
27
  import { getMessageText } from "../types.js";
28
+ import { execChildPrompt } from "./pi-child-process.js";
28
29
 
29
30
  /**
30
31
  * Extract the directive part from a correction message.
@@ -205,9 +206,9 @@ export function setupCorrectionDetector(
205
206
  recentParts.join("\n\n"),
206
207
  );
207
208
 
208
- const result = await pi.exec("pi", ["-p", "--no-session", prompt.join("\n")], {
209
+ const result = await execChildPrompt(pi, prompt.join("\n"), config, {
209
210
  signal: ctx.signal,
210
- timeout: 30000,
211
+ timeoutMs: 30000,
211
212
  });
212
213
 
213
214
  if (result.code === 0 && result.stdout) {
@@ -0,0 +1,119 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { MemoryConfig, ThinkingLevel } from "../types.js";
3
+
4
+ type ChildLlmConfig = Pick<MemoryConfig, "llmModelOverride" | "llmThinkingOverride">;
5
+
6
+ interface PiExecResult {
7
+ code: number;
8
+ stdout?: string;
9
+ stderr?: string;
10
+ }
11
+
12
+ interface ExecChildPromptOptions {
13
+ signal?: AbortSignal;
14
+ timeoutMs: number;
15
+ retryWithoutOverrides?: boolean;
16
+ }
17
+
18
+ const OVERRIDE_FAILURE_SUBJECT = /\b(model|provider|thinking)\b/i;
19
+ const OVERRIDE_FAILURE_REASON = /\b(not found|unknown|invalid|unsupported|unavailable|unrecognized|no match|no matches|cannot resolve|failed to resolve)\b/i;
20
+
21
+ function normalizedModelOverride(config: ChildLlmConfig): string | undefined {
22
+ const trimmed = config.llmModelOverride?.trim();
23
+ return trimmed ? trimmed : undefined;
24
+ }
25
+
26
+ function effectiveThinkingOverride(config: ChildLlmConfig): ThinkingLevel | undefined {
27
+ return config.llmThinkingOverride ?? (normalizedModelOverride(config) ? "off" : undefined);
28
+ }
29
+
30
+ export function hasChildLlmOverrides(config: ChildLlmConfig): boolean {
31
+ return normalizedModelOverride(config) !== undefined || effectiveThinkingOverride(config) !== undefined;
32
+ }
33
+
34
+ export function inheritedExtensionArgs(argv: string[] = process.argv.slice(2)): string[] {
35
+ const args: string[] = [];
36
+
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const current = argv[i];
39
+ if (current === "-e" || current === "--extension") {
40
+ const next = argv[i + 1];
41
+ if (typeof next === "string" && next.length > 0) {
42
+ args.push(current, next);
43
+ i++;
44
+ }
45
+ continue;
46
+ }
47
+
48
+ if (current.startsWith("--extension=")) {
49
+ args.push(current);
50
+ }
51
+ }
52
+
53
+ return args;
54
+ }
55
+
56
+ export function buildChildPiPromptArgs(prompt: string, config: ChildLlmConfig, argv: string[] = process.argv.slice(2)): string[] {
57
+ const args = ["-p", "--no-session"];
58
+ const model = normalizedModelOverride(config);
59
+ const thinking = effectiveThinkingOverride(config);
60
+ const inheritedExtensions = inheritedExtensionArgs(argv);
61
+
62
+ if (model) args.push("--model", model);
63
+ if (thinking) args.push("--thinking", thinking);
64
+ args.push(...inheritedExtensions);
65
+ args.push(prompt);
66
+
67
+ return args;
68
+ }
69
+
70
+ function basePromptArgs(prompt: string): string[] {
71
+ return ["-p", "--no-session", prompt];
72
+ }
73
+
74
+ function shouldRetryWithoutOverridesFromText(text: string | undefined): boolean {
75
+ if (!text) return false;
76
+ return OVERRIDE_FAILURE_SUBJECT.test(text) && OVERRIDE_FAILURE_REASON.test(text);
77
+ }
78
+
79
+ function shouldRetryWithoutOverrides(result: PiExecResult): boolean {
80
+ return shouldRetryWithoutOverridesFromText(result.stderr) || shouldRetryWithoutOverridesFromText(result.stdout);
81
+ }
82
+
83
+ function shouldRetryWithoutOverridesForError(error: unknown): boolean {
84
+ return shouldRetryWithoutOverridesFromText(String(error));
85
+ }
86
+
87
+ export async function execChildPrompt(
88
+ pi: Pick<ExtensionAPI, "exec">,
89
+ prompt: string,
90
+ config: ChildLlmConfig,
91
+ options: ExecChildPromptOptions,
92
+ ): Promise<PiExecResult> {
93
+ const execOptions = {
94
+ signal: options.signal,
95
+ timeout: options.timeoutMs,
96
+ };
97
+
98
+ try {
99
+ const result = await pi.exec("pi", buildChildPiPromptArgs(prompt, config), execOptions) as PiExecResult;
100
+ if (
101
+ result.code === 0 ||
102
+ !options.retryWithoutOverrides ||
103
+ !hasChildLlmOverrides(config) ||
104
+ !shouldRetryWithoutOverrides(result)
105
+ ) {
106
+ return result;
107
+ }
108
+ } catch (error) {
109
+ if (
110
+ !options.retryWithoutOverrides ||
111
+ !hasChildLlmOverrides(config) ||
112
+ !shouldRetryWithoutOverridesForError(error)
113
+ ) {
114
+ throw error;
115
+ }
116
+ }
117
+
118
+ return pi.exec("pi", basePromptArgs(prompt), execOptions) as Promise<PiExecResult>;
119
+ }
@@ -9,6 +9,7 @@ import { MemoryStore } from "../store/memory-store.js";
9
9
  import { FLUSH_PROMPT } from "../constants.js";
10
10
  import type { MemoryConfig } from "../types.js";
11
11
  import { collectMessageParts } from "./message-parts.js";
12
+ import { execChildPrompt } from "./pi-child-process.js";
12
13
 
13
14
  export function setupSessionFlush(
14
15
  pi: ExtensionAPI,
@@ -42,9 +43,9 @@ export function setupSessionFlush(
42
43
  ].join("\n");
43
44
 
44
45
  try {
45
- await pi.exec("pi", ["-p", "--no-session", flushMessage], {
46
+ await execChildPrompt(pi, flushMessage, config, {
46
47
  signal,
47
- timeout: timeoutMs,
48
+ timeoutMs,
48
49
  });
49
50
  } catch {
50
51
  // Best-effort flush — never block shutdown
package/src/index.ts CHANGED
@@ -179,15 +179,15 @@ export default function (pi: ExtensionAPI) {
179
179
 
180
180
  // ── 7. Setup auto-consolidation (inject consolidator into stores) ──
181
181
  store.setConsolidator(async (target, signal) => {
182
- return triggerConsolidation(pi, store, target, signal, config.consolidationTimeoutMs);
182
+ return triggerConsolidation(pi, store, target, signal, config.consolidationTimeoutMs, target, config);
183
183
  });
184
184
  if (projectStore) {
185
185
  projectStore.setConsolidator(async (target, signal) => {
186
186
  const toolTarget = target === "memory" ? "project" : target;
187
- return triggerConsolidation(pi, projectStore, target, signal, config.consolidationTimeoutMs, toolTarget);
187
+ return triggerConsolidation(pi, projectStore, target, signal, config.consolidationTimeoutMs, toolTarget, config);
188
188
  });
189
189
  }
190
- registerConsolidateCommand(pi, store, config.consolidationTimeoutMs, projectStore, projectName);
190
+ registerConsolidateCommand(pi, store, config.consolidationTimeoutMs, projectStore, projectName, config);
191
191
 
192
192
  // ── 8. Setup correction detection ──
193
193
  setupCorrectionDetector(pi, store, projectStore, config, dbManager, projectName);
package/src/store/db.ts CHANGED
@@ -25,50 +25,50 @@ type BunDatabaseInstance = {
25
25
  transaction?: (fn: any) => any;
26
26
  };
27
27
 
28
- function loadDatabaseCtor(): DatabaseCtor {
29
- const require = createRequire(import.meta.url);
30
- try {
31
- const mod = require('better-sqlite3') as { default?: DatabaseCtor } | DatabaseCtor;
32
- return (mod as { default?: DatabaseCtor }).default ?? (mod as DatabaseCtor);
33
- } catch (err) {
34
- const msg = err instanceof Error ? err.message.toLowerCase() : '';
35
- const isBunRuntime = typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined';
36
- const isBunIncompat = msg.includes('better-sqlite3 is not yet supported in bun') || msg.includes('not yet supported in bun');
37
- if (!isBunIncompat) {
38
- throw err;
39
- }
40
- if (!isBunRuntime) {
41
- throw err;
28
+ function createBunCompatDatabaseCtor(require: NodeRequire): DatabaseCtor {
29
+ const bunSqlite = require('bun:sqlite') as { Database: new (dbPath: string) => BunDatabaseInstance };
30
+
31
+ return class BunCompatDatabase implements DatabaseLike {
32
+ private readonly db: BunDatabaseInstance;
33
+
34
+ constructor(dbPath: string) {
35
+ this.db = new bunSqlite.Database(dbPath);
42
36
  }
43
37
 
44
- const bunSqlite = require('bun:sqlite') as { Database: new (dbPath: string) => BunDatabaseInstance };
38
+ prepare(sql: string): StatementLike {
39
+ return this.db.prepare(sql);
40
+ }
45
41
 
46
- return class BunCompatDatabase implements DatabaseLike {
47
- private readonly db: BunDatabaseInstance;
42
+ exec(sql: string): void {
43
+ this.db.exec(sql);
44
+ }
48
45
 
49
- constructor(dbPath: string) {
50
- this.db = new bunSqlite.Database(dbPath);
51
- }
46
+ close(): void {
47
+ this.db.close();
48
+ }
52
49
 
53
- prepare(sql: string): StatementLike {
54
- return this.db.prepare(sql);
50
+ transaction(fn: any): any {
51
+ if (!this.db.transaction) {
52
+ return undefined;
55
53
  }
54
+ return this.db.transaction(fn);
55
+ }
56
+ };
57
+ }
56
58
 
57
- exec(sql: string): void {
58
- this.db.exec(sql);
59
- }
59
+ function loadDatabaseCtor(): DatabaseCtor {
60
+ const require = createRequire(import.meta.url);
61
+ const isBunRuntime = typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined';
60
62
 
61
- close(): void {
62
- this.db.close();
63
- }
63
+ if (isBunRuntime) {
64
+ return createBunCompatDatabaseCtor(require);
65
+ }
64
66
 
65
- transaction(fn: any): any {
66
- if (!this.db.transaction) {
67
- return undefined;
68
- }
69
- return this.db.transaction(fn);
70
- }
71
- };
67
+ try {
68
+ const mod = require('better-sqlite3') as { default?: DatabaseCtor } | DatabaseCtor;
69
+ return (mod as { default?: DatabaseCtor }).default ?? (mod as DatabaseCtor);
70
+ } catch (err) {
71
+ throw err;
72
72
  }
73
73
  }
74
74
 
@@ -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,5 +1,6 @@
1
1
  import { DatabaseManager } from './db.js';
2
2
  import { isFts5QueryError, normalizeFts5Query } from './fts-query.js';
3
+ import { normalizeMemoryLookupText } from './memory-lookup.js';
3
4
  import type { MemoryCategory } from '../types.js';
4
5
 
5
6
  const MEMORY_SELECT_COLUMNS = `
@@ -421,10 +422,12 @@ export function replaceSyncedMemories(
421
422
  },
422
423
  ): SqliteMemoryUpdateResult {
423
424
  const db = dbManager.getDb();
425
+ const normalizedOldText = normalizeMemoryLookupText(oldText);
426
+ if (!normalizedOldText) return { matched: 0, updated: 0, entries: [] };
424
427
  const params: unknown[] = [];
425
428
  const conditions = buildScopeConditions(params, updates.target, updates.project ?? undefined);
426
429
  conditions.push(`content LIKE ? ESCAPE '\\'`);
427
- params.push(`%${escapeLikePattern(oldText)}%`);
430
+ params.push(`%${escapeLikePattern(normalizedOldText)}%`);
428
431
 
429
432
  const rows = db.prepare(`
430
433
  SELECT ${MEMORY_SELECT_COLUMNS}
@@ -490,10 +493,12 @@ export function removeSyncedMemories(
490
493
  options: SqliteMemoryRemoveOptions,
491
494
  ): SqliteMemoryRemoveResult {
492
495
  const db = dbManager.getDb();
496
+ const normalizedOldText = normalizeMemoryLookupText(oldText);
497
+ if (!normalizedOldText) return { matched: 0, removed: 0 };
493
498
  const params: unknown[] = [];
494
499
  const conditions = buildScopeConditions(params, options.target, options.project ?? undefined);
495
500
  conditions.push(`content LIKE ? ESCAPE '\\'`);
496
- params.push(`%${escapeLikePattern(oldText)}%`);
501
+ params.push(`%${escapeLikePattern(normalizedOldText)}%`);
497
502
 
498
503
  const matchingIds = db.prepare(`
499
504
  SELECT id
package/src/types.ts CHANGED
@@ -8,6 +8,8 @@ export type MemoryOverflowStrategy = "auto-consolidate" | "reject" | "fifo-evict
8
8
 
9
9
  export type SessionSearchVariant = "legacy" | "anchors";
10
10
 
11
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
12
+
11
13
  export interface SessionSearchConfig {
12
14
  /** Session search implementation variant. Default: legacy */
13
15
  variant: SessionSearchVariant;
@@ -46,6 +48,10 @@ export interface MemoryConfig {
46
48
  projectsMemoryDir?: string;
47
49
  /** Session search configuration. Default: { variant: "legacy" } */
48
50
  sessionSearch?: SessionSearchConfig;
51
+ /** Override model used for child pi -p subprocess LLM calls. Default: unset */
52
+ llmModelOverride?: string;
53
+ /** Override thinking level used for child pi -p subprocess LLM calls. Default: unset */
54
+ llmThinkingOverride?: ThinkingLevel;
49
55
  /** Strategy when memory is full. Default: auto-consolidate */
50
56
  memoryOverflowStrategy?: MemoryOverflowStrategy;
51
57
  /** Legacy alias for memoryOverflowStrategy. Default: true */