@henryqw/pi-memory 1.0.0 → 1.1.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
@@ -18,11 +18,12 @@ pi install npm:@henryqw/pi-memory
18
18
 
19
19
  | Surface | Type | Purpose |
20
20
  | --- | --- | --- |
21
+ | `/remember <instruction>` | command | Process an instruction into compact durable memory, deduplicating against live entries. |
21
22
  | `memory` | tool | Add, replace, remove, or batch-edit entries across sessions. |
22
23
 
23
24
  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.
24
25
 
25
- At session start, the current contents of both stores are frozen into the system prompt; later edits during the session do not alter what the model already saw.
26
+ At session start, the current contents of both stores are frozen into the system prompt; later edits during the session do not alter what the model already saw. 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.
26
27
 
27
28
  To inspect live state, read `<directory>/MEMORY.md`.
28
29
 
@@ -6,7 +6,7 @@ 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, type Target } from "../src/store.ts";
9
+ import { ENTRY_DELIMITER, 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
@@ -19,6 +19,8 @@ const DISPLAY_CONTROL_CHARACTER = /[\p{Cc}\p{Cf}]/gu;
19
19
  // @henryqw/pi-herdr-btw does not export internal/core.ts from its package root.
20
20
  const BTW_CHILD_PAYLOAD_ARG = "--pi-herdr-btw-payload";
21
21
  const CONSOLIDATION_FAILURE = /(?:exceed|over) the limit|would put memory|no entry matched|[Mm]ultiple entries matched|matched multiple distinct/i;
22
+ const MEMORY_CHECK = "MEMORY CHECK: Save explicit durable user preferences or corrections immediately. Save an inferred habit only after two independent signals from the conversation and/or existing profile. Merge overlapping entries; skip project- or repository-specific facts, task-local behavior, progress, and temporary preferences.";
23
+ const REMEMBER_USAGE = "Usage: /remember <instruction>";
22
24
  const MEMORY_DESCRIPTION = `Save durable facts to persistent memory that survive across sessions. Memory is injected into every future turn, so keep entries compact and high-signal.
23
25
 
24
26
  HOW: Prefer one operations batch for multiple changes or consolidation. A batch applies atomically and checks the character limit only on the final result, so it can remove or shorten stale entries and add new ones in one call. Use action/content/old_text only for one lone change. A successful response finishes the update; do not repeat it.
@@ -87,31 +89,9 @@ function renderBlock(target: Target, entries: string[], config: MemoryConfig, wa
87
89
  // Everything omitted (e.g. one entry larger than the whole cap): no block,
88
90
  // the standalone warning above still reaches the prompt.
89
91
  if (!kept.length) return "";
90
- const usage = `${Math.min(100, Math.floor((used / limit) * 100))}% — ${used.toLocaleString()}/${limit.toLocaleString()} chars`;
92
+ const usageText = usage(used, limit);
91
93
  const header = target === "user" ? "USER PROFILE (who the user is)" : "MEMORY (your personal notes)";
92
- return `${SEPARATOR}\n${header} [${usage}]\n${SEPARATOR}\n${content}`;
93
- }
94
-
95
- // Bound aggregate preview size: thousands of short entries over cap must not
96
- // inject nearly the whole file into one tool error.
97
- function failureMessage(error: string, entries?: string[]): string {
98
- if (!entries?.length) return error;
99
- const MAX_PREVIEW_ENTRIES = 20;
100
- const MAX_PREVIEW_CHARS = 1500;
101
- const shown: string[] = [];
102
- let chars = 0;
103
- let moreEntries = 0;
104
- for (const entry of entries) {
105
- const clipped = entry.length > 120 ? `${entry.slice(0, 120)}...` : entry;
106
- if (shown.length >= MAX_PREVIEW_ENTRIES || chars + clipped.length > MAX_PREVIEW_CHARS) {
107
- moreEntries = entries.length - shown.length;
108
- break;
109
- }
110
- shown.push(clipped);
111
- chars += clipped.length;
112
- }
113
- const suffix = moreEntries > 0 ? ` (and ${moreEntries} more entries)` : "";
114
- return `${error}\nCurrent entries: ${JSON.stringify(shown)}${suffix}`;
94
+ return `${SEPARATOR}\n${header} [${usageText}]\n${SEPARATOR}\n${content}`;
115
95
  }
116
96
 
117
97
  export default function memoryExtension(pi: ExtensionAPI): void {
@@ -123,6 +103,53 @@ export default function memoryExtension(pi: ExtensionAPI): void {
123
103
  initError?: string;
124
104
  } = { conflictWarnings: [] };
125
105
 
106
+ pi.registerCommand("remember", {
107
+ description: "Process an instruction into durable memory",
108
+ handler: async (args, ctx) => {
109
+ const candidate = args.trim();
110
+ if (!candidate) {
111
+ ctx.ui.notify(REMEMBER_USAGE, "warning");
112
+ return;
113
+ }
114
+ if (!ctx.isIdle()) {
115
+ ctx.ui.notify("Cannot run /remember while the agent is busy.", "warning");
116
+ return;
117
+ }
118
+ if (state.initError) {
119
+ ctx.ui.notify(`Cannot run /remember: persistent memory is disabled — ${sanitizeName(state.initError)}`, "warning");
120
+ return;
121
+ }
122
+ if (!state.config || !state.stores) {
123
+ ctx.ui.notify("Cannot run /remember: persistent memory is not initialized.", "warning");
124
+ return;
125
+ }
126
+ try {
127
+ const loaded = await Promise.all((Object.keys(state.stores) as Target[]).map(async (target) => [target, await state.stores![target].load(target)] as const));
128
+ const invalid = loaded.filter(([, result]) => result.status);
129
+ if (invalid.length) {
130
+ ctx.ui.notify(`Cannot run /remember: live memory state is unreadable or oversized. ${invalid.map(([, result]) => result.conflictWarning).join(" ")}`, "warning");
131
+ return;
132
+ }
133
+ if (!ctx.isIdle()) {
134
+ ctx.ui.notify("Cannot run /remember while the agent is busy.", "warning");
135
+ return;
136
+ }
137
+ const overLimit = loaded.filter(([target, result]) => {
138
+ const limit = target === "user" ? state.config!.userCharLimit : state.config!.memoryCharLimit;
139
+ return result.entries.join(ENTRY_DELIMITER).length > limit;
140
+ });
141
+ if (overLimit.length) {
142
+ ctx.ui.notify(`Cannot run /remember: live ${overLimit.map(([target]) => target).join(" and ")} entries exceed the configured character limit. Consolidate them before using /remember.`, "warning");
143
+ return;
144
+ }
145
+ const entries = Object.fromEntries(loaded.map(([target, result]) => [target, result.entries]));
146
+ pi.sendUserMessage(`Process this /remember instruction; do not blindly copy it. Normalize the candidate into compact durable memory, choose the correct memory target, semantically compare it with the live entries, and merge or replace overlap instead of adding duplicates. Use the existing memory tool. Refuse project/repository-specific, temporary, trivial, or otherwise unsuitable content.\n\nCandidate:\n${JSON.stringify(candidate)}\n\nLive entries by target:\n${JSON.stringify(entries)}`);
147
+ } catch (error) {
148
+ ctx.ui.notify(`Cannot run /remember: ${error instanceof Error ? error.message : String(error)}`, "warning");
149
+ }
150
+ },
151
+ });
152
+
126
153
  pi.on("session_start", async () => {
127
154
  state.config = undefined;
128
155
  state.stores = undefined;
@@ -229,7 +256,8 @@ export default function memoryExtension(pi: ExtensionAPI): void {
229
256
  if (CONSOLIDATION_FAILURE.test(error) && store.incrementFailure().done) {
230
257
  throw new Error("Memory consolidation failed repeatedly this turn. Stop retrying memory calls, continue replying to the user.");
231
258
  }
232
- throw new Error(failureMessage(error, result.currentEntries));
259
+ if (result.currentEntries?.length) error += `\nCurrent entries: ${JSON.stringify(result.currentEntries)}`;
260
+ throw new Error(error);
233
261
  }
234
262
  store.resetOnSuccess();
235
263
  return {
@@ -275,6 +303,6 @@ export default function memoryExtension(pi: ExtensionAPI): void {
275
303
  }
276
304
  if (!state.config || !state.stores || !state.snapshotBlocks) return;
277
305
  const blocks = [...state.snapshotBlocks, ...state.conflictWarnings].filter(Boolean).join("\n\n");
278
- return { systemPrompt: `${event.systemPrompt}\n\n${blocks}` };
306
+ return { systemPrompt: `${event.systemPrompt}\n\n${blocks ? `${blocks}\n\n` : ""}${MEMORY_CHECK}` };
279
307
  });
280
308
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-memory",
3
- "version": "1.0.0",
3
+ "version": "1.1.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/config.ts CHANGED
@@ -19,6 +19,14 @@ export function DEFAULT_DIRECTORY(): string {
19
19
  export const DEFAULT_MEMORY_CHAR_LIMIT = 8800;
20
20
  export const DEFAULT_USER_CHAR_LIMIT = 5500;
21
21
 
22
+ function charLimit(key: "memoryCharLimit" | "userCharLimit", value: unknown, defaultValue: number, path: string): number {
23
+ if (value === undefined) return defaultValue;
24
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0 || value > 100_000) {
25
+ throw new Error(`Invalid '${key}' in memory config at ${path}: must be a positive safe integer <= 100000, got ${JSON.stringify(value)}`);
26
+ }
27
+ return value;
28
+ }
29
+
22
30
  export function loadMemoryConfig(explicitPath?: string): MemoryConfig {
23
31
  const path = explicitPath ?? configPath();
24
32
  let raw: string;
@@ -87,23 +95,8 @@ export function loadMemoryConfig(explicitPath?: string): MemoryConfig {
87
95
  directory = obj.directory;
88
96
  }
89
97
 
90
- let memoryCharLimit = DEFAULT_MEMORY_CHAR_LIMIT;
91
- if (obj.memoryCharLimit !== undefined) {
92
- const val = obj.memoryCharLimit;
93
- if (typeof val !== "number" || !Number.isSafeInteger(val) || val <= 0 || val > 100_000) {
94
- throw new Error(`Invalid 'memoryCharLimit' in memory config at ${path}: must be a positive safe integer <= 100000, got ${JSON.stringify(val)}`);
95
- }
96
- memoryCharLimit = val;
97
- }
98
-
99
- let userCharLimit = DEFAULT_USER_CHAR_LIMIT;
100
- if (obj.userCharLimit !== undefined) {
101
- const val = obj.userCharLimit;
102
- if (typeof val !== "number" || !Number.isSafeInteger(val) || val <= 0 || val > 100_000) {
103
- throw new Error(`Invalid 'userCharLimit' in memory config at ${path}: must be a positive safe integer <= 100000, got ${JSON.stringify(val)}`);
104
- }
105
- userCharLimit = val;
106
- }
98
+ const memoryCharLimit = charLimit("memoryCharLimit", obj.memoryCharLimit, DEFAULT_MEMORY_CHAR_LIMIT, path);
99
+ const userCharLimit = charLimit("userCharLimit", obj.userCharLimit, DEFAULT_USER_CHAR_LIMIT, path);
107
100
 
108
101
  return {
109
102
  directory,
package/src/store.ts CHANGED
@@ -48,9 +48,6 @@ type Result = {
48
48
  writtenEntries?: string[];
49
49
  currentEntries?: string[];
50
50
  matches?: string[];
51
- done?: boolean;
52
- note?: string;
53
- target?: Target;
54
51
  };
55
52
 
56
53
  const PREVIEW_WIDTH = 80;
@@ -68,6 +65,11 @@ function previews(entries: string[]): string[] {
68
65
  return shown;
69
66
  }
70
67
 
68
+ export function usage(current: number, limit: number): string {
69
+ const pct = limit > 0 ? Math.min(100, Math.floor((current / limit) * 100)) : 0;
70
+ return `${pct}% — ${current.toLocaleString()}/${limit.toLocaleString()} chars`;
71
+ }
72
+
71
73
  /**
72
74
  * Binding normalization order: strip BOM -> all line terminators to LF -> trim.
73
75
  * Delimiter validation, parsing, budgeting, and matching all operate on
@@ -122,34 +124,26 @@ export class MemoryStore {
122
124
  }
123
125
 
124
126
  private usage(target: Target): string {
125
- const current = this.charCount(target);
126
- const limit = this.limit(target);
127
- const pct = limit > 0 ? Math.min(100, Math.floor((current / limit) * 100)) : 0;
128
- return `${pct}% — ${current.toLocaleString()}/${limit.toLocaleString()} chars`;
127
+ return usage(this.charCount(target), this.limit(target));
129
128
  }
130
129
 
131
130
  private successResponse(target: Target, message?: string, writtenEntries: string[] = []): Result {
132
131
  this.resetOnSuccess();
133
132
  return {
134
133
  success: true,
135
- done: true,
136
- target,
137
134
  message,
138
135
  usage: this.usage(target),
139
136
  entryCount: this.entries.get(target)!.length,
140
137
  writtenEntries,
141
- note: "Write saved. This update is complete — do not repeat it.",
142
138
  };
143
139
  }
144
140
 
145
- private consolidationFailure(error: string, extra?: Partial<Result>): Result {
146
- const target = extra?.target ?? "memory";
141
+ private consolidationFailure(error: string, target: Target = "memory", resultUsage?: string): Result {
147
142
  return {
148
143
  success: false,
149
144
  error,
150
145
  currentEntries: previews(this.entries.get(target)!),
151
- usage: this.usage(target),
152
- ...extra,
146
+ usage: resultUsage ?? this.usage(target),
153
147
  };
154
148
  }
155
149
 
@@ -404,7 +398,7 @@ export class MemoryStore {
404
398
  + `Adding this entry (${text.length} chars) would exceed the limit. Consolidate now: use 'replace' to merge `
405
399
  + `overlapping entries into shorter ones or 'remove' stale or less important entries (see current_entries below), `
406
400
  + `then retry this add — all in this turn.`,
407
- { target },
401
+ target,
408
402
  );
409
403
  }
410
404
 
@@ -447,7 +441,7 @@ export class MemoryStore {
447
441
  if (resolved[0] === "missing") {
448
442
  return this.consolidationFailure(
449
443
  `No entry matched '${trimmedOld}'. Check current_entries below and retry with the exact text of the entry you want to replace.`,
450
- { target },
444
+ target,
451
445
  );
452
446
  }
453
447
  if (resolved[0] === "ambiguous") return MemoryStore.ambiguousError(trimmedOld, resolved[1]);
@@ -463,7 +457,7 @@ export class MemoryStore {
463
457
  `Replacement would put memory at ${newTotal.toLocaleString()}/${this.limit(target).toLocaleString()} chars. `
464
458
  + `Shorten the new content, or 'remove' other stale or less important entries to make room `
465
459
  + `(see current_entries below), then retry — all in this turn.`,
466
- { target },
460
+ target,
467
461
  );
468
462
  }
469
463
 
@@ -483,7 +477,7 @@ export class MemoryStore {
483
477
  if (resolved[0] === "missing") {
484
478
  return this.consolidationFailure(
485
479
  `No entry matched '${trimmedOld}'. Check current_entries below and retry with the exact text of the entry you want to remove.`,
486
- { target },
480
+ target,
487
481
  );
488
482
  }
489
483
  if (resolved[0] === "ambiguous") return MemoryStore.ambiguousError(trimmedOld, resolved[1]);
@@ -512,8 +506,9 @@ export class MemoryStore {
512
506
  if (!(await this.reloadTarget(target))) return this.unreadableAbort(target);
513
507
 
514
508
  let working = [...this.entries.get(target)!];
509
+ const writtenEntries = new Set<string>();
515
510
  const fail = (message: string): Result =>
516
- this.consolidationFailure(`${message} No operations were applied (batch is all-or-nothing).`, { target });
511
+ this.consolidationFailure(`${message} No operations were applied (batch is all-or-nothing).`, target);
517
512
 
518
513
  for (let i = 0; i < operations.length; i++) {
519
514
  const op = operations[i] ?? {};
@@ -524,8 +519,9 @@ export class MemoryStore {
524
519
 
525
520
  if (action === "add") {
526
521
  if (!content) return fail(`${pos}: content is required.`);
527
- if (working.includes(normalize(content))) continue; // idempotent duplicate
528
- working.push(normalize(content));
522
+ writtenEntries.add(content);
523
+ if (working.includes(content)) continue; // idempotent duplicate
524
+ working.push(content);
529
525
  } else if (action === "replace") {
530
526
  if (!oldText) return fail(`${pos}: old_text is required.`);
531
527
  if (!content) return fail(`${pos}: content is required (use action='remove' to delete).`);
@@ -533,6 +529,7 @@ export class MemoryStore {
533
529
  if (resolved[0] === "missing") return fail(`${pos}: no entry matched '${oldText}'.`);
534
530
  if (resolved[0] === "ambiguous") return fail(`${pos}: '${oldText}' matched multiple distinct entries -- be more specific.`);
535
531
  working[resolved[0]] = content;
532
+ writtenEntries.add(content);
536
533
  // A replace can create a duplicate; dedupe order-preserving before later ops/budget.
537
534
  working = [...new Set(working)];
538
535
  } else if (action === "remove") {
@@ -553,16 +550,13 @@ export class MemoryStore {
553
550
  `After applying all ${operations.length} operations, memory would be at ${newTotal.toLocaleString()}/`
554
551
  + `${this.limit(target).toLocaleString()} chars -- over the limit. Remove or shorten more entries in the same batch `
555
552
  + `(see current_entries below), then retry.`,
556
- { target, usage: `${current.toLocaleString()}/${this.limit(target).toLocaleString()}` },
553
+ target,
554
+ `${current.toLocaleString()}/${this.limit(target).toLocaleString()}`,
557
555
  );
558
556
  }
559
557
 
560
558
  this.entries.set(target, working);
561
559
  await this.persist(target);
562
- const writtenEntries = [...new Set(operations.flatMap((operation) => {
563
- const content = normalize(operation.content ?? operation.new_text ?? "");
564
- return (operation.action === "add" || operation.action === "replace") && working.includes(content) ? [content] : [];
565
- }))];
566
- return this.successResponse(target, `Applied ${operations.length} operation(s).`, writtenEntries);
560
+ return this.successResponse(target, `Applied ${operations.length} operation(s).`, [...writtenEntries].filter((entry) => working.includes(entry)));
567
561
  }
568
562
  }