pi-hermes-memory 0.7.23 → 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.
- package/README.md +6 -5
- package/package.json +2 -2
- package/src/config.ts +6 -0
- package/src/constants.ts +53 -11
- package/src/extension-root-migration.ts +429 -5
- package/src/handlers/auto-consolidate.ts +118 -8
- package/src/handlers/background-review.ts +29 -23
- package/src/handlers/child-process-watchdog.mjs +90 -0
- package/src/handlers/correction-detector.ts +52 -36
- package/src/handlers/pi-child-process.ts +270 -37
- package/src/handlers/review-memory-ops.ts +18 -116
- package/src/handlers/session-flush.ts +71 -4
- package/src/handlers/sync-markdown-memories.ts +143 -51
- package/src/index.ts +32 -16
- package/src/store/atomic-lock-coordinator.ts +252 -0
- package/src/store/canonical-storage-path.ts +54 -0
- package/src/store/db.ts +244 -9
- package/src/store/markdown-mutation-lock.ts +38 -0
- package/src/store/memory-store.ts +455 -57
- package/src/store/sqlite-memory-store.ts +121 -4
- package/src/tools/memory-tool.ts +48 -9
- package/src/tools/session-search-tool.ts +70 -12
- package/src/types.ts +2 -0
|
@@ -2,19 +2,72 @@
|
|
|
2
2
|
* Auto-consolidation — when memory hits capacity, trigger automatic
|
|
3
3
|
* consolidation instead of returning an error.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Default transport: in-process direct completion (same mechanism as
|
|
6
|
+
* background review — see review-memory-ops.ts), used only when a caller
|
|
7
|
+
* supplies model/modelRegistry access (the manual `/memory-consolidate`
|
|
8
|
+
* command has it; the automatic over-capacity consolidator registered on
|
|
9
|
+
* MemoryStore does not, since MemoryStore itself has no extension-runtime
|
|
10
|
+
* access, so that path stays subprocess-only). Falls back to a `pi -p`
|
|
11
|
+
* subprocess when direct mode is unavailable, declines, or fails.
|
|
12
|
+
*
|
|
13
|
+
* The subprocess child process modifies files on disk, so the parent MUST
|
|
14
|
+
* reload from disk after a subprocess-based consolidation completes.
|
|
8
15
|
*/
|
|
9
16
|
|
|
10
|
-
import
|
|
17
|
+
import * as fs from "node:fs/promises";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
21
|
import { MemoryStore } from "../store/memory-store.js";
|
|
12
|
-
import {
|
|
22
|
+
import { DatabaseManager } from "../store/db.js";
|
|
23
|
+
import { CONSOLIDATION_PROMPT, DIRECT_CONSOLIDATION_SYSTEM_PROMPT, ENTRY_DELIMITER } from "../constants.js";
|
|
13
24
|
import type { ConsolidationResult, MemoryConfig } from "../types.js";
|
|
25
|
+
import { AGENT_ROOT } from "../paths.js";
|
|
14
26
|
import { execChildPrompt } from "./pi-child-process.js";
|
|
27
|
+
import { runDirectMemoryCompletion, usesDirectTransport } from "./review-memory-ops.js";
|
|
28
|
+
import { AtomicLockCoordinator } from "../store/atomic-lock-coordinator.js";
|
|
15
29
|
|
|
16
30
|
type MemoryTarget = "memory" | "user" | "failure";
|
|
17
31
|
type ToolMemoryTarget = MemoryTarget | "project";
|
|
32
|
+
type ConsolidationLlmConfig = Pick<MemoryConfig, "llmModelOverride" | "llmThinkingOverride" | "reviewTransport">;
|
|
33
|
+
|
|
34
|
+
const CONSOLIDATION_LOCK_STALE_GRACE_MS = 30000;
|
|
35
|
+
const CONSOLIDATION_LOCK_ENV = "PI_HERMES_CONSOLIDATION_LOCK_DIR";
|
|
36
|
+
|
|
37
|
+
interface ConsolidationLock {
|
|
38
|
+
release: () => Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function consolidationLockRoot(): string {
|
|
42
|
+
return process.env[CONSOLIDATION_LOCK_ENV]?.trim()
|
|
43
|
+
|| path.join(AGENT_ROOT, "pi-hermes-memory", ".consolidation-locks");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sanitizeLockPart(value: string): string {
|
|
47
|
+
return value.replace(/[^a-z0-9._-]+/gi, "_").slice(0, 80) || "unknown";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function consolidationLockKey(target: MemoryTarget, toolTarget: ToolMemoryTarget, storageIdentity: string): string {
|
|
51
|
+
const storageHash = createHash("sha256").update(storageIdentity).digest("hex");
|
|
52
|
+
return `${sanitizeLockPart(toolTarget)}:${sanitizeLockPart(target)}:${storageHash}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function tryAcquireConsolidationLock(
|
|
56
|
+
store: MemoryStore,
|
|
57
|
+
target: MemoryTarget,
|
|
58
|
+
toolTarget: ToolMemoryTarget,
|
|
59
|
+
timeoutMs: number,
|
|
60
|
+
): Promise<ConsolidationLock | null> {
|
|
61
|
+
const storageIdentity = await store.getStorageIdentity(target);
|
|
62
|
+
const root = consolidationLockRoot();
|
|
63
|
+
await fs.mkdir(root, { recursive: true });
|
|
64
|
+
const coordinator = new AtomicLockCoordinator(path.join(root, "locks.sqlite"));
|
|
65
|
+
const lease = coordinator.tryAcquire(
|
|
66
|
+
consolidationLockKey(target, toolTarget, storageIdentity),
|
|
67
|
+
{ staleMs: Math.max(timeoutMs, 0) + CONSOLIDATION_LOCK_STALE_GRACE_MS },
|
|
68
|
+
);
|
|
69
|
+
return lease ? { release: async () => lease.release() } : null;
|
|
70
|
+
}
|
|
18
71
|
|
|
19
72
|
function entriesForTarget(store: MemoryStore, target: MemoryTarget): string[] {
|
|
20
73
|
if (target === "user") return store.getUserEntries();
|
|
@@ -34,7 +87,7 @@ function describeConsolidationFailure(
|
|
|
34
87
|
timeoutMs: number,
|
|
35
88
|
): string {
|
|
36
89
|
const stderr = result.stderr?.trim();
|
|
37
|
-
const terminated = result.killed || result.code === 143;
|
|
90
|
+
const terminated = result.killed || result.code === 124 || result.code === 143;
|
|
38
91
|
|
|
39
92
|
if (terminated) {
|
|
40
93
|
return `Consolidation subprocess was terminated (likely timeout or cancellation). Timeout: ${timeoutMs}ms. Consider increasing consolidationTimeoutMs if this is a manual run.`;
|
|
@@ -50,10 +103,47 @@ export async function triggerConsolidation(
|
|
|
50
103
|
signal?: AbortSignal,
|
|
51
104
|
timeoutMs: number = 60000,
|
|
52
105
|
toolTarget: ToolMemoryTarget = target,
|
|
53
|
-
llmConfig:
|
|
106
|
+
llmConfig: ConsolidationLlmConfig = {},
|
|
107
|
+
directCtx: Pick<ExtensionContext, "model" | "modelRegistry"> | null = null,
|
|
108
|
+
dbManager: DatabaseManager | null = null,
|
|
109
|
+
projectName?: string | null,
|
|
110
|
+
deps: { runDirectMemoryCompletion?: typeof runDirectMemoryCompletion } = {},
|
|
54
111
|
): Promise<ConsolidationResult> {
|
|
55
112
|
const entries = entriesForTarget(store, target);
|
|
56
113
|
const currentContent = entries.join(ENTRY_DELIMITER);
|
|
114
|
+
const runDirect = deps.runDirectMemoryCompletion ?? runDirectMemoryCompletion;
|
|
115
|
+
|
|
116
|
+
if (directCtx && usesDirectTransport(llmConfig)) {
|
|
117
|
+
try {
|
|
118
|
+
const directResult = await runDirect(
|
|
119
|
+
directCtx,
|
|
120
|
+
store,
|
|
121
|
+
toolTarget === "project" ? store : null,
|
|
122
|
+
{
|
|
123
|
+
systemPrompt: DIRECT_CONSOLIDATION_SYSTEM_PROMPT,
|
|
124
|
+
userPrompt: [
|
|
125
|
+
`--- Current ${labelForTarget(target, toolTarget)} Entries (target: '${toolTarget}') ---`,
|
|
126
|
+
currentContent || "(empty)",
|
|
127
|
+
"",
|
|
128
|
+
`Only emit operations with "target": "${toolTarget}".`,
|
|
129
|
+
].join("\n"),
|
|
130
|
+
config: llmConfig,
|
|
131
|
+
timeoutMs,
|
|
132
|
+
signal,
|
|
133
|
+
},
|
|
134
|
+
dbManager,
|
|
135
|
+
projectName,
|
|
136
|
+
);
|
|
137
|
+
// Consolidation only did its job if it actually freed space — unlike
|
|
138
|
+
// review/flush/correction, an empty or fully-skipped result here is a
|
|
139
|
+
// failure worth falling back to subprocess for, not a normal outcome.
|
|
140
|
+
if (directResult.ok && directResult.appliedCount > 0) {
|
|
141
|
+
return { consolidated: true };
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// Fall through to subprocess below.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
57
147
|
|
|
58
148
|
const prompt = [
|
|
59
149
|
CONSOLIDATION_PROMPT,
|
|
@@ -64,7 +154,17 @@ export async function triggerConsolidation(
|
|
|
64
154
|
`Use the memory tool to consolidate. Target: '${toolTarget}'`,
|
|
65
155
|
].join("\n");
|
|
66
156
|
|
|
157
|
+
let lock: ConsolidationLock | null = null;
|
|
158
|
+
|
|
67
159
|
try {
|
|
160
|
+
lock = await tryAcquireConsolidationLock(store, target, toolTarget, timeoutMs);
|
|
161
|
+
if (!lock) {
|
|
162
|
+
return {
|
|
163
|
+
consolidated: false,
|
|
164
|
+
error: `Consolidation already in progress for target '${toolTarget}'. Skipping duplicate subprocess.`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
68
168
|
const result = await execChildPrompt(pi, prompt, llmConfig, {
|
|
69
169
|
signal,
|
|
70
170
|
timeoutMs,
|
|
@@ -83,6 +183,10 @@ export async function triggerConsolidation(
|
|
|
83
183
|
consolidated: false,
|
|
84
184
|
error: `Consolidation failed: ${String(err).slice(0, 200)}`,
|
|
85
185
|
};
|
|
186
|
+
} finally {
|
|
187
|
+
if (lock) {
|
|
188
|
+
try { await lock.release(); } catch { /* best-effort cleanup */ }
|
|
189
|
+
}
|
|
86
190
|
}
|
|
87
191
|
}
|
|
88
192
|
|
|
@@ -95,7 +199,9 @@ export function registerConsolidateCommand(
|
|
|
95
199
|
timeoutMs: number = 60000,
|
|
96
200
|
projectStore: MemoryStore | null = null,
|
|
97
201
|
projectName?: string | null,
|
|
98
|
-
llmConfig:
|
|
202
|
+
llmConfig: ConsolidationLlmConfig = {},
|
|
203
|
+
dbManager: DatabaseManager | null = null,
|
|
204
|
+
deps: { runDirectMemoryCompletion?: typeof runDirectMemoryCompletion } = {},
|
|
99
205
|
): void {
|
|
100
206
|
pi.registerCommand("memory-consolidate", {
|
|
101
207
|
description: "Manually trigger memory consolidation to free up space",
|
|
@@ -157,6 +263,10 @@ export function registerConsolidateCommand(
|
|
|
157
263
|
manualTimeoutMs,
|
|
158
264
|
item.toolTarget,
|
|
159
265
|
llmConfig,
|
|
266
|
+
ctx,
|
|
267
|
+
dbManager,
|
|
268
|
+
projectName,
|
|
269
|
+
deps,
|
|
160
270
|
);
|
|
161
271
|
|
|
162
272
|
if (result.consolidated) {
|
|
@@ -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 {
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
|
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
|
-
|
|
194
|
+
promptBody.push(
|
|
197
195
|
"",
|
|
198
196
|
"--- Current Project Memory ---",
|
|
199
197
|
currentProject || "(empty)",
|
|
200
198
|
);
|
|
201
199
|
}
|
|
202
200
|
|
|
203
|
-
|
|
201
|
+
promptBody.push(
|
|
204
202
|
"",
|
|
205
203
|
"--- Recent Conversation ---",
|
|
206
204
|
recentParts.join("\n\n"),
|
|
207
205
|
);
|
|
208
206
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|