@henryqw/pi-memory 0.2.2 → 1.0.1
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 +3 -3
- package/extensions/memory.ts +8 -29
- package/package.json +1 -1
- package/src/config.ts +14 -20
- package/src/store.ts +21 -27
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ To inspect live state, read `<directory>/MEMORY.md`.
|
|
|
28
28
|
|
|
29
29
|
## Config
|
|
30
30
|
|
|
31
|
-
`~/.pi/agent/config/pi-memory.json`
|
|
31
|
+
`~/.pi/agent/config/pi-memory/config.json`
|
|
32
32
|
|
|
33
33
|
```json
|
|
34
34
|
{
|
|
@@ -38,7 +38,7 @@ To inspect live state, read `<directory>/MEMORY.md`.
|
|
|
38
38
|
}
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
- `directory`: absolute path where `MEMORY.md` and `USER.md` live. Required only when overriding the default (`~/.pi/agent/memory`). Point it at an iCloud- or Obsidian-synced folder to sync across machines.
|
|
41
|
+
- `directory`: absolute path where `MEMORY.md` and `USER.md` live. Required only when overriding the default (`~/.pi/agent/config/pi-memory/memory`). Point it at an iCloud- or Obsidian-synced folder to sync across machines.
|
|
42
42
|
- `memoryCharLimit` / `userCharLimit`: positive integers, maximum 100000.
|
|
43
43
|
|
|
44
44
|
Invalid configuration fails fast; malformed config files are never rewritten.
|
|
@@ -47,7 +47,7 @@ Invalid configuration fails fast; malformed config files are never rewritten.
|
|
|
47
47
|
|
|
48
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.
|
|
49
49
|
|
|
50
|
-
Backups and the lock file live outside `directory`, under `~/.pi/agent/
|
|
50
|
+
Backups and the lock file live outside `directory`, under `~/.pi/agent/config/pi-memory/backups/`.
|
|
51
51
|
|
|
52
52
|
## Threat model
|
|
53
53
|
|
package/extensions/memory.ts
CHANGED
|
@@ -5,13 +5,13 @@ import { getAgentDir, withFileMutationQueue, type ExtensionAPI } from "@earendil
|
|
|
5
5
|
import { Text } from "@earendil-works/pi-tui";
|
|
6
6
|
import { lock } from "proper-lockfile";
|
|
7
7
|
import { Type } from "typebox";
|
|
8
|
-
import { loadMemoryConfig, type MemoryConfig } from "../src/config.ts";
|
|
9
|
-
import { ENTRY_DELIMITER, MemoryStore, type Target } from "../src/store.ts";
|
|
8
|
+
import { configPath, loadMemoryConfig, type MemoryConfig } from "../src/config.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
|
|
13
13
|
// iCloud-synced) so the memory dir holds exactly MEMORY.md and USER.md (ADR 005).
|
|
14
|
-
const BACKUP_DIR = () => join(getAgentDir(), "
|
|
14
|
+
const BACKUP_DIR = () => join(getAgentDir(), "config", "pi-memory", "backups");
|
|
15
15
|
// Defense-in-depth against snapshot frame spoofing by poisoned on-disk entries.
|
|
16
16
|
const FRAME_TOKEN_LINE = /^\s*(?:═{3,}|MEMORY \(your personal notes|USER PROFILE \(who the user is)/;
|
|
17
17
|
const FRAME_TOKEN_REPLACEMENT = "[filtered frame token]";
|
|
@@ -87,31 +87,9 @@ function renderBlock(target: Target, entries: string[], config: MemoryConfig, wa
|
|
|
87
87
|
// Everything omitted (e.g. one entry larger than the whole cap): no block,
|
|
88
88
|
// the standalone warning above still reaches the prompt.
|
|
89
89
|
if (!kept.length) return "";
|
|
90
|
-
const
|
|
90
|
+
const usageText = usage(used, limit);
|
|
91
91
|
const header = target === "user" ? "USER PROFILE (who the user is)" : "MEMORY (your personal notes)";
|
|
92
|
-
return `${SEPARATOR}\n${header} [${
|
|
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}`;
|
|
92
|
+
return `${SEPARATOR}\n${header} [${usageText}]\n${SEPARATOR}\n${content}`;
|
|
115
93
|
}
|
|
116
94
|
|
|
117
95
|
export default function memoryExtension(pi: ExtensionAPI): void {
|
|
@@ -229,7 +207,8 @@ export default function memoryExtension(pi: ExtensionAPI): void {
|
|
|
229
207
|
if (CONSOLIDATION_FAILURE.test(error) && store.incrementFailure().done) {
|
|
230
208
|
throw new Error("Memory consolidation failed repeatedly this turn. Stop retrying memory calls, continue replying to the user.");
|
|
231
209
|
}
|
|
232
|
-
|
|
210
|
+
if (result.currentEntries?.length) error += `\nCurrent entries: ${JSON.stringify(result.currentEntries)}`;
|
|
211
|
+
throw new Error(error);
|
|
233
212
|
}
|
|
234
213
|
store.resetOnSuccess();
|
|
235
214
|
return {
|
|
@@ -271,7 +250,7 @@ export default function memoryExtension(pi: ExtensionAPI): void {
|
|
|
271
250
|
// Failed init stays visible every turn (correctness-critical config must
|
|
272
251
|
// not vanish silently) but as a warning line, not a per-turn throw-loop.
|
|
273
252
|
if (state.initError) {
|
|
274
|
-
return { systemPrompt: `${event.systemPrompt}\n\nWARNING: persistent memory is DISABLED this session — initialization failed: ${sanitizeName(state.initError)} Fix
|
|
253
|
+
return { systemPrompt: `${event.systemPrompt}\n\nWARNING: persistent memory is DISABLED this session — initialization failed: ${sanitizeName(state.initError)} Fix ${configPath()} and restart.` };
|
|
275
254
|
}
|
|
276
255
|
if (!state.config || !state.stores || !state.snapshotBlocks) return;
|
|
277
256
|
const blocks = [...state.snapshotBlocks, ...state.conflictWarnings].filter(Boolean).join("\n\n");
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -2,8 +2,9 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
3
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
|
|
5
|
-
// Repository-mandated
|
|
6
|
-
|
|
5
|
+
// Repository-mandated extension config directory boundary (root AGENTS.md):
|
|
6
|
+
// all pi-memory state lives under config/pi-memory/.
|
|
7
|
+
export const configPath = () => join(getAgentDir(), "config", "pi-memory", "config.json");
|
|
7
8
|
|
|
8
9
|
export interface MemoryConfig {
|
|
9
10
|
directory: string;
|
|
@@ -12,12 +13,20 @@ export interface MemoryConfig {
|
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export function DEFAULT_DIRECTORY(): string {
|
|
15
|
-
return join(getAgentDir(), "memory");
|
|
16
|
+
return join(getAgentDir(), "config", "pi-memory", "memory");
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export const DEFAULT_MEMORY_CHAR_LIMIT = 8800;
|
|
19
20
|
export const DEFAULT_USER_CHAR_LIMIT = 5500;
|
|
20
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
|
+
|
|
21
30
|
export function loadMemoryConfig(explicitPath?: string): MemoryConfig {
|
|
22
31
|
const path = explicitPath ?? configPath();
|
|
23
32
|
let raw: string;
|
|
@@ -86,23 +95,8 @@ export function loadMemoryConfig(explicitPath?: string): MemoryConfig {
|
|
|
86
95
|
directory = obj.directory;
|
|
87
96
|
}
|
|
88
97
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const val = obj.memoryCharLimit;
|
|
92
|
-
if (typeof val !== "number" || !Number.isSafeInteger(val) || val <= 0 || val > 100_000) {
|
|
93
|
-
throw new Error(`Invalid 'memoryCharLimit' in memory config at ${path}: must be a positive safe integer <= 100000, got ${JSON.stringify(val)}`);
|
|
94
|
-
}
|
|
95
|
-
memoryCharLimit = val;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
let userCharLimit = DEFAULT_USER_CHAR_LIMIT;
|
|
99
|
-
if (obj.userCharLimit !== undefined) {
|
|
100
|
-
const val = obj.userCharLimit;
|
|
101
|
-
if (typeof val !== "number" || !Number.isSafeInteger(val) || val <= 0 || val > 100_000) {
|
|
102
|
-
throw new Error(`Invalid 'userCharLimit' in memory config at ${path}: must be a positive safe integer <= 100000, got ${JSON.stringify(val)}`);
|
|
103
|
-
}
|
|
104
|
-
userCharLimit = val;
|
|
105
|
-
}
|
|
98
|
+
const memoryCharLimit = charLimit("memoryCharLimit", obj.memoryCharLimit, DEFAULT_MEMORY_CHAR_LIMIT, path);
|
|
99
|
+
const userCharLimit = charLimit("userCharLimit", obj.userCharLimit, DEFAULT_USER_CHAR_LIMIT, path);
|
|
106
100
|
|
|
107
101
|
return {
|
|
108
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).`,
|
|
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
|
-
|
|
528
|
-
working.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|