Skip to content

Commit 5c562dd

Browse files
committed
fix(pi-memory): harden dream completion tracking
1 parent 9d608c7 commit 5c562dd

4 files changed

Lines changed: 78 additions & 14 deletions

File tree

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/pi-memory/extensions/memory.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { lstat, mkdir, open, readFile, readdir, realpath, writeFile } from "node:fs/promises";
1+
import { lstat, mkdir, open, readFile, readdir, realpath, rename, unlink } from "node:fs/promises";
22
import { join, sep } from "node:path";
33
import { StringEnum } from "@earendil-works/pi-ai";
44
import { getAgentDir, withFileMutationQueue, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -78,6 +78,29 @@ async function loadLastDreamAt(): Promise<number | undefined> {
7878
}
7979
}
8080

81+
async function saveLastDreamAt(): Promise<void> {
82+
const path = LAST_DREAM_PATH();
83+
const tempPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
84+
let created = false;
85+
try {
86+
const handle = await open(tempPath, "wx", 0o600);
87+
created = true;
88+
try {
89+
await handle.writeFile(`${new Date().toISOString()}\n`);
90+
} finally {
91+
await handle.close();
92+
}
93+
// rename replaces a destination symlink rather than following it.
94+
await rename(tempPath, path);
95+
} finally {
96+
if (created) {
97+
await unlink(tempPath).catch((error: NodeJS.ErrnoException) => {
98+
if (error.code !== "ENOENT") throw error;
99+
});
100+
}
101+
}
102+
}
103+
81104
function sanitizeEntry(entry: string): string {
82105
return entry.split("\n").map((line) => isReservedFrameLine(line) ? FRAME_TOKEN_REPLACEMENT : line).join("\n");
83106
}
@@ -249,7 +272,7 @@ export default function memoryExtension(pi: ExtensionAPI): void {
249272
for (let index = event.messages.length - 1; index >= 0; index--) {
250273
const message = event.messages[index];
251274
if (message?.role !== "assistant") continue;
252-
state.dreamSucceeded = message.stopReason !== "error" && message.stopReason !== "aborted" && message.stopReason !== "length";
275+
state.dreamSucceeded = message.stopReason === "stop";
253276
break;
254277
}
255278
});
@@ -264,7 +287,7 @@ export default function memoryExtension(pi: ExtensionAPI): void {
264287
return;
265288
}
266289
try {
267-
await writeFile(LAST_DREAM_PATH(), `${new Date().toISOString()}\n`, { mode: 0o600 });
290+
await saveLastDreamAt();
268291
} catch (error) {
269292
ctx.ui.notify(`Dream completed, but its timestamp could not be recorded: ${error instanceof Error ? error.message : String(error)}`, "warning");
270293
}
@@ -323,12 +346,14 @@ export default function memoryExtension(pi: ExtensionAPI): void {
323346
state.snapshotSanitized = rendered.some(({ sanitized }) => sanitized);
324347
state.conflictWarnings = conflictWarnings;
325348

326-
if (!process.argv.includes(BTW_CHILD_PAYLOAD_ARG) && (memory.entries.length || user.entries.length)) {
349+
const memoryChars = memory.entries.join(ENTRY_DELIMITER).length;
350+
const userChars = user.entries.join(ENTRY_DELIMITER).length;
351+
const validWithinCap = !memory.status && !user.status
352+
&& memoryChars <= config.memoryCharLimit && userChars <= config.userCharLimit;
353+
if (!process.argv.includes(BTW_CHILD_PAYLOAD_ARG) && validWithinCap && (memory.entries.length || user.entries.length)) {
327354
try {
328355
const lastDreamAt = await loadLastDreamAt();
329356
const age = lastDreamAt === undefined ? undefined : Date.now() - lastDreamAt;
330-
const memoryChars = memory.entries.join(ENTRY_DELIMITER).length;
331-
const userChars = user.entries.join(ENTRY_DELIMITER).length;
332357
const full = memoryChars * 100 >= config.memoryCharLimit * DREAM_USAGE_PERCENT
333358
|| userChars * 100 >= config.userCharLimit * DREAM_USAGE_PERCENT;
334359
if (age === undefined || age >= DREAM_AFTER_MS || (full && age >= DREAM_FULL_COOLDOWN_MS)) {

packages/pi-memory/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@henryqw/pi-memory",
3-
"version": "1.2.1",
3+
"version": "1.3.0",
44
"description": "Auto-managed markdown memory for Pi: capped MEMORY.md/USER.md entry stores with frozen session snapshots.",
55
"keywords": [
66
"pi-package",

packages/pi-memory/test/memory.test.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from "node:assert/strict";
2-
import { mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
2+
import { lstat, mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import test from "node:test";
@@ -101,7 +101,7 @@ test("/remember validates input, rejects busy agents, and sends live state", asy
101101
}
102102
});
103103

104-
test("session start recommends /dream when memory is new, stale, or 70% full", async () => {
104+
test("session start recommends /dream only for valid stores within their caps", async () => {
105105
const root = await mkdtemp(join(tmpdir(), "pi-memory-dream-reminder-"));
106106
const agentDir = join(root, "agent");
107107
const memoryDir = join(root, "memory");
@@ -147,6 +147,28 @@ test("session start recommends /dream when memory is new, stale, or 70% full", a
147147
assert.deepEqual(notifications, ["Memory dream recommended; run /dream."]);
148148

149149
notifications.length = 0;
150+
await rm(statePath);
151+
await writeFile(join(memoryDir, "MEMORY.md"), "x".repeat(11));
152+
await handlers.get("session_start")!({ type: "session_start" }, ctx);
153+
assert.deepEqual(notifications, []);
154+
155+
await writeFile(join(memoryDir, "MEMORY.md"), "123456");
156+
await writeFile(join(memoryDir, "USER.md"), "x".repeat(11));
157+
await handlers.get("session_start")!({ type: "session_start" }, ctx);
158+
assert.deepEqual(notifications, []);
159+
160+
await writeFile(join(memoryDir, "USER.md"), "");
161+
await writeFile(join(memoryDir, "MEMORY.md"), "x".repeat(MAX_FILE_BYTES + 1));
162+
await handlers.get("session_start")!({ type: "session_start" }, ctx);
163+
assert.deepEqual(notifications, []);
164+
165+
await rm(join(memoryDir, "MEMORY.md"));
166+
await symlink(join(root, "missing-MEMORY.md"), join(memoryDir, "MEMORY.md"));
167+
await handlers.get("session_start")!({ type: "session_start" }, ctx);
168+
assert.deepEqual(notifications, []);
169+
170+
await rm(join(memoryDir, "MEMORY.md"));
171+
await writeFile(join(memoryDir, "MEMORY.md"), "123456");
150172
await writeFile(statePath, "x".repeat(65));
151173
await handlers.get("session_start")!({ type: "session_start" }, ctx);
152174
assert.match(notifications[0]!, /Timestamp file is too large/);
@@ -248,19 +270,36 @@ test("/dream reuses unchanged memory snapshots and guards the agent-global SYSTE
248270
assert.match(messages[0]!, /one memory batch per affected target/);
249271
assert.match(messages[0]!, /no memory call if none/);
250272

273+
await rm(lastDreamPath);
274+
await dream.handler("", context(true));
275+
await handlers.get("agent_end")!({ type: "agent_end", messages: [{ role: "assistant", stopReason: "toolUse" }] });
276+
await handlers.get("agent_settled")!({ type: "agent_settled" }, context(true));
277+
await assert.rejects(readFile(lastDreamPath), /ENOENT/);
278+
assert.equal(notifications.at(-1), "Dream did not complete; its timestamp was not updated.");
279+
280+
const dreamTarget = join(root, "dream-target.txt");
281+
await writeFile(dreamTarget, "keep this target");
282+
await symlink(dreamTarget, lastDreamPath);
283+
await dream.handler("", context(true));
284+
await handlers.get("agent_end")!({ type: "agent_end", messages: [{ role: "assistant", stopReason: "stop" }] });
285+
await handlers.get("agent_settled")!({ type: "agent_settled" }, context(true));
286+
assert.equal(await readFile(dreamTarget, "utf8"), "keep this target");
287+
assert.equal((await lstat(lastDreamPath)).isSymbolicLink(), false);
288+
assert.ok(Number.isFinite(Date.parse((await readFile(lastDreamPath, "utf8")).trim())));
289+
251290
process.argv.push(CHILD_PAYLOAD_ARG);
252291
try {
253292
await dream.handler("", context(true));
254-
assert.ok(messages[1]!.includes(JSON.stringify({ memory: ["stable fact"], user: ["likes concise replies"] })));
255-
assert.doesNotMatch(messages[1]!, /do not reread those files/);
256-
assert.ok(messages[1]!.includes(`Read ${JSON.stringify(systemPath)} before semantic deduplication or editing.`));
293+
assert.ok(messages.at(-1)!.includes(JSON.stringify({ memory: ["stable fact"], user: ["likes concise replies"] })));
294+
assert.doesNotMatch(messages.at(-1)!, /do not reread those files/);
295+
assert.ok(messages.at(-1)!.includes(`Read ${JSON.stringify(systemPath)} before semantic deduplication or editing.`));
257296
} finally {
258297
process.argv.pop();
259298
}
260299

261300
await writeFile(join(memoryDir, "MEMORY.md"), "changed fact");
262301
await dream.handler("", context(true));
263-
assert.ok(messages[2]!.includes(JSON.stringify({ memory: ["changed fact"], user: ["likes concise replies"] })));
302+
assert.ok(messages.at(-1)!.includes(JSON.stringify({ memory: ["changed fact"], user: ["likes concise replies"] })));
264303

265304
await rm(lastDreamPath);
266305
await dream.handler("", context(true));

0 commit comments

Comments
 (0)