pi-hermes-memory 0.7.22 → 0.7.23

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
@@ -339,6 +339,19 @@ Background review triggers based on **activity level**, not just turn count:
339
339
 
340
340
  Both counters reset after each review.
341
341
 
342
+ ### Background Review Transport
343
+
344
+ By default, background review uses an in-process `completeSimple()` side-channel: a small JSON-only prompt, no child `pi` process, and memory writes applied directly by the extension. This keeps the main session's system prompt, tools, and LLM prefix cache intact.
345
+
346
+ If direct review fails (no model, no auth, provider error, unparseable response), it automatically falls back to the legacy `pi -p --no-session` subprocess path.
347
+
348
+ Set `reviewTransport` in config only when you need to override this:
349
+
350
+ | Value | Behavior |
351
+ |---|---|
352
+ | `direct` (default) | Try in-process `completeSimple()` first; fall back to subprocess on failure |
353
+ | `subprocess` | Always use `pi -p` subprocess (pre-PR #92 behavior) |
354
+
342
355
  ### Skill Auto-Extraction
343
356
 
344
357
  After a complex task (8+ tool calls using 2+ different tools in a single turn), the extension automatically asks the agent:
@@ -430,6 +443,7 @@ Create `~/.pi/agent/hermes-memory-config.json`:
430
443
  "nudgeToolCalls": 15,
431
444
  "reviewRecentMessages": 0,
432
445
  "reviewEnabled": true,
446
+ "reviewTransport": "direct",
433
447
  "memoryOverflowStrategy": "auto-consolidate",
434
448
  "autoConsolidate": true,
435
449
  "correctionDetection": true,
@@ -455,12 +469,13 @@ Create `~/.pi/agent/hermes-memory-config.json`:
455
469
  | `memoryDir` | `~/.pi/agent/pi-hermes-memory` | Custom directory for extension storage files |
456
470
  | `projectsMemoryDir` | `projects-memory` | Subdirectory under `~/.pi/agent/` for project-scoped memory |
457
471
  | `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` |
472
+ | `llmModelOverride` | unset | Optional model override for background review (direct and subprocess), correction save, session flush, and consolidation |
473
+ | `llmThinkingOverride` | unset | Optional thinking override for those LLM calls; valid values are `off`, `minimal`, `low`, `medium`, `high`, and `xhigh`. If `llmModelOverride` is set and this is omitted, review/child calls default to `off` |
460
474
  | `nudgeInterval` | `10` | Turns between auto-reviews |
461
475
  | `nudgeToolCalls` | `15` | Tool calls between auto-reviews (OR with turns) |
462
476
  | `reviewRecentMessages` | `0` | Recent messages included in background review (`0` = all) |
463
477
  | `reviewEnabled` | `true` | Enable/disable background learning loop |
478
+ | `reviewTransport` | `direct` | Background review LLM transport: `direct` uses in-process `completeSimple()` with subprocess fallback; `subprocess` forces legacy `pi -p` only |
464
479
  | `memoryOverflowStrategy` | `auto-consolidate` | Behavior when MEMORY.md, USER.md, failures.md, or project-scoped memory reaches its character limit: `auto-consolidate` runs the existing consolidation flow; `reject` returns an error; `fifo-evict` rotates older entries in file order until the new entry fits |
465
480
  | `autoConsolidate` | `true` | Legacy alias for `memoryOverflowStrategy` when `memoryOverflowStrategy` is not set (`true` = `auto-consolidate`, `false` = `reject`) |
466
481
  | `consolidationTimeoutMs` | `60000` | Maximum time in milliseconds for auto-consolidation to complete |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hermes-memory",
3
- "version": "0.7.22",
3
+ "version": "0.7.23",
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",
@@ -45,6 +45,7 @@
45
45
  "url": "https://github.com/chandra447/pi-hermes-memory"
46
46
  },
47
47
  "peerDependencies": {
48
+ "@earendil-works/pi-ai": "*",
48
49
  "@earendil-works/pi-coding-agent": ">=0.74.0"
49
50
  },
50
51
  "devDependencies": {
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { MemoryConfig, MemoryOverflowStrategy, SessionSearchVariant, ThinkingLevel } from "./types.js";
3
+ import type { MemoryConfig, MemoryOverflowStrategy, ReviewTransport, SessionSearchVariant, ThinkingLevel } from "./types.js";
4
4
  import {
5
5
  DEFAULT_MEMORY_CHAR_LIMIT,
6
6
  DEFAULT_USER_CHAR_LIMIT,
@@ -19,8 +19,13 @@ import { AGENT_ROOT, normalizeConfiguredMemoryDir, normalizeProjectsMemoryDir }
19
19
 
20
20
  const MEMORY_OVERFLOW_STRATEGIES: readonly MemoryOverflowStrategy[] = ["auto-consolidate", "reject", "fifo-evict"];
21
21
  const SESSION_SEARCH_VARIANTS: readonly SessionSearchVariant[] = ["legacy", "anchors"];
22
+ const REVIEW_TRANSPORTS: readonly ReviewTransport[] = ["direct", "subprocess"];
22
23
  const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
23
24
 
25
+ function isReviewTransport(value: unknown): value is ReviewTransport {
26
+ return typeof value === "string" && REVIEW_TRANSPORTS.includes(value as ReviewTransport);
27
+ }
28
+
24
29
  function isMemoryOverflowStrategy(value: unknown): value is MemoryOverflowStrategy {
25
30
  return typeof value === "string" && MEMORY_OVERFLOW_STRATEGIES.includes(value as MemoryOverflowStrategy);
26
31
  }
@@ -42,6 +47,7 @@ const DEFAULT_CONFIG: MemoryConfig = {
42
47
  nudgeInterval: DEFAULT_NUDGE_INTERVAL,
43
48
  reviewRecentMessages: DEFAULT_REVIEW_RECENT_MESSAGES,
44
49
  reviewEnabled: true,
50
+ reviewTransport: "direct",
45
51
  flushOnCompact: true,
46
52
  flushOnShutdown: true,
47
53
  flushMinTurns: DEFAULT_FLUSH_MIN_TURNS,
@@ -91,6 +97,7 @@ export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig {
91
97
  if (typeof parsed.nudgeInterval === "number") config.nudgeInterval = parsed.nudgeInterval;
92
98
  if (isNonNegativeNumber(parsed.reviewRecentMessages)) config.reviewRecentMessages = parsed.reviewRecentMessages;
93
99
  if (typeof parsed.reviewEnabled === "boolean") config.reviewEnabled = parsed.reviewEnabled;
100
+ if (isReviewTransport(parsed.reviewTransport)) config.reviewTransport = parsed.reviewTransport;
94
101
  if (typeof parsed.flushOnCompact === "boolean") config.flushOnCompact = parsed.flushOnCompact;
95
102
  if (typeof parsed.flushOnShutdown === "boolean") config.flushOnShutdown = parsed.flushOnShutdown;
96
103
  if (typeof parsed.flushMinTurns === "number") config.flushMinTurns = parsed.flushMinTurns;
package/src/constants.ts CHANGED
@@ -145,6 +145,36 @@ For failures, include: what was tried, why it failed, what error occurred, and w
145
145
 
146
146
  Only act if there's something genuinely worth saving. If nothing stands out, just say 'Nothing to save.' and stop.`;
147
147
 
148
+ // ─── Direct (in-process) background review prompts ───
149
+ export const DIRECT_REVIEW_SYSTEM_PROMPT = `You review coding conversations and extract durable memories worth saving across sessions.
150
+
151
+ Review these aspects:
152
+ - **Memory**: User persona, preferences, expectations about how the agent should behave, work style.
153
+ - **Failures & Corrections**: What failed, user corrections, insights, conventions, tool quirks.
154
+
155
+ Do NOT create or modify skills. Only save genuinely durable facts — not task progress, session outcomes, or temporary state.
156
+
157
+ Respond with JSON only (no markdown fences):
158
+ {
159
+ "operations": [
160
+ {
161
+ "action": "add",
162
+ "target": "memory",
163
+ "content": "entry text"
164
+ }
165
+ ]
166
+ }
167
+
168
+ Operation fields:
169
+ - action: "add" | "replace" | "remove"
170
+ - target: "memory" | "user" | "project" | "failure"
171
+ - content: required for add/replace
172
+ - old_text: required for replace/remove (substring match)
173
+ - category: for failure target — failure | correction | insight | convention | tool-quirk | preference
174
+ - failure_reason: optional context for failure entries
175
+
176
+ If nothing is worth saving, return {"operations":[]}.`;
177
+
148
178
  // ─── Flush prompt (ported from flush_memories() in run_agent.py ~L7379) ───
149
179
  export const FLUSH_PROMPT = `[System: The session is being compressed. Save anything worth remembering — prioritize user preferences, corrections, and recurring patterns over task-specific details.]`;
150
180
 
@@ -3,23 +3,128 @@
3
3
  * Ported from hermes-agent/run_agent.py (_spawn_background_review, _memory_nudge_interval).
4
4
  * See PLAN.md → "Hermes Source File Reference Map" for source lines.
5
5
  *
6
- * Uses pi.exec("pi", ["-p", ...]) for isolated one-shot review,
7
- * keeping us within Pi's intended extension API.
6
+ * Default transport: in-process complete() side-channel (preserves parent LLM cache).
7
+ * Fallback: pi.exec("pi", ["-p", ...]) subprocess when direct path is unavailable.
8
8
  */
9
9
 
10
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
- import { MemoryStore } from "../store/memory-store.js";
10
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
11
  import { COMBINED_REVIEW_PROMPT } from "../constants.js";
12
+ import { MemoryStore } from "../store/memory-store.js";
13
+ import { DatabaseManager } from "../store/db.js";
13
14
  import type { MemoryConfig } from "../types.js";
14
15
  import { applyRecentMessageLimit, collectMessageParts } from "./message-parts.js";
15
16
  import { execChildPrompt } from "./pi-child-process.js";
17
+ import { runDirectBackgroundReview, type DirectReviewResult } from "./review-memory-ops.js";
18
+
19
+ export interface BackgroundReviewOptions {
20
+ dbManager?: DatabaseManager | null;
21
+ projectName?: string | null;
22
+ deps?: BackgroundReviewDeps;
23
+ }
24
+
25
+ export interface BackgroundReviewDeps {
26
+ runDirectReview?: typeof runDirectBackgroundReview;
27
+ execChildPrompt?: typeof execChildPrompt;
28
+ }
29
+
30
+ export interface ReviewPromptInput {
31
+ parts: string[];
32
+ currentMemory: string;
33
+ currentUser: string;
34
+ currentProject: string | null;
35
+ }
36
+
37
+ export function buildSubprocessReviewPrompt(input: ReviewPromptInput): string {
38
+ const reviewPrompt = [
39
+ COMBINED_REVIEW_PROMPT,
40
+ "",
41
+ "--- Current Memory ---",
42
+ input.currentMemory || "(empty)",
43
+ "",
44
+ "--- Current User Profile ---",
45
+ input.currentUser || "(empty)",
46
+ ];
47
+
48
+ if (input.currentProject !== null) {
49
+ reviewPrompt.push(
50
+ "",
51
+ "--- Current Project Memory ---",
52
+ input.currentProject || "(empty)",
53
+ );
54
+ }
55
+
56
+ reviewPrompt.push(
57
+ "",
58
+ "--- Conversation to Review ---",
59
+ input.parts.join("\n\n"),
60
+ );
61
+
62
+ return reviewPrompt.join("\n");
63
+ }
64
+
65
+ export function buildDirectReviewUserPrompt(input: ReviewPromptInput): string {
66
+ const sections = [
67
+ "--- Current Memory ---",
68
+ input.currentMemory || "(empty)",
69
+ "",
70
+ "--- Current User Profile ---",
71
+ input.currentUser || "(empty)",
72
+ ];
73
+
74
+ if (input.currentProject !== null) {
75
+ sections.push(
76
+ "",
77
+ "--- Current Project Memory ---",
78
+ input.currentProject || "(empty)",
79
+ );
80
+ }
81
+
82
+ sections.push(
83
+ "",
84
+ "--- Conversation to Review ---",
85
+ input.parts.join("\n\n"),
86
+ );
87
+
88
+ return sections.join("\n");
89
+ }
90
+
91
+ function shouldNotifyDirect(result: DirectReviewResult): boolean {
92
+ return result.ok && result.appliedCount > 0;
93
+ }
94
+
95
+ function shouldNotifySubprocess(stdout: string | undefined): boolean {
96
+ const output = stdout?.trim();
97
+ return !!output && !output.toLowerCase().includes("nothing to save");
98
+ }
99
+
100
+ function usesDirectTransport(config: MemoryConfig): boolean {
101
+ return (config.reviewTransport ?? "direct") === "direct";
102
+ }
103
+
104
+ async function runSubprocessReview(
105
+ pi: ExtensionAPI,
106
+ prompt: string,
107
+ config: MemoryConfig,
108
+ execChild: typeof execChildPrompt,
109
+ ): Promise<{ code: number; stdout?: string }> {
110
+ return execChild(pi, prompt, config, {
111
+ signal: undefined,
112
+ timeoutMs: 120000,
113
+ });
114
+ }
16
115
 
17
116
  export function setupBackgroundReview(
18
117
  pi: ExtensionAPI,
19
118
  store: MemoryStore,
20
119
  projectStore: MemoryStore | null,
21
120
  config: MemoryConfig,
121
+ options: BackgroundReviewOptions = {},
22
122
  ): void {
123
+ const dbManager = options.dbManager ?? null;
124
+ const projectName = options.projectName ?? null;
125
+ const runDirectReview = options.deps?.runDirectReview ?? runDirectBackgroundReview;
126
+ const execChild = options.deps?.execChildPrompt ?? execChildPrompt;
127
+
23
128
  let turnsSinceReview = 0;
24
129
  let toolCallsSinceReview = 0;
25
130
  let userTurnCount = 0;
@@ -37,9 +142,6 @@ export function setupBackgroundReview(
37
142
  if (!config.reviewEnabled) return;
38
143
  if (reviewInProgress) return;
39
144
 
40
- // Count tool calls from this turn's message only (not cumulative branch scan —
41
- // otherwise the counter resets to 0 at review, then immediately re-counts all
42
- // historical tool calls and re-triggers on every subsequent turn).
43
145
  try {
44
146
  const msg = event.message;
45
147
  if (msg?.role === "assistant") {
@@ -56,7 +158,6 @@ export function setupBackgroundReview(
56
158
  // If we can't count tool calls, fall back to turn-based only
57
159
  }
58
160
 
59
- // Trigger on EITHER turn count OR tool call count
60
161
  const turnThresholdMet = turnsSinceReview >= config.nudgeInterval;
61
162
  const toolCallThresholdMet = toolCallsSinceReview >= config.nudgeToolCalls;
62
163
 
@@ -67,78 +168,71 @@ export function setupBackgroundReview(
67
168
  toolCallsSinceReview = 0;
68
169
  reviewInProgress = true;
69
170
 
70
- // Build conversation snapshot from session entries (crash-safe)
71
171
  let allParts: string[] = [];
72
172
  try {
73
173
  const entries = ctx.sessionManager.getBranch();
74
174
  allParts = collectMessageParts(entries);
75
175
  } catch {
76
176
  reviewInProgress = false;
77
- return; // Session expired or empty — nothing to review
177
+ return;
78
178
  }
79
179
  if (allParts.length < 4) {
80
180
  reviewInProgress = false;
81
- return; // Not enough conversation to review
181
+ return;
82
182
  }
183
+
83
184
  const parts = applyRecentMessageLimit(allParts, config.reviewRecentMessages);
185
+ const promptInput: ReviewPromptInput = {
186
+ parts,
187
+ currentMemory: store.getMemoryEntries().join("\n§\n"),
188
+ currentUser: store.getUserEntries().join("\n§\n"),
189
+ currentProject: projectStore ? projectStore.getMemoryEntries().join("\n§\n") : null,
190
+ };
84
191
 
85
- const currentMemory = store.getMemoryEntries().join("\n§\n");
86
- const currentUser = store.getUserEntries().join("\n§\n");
87
- const currentProject = projectStore ? projectStore.getMemoryEntries().join("\n§\n") : null;
192
+ const subprocessPrompt = buildSubprocessReviewPrompt(promptInput);
193
+ const directPrompt = buildDirectReviewUserPrompt(promptInput);
88
194
 
89
- const reviewPrompt = [
90
- COMBINED_REVIEW_PROMPT,
91
- "",
92
- "--- Current Memory ---",
93
- currentMemory || "(empty)",
94
- "",
95
- "--- Current User Profile ---",
96
- currentUser || "(empty)",
97
- ];
98
-
99
- if (currentProject !== null) {
100
- reviewPrompt.push(
101
- "",
102
- "--- Current Project Memory ---",
103
- currentProject || "(empty)",
104
- );
105
- }
195
+ const finishReview = () => {
196
+ reviewInProgress = false;
197
+ };
106
198
 
107
- reviewPrompt.push(
108
- "",
109
- "--- Conversation to Review ---",
110
- parts.join("\n\n"),
111
- );
199
+ const notifyIfSaved = (saved: boolean) => {
200
+ if (saved) {
201
+ ctx.ui.notify("💾 Memory auto-reviewed and updated", "info");
202
+ }
203
+ };
204
+
205
+ const runReview = async (): Promise<void> => {
206
+ if (usesDirectTransport(config)) {
207
+ const directResult = await runDirectReview(
208
+ ctx as Pick<ExtensionContext, "model" | "modelRegistry">,
209
+ store,
210
+ projectStore,
211
+ { userPrompt: directPrompt, config, timeoutMs: 120000 },
212
+ dbManager,
213
+ projectName,
214
+ );
215
+
216
+ if (directResult.ok) {
217
+ notifyIfSaved(shouldNotifyDirect(directResult));
218
+ return;
219
+ }
112
220
 
113
- // Fire-and-forget: do NOT await. The review runs in a subprocess;
114
- // blocking turn_end would freeze the interactive chat.
115
- // Notifications are delivered via .then() once the subprocess completes.
116
- //
117
- // We intentionally omit ctx.signal — the signal is tied to the turn
118
- // lifetime and would abort the subprocess before it finishes now that
119
- // we're not awaiting. The timeout (120s) provides its own safety net.
120
- const reviewPromise = execChildPrompt(pi, reviewPrompt.join("\n"), config, {
121
- signal: undefined,
122
- timeoutMs: 120000,
123
- });
124
-
125
- reviewPromise
126
- .then((result) => {
127
- reviewInProgress = false;
128
- if (result.code === 0 && result.stdout) {
129
- const output = result.stdout.trim();
130
- if (output && !output.toLowerCase().includes("nothing to save")) {
131
- ctx.ui.notify("💾 Memory auto-reviewed and updated", "info");
132
- }
221
+ if (directResult.fallbackReason === "empty") {
222
+ return;
133
223
  }
134
- // Auto-review is best-effort. Non-zero exits are silently skipped —
135
- // common on Windows where pi CLI may resolve differently. The next
136
- // review cycle will retry.
137
- })
224
+ }
225
+
226
+ const subprocessResult = await runSubprocessReview(pi, subprocessPrompt, config, execChild);
227
+ if (subprocessResult.code === 0) {
228
+ notifyIfSaved(shouldNotifySubprocess(subprocessResult.stdout));
229
+ }
230
+ };
231
+
232
+ runReview()
138
233
  .catch(() => {
139
- // Best-effort: subprocess failures (timeout, signal, spawn errors)
140
- // are silently ignored. The next review cycle will retry.
141
- reviewInProgress = false;
142
- });
234
+ // Best-effort only
235
+ })
236
+ .finally(finishReview);
143
237
  });
144
- }
238
+ }
@@ -0,0 +1,481 @@
1
+ /**
2
+ * Parse and apply structured memory operations from direct background review.
3
+ */
4
+
5
+ import type { Api, Model } from "@earendil-works/pi-ai";
6
+ import { completeSimple, type Message, type SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
7
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import { DIRECT_REVIEW_SYSTEM_PROMPT } from "../constants.js";
9
+ import { MemoryStore } from "../store/memory-store.js";
10
+ import { DatabaseManager } from "../store/db.js";
11
+ import {
12
+ formatFailureMemoryContent,
13
+ removeExactSyncedMemories,
14
+ removeSyncedMemories,
15
+ replaceSyncedMemories,
16
+ syncMemoryEntry,
17
+ } from "../store/sqlite-memory-store.js";
18
+ import type { MemoryCategory, MemoryConfig, MemoryResult, ThinkingLevel } from "../types.js";
19
+
20
+ export interface ReviewMemoryOperation {
21
+ action: "add" | "replace" | "remove";
22
+ target: "memory" | "user" | "project" | "failure";
23
+ content?: string;
24
+ old_text?: string;
25
+ category?: MemoryCategory;
26
+ failure_reason?: string;
27
+ }
28
+
29
+ export interface ApplyReviewOperationsResult {
30
+ appliedCount: number;
31
+ skippedCount: number;
32
+ }
33
+
34
+ export interface DirectReviewResult {
35
+ ok: boolean;
36
+ appliedCount: number;
37
+ fallbackReason?: "no_model" | "no_auth" | "aborted" | "parse_error" | "provider_error" | "empty";
38
+ error?: string;
39
+ }
40
+
41
+ export interface RunDirectBackgroundReviewOptions {
42
+ userPrompt: string;
43
+ config: Pick<MemoryConfig, "llmModelOverride" | "llmThinkingOverride">;
44
+ timeoutMs?: number;
45
+ signal?: AbortSignal;
46
+ }
47
+
48
+ type ReviewLlmConfig = Pick<MemoryConfig, "llmModelOverride" | "llmThinkingOverride">;
49
+
50
+ function findExactModelReferenceMatch(modelReference: string, availableModels: Model<Api>[]): Model<Api> | undefined {
51
+ const trimmedReference = modelReference.trim();
52
+ if (!trimmedReference) return undefined;
53
+
54
+ const normalizedReference = trimmedReference.toLowerCase();
55
+ const canonicalMatches = availableModels.filter(
56
+ (model) => `${model.provider}/${model.id}`.toLowerCase() === normalizedReference,
57
+ );
58
+ if (canonicalMatches.length === 1) return canonicalMatches[0];
59
+ if (canonicalMatches.length > 1) return undefined;
60
+
61
+ const slashIndex = trimmedReference.indexOf("/");
62
+ if (slashIndex !== -1) {
63
+ const provider = trimmedReference.substring(0, slashIndex).trim();
64
+ const modelId = trimmedReference.substring(slashIndex + 1).trim();
65
+ if (provider && modelId) {
66
+ const providerMatches = availableModels.filter(
67
+ (model) => model.provider.toLowerCase() === provider.toLowerCase()
68
+ && model.id.toLowerCase() === modelId.toLowerCase(),
69
+ );
70
+ if (providerMatches.length === 1) return providerMatches[0];
71
+ }
72
+ }
73
+
74
+ const idMatches = availableModels.filter((model) => model.id.toLowerCase() === normalizedReference);
75
+ return idMatches.length === 1 ? idMatches[0] : undefined;
76
+ }
77
+
78
+ function normalizedModelOverride(config: ReviewLlmConfig): string | undefined {
79
+ const trimmed = config.llmModelOverride?.trim();
80
+ return trimmed ? trimmed : undefined;
81
+ }
82
+
83
+ function effectiveThinkingOverride(config: ReviewLlmConfig): ThinkingLevel | undefined {
84
+ return config.llmThinkingOverride ?? (normalizedModelOverride(config) ? "off" : undefined);
85
+ }
86
+
87
+ type ReviewModelRegistry = ExtensionContext["modelRegistry"];
88
+
89
+ export function buildDirectReviewCompletionOptions(
90
+ model: Model<Api>,
91
+ auth: {
92
+ apiKey: string;
93
+ headers?: Record<string, string>;
94
+ env?: Record<string, string>;
95
+ },
96
+ thinking: ThinkingLevel | undefined,
97
+ signal: AbortSignal,
98
+ ): SimpleStreamOptions {
99
+ const options: SimpleStreamOptions = {
100
+ apiKey: auth.apiKey,
101
+ headers: auth.headers,
102
+ env: auth.env,
103
+ signal,
104
+ };
105
+ if (model.reasoning && thinking && thinking !== "off") {
106
+ options.reasoning = thinking;
107
+ }
108
+ return options;
109
+ }
110
+
111
+ export function resolveReviewModel(
112
+ ctxModel: Model<Api> | undefined,
113
+ modelRegistry: ReviewModelRegistry,
114
+ config: ReviewLlmConfig,
115
+ ): Model<Api> | undefined {
116
+ const override = normalizedModelOverride(config);
117
+ if (override) {
118
+ const matched = findExactModelReferenceMatch(override, modelRegistry.getAll());
119
+ if (matched) return matched;
120
+ }
121
+ return ctxModel;
122
+ }
123
+
124
+ function extractJsonPayload(text: string): unknown {
125
+ const trimmed = text.trim();
126
+ if (!trimmed) return null;
127
+
128
+ try {
129
+ return JSON.parse(trimmed);
130
+ } catch {
131
+ // continue
132
+ }
133
+
134
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
135
+ if (fenced?.[1]) {
136
+ try {
137
+ return JSON.parse(fenced[1].trim());
138
+ } catch {
139
+ // continue
140
+ }
141
+ }
142
+
143
+ const start = trimmed.indexOf("{");
144
+ const end = trimmed.lastIndexOf("}");
145
+ if (start >= 0 && end > start) {
146
+ try {
147
+ return JSON.parse(trimmed.slice(start, end + 1));
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ return null;
154
+ }
155
+
156
+ function isMemoryCategory(value: unknown): value is MemoryCategory {
157
+ return value === "failure"
158
+ || value === "correction"
159
+ || value === "insight"
160
+ || value === "preference"
161
+ || value === "convention"
162
+ || value === "tool-quirk";
163
+ }
164
+
165
+ function isReviewTarget(value: unknown): value is ReviewMemoryOperation["target"] {
166
+ return value === "memory" || value === "user" || value === "project" || value === "failure";
167
+ }
168
+
169
+ function isReviewAction(value: unknown): value is ReviewMemoryOperation["action"] {
170
+ return value === "add" || value === "replace" || value === "remove";
171
+ }
172
+
173
+ export function parseReviewOperations(text: string): ReviewMemoryOperation[] | null {
174
+ if (/nothing to save/i.test(text) && !text.includes("{")) {
175
+ return [];
176
+ }
177
+
178
+ const payload = extractJsonPayload(text);
179
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
180
+ return null;
181
+ }
182
+
183
+ const operations = (payload as { operations?: unknown }).operations;
184
+ if (!Array.isArray(operations)) return null;
185
+
186
+ const parsed: ReviewMemoryOperation[] = [];
187
+ for (const item of operations) {
188
+ if (!item || typeof item !== "object") continue;
189
+ const op = item as Record<string, unknown>;
190
+ if (!isReviewAction(op.action) || !isReviewTarget(op.target)) continue;
191
+
192
+ const operation: ReviewMemoryOperation = {
193
+ action: op.action,
194
+ target: op.target,
195
+ };
196
+ if (typeof op.content === "string") operation.content = op.content;
197
+ if (typeof op.old_text === "string") operation.old_text = op.old_text;
198
+ if (isMemoryCategory(op.category)) operation.category = op.category;
199
+ if (typeof op.failure_reason === "string") operation.failure_reason = op.failure_reason;
200
+ parsed.push(operation);
201
+ }
202
+
203
+ return parsed;
204
+ }
205
+
206
+ function sqliteProjectFor(
207
+ rawTarget: ReviewMemoryOperation["target"],
208
+ projectName?: string | null,
209
+ ): string | null | undefined {
210
+ if (rawTarget === "project") return projectName?.trim() || null;
211
+ if (rawTarget === "memory" || rawTarget === "user" || rawTarget === "failure") return null;
212
+ return undefined;
213
+ }
214
+
215
+ function sqliteTargetFor(rawTarget: ReviewMemoryOperation["target"]): "memory" | "user" | "failure" {
216
+ return rawTarget === "user" ? "user" : rawTarget === "failure" ? "failure" : "memory";
217
+ }
218
+
219
+ async function syncAdd(
220
+ rawTarget: ReviewMemoryOperation["target"],
221
+ content: string,
222
+ category: MemoryCategory | undefined,
223
+ failureReason: string | undefined,
224
+ dbManager: DatabaseManager | null,
225
+ projectName?: string | null,
226
+ ): Promise<void> {
227
+ if (!dbManager) return;
228
+
229
+ const sqliteTarget = sqliteTargetFor(rawTarget);
230
+ const sqliteProject = sqliteProjectFor(rawTarget, projectName);
231
+
232
+ if (rawTarget === "failure") {
233
+ const failureCategory = category ?? "failure";
234
+ syncMemoryEntry(dbManager, {
235
+ content: formatFailureMemoryContent(content, {
236
+ category: failureCategory,
237
+ failureReason,
238
+ }),
239
+ target: "failure",
240
+ project: sqliteProject ?? null,
241
+ category: failureCategory,
242
+ failureReason,
243
+ });
244
+ return;
245
+ }
246
+
247
+ syncMemoryEntry(dbManager, {
248
+ content,
249
+ target: sqliteTarget,
250
+ project: sqliteProject ?? null,
251
+ });
252
+ }
253
+
254
+ async function syncReplace(
255
+ rawTarget: ReviewMemoryOperation["target"],
256
+ oldText: string,
257
+ newContent: string,
258
+ dbManager: DatabaseManager | null,
259
+ projectName?: string | null,
260
+ ): Promise<void> {
261
+ if (!dbManager) return;
262
+ replaceSyncedMemories(dbManager, oldText, {
263
+ content: newContent,
264
+ target: sqliteTargetFor(rawTarget),
265
+ project: sqliteProjectFor(rawTarget, projectName),
266
+ });
267
+ }
268
+
269
+ async function syncRemove(
270
+ rawTarget: ReviewMemoryOperation["target"],
271
+ oldText: string,
272
+ dbManager: DatabaseManager | null,
273
+ projectName?: string | null,
274
+ ): Promise<void> {
275
+ if (!dbManager) return;
276
+ removeSyncedMemories(dbManager, oldText, {
277
+ target: sqliteTargetFor(rawTarget),
278
+ project: sqliteProjectFor(rawTarget, projectName),
279
+ });
280
+ }
281
+
282
+ async function syncEvictions(
283
+ rawTarget: ReviewMemoryOperation["target"],
284
+ evictedEntries: string[] | undefined,
285
+ dbManager: DatabaseManager | null,
286
+ projectName?: string | null,
287
+ ): Promise<void> {
288
+ if (!dbManager || !evictedEntries?.length) return;
289
+ for (const entry of evictedEntries) {
290
+ try {
291
+ removeExactSyncedMemories(dbManager, entry, {
292
+ target: sqliteTargetFor(rawTarget),
293
+ project: sqliteProjectFor(rawTarget, projectName),
294
+ });
295
+ } catch {
296
+ // best effort
297
+ }
298
+ }
299
+ }
300
+
301
+ export async function applyReviewOperations(
302
+ store: MemoryStore,
303
+ projectStore: MemoryStore | null,
304
+ operations: ReviewMemoryOperation[],
305
+ dbManager: DatabaseManager | null = null,
306
+ projectName?: string | null,
307
+ ): Promise<ApplyReviewOperationsResult> {
308
+ let appliedCount = 0;
309
+ let skippedCount = 0;
310
+
311
+ for (const op of operations) {
312
+ if (op.target === "project" && !projectStore) {
313
+ skippedCount++;
314
+ continue;
315
+ }
316
+
317
+ const rawTarget = op.target;
318
+ const memoryTarget = rawTarget === "project" ? "memory" : rawTarget === "failure" ? "failure" : rawTarget;
319
+ const activeStore = rawTarget === "project" ? projectStore! : store;
320
+
321
+ let result: MemoryResult;
322
+ switch (op.action) {
323
+ case "add": {
324
+ if (!op.content?.trim()) {
325
+ skippedCount++;
326
+ continue;
327
+ }
328
+ if (rawTarget === "failure") {
329
+ const category = op.category ?? "failure";
330
+ result = await activeStore.addFailure(op.content, {
331
+ category,
332
+ failureReason: op.failure_reason,
333
+ });
334
+ if (result.success) {
335
+ await syncAdd(rawTarget, op.content, category, op.failure_reason, dbManager, projectName);
336
+ appliedCount++;
337
+ } else {
338
+ skippedCount++;
339
+ }
340
+ } else {
341
+ result = await activeStore.add(memoryTarget, op.content);
342
+ if (result.success) {
343
+ await syncEvictions(rawTarget, result.evicted_entries, dbManager, projectName);
344
+ await syncAdd(rawTarget, op.content, undefined, undefined, dbManager, projectName);
345
+ appliedCount++;
346
+ } else {
347
+ skippedCount++;
348
+ }
349
+ }
350
+ break;
351
+ }
352
+ case "replace": {
353
+ if (!op.old_text || !op.content?.trim()) {
354
+ skippedCount++;
355
+ continue;
356
+ }
357
+ result = await activeStore.replace(memoryTarget, op.old_text, op.content);
358
+ if (result.success) {
359
+ await syncReplace(rawTarget, op.old_text, op.content, dbManager, projectName);
360
+ appliedCount++;
361
+ } else {
362
+ skippedCount++;
363
+ }
364
+ break;
365
+ }
366
+ case "remove": {
367
+ if (!op.old_text) {
368
+ skippedCount++;
369
+ continue;
370
+ }
371
+ result = await activeStore.remove(memoryTarget, op.old_text);
372
+ if (result.success) {
373
+ await syncRemove(rawTarget, op.old_text, dbManager, projectName);
374
+ appliedCount++;
375
+ } else {
376
+ skippedCount++;
377
+ }
378
+ break;
379
+ }
380
+ default:
381
+ skippedCount++;
382
+ }
383
+ }
384
+
385
+ return { appliedCount, skippedCount };
386
+ }
387
+
388
+ function responseText(content: unknown): string {
389
+ if (!Array.isArray(content)) return "";
390
+ return content
391
+ .filter((block): block is { type: "text"; text: string } => (
392
+ !!block && typeof block === "object" && (block as { type?: string }).type === "text"
393
+ ))
394
+ .map((block) => block.text)
395
+ .join("\n");
396
+ }
397
+
398
+ export async function runDirectBackgroundReview(
399
+ ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
400
+ store: MemoryStore,
401
+ projectStore: MemoryStore | null,
402
+ options: RunDirectBackgroundReviewOptions,
403
+ dbManager: DatabaseManager | null = null,
404
+ projectName?: string | null,
405
+ ): Promise<DirectReviewResult> {
406
+ const model = resolveReviewModel(ctx.model, ctx.modelRegistry, options.config);
407
+ if (!model) {
408
+ return { ok: false, appliedCount: 0, fallbackReason: "no_model" };
409
+ }
410
+
411
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
412
+ if (!auth.ok || !auth.apiKey) {
413
+ return {
414
+ ok: false,
415
+ appliedCount: 0,
416
+ fallbackReason: "no_auth",
417
+ error: auth.ok ? `No API key for ${model.provider}` : auth.error,
418
+ };
419
+ }
420
+
421
+ const controller = new AbortController();
422
+ const timeoutMs = options.timeoutMs ?? 120000;
423
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
424
+ if (options.signal) {
425
+ options.signal.addEventListener("abort", () => controller.abort(), { once: true });
426
+ }
427
+
428
+ const thinking = effectiveThinkingOverride(options.config);
429
+ const userMessage: Message = {
430
+ role: "user",
431
+ content: [{ type: "text", text: options.userPrompt }],
432
+ timestamp: Date.now(),
433
+ };
434
+
435
+ try {
436
+ const response = await completeSimple(
437
+ model,
438
+ { systemPrompt: DIRECT_REVIEW_SYSTEM_PROMPT, messages: [userMessage] },
439
+ buildDirectReviewCompletionOptions(
440
+ model,
441
+ { apiKey: auth.apiKey, headers: auth.headers, env: auth.env },
442
+ thinking,
443
+ controller.signal,
444
+ ),
445
+ );
446
+
447
+ if (response.stopReason === "aborted") {
448
+ return { ok: false, appliedCount: 0, fallbackReason: "aborted" };
449
+ }
450
+
451
+ const text = responseText(response.content);
452
+ const operations = parseReviewOperations(text);
453
+ if (operations === null) {
454
+ return { ok: false, appliedCount: 0, fallbackReason: "parse_error" };
455
+ }
456
+ if (operations.length === 0) {
457
+ return { ok: true, appliedCount: 0, fallbackReason: "empty" };
458
+ }
459
+
460
+ const { appliedCount } = await applyReviewOperations(
461
+ store,
462
+ projectStore,
463
+ operations,
464
+ dbManager,
465
+ projectName,
466
+ );
467
+ return { ok: true, appliedCount };
468
+ } catch (err) {
469
+ if (controller.signal.aborted) {
470
+ return { ok: false, appliedCount: 0, fallbackReason: "aborted" };
471
+ }
472
+ return {
473
+ ok: false,
474
+ appliedCount: 0,
475
+ fallbackReason: "provider_error",
476
+ error: err instanceof Error ? err.message : String(err),
477
+ };
478
+ } finally {
479
+ clearTimeout(timeout);
480
+ }
481
+ }
package/src/index.ts CHANGED
@@ -188,7 +188,10 @@ export default function (pi: ExtensionAPI) {
188
188
  registerSkillTool(pi, skillStore);
189
189
 
190
190
  // ── 5. Setup background learning loop (with tool-call-aware nudge) ──
191
- setupBackgroundReview(pi, store, projectStore, config);
191
+ setupBackgroundReview(pi, store, projectStore, config, {
192
+ dbManager,
193
+ projectName: projectName || null,
194
+ });
192
195
 
193
196
  // ── 6. Setup session-end flush ──
194
197
  setupSessionFlush(pi, store, projectStore, config);
package/src/types.ts CHANGED
@@ -10,6 +10,8 @@ export type SessionSearchVariant = "legacy" | "anchors";
10
10
 
11
11
  export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
12
12
 
13
+ export type ReviewTransport = "direct" | "subprocess";
14
+
13
15
  export interface SessionSearchConfig {
14
16
  /** Session search implementation variant. Default: legacy */
15
17
  variant: SessionSearchVariant;
@@ -34,6 +36,8 @@ export interface MemoryConfig {
34
36
  reviewRecentMessages?: number;
35
37
  /** Enable background learning loop. Default: true */
36
38
  reviewEnabled: boolean;
39
+ /** How background review invokes the LLM. Default: direct */
40
+ reviewTransport?: ReviewTransport;
37
41
  /** Flush memories before compaction. Default: true */
38
42
  flushOnCompact: boolean;
39
43
  /** Flush memories on session shutdown. Default: true */