pi-hermes-memory 0.7.22 → 0.8.0

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.
@@ -3,23 +3,129 @@
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";
10
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import { COMBINED_REVIEW_PROMPT, DIRECT_REVIEW_SYSTEM_PROMPT } from "../constants.js";
11
12
  import { MemoryStore } from "../store/memory-store.js";
12
- import { COMBINED_REVIEW_PROMPT } from "../constants.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 { runDirectMemoryCompletion, usesDirectTransport, 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 runDirectMemoryCompletion;
27
+ execChildPrompt?: typeof execChildPrompt;
28
+ /** Test-only hook: called once runReview() has fully settled (after the
29
+ * fire-and-forget review work completes and reviewInProgress resets),
30
+ * since production callers never await runReview() directly. */
31
+ onReviewSettled?: () => void;
32
+ }
33
+
34
+ export interface ReviewPromptInput {
35
+ parts: string[];
36
+ currentMemory: string;
37
+ currentUser: string;
38
+ currentProject: string | null;
39
+ }
40
+
41
+ export function buildSubprocessReviewPrompt(input: ReviewPromptInput): string {
42
+ const reviewPrompt = [
43
+ COMBINED_REVIEW_PROMPT,
44
+ "",
45
+ "--- Current Memory ---",
46
+ input.currentMemory || "(empty)",
47
+ "",
48
+ "--- Current User Profile ---",
49
+ input.currentUser || "(empty)",
50
+ ];
51
+
52
+ if (input.currentProject !== null) {
53
+ reviewPrompt.push(
54
+ "",
55
+ "--- Current Project Memory ---",
56
+ input.currentProject || "(empty)",
57
+ );
58
+ }
59
+
60
+ reviewPrompt.push(
61
+ "",
62
+ "--- Conversation to Review ---",
63
+ input.parts.join("\n\n"),
64
+ );
65
+
66
+ return reviewPrompt.join("\n");
67
+ }
68
+
69
+ export function buildDirectReviewUserPrompt(input: ReviewPromptInput): string {
70
+ const sections = [
71
+ "--- Current Memory ---",
72
+ input.currentMemory || "(empty)",
73
+ "",
74
+ "--- Current User Profile ---",
75
+ input.currentUser || "(empty)",
76
+ ];
77
+
78
+ if (input.currentProject !== null) {
79
+ sections.push(
80
+ "",
81
+ "--- Current Project Memory ---",
82
+ input.currentProject || "(empty)",
83
+ );
84
+ }
85
+
86
+ sections.push(
87
+ "",
88
+ "--- Conversation to Review ---",
89
+ input.parts.join("\n\n"),
90
+ );
91
+
92
+ return sections.join("\n");
93
+ }
94
+
95
+ function shouldNotifyDirect(result: DirectReviewResult): boolean {
96
+ return result.ok && result.appliedCount > 0;
97
+ }
98
+
99
+ function shouldNotifySubprocess(stdout: string | undefined): boolean {
100
+ const output = stdout?.trim();
101
+ return !!output && !output.toLowerCase().includes("nothing to save");
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 ?? runDirectMemoryCompletion;
126
+ const execChild = options.deps?.execChildPrompt ?? execChildPrompt;
127
+ const onReviewSettled = options.deps?.onReviewSettled;
128
+
23
129
  let turnsSinceReview = 0;
24
130
  let toolCallsSinceReview = 0;
25
131
  let userTurnCount = 0;
@@ -37,9 +143,6 @@ export function setupBackgroundReview(
37
143
  if (!config.reviewEnabled) return;
38
144
  if (reviewInProgress) return;
39
145
 
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
146
  try {
44
147
  const msg = event.message;
45
148
  if (msg?.role === "assistant") {
@@ -56,7 +159,6 @@ export function setupBackgroundReview(
56
159
  // If we can't count tool calls, fall back to turn-based only
57
160
  }
58
161
 
59
- // Trigger on EITHER turn count OR tool call count
60
162
  const turnThresholdMet = turnsSinceReview >= config.nudgeInterval;
61
163
  const toolCallThresholdMet = toolCallsSinceReview >= config.nudgeToolCalls;
62
164
 
@@ -67,78 +169,76 @@ export function setupBackgroundReview(
67
169
  toolCallsSinceReview = 0;
68
170
  reviewInProgress = true;
69
171
 
70
- // Build conversation snapshot from session entries (crash-safe)
71
172
  let allParts: string[] = [];
72
173
  try {
73
174
  const entries = ctx.sessionManager.getBranch();
74
175
  allParts = collectMessageParts(entries);
75
176
  } catch {
76
177
  reviewInProgress = false;
77
- return; // Session expired or empty — nothing to review
178
+ return;
78
179
  }
79
180
  if (allParts.length < 4) {
80
181
  reviewInProgress = false;
81
- return; // Not enough conversation to review
182
+ return;
82
183
  }
184
+
83
185
  const parts = applyRecentMessageLimit(allParts, config.reviewRecentMessages);
186
+ const promptInput: ReviewPromptInput = {
187
+ parts,
188
+ currentMemory: store.getMemoryEntries().join("\n§\n"),
189
+ currentUser: store.getUserEntries().join("\n§\n"),
190
+ currentProject: projectStore ? projectStore.getMemoryEntries().join("\n§\n") : null,
191
+ };
84
192
 
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;
193
+ const subprocessPrompt = buildSubprocessReviewPrompt(promptInput);
194
+ const directPrompt = buildDirectReviewUserPrompt(promptInput);
88
195
 
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
- }
196
+ const finishReview = () => {
197
+ reviewInProgress = false;
198
+ onReviewSettled?.();
199
+ };
106
200
 
107
- reviewPrompt.push(
108
- "",
109
- "--- Conversation to Review ---",
110
- parts.join("\n\n"),
111
- );
201
+ const notifyIfSaved = (saved: boolean) => {
202
+ if (saved) {
203
+ ctx.ui.notify("💾 Memory auto-reviewed and updated", "info");
204
+ }
205
+ };
206
+
207
+ const runReview = async (): Promise<void> => {
208
+ if (usesDirectTransport(config)) {
209
+ try {
210
+ const directResult = await runDirectReview(
211
+ ctx as Pick<ExtensionContext, "model" | "modelRegistry">,
212
+ store,
213
+ projectStore,
214
+ { userPrompt: directPrompt, systemPrompt: DIRECT_REVIEW_SYSTEM_PROMPT, config, timeoutMs: 120000 },
215
+ dbManager,
216
+ projectName,
217
+ );
218
+
219
+ if (directResult.ok) {
220
+ notifyIfSaved(shouldNotifyDirect(directResult));
221
+ return;
222
+ }
112
223
 
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");
224
+ if (directResult.fallbackReason === "empty") {
225
+ return;
132
226
  }
227
+ } catch {
228
+ // Fall through to subprocess below.
133
229
  }
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
- })
230
+ }
231
+
232
+ const subprocessResult = await runSubprocessReview(pi, subprocessPrompt, config, execChild);
233
+ if (subprocessResult.code === 0) {
234
+ notifyIfSaved(shouldNotifySubprocess(subprocessResult.stdout));
235
+ }
236
+ };
237
+
238
+ runReview()
138
239
  .catch(() => {
139
- // Best-effort: subprocess failures (timeout, signal, spawn errors)
140
- // are silently ignored. The next review cycle will retry.
141
- reviewInProgress = false;
142
- });
240
+ // Best-effort only
241
+ })
242
+ .finally(finishReview);
143
243
  });
144
- }
244
+ }
@@ -0,0 +1,90 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+
4
+ const [timeoutValue, cancellationPath, command, ...args] = process.argv.slice(2);
5
+ const timeoutMs = Number(timeoutValue);
6
+
7
+ if (!cancellationPath || !command || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
8
+ process.stderr.write("pi-hermes-memory watchdog: invalid invocation\n");
9
+ process.exit(2);
10
+ }
11
+
12
+ const child = spawn(command, args, {
13
+ detached: process.platform !== "win32",
14
+ stdio: ["ignore", "pipe", "pipe"],
15
+ });
16
+
17
+ child.stdout?.pipe(process.stdout);
18
+ child.stderr?.pipe(process.stderr);
19
+
20
+ let timedOut = false;
21
+ let cancelled = false;
22
+ let terminating = false;
23
+ let forceTimer;
24
+
25
+ function signalTree(signal) {
26
+ if (!child.pid) return;
27
+ if (process.platform === "win32") {
28
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
29
+ stdio: "ignore",
30
+ windowsHide: true,
31
+ });
32
+ killer.unref();
33
+ return;
34
+ }
35
+ try {
36
+ process.kill(-child.pid, signal);
37
+ } catch {
38
+ try { child.kill(signal); } catch {}
39
+ }
40
+ }
41
+
42
+ function terminateTree() {
43
+ if (terminating) return;
44
+ terminating = true;
45
+ signalTree("SIGTERM");
46
+ forceTimer = setTimeout(() => signalTree("SIGKILL"), 500);
47
+ forceTimer.unref();
48
+ }
49
+
50
+ const timeout = setTimeout(() => {
51
+ timedOut = true;
52
+ process.stderr.write(`[pi-hermes-memory] child timed out after ${timeoutMs}ms; terminating process tree\n`);
53
+ terminateTree();
54
+ }, timeoutMs);
55
+ timeout.unref();
56
+
57
+ const cancellationPoll = cancellationPath === "-" ? undefined : setInterval(() => {
58
+ if (!existsSync(cancellationPath)) return;
59
+ cancelled = true;
60
+ process.stderr.write("[pi-hermes-memory] child cancellation requested; terminating process tree\n");
61
+ terminateTree();
62
+ }, 25);
63
+ cancellationPoll?.unref();
64
+
65
+ for (const signal of ["SIGTERM", "SIGINT"]) {
66
+ process.on(signal, terminateTree);
67
+ }
68
+
69
+ child.once("error", (error) => {
70
+ clearTimeout(timeout);
71
+ if (cancellationPoll) clearInterval(cancellationPoll);
72
+ if (forceTimer) clearTimeout(forceTimer);
73
+ process.stderr.write(`pi-hermes-memory watchdog: ${error.message}\n`);
74
+ process.exitCode = timedOut ? 124 : cancelled ? 143 : 127;
75
+ });
76
+
77
+ child.once("close", (code, signal) => {
78
+ clearTimeout(timeout);
79
+ if (cancellationPoll) clearInterval(cancellationPoll);
80
+ if (forceTimer) clearTimeout(forceTimer);
81
+ if (timedOut) {
82
+ process.exitCode = 124;
83
+ } else if (cancelled) {
84
+ process.exitCode = 143;
85
+ } else if (typeof code === "number") {
86
+ process.exitCode = code;
87
+ } else {
88
+ process.exitCode = signal === "SIGTERM" ? 143 : 1;
89
+ }
90
+ });
@@ -11,21 +11,19 @@
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { MemoryStore } from "../store/memory-store.js";
13
13
  import { DatabaseManager } from "../store/db.js";
14
- import {
15
- formatFailureMemoryContent,
16
- syncMemoryEntry,
17
- } from "../store/sqlite-memory-store.js";
18
14
  import {
19
15
  CORRECTION_SAVE_PROMPT,
20
16
  CORRECTION_STRONG_PATTERNS,
21
17
  CORRECTION_WEAK_PATTERNS,
22
18
  CORRECTION_NEGATIVE_PATTERNS,
23
19
  CORRECTION_DIRECTIVE_WORDS,
20
+ DIRECT_CORRECTION_SYSTEM_PROMPT,
24
21
  ENTRY_DELIMITER,
25
22
  } from "../constants.js";
26
23
  import type { MemoryConfig } from "../types.js";
27
24
  import { getMessageText } from "../types.js";
28
25
  import { execChildPrompt } from "./pi-child-process.js";
26
+ import { runDirectMemoryCompletion, usesDirectTransport } from "./review-memory-ops.js";
29
27
 
30
28
  /**
31
29
  * Extract the directive part from a correction message.
@@ -129,12 +127,14 @@ export function setupCorrectionDetector(
129
127
  config: MemoryConfig,
130
128
  dbManager: DatabaseManager | null = null,
131
129
  projectName?: string | null,
130
+ deps: { runDirectMemoryCompletion?: typeof runDirectMemoryCompletion } = {},
132
131
  ): void {
133
132
  if (!config.correctionDetection) return;
134
133
 
135
134
  let pendingCorrection = false;
136
135
  let turnsSinceLastCorrection = 3; // Start at threshold so first correction can fire immediately
137
136
  let correctionInProgress = false;
137
+ const runDirect = deps.runDirectMemoryCompletion ?? runDirectMemoryCompletion;
138
138
 
139
139
  // Flag on message_end (user role)
140
140
  pi.on("message_end", async (event, _ctx) => {
@@ -182,9 +182,7 @@ export function setupCorrectionDetector(
182
182
  const currentUser = store.getUserEntries().join(ENTRY_DELIMITER);
183
183
  const currentProject = projectStore ? projectStore.getMemoryEntries().join(ENTRY_DELIMITER) : null;
184
184
 
185
- const prompt = [
186
- CORRECTION_SAVE_PROMPT,
187
- "",
185
+ const promptBody = [
188
186
  "--- Current Memory ---",
189
187
  currentMemory || "(empty)",
190
188
  "",
@@ -193,31 +191,67 @@ export function setupCorrectionDetector(
193
191
  ];
194
192
 
195
193
  if (currentProject !== null) {
196
- prompt.push(
194
+ promptBody.push(
197
195
  "",
198
196
  "--- Current Project Memory ---",
199
197
  currentProject || "(empty)",
200
198
  );
201
199
  }
202
200
 
203
- prompt.push(
201
+ promptBody.push(
204
202
  "",
205
203
  "--- Recent Conversation ---",
206
204
  recentParts.join("\n\n"),
207
205
  );
208
206
 
209
- const result = await execChildPrompt(pi, prompt.join("\n"), config, {
210
- signal: ctx.signal,
211
- timeoutMs: 30000,
212
- });
207
+ let savedViaLlm = false;
208
+
209
+ const runSubprocessCorrection = async (): Promise<void> => {
210
+ const subprocessPrompt = [CORRECTION_SAVE_PROMPT, "", ...promptBody].join("\n");
211
+ const result = await execChildPrompt(pi, subprocessPrompt, config, {
212
+ signal: ctx.signal,
213
+ timeoutMs: 30000,
214
+ });
215
+ if (result.code === 0 && result.stdout) {
216
+ const output = result.stdout.trim();
217
+ savedViaLlm = !!output && !output.toLowerCase().includes("nothing to save");
218
+ }
219
+ };
213
220
 
214
- if (result.code === 0 && result.stdout) {
215
- const output = result.stdout.trim();
216
- if (output && !output.toLowerCase().includes("nothing to save")) {
217
- ctx.ui.notify("🔧 Correction detected memory updated", "info");
221
+ let handledDirect = false;
222
+ if (usesDirectTransport(config)) {
223
+ try {
224
+ const directResult = await runDirect(
225
+ ctx,
226
+ store,
227
+ projectStore,
228
+ {
229
+ systemPrompt: DIRECT_CORRECTION_SYSTEM_PROMPT,
230
+ userPrompt: promptBody.join("\n"),
231
+ config,
232
+ timeoutMs: 30000,
233
+ signal: ctx.signal,
234
+ },
235
+ dbManager,
236
+ projectName,
237
+ );
238
+ if (directResult.ok) {
239
+ savedViaLlm = directResult.appliedCount > 0;
240
+ handledDirect = true;
241
+ }
242
+ } catch {
243
+ // Fall through to subprocess below.
218
244
  }
219
245
  }
220
246
 
247
+ if (!handledDirect) {
248
+ await runSubprocessCorrection();
249
+ }
250
+
251
+ if (savedViaLlm) {
252
+ ctx.ui.notify("🔧 Correction detected — memory updated", "info");
253
+ }
254
+
221
255
  // Also save as a failure memory for learning
222
256
  try {
223
257
  const lastUserMsg = recentParts.find(p => p.startsWith("[USER]"));
@@ -226,29 +260,11 @@ export function setupCorrectionDetector(
226
260
  const directive = extractCorrectionDirective(correctionText);
227
261
  const failureReason = "User corrected the agent";
228
262
  const scopedProjectName = projectStore ? projectName?.trim() || null : null;
229
- const addResult = await store.addFailure(directive, {
263
+ await store.addFailure(directive, {
230
264
  category: "correction",
231
265
  failureReason,
232
266
  project: scopedProjectName ?? undefined,
233
267
  });
234
-
235
- if (addResult.success && dbManager) {
236
- try {
237
- syncMemoryEntry(dbManager, {
238
- content: formatFailureMemoryContent(directive, {
239
- category: "correction",
240
- failureReason,
241
- project: scopedProjectName,
242
- }),
243
- target: "failure",
244
- project: scopedProjectName,
245
- category: "correction",
246
- failureReason,
247
- });
248
- } catch {
249
- // Best-effort — searchable sync should not block correction capture
250
- }
251
- }
252
268
  }
253
269
  } catch {
254
270
  // Best-effort — don't block the session