@henryqw/pi-memory 0.1.0 → 0.2.2

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
@@ -2,6 +2,12 @@
2
2
 
3
3
  Auto-managed markdown memory for Pi: two size-capped entry stores (`MEMORY.md`, `USER.md`) with a frozen system-prompt snapshot per session.
4
4
 
5
+ ## Why
6
+
7
+ - **Created for**: Giving Pi compact global notes and user facts that survive across sessions.
8
+ - **Advantage**: Size-capped, auto-managed Markdown stores provide predictable prompt cost without a hand-maintained knowledge tree.
9
+ - **Inspired by**: [Hermes Agent](https://github.com/NousResearch/hermes-agent) and its bounded `MEMORY.md`/`USER.md` cross-session memory pattern.
10
+
5
11
  ## Install
6
12
 
7
13
  ```bash
@@ -41,6 +47,8 @@ Invalid configuration fails fast; malformed config files are never rewritten.
41
47
 
42
48
  Point `directory` at an iCloud Drive or Obsidian-vault-synced folder. The synced vault acts as a dumb sync pipe: pi-memory owns the file format and treats the remote as opaque storage, so no merge logic runs on the Pi side.
43
49
 
50
+ Backups and the lock file live outside `directory`, under `~/.pi/agent/backups/pi-memory/`.
51
+
44
52
  ## Threat model
45
53
 
46
54
  Because the directory can be a globally synced location readable outside Pi, review [`ADR 006 — pi-memory global store threat model`](https://github.com/HenryQW/pi-packages/blob/main/docs/adr/006-pi-memory-global-store-threat-model.md) before pointing it at a shared or cloud-synced path.
@@ -2,6 +2,7 @@ import { mkdir, readdir, realpath } 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";
5
+ import { Text } from "@earendil-works/pi-tui";
5
6
  import { lock } from "proper-lockfile";
6
7
  import { Type } from "typebox";
7
8
  import { loadMemoryConfig, type MemoryConfig } from "../src/config.ts";
@@ -10,10 +11,11 @@ import { ENTRY_DELIMITER, MemoryStore, type Target } from "../src/store.ts";
10
11
  const SEPARATOR = "═".repeat(46);
11
12
  // Backups and the lock file live OUTSIDE config.directory (which may be
12
13
  // iCloud-synced) so the memory dir holds exactly MEMORY.md and USER.md (ADR 005).
13
- const BACKUP_DIR = () => join(getAgentDir(), "memory-backups");
14
+ const BACKUP_DIR = () => join(getAgentDir(), "backups", "pi-memory");
14
15
  // Defense-in-depth against snapshot frame spoofing by poisoned on-disk entries.
15
16
  const FRAME_TOKEN_LINE = /^\s*(?:═{3,}|MEMORY \(your personal notes|USER PROFILE \(who the user is)/;
16
17
  const FRAME_TOKEN_REPLACEMENT = "[filtered frame token]";
18
+ const DISPLAY_CONTROL_CHARACTER = /[\p{Cc}\p{Cf}]/gu;
17
19
  // @henryqw/pi-herdr-btw does not export internal/core.ts from its package root.
18
20
  const BTW_CHILD_PAYLOAD_ARG = "--pi-herdr-btw-payload";
19
21
  const CONSOLIDATION_FAILURE = /(?:exceed|over) the limit|would put memory|no entry matched|[Mm]ultiple entries matched|matched multiple distinct/i;
@@ -41,6 +43,16 @@ function sanitizeName(name: string): string {
41
43
  return name.replace(/[\p{C}]/gu, "").slice(0, 120);
42
44
  }
43
45
 
46
+ function escapeDisplayControls(text: string): string {
47
+ return text.replace(DISPLAY_CONTROL_CHARACTER, (character) => {
48
+ if (character === "\n") return character;
49
+ const codePoint = character.codePointAt(0)!;
50
+ return codePoint <= 0xffff
51
+ ? `\\u${codePoint.toString(16).padStart(4, "0")}`
52
+ : `\\u{${codePoint.toString(16)}}`;
53
+ });
54
+ }
55
+
44
56
  function renderBlock(target: Target, entries: string[], config: MemoryConfig, warnings: string[]): string {
45
57
  if (!entries.length) return "";
46
58
  const limit = target === "user" ? config.userCharLimit : config.memoryCharLimit;
@@ -231,13 +243,26 @@ export default function memoryExtension(pi: ExtensionAPI): void {
231
243
  message: "Write saved. This update is complete — do not repeat it.",
232
244
  }),
233
245
  }],
234
- details: {},
246
+ details: { status: result.message ?? "Write saved.", entries: result.writtenEntries ?? [] },
235
247
  };
236
248
  } finally {
237
249
  await release();
238
250
  }
239
251
  });
240
252
  },
253
+
254
+ renderResult(result, _options, theme, _context) {
255
+ const details = result.details as { status: string; entries: string[] } | undefined;
256
+ if (!details) {
257
+ const content = result.content[0];
258
+ return new Text(content?.type === "text" ? content.text : "", 0, 0);
259
+ }
260
+ let text = theme.fg("success", `✓ ${details.status}`);
261
+ for (const entry of details.entries) {
262
+ text += `\n ${theme.fg("accent", escapeDisplayControls(entry).replaceAll("\n", "\n "))}`;
263
+ }
264
+ return new Text(text, 0, 0);
265
+ },
241
266
  });
242
267
 
243
268
  pi.on("before_agent_start", (event) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-memory",
3
- "version": "0.1.0",
3
+ "version": "0.2.2",
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",
@@ -29,6 +29,7 @@
29
29
  "peerDependencies": {
30
30
  "@earendil-works/pi-ai": "^0.84.2",
31
31
  "@earendil-works/pi-coding-agent": "^0.84.2",
32
+ "@earendil-works/pi-tui": "^0.84.2",
32
33
  "typebox": "^1.3.15"
33
34
  },
34
35
  "devDependencies": {
package/src/store.ts CHANGED
@@ -45,6 +45,7 @@ type Result = {
45
45
  error?: string;
46
46
  usage?: string;
47
47
  entryCount?: number;
48
+ writtenEntries?: string[];
48
49
  currentEntries?: string[];
49
50
  matches?: string[];
50
51
  done?: boolean;
@@ -127,7 +128,7 @@ export class MemoryStore {
127
128
  return `${pct}% — ${current.toLocaleString()}/${limit.toLocaleString()} chars`;
128
129
  }
129
130
 
130
- private successResponse(target: Target, message?: string): Result {
131
+ private successResponse(target: Target, message?: string, writtenEntries: string[] = []): Result {
131
132
  this.resetOnSuccess();
132
133
  return {
133
134
  success: true,
@@ -136,6 +137,7 @@ export class MemoryStore {
136
137
  message,
137
138
  usage: this.usage(target),
138
139
  entryCount: this.entries.get(target)!.length,
140
+ writtenEntries,
139
141
  note: "Write saved. This update is complete — do not repeat it.",
140
142
  };
141
143
  }
@@ -392,7 +394,7 @@ export class MemoryStore {
392
394
  const entries = this.entries.get(target)!;
393
395
 
394
396
  if (entries.includes(text)) {
395
- return this.successResponse(target, "Entry already exists (no duplicate added).");
397
+ return this.successResponse(target, "Entry already exists (no duplicate added).", [text]);
396
398
  }
397
399
 
398
400
  const newTotal = [...entries, text].join(ENTRY_DELIMITER).length;
@@ -408,7 +410,7 @@ export class MemoryStore {
408
410
 
409
411
  entries.push(text);
410
412
  await this.persist(target);
411
- return this.successResponse(target, "Entry added.");
413
+ return this.successResponse(target, "Entry added.", [text]);
412
414
  }
413
415
 
414
416
  private unreadableAbort(target: Target): Result {
@@ -467,7 +469,7 @@ export class MemoryStore {
467
469
 
468
470
  this.entries.set(target, deduped);
469
471
  await this.persist(target);
470
- return this.successResponse(target, "Entry replaced.");
472
+ return this.successResponse(target, "Entry replaced.", [text]);
471
473
  }
472
474
 
473
475
  async remove(target: Target, oldText: string): Promise<Result> {
@@ -557,6 +559,10 @@ export class MemoryStore {
557
559
 
558
560
  this.entries.set(target, working);
559
561
  await this.persist(target);
560
- return this.successResponse(target, `Applied ${operations.length} operation(s).`);
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);
561
567
  }
562
568
  }