pi-hermes-memory 0.7.23 → 0.8.1

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.
@@ -8,13 +8,13 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
- import { COMBINED_REVIEW_PROMPT } from "../constants.js";
11
+ import { COMBINED_REVIEW_PROMPT, DIRECT_REVIEW_SYSTEM_PROMPT } from "../constants.js";
12
12
  import { MemoryStore } from "../store/memory-store.js";
13
13
  import { DatabaseManager } from "../store/db.js";
14
14
  import type { MemoryConfig } from "../types.js";
15
15
  import { applyRecentMessageLimit, collectMessageParts } from "./message-parts.js";
16
16
  import { execChildPrompt } from "./pi-child-process.js";
17
- import { runDirectBackgroundReview, type DirectReviewResult } from "./review-memory-ops.js";
17
+ import { runDirectMemoryCompletion, usesDirectTransport, type DirectReviewResult } from "./review-memory-ops.js";
18
18
 
19
19
  export interface BackgroundReviewOptions {
20
20
  dbManager?: DatabaseManager | null;
@@ -23,8 +23,12 @@ export interface BackgroundReviewOptions {
23
23
  }
24
24
 
25
25
  export interface BackgroundReviewDeps {
26
- runDirectReview?: typeof runDirectBackgroundReview;
26
+ runDirectReview?: typeof runDirectMemoryCompletion;
27
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;
28
32
  }
29
33
 
30
34
  export interface ReviewPromptInput {
@@ -97,10 +101,6 @@ function shouldNotifySubprocess(stdout: string | undefined): boolean {
97
101
  return !!output && !output.toLowerCase().includes("nothing to save");
98
102
  }
99
103
 
100
- function usesDirectTransport(config: MemoryConfig): boolean {
101
- return (config.reviewTransport ?? "direct") === "direct";
102
- }
103
-
104
104
  async function runSubprocessReview(
105
105
  pi: ExtensionAPI,
106
106
  prompt: string,
@@ -122,8 +122,9 @@ export function setupBackgroundReview(
122
122
  ): void {
123
123
  const dbManager = options.dbManager ?? null;
124
124
  const projectName = options.projectName ?? null;
125
- const runDirectReview = options.deps?.runDirectReview ?? runDirectBackgroundReview;
125
+ const runDirectReview = options.deps?.runDirectReview ?? runDirectMemoryCompletion;
126
126
  const execChild = options.deps?.execChildPrompt ?? execChildPrompt;
127
+ const onReviewSettled = options.deps?.onReviewSettled;
127
128
 
128
129
  let turnsSinceReview = 0;
129
130
  let toolCallsSinceReview = 0;
@@ -194,6 +195,7 @@ export function setupBackgroundReview(
194
195
 
195
196
  const finishReview = () => {
196
197
  reviewInProgress = false;
198
+ onReviewSettled?.();
197
199
  };
198
200
 
199
201
  const notifyIfSaved = (saved: boolean) => {
@@ -204,22 +206,26 @@ export function setupBackgroundReview(
204
206
 
205
207
  const runReview = async (): Promise<void> => {
206
208
  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
- }
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
+ }
220
223
 
221
- if (directResult.fallbackReason === "empty") {
222
- return;
224
+ if (directResult.fallbackReason === "empty") {
225
+ return;
226
+ }
227
+ } catch {
228
+ // Fall through to subprocess below.
223
229
  }
224
230
  }
225
231
 
@@ -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