Skip to content

Commit 9d608c7

Browse files
committed
feat(pi-memory): recommend periodic dream sessions
1 parent 70e7c2d commit 9d608c7

7 files changed

Lines changed: 231 additions & 28 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/last-dream.txt`, 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: 86 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, writeFile } 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 LAST_DREAM_PATH = () => join(getAgentDir(), "config", "pi-memory", "last-dream.txt");
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 LAST_DREAM_MAX_BYTES = 64;
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,31 @@ 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(LAST_DREAM_PATH(), "r");
62+
const buffer = Buffer.alloc(LAST_DREAM_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 > LAST_DREAM_MAX_BYTES) throw new Error(`Timestamp file is too large: ${LAST_DREAM_PATH()}`);
70+
const value = Date.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, total)).trim());
71+
if (!Number.isFinite(value) || value > Date.now()) throw new Error(`Invalid timestamp in ${LAST_DREAM_PATH()}`);
72+
return value;
73+
} catch (error) {
74+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
75+
throw error;
76+
} finally {
77+
await handle?.close();
78+
}
79+
}
80+
5481
function sanitizeEntry(entry: string): string {
55-
return entry.split("\n").map((line) => FRAME_TOKEN_LINE.test(line) ? FRAME_TOKEN_REPLACEMENT : line).join("\n");
82+
return entry.split("\n").map((line) => isReservedFrameLine(line) ? FRAME_TOKEN_REPLACEMENT : line).join("\n");
5683
}
5784

5885
// Strip control characters so externally-influenced names can't smuggle
@@ -123,6 +150,8 @@ export default function memoryExtension(pi: ExtensionAPI): void {
123150
snapshotSanitized?: boolean;
124151
conflictWarnings: string[];
125152
initError?: string;
153+
dreamPending?: boolean;
154+
dreamSucceeded?: boolean;
126155
} = { conflictWarnings: [] };
127156

128157
const loadLiveEntries = async (command: string, isIdle: () => boolean, warn: (message: string) => void): Promise<Record<Target, string[]> | undefined> => {
@@ -204,18 +233,53 @@ export default function memoryExtension(pi: ExtensionAPI): void {
204233
const memoryMessage = unchanged
205234
? "Use USER PROFILE/MEMORY already in your system context; do not reread those files."
206235
: `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.`);
236+
state.dreamPending = true;
237+
state.dreamSucceeded = false;
238+
try {
239+
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.`);
240+
} catch (error) {
241+
state.dreamPending = false;
242+
throw error;
243+
}
208244
},
209245
});
210246

211-
pi.on("session_start", async () => {
247+
pi.on("agent_end", (event) => {
248+
if (!state.dreamPending) return;
249+
for (let index = event.messages.length - 1; index >= 0; index--) {
250+
const message = event.messages[index];
251+
if (message?.role !== "assistant") continue;
252+
state.dreamSucceeded = message.stopReason !== "error" && message.stopReason !== "aborted" && message.stopReason !== "length";
253+
break;
254+
}
255+
});
256+
257+
pi.on("agent_settled", async (_event, ctx) => {
258+
if (!state.dreamPending) return;
259+
const succeeded = state.dreamSucceeded;
260+
state.dreamPending = false;
261+
state.dreamSucceeded = false;
262+
if (!succeeded) {
263+
ctx.ui.notify("Dream did not complete; its timestamp was not updated.", "warning");
264+
return;
265+
}
266+
try {
267+
await writeFile(LAST_DREAM_PATH(), `${new Date().toISOString()}\n`, { mode: 0o600 });
268+
} catch (error) {
269+
ctx.ui.notify(`Dream completed, but its timestamp could not be recorded: ${error instanceof Error ? error.message : String(error)}`, "warning");
270+
}
271+
});
272+
273+
pi.on("session_start", async (_event, ctx) => {
212274
state.config = undefined;
213275
state.stores = undefined;
214276
state.initialEntries = undefined;
215277
state.snapshotBlocks = undefined;
216278
state.snapshotSanitized = undefined;
217279
state.conflictWarnings = [];
218280
state.initError = undefined;
281+
state.dreamPending = false;
282+
state.dreamSucceeded = false;
219283
try {
220284
await mkdir(BACKUP_DIR(), { recursive: true });
221285
const config = loadMemoryConfig();
@@ -258,6 +322,22 @@ export default function memoryExtension(pi: ExtensionAPI): void {
258322
state.snapshotBlocks = rendered.map(({ block }) => block);
259323
state.snapshotSanitized = rendered.some(({ sanitized }) => sanitized);
260324
state.conflictWarnings = conflictWarnings;
325+
326+
if (!process.argv.includes(BTW_CHILD_PAYLOAD_ARG) && (memory.entries.length || user.entries.length)) {
327+
try {
328+
const lastDreamAt = await loadLastDreamAt();
329+
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;
332+
const full = memoryChars * 100 >= config.memoryCharLimit * DREAM_USAGE_PERCENT
333+
|| userChars * 100 >= config.userCharLimit * DREAM_USAGE_PERCENT;
334+
if (age === undefined || age >= DREAM_AFTER_MS || (full && age >= DREAM_FULL_COOLDOWN_MS)) {
335+
ctx.ui.notify("Memory dream recommended; run /dream.", "info");
336+
}
337+
} catch (error) {
338+
ctx.ui.notify(`Cannot check dream reminder: ${error instanceof Error ? error.message : String(error)}`, "warning");
339+
}
340+
}
261341
} catch (error) {
262342
// Surface once, disable quietly: no throw-loop every turn.
263343
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.2.1",
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)