Skip to content

Commit 37c10e8

Browse files
authored
feat(pi-memory): recommend periodic dream sessions (#178)
1 parent c6b368f commit 37c10e8

7 files changed

Lines changed: 304 additions & 33 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/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ pi install npm:@henryqw/pi-memory
2424

2525
The extension maintains two markdown stores: `MEMORY.md` (global agent notes shared across all projects — do not store project-specific facts here, those belong in the repo) and `USER.md` (user profile). Each file holds `§`-delimited entries and is size-capped — 8800 characters by default for `MEMORY.md`, 5500 for `USER.md`. When a write would exceed the cap, the tool rejects it and reports current usage; consolidate by issuing one batch that removes or shortens stale entries and adds the new entry together (batch checks the final size only). If the on-disk file exceeds the cap (external edit or sync), the session snapshot omits the overflow and warns instead of injecting it.
2626

27-
At session start, both stores are captured; later edits do not alter injected memory. `/dream` validates live state first and reuses unchanged memory snapshots, but always requires the model to read and edit only the agent-global `~/.pi/agent/SYSTEM.md`—never a project `.pi/SYSTEM.md`. That global file must already exist and be readable; establish it deliberately and completely, because a partial SYSTEM replaces Pi's default prompt. Use `/remember <instruction>` to ask the agent to normalize and deduplicate an instruction against the live contents of both stores before using the memory tool; unsuitable project-specific, temporary, trivial, or otherwise unsuitable content is refused. Each turn also includes a short memory check: save explicit durable preferences or corrections immediately, inferred habits after two independent signals from the conversation and/or existing profile, merge overlaps, and skip project- or repository-specific facts, task-local behavior, progress, and temporary preferences.
27+
At session start, both stores are captured; later edits do not alter injected memory. Pi recommends `/dream` when memory is non-empty and no previous dream is recorded, the last dream was over 30 days ago, or either store is at least 70% full and the last dream was at least 7 days ago. `/dream` records its completed run time in `~/.pi/agent/config/pi-memory/dream.json`, validates live state first, and reuses unchanged memory snapshots, but always requires the model to read and edit only the agent-global `~/.pi/agent/SYSTEM.md`—never a project `.pi/SYSTEM.md`. That global file must already exist and be readable; establish it deliberately and completely, because a partial SYSTEM replaces Pi's default prompt. Use `/remember <instruction>` to ask the agent to normalize and deduplicate an instruction against the live contents of both stores before using the memory tool; unsuitable project-specific, temporary, trivial, or otherwise unsuitable content is refused. Each turn also includes a short memory check: save explicit durable preferences or corrections immediately, inferred habits after two independent signals from the conversation and/or existing profile, merge overlaps, and skip project- or repository-specific facts, task-local behavior, progress, and temporary preferences.
2828

2929
To inspect live state, read `<directory>/MEMORY.md`.
3030

packages/pi-memory/extensions/memory.ts

Lines changed: 115 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
1-
import { lstat, mkdir, readFile, readdir, realpath } 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";
55
import { Text } from "@earendil-works/pi-tui";
66
import { lock } from "proper-lockfile";
77
import { Type } from "typebox";
88
import { configPath, loadMemoryConfig, type MemoryConfig } from "../src/config.ts";
9-
import { ENTRY_DELIMITER, MemoryStore, usage, type Target } from "../src/store.ts";
9+
import { ENTRY_DELIMITER, isReservedFrameLine, MemoryStore, usage, type Target } from "../src/store.ts";
1010

1111
const SEPARATOR = "═".repeat(46);
1212
// Backups and the lock file live OUTSIDE config.directory (which may be
1313
// iCloud-synced) so the memory dir holds exactly MEMORY.md and USER.md (ADR 005).
1414
const BACKUP_DIR = () => join(getAgentDir(), "config", "pi-memory", "backups");
15+
const DREAM_STATE_PATH = () => join(getAgentDir(), "config", "pi-memory", "dream.json");
16+
const DREAM_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
17+
const DREAM_FULL_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
18+
const DREAM_USAGE_PERCENT = 70;
19+
const DREAM_STATE_MAX_BYTES = 4 * 1024;
1520
// Defense-in-depth against snapshot frame spoofing by poisoned on-disk entries.
16-
const FRAME_TOKEN_LINE = /^\s*(?:{3,}|MEMORY \(your personal notes|USER PROFILE \(who the user is)/;
1721
const FRAME_TOKEN_REPLACEMENT = "[filtered frame token]";
1822
const DISPLAY_CONTROL_CHARACTER = /[\p{Cc}\p{Cf}]/gu;
1923
// @henryqw/pi-herdr-btw does not export internal/core.ts from its package root.
@@ -51,8 +55,58 @@ async function loadSystemState(path: string): Promise<SystemState> {
5155
}
5256
}
5357

58+
async function loadLastDreamAt(): Promise<number | undefined> {
59+
let handle: Awaited<ReturnType<typeof open>> | undefined;
60+
try {
61+
handle = await open(DREAM_STATE_PATH(), "r");
62+
const buffer = Buffer.alloc(DREAM_STATE_MAX_BYTES + 1);
63+
let total = 0;
64+
while (total < buffer.length) {
65+
const { bytesRead } = await handle.read(buffer, total, buffer.length - total, null);
66+
if (bytesRead === 0) break;
67+
total += bytesRead;
68+
}
69+
if (total > DREAM_STATE_MAX_BYTES) throw new Error(`Dream state file is too large: ${DREAM_STATE_PATH()}`);
70+
const parsed: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, total)));
71+
const lastDreamAt = parsed && typeof parsed === "object" && !Array.isArray(parsed)
72+
? (parsed as Record<string, unknown>).lastDreamAt
73+
: undefined;
74+
const value = typeof lastDreamAt === "string" ? Date.parse(lastDreamAt) : Number.NaN;
75+
if (!Number.isFinite(value) || value > Date.now()) throw new Error(`Invalid lastDreamAt in ${DREAM_STATE_PATH()}`);
76+
return value;
77+
} catch (error) {
78+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
79+
throw error;
80+
} finally {
81+
await handle?.close();
82+
}
83+
}
84+
85+
async function saveLastDreamAt(): Promise<void> {
86+
const path = DREAM_STATE_PATH();
87+
const tempPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
88+
let created = false;
89+
try {
90+
const handle = await open(tempPath, "wx", 0o600);
91+
created = true;
92+
try {
93+
await handle.writeFile(`${JSON.stringify({ lastDreamAt: new Date().toISOString() }, null, 2)}\n`);
94+
} finally {
95+
await handle.close();
96+
}
97+
// rename replaces a destination symlink rather than following it.
98+
await rename(tempPath, path);
99+
} finally {
100+
if (created) {
101+
await unlink(tempPath).catch((error: NodeJS.ErrnoException) => {
102+
if (error.code !== "ENOENT") throw error;
103+
});
104+
}
105+
}
106+
}
107+
54108
function sanitizeEntry(entry: string): string {
55-
return entry.split("\n").map((line) => FRAME_TOKEN_LINE.test(line) ? FRAME_TOKEN_REPLACEMENT : line).join("\n");
109+
return entry.split("\n").map((line) => isReservedFrameLine(line) ? FRAME_TOKEN_REPLACEMENT : line).join("\n");
56110
}
57111

58112
// Strip control characters so externally-influenced names can't smuggle
@@ -123,6 +177,8 @@ export default function memoryExtension(pi: ExtensionAPI): void {
123177
snapshotSanitized?: boolean;
124178
conflictWarnings: string[];
125179
initError?: string;
180+
dreamPending?: boolean;
181+
dreamSucceeded?: boolean;
126182
} = { conflictWarnings: [] };
127183

128184
const loadLiveEntries = async (command: string, isIdle: () => boolean, warn: (message: string) => void): Promise<Record<Target, string[]> | undefined> => {
@@ -204,18 +260,53 @@ export default function memoryExtension(pi: ExtensionAPI): void {
204260
const memoryMessage = unchanged
205261
? "Use USER PROFILE/MEMORY already in your system context; do not reread those files."
206262
: `Live entries by target:\n${JSON.stringify(entries)}`;
207-
pi.sendUserMessage(`${DREAM_INSTRUCTION}\n\n${memoryMessage}\n\nRead ${JSON.stringify(systemPath)} before semantic deduplication or editing. Edit only ${JSON.stringify(systemPath)}; never edit a project SYSTEM.md.`);
263+
state.dreamPending = true;
264+
state.dreamSucceeded = false;
265+
try {
266+
pi.sendUserMessage(`${DREAM_INSTRUCTION}\n\n${memoryMessage}\n\nRead ${JSON.stringify(systemPath)} before semantic deduplication or editing. Edit only ${JSON.stringify(systemPath)}; never edit a project SYSTEM.md.`);
267+
} catch (error) {
268+
state.dreamPending = false;
269+
throw error;
270+
}
208271
},
209272
});
210273

211-
pi.on("session_start", async () => {
274+
pi.on("agent_end", (event) => {
275+
if (!state.dreamPending) return;
276+
for (let index = event.messages.length - 1; index >= 0; index--) {
277+
const message = event.messages[index];
278+
if (message?.role !== "assistant") continue;
279+
state.dreamSucceeded = message.stopReason === "stop";
280+
break;
281+
}
282+
});
283+
284+
pi.on("agent_settled", async (_event, ctx) => {
285+
if (!state.dreamPending) return;
286+
const succeeded = state.dreamSucceeded;
287+
state.dreamPending = false;
288+
state.dreamSucceeded = false;
289+
if (!succeeded) {
290+
ctx.ui.notify("Dream did not complete; its timestamp was not updated.", "warning");
291+
return;
292+
}
293+
try {
294+
await saveLastDreamAt();
295+
} catch (error) {
296+
ctx.ui.notify(`Dream completed, but its timestamp could not be recorded: ${error instanceof Error ? error.message : String(error)}`, "warning");
297+
}
298+
});
299+
300+
pi.on("session_start", async (_event, ctx) => {
212301
state.config = undefined;
213302
state.stores = undefined;
214303
state.initialEntries = undefined;
215304
state.snapshotBlocks = undefined;
216305
state.snapshotSanitized = undefined;
217306
state.conflictWarnings = [];
218307
state.initError = undefined;
308+
state.dreamPending = false;
309+
state.dreamSucceeded = false;
219310
try {
220311
await mkdir(BACKUP_DIR(), { recursive: true });
221312
const config = loadMemoryConfig();
@@ -258,6 +349,24 @@ export default function memoryExtension(pi: ExtensionAPI): void {
258349
state.snapshotBlocks = rendered.map(({ block }) => block);
259350
state.snapshotSanitized = rendered.some(({ sanitized }) => sanitized);
260351
state.conflictWarnings = conflictWarnings;
352+
353+
const memoryChars = memory.entries.join(ENTRY_DELIMITER).length;
354+
const userChars = user.entries.join(ENTRY_DELIMITER).length;
355+
const validWithinCap = !memory.status && !user.status
356+
&& memoryChars <= config.memoryCharLimit && userChars <= config.userCharLimit;
357+
if (!process.argv.includes(BTW_CHILD_PAYLOAD_ARG) && validWithinCap && (memory.entries.length || user.entries.length)) {
358+
try {
359+
const lastDreamAt = await loadLastDreamAt();
360+
const age = lastDreamAt === undefined ? undefined : Date.now() - lastDreamAt;
361+
const full = memoryChars * 100 >= config.memoryCharLimit * DREAM_USAGE_PERCENT
362+
|| userChars * 100 >= config.userCharLimit * DREAM_USAGE_PERCENT;
363+
if (age === undefined || age >= DREAM_AFTER_MS || (full && age >= DREAM_FULL_COOLDOWN_MS)) {
364+
ctx.ui.notify("Memory dream recommended; run /dream.", "info");
365+
}
366+
} catch (error) {
367+
ctx.ui.notify(`Cannot check dream reminder: ${error instanceof Error ? error.message : String(error)}`, "warning");
368+
}
369+
}
261370
} catch (error) {
262371
// Surface once, disable quietly: no throw-loop every turn.
263372
state.initError = error instanceof Error ? error.message : String(error);

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.0",
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/src/store.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
import { createHash } from "node:crypto";
12
import { copyFile, lstat, mkdir, open, rename, stat, writeFile, rm } from "node:fs/promises";
23
import { dirname, join } from "node:path";
34

45
export const ENTRY_DELIMITER: string = "\n§\n";
6+
const RESERVED_FRAME_LINE = /^\s*(?:{3,}|MEMORY \(your personal notes|USER PROFILE \(who the user is)/;
7+
8+
export function isReservedFrameLine(line: string): boolean {
9+
return RESERVED_FRAME_LINE.test(line);
10+
}
511

612
export type Target = "memory" | "user";
713

@@ -103,8 +109,8 @@ export class MemoryStore {
103109
private readonly observedExisting = new Set<Target>();
104110
private disappearanceDetected = false;
105111
private unreadableReason: string | undefined;
106-
// mtime/size of the last successfully loaded file, per target.
107-
private readonly loadedFingerprints = new Map<Target, { mtimeMs: number; size: number }>();
112+
// Metadata and content digest of the last successfully loaded file, per target.
113+
private readonly loadedFingerprints = new Map<Target, { mtimeMs: number; size: number; digest: string }>();
108114

109115
constructor(config: StoreConfig) {
110116
this.config = config;
@@ -183,6 +189,24 @@ export class MemoryStore {
183189
return { entries: file.kind === "ok" ? parseEntries(file.raw) : [] };
184190
}
185191

192+
private async digestFile(path: string): Promise<string> {
193+
const handle = await open(path, "r");
194+
try {
195+
const hash = createHash("sha256");
196+
const buffer = Buffer.alloc(64 * 1024);
197+
let total = 0;
198+
for (;;) {
199+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
200+
if (bytesRead === 0) return hash.digest("base64url");
201+
total += bytesRead;
202+
if (total > MAX_FILE_BYTES) throw new Error(`${path} grew over the ${MAX_FILE_BYTES.toLocaleString()}-byte limit during mutation.`);
203+
hash.update(buffer.subarray(0, bytesRead));
204+
}
205+
} finally {
206+
await handle.close();
207+
}
208+
}
209+
186210
/**
187211
* Returns file state: "absent" for a missing file, "unreadable" when the file
188212
* EXISTS but could not be read (permissions or invalid UTF-8), "oversized"
@@ -221,7 +245,11 @@ export class MemoryStore {
221245
// by V1-plus-mutation.
222246
try {
223247
const st = await (this.config.statFn ?? stat)(this.pathFor(target));
224-
this.loadedFingerprints.set(target, { mtimeMs: st.mtimeMs, size: st.size });
248+
this.loadedFingerprints.set(target, {
249+
mtimeMs: st.mtimeMs,
250+
size: st.size,
251+
digest: createHash("sha256").update(buffer.subarray(0, total)).digest("base64url"),
252+
});
225253
} catch {
226254
this.loadedFingerprints.delete(target);
227255
}
@@ -317,7 +345,8 @@ export class MemoryStore {
317345
const fingerprint = this.loadedFingerprints.get(target);
318346
if (fingerprint) {
319347
const current = await (this.config.statFn ?? stat)(path);
320-
if (current.mtimeMs !== fingerprint.mtimeMs || current.size !== fingerprint.size) {
348+
if (current.mtimeMs !== fingerprint.mtimeMs || current.size !== fingerprint.size
349+
|| await this.digestFile(path) !== fingerprint.digest) {
321350
throw new Error(`${path} changed during this mutation (likely sync); retry to merge its content.`);
322351
}
323352
}
@@ -339,7 +368,7 @@ export class MemoryStore {
339368
// included): anything the sanitizer would filter must be rejected here,
340369
// or writes report success while vanishing from snapshots.
341370
for (const line of normalized.split("\n")) {
342-
if (/^\s*(?:{3,}|MEMORY \(your personal notes|USER PROFILE \(who the user is)/.test(line)) {
371+
if (isReservedFrameLine(line)) {
343372
return "Content must not contain lines starting with '═' separators or the reserved headers 'MEMORY (your personal notes' / 'USER PROFILE (who the user is'.";
344373
}
345374
}

0 commit comments

Comments
 (0)