@henryqw/pi-memory 1.2.0 → 1.3.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 CHANGED
@@ -24,7 +24,7 @@ pi install npm:@henryqw/pi-memory
24
24
 
25
25
  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.
26
26
 
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.
28
28
 
29
29
  To inspect live state, read `<directory>/MEMORY.md`.
30
30
 
@@ -1,4 +1,4 @@
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";
2
2
  import { join, sep } from "node:path";
3
3
  import { StringEnum } from "@earendil-works/pi-ai";
4
4
  import { getAgentDir, withFileMutationQueue, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -6,14 +6,18 @@ import { Text } from "@earendil-works/pi-tui";
6
6
  import { lock } from "proper-lockfile";
7
7
  import { Type } from "typebox";
8
8
  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";
10
10
 
11
11
  const SEPARATOR = "═".repeat(46);
12
12
  // Backups and the lock file live OUTSIDE config.directory (which may be
13
13
  // iCloud-synced) so the memory dir holds exactly MEMORY.md and USER.md (ADR 005).
14
14
  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;
15
20
  // 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)/;
17
21
  const FRAME_TOKEN_REPLACEMENT = "[filtered frame token]";
18
22
  const DISPLAY_CONTROL_CHARACTER = /[\p{Cc}\p{Cf}]/gu;
19
23
  // @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> {
51
55
  }
52
56
  }
53
57
 
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
+
54
108
  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");
56
110
  }
57
111
 
58
112
  // Strip control characters so externally-influenced names can't smuggle
@@ -123,6 +177,8 @@ export default function memoryExtension(pi: ExtensionAPI): void {
123
177
  snapshotSanitized?: boolean;
124
178
  conflictWarnings: string[];
125
179
  initError?: string;
180
+ dreamPending?: boolean;
181
+ dreamSucceeded?: boolean;
126
182
  } = { conflictWarnings: [] };
127
183
 
128
184
  const loadLiveEntries = async (command: string, isIdle: () => boolean, warn: (message: string) => void): Promise<Record<Target, string[]> | undefined> => {
@@ -204,11 +260,44 @@ export default function memoryExtension(pi: ExtensionAPI): void {
204
260
  const memoryMessage = unchanged
205
261
  ? "Use USER PROFILE/MEMORY already in your system context; do not reread those files."
206
262
  : `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
+ }
208
271
  },
209
272
  });
210
273
 
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) => {
212
301
  state.config = undefined;
213
302
  state.stores = undefined;
214
303
  state.initialEntries = undefined;
@@ -216,6 +305,8 @@ export default function memoryExtension(pi: ExtensionAPI): void {
216
305
  state.snapshotSanitized = undefined;
217
306
  state.conflictWarnings = [];
218
307
  state.initError = undefined;
308
+ state.dreamPending = false;
309
+ state.dreamSucceeded = false;
219
310
  try {
220
311
  await mkdir(BACKUP_DIR(), { recursive: true });
221
312
  const config = loadMemoryConfig();
@@ -258,6 +349,24 @@ export default function memoryExtension(pi: ExtensionAPI): void {
258
349
  state.snapshotBlocks = rendered.map(({ block }) => block);
259
350
  state.snapshotSanitized = rendered.some(({ sanitized }) => sanitized);
260
351
  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
+ }
261
370
  } catch (error) {
262
371
  // Surface once, disable quietly: no throw-loop every turn.
263
372
  state.initError = error instanceof Error ? error.message : String(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-memory",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Auto-managed markdown memory for Pi: capped MEMORY.md/USER.md entry stores with frozen session snapshots.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/store.ts CHANGED
@@ -1,7 +1,13 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { copyFile, lstat, mkdir, open, rename, stat, writeFile, rm } from "node:fs/promises";
2
3
  import { dirname, join } from "node:path";
3
4
 
4
5
  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
+ }
5
11
 
6
12
  export type Target = "memory" | "user";
7
13
 
@@ -103,8 +109,8 @@ export class MemoryStore {
103
109
  private readonly observedExisting = new Set<Target>();
104
110
  private disappearanceDetected = false;
105
111
  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 }>();
108
114
 
109
115
  constructor(config: StoreConfig) {
110
116
  this.config = config;
@@ -183,6 +189,24 @@ export class MemoryStore {
183
189
  return { entries: file.kind === "ok" ? parseEntries(file.raw) : [] };
184
190
  }
185
191
 
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
+
186
210
  /**
187
211
  * Returns file state: "absent" for a missing file, "unreadable" when the file
188
212
  * EXISTS but could not be read (permissions or invalid UTF-8), "oversized"
@@ -221,7 +245,11 @@ export class MemoryStore {
221
245
  // by V1-plus-mutation.
222
246
  try {
223
247
  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
+ });
225
253
  } catch {
226
254
  this.loadedFingerprints.delete(target);
227
255
  }
@@ -317,7 +345,8 @@ export class MemoryStore {
317
345
  const fingerprint = this.loadedFingerprints.get(target);
318
346
  if (fingerprint) {
319
347
  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) {
321
350
  throw new Error(`${path} changed during this mutation (likely sync); retry to merge its content.`);
322
351
  }
323
352
  }
@@ -339,7 +368,7 @@ export class MemoryStore {
339
368
  // included): anything the sanitizer would filter must be rejected here,
340
369
  // or writes report success while vanishing from snapshots.
341
370
  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)) {
343
372
  return "Content must not contain lines starting with '═' separators or the reserved headers 'MEMORY (your personal notes' / 'USER PROFILE (who the user is'.";
344
373
  }
345
374
  }