@henryqw/pi-memory 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Henry Wang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # `@henryqw/pi-memory`
2
+
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
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@henryqw/pi-memory
9
+ ```
10
+
11
+ ## Use
12
+
13
+ | Surface | Type | Purpose |
14
+ | --- | --- | --- |
15
+ | `memory` | tool | Add, replace, remove, or batch-edit entries across sessions. |
16
+
17
+ 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.
18
+
19
+ 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.
20
+
21
+ To inspect live state, read `<directory>/MEMORY.md`.
22
+
23
+ ## Config
24
+
25
+ `~/.pi/agent/config/pi-memory.json`
26
+
27
+ ```json
28
+ {
29
+ "directory": "/absolute/path/to/memory",
30
+ "memoryCharLimit": 8800,
31
+ "userCharLimit": 5500
32
+ }
33
+ ```
34
+
35
+ - `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.
36
+ - `memoryCharLimit` / `userCharLimit`: positive integers, maximum 100000.
37
+
38
+ Invalid configuration fails fast; malformed config files are never rewritten.
39
+
40
+ ## Storage & sync
41
+
42
+ 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
+
44
+ ## Threat model
45
+
46
+ 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.
47
+
48
+ ## Remove
49
+
50
+ ```bash
51
+ pi remove npm:@henryqw/pi-memory
52
+ ```
53
+
54
+ ## Development
55
+
56
+ ```bash
57
+ npm test --workspace @henryqw/pi-memory
58
+ npm run typecheck --workspace @henryqw/pi-memory
59
+ npm run pack:check --workspace @henryqw/pi-memory
60
+ ```
@@ -0,0 +1,255 @@
1
+ import { mkdir, readdir, realpath } from "node:fs/promises";
2
+ import { join, sep } from "node:path";
3
+ import { StringEnum } from "@earendil-works/pi-ai";
4
+ import { getAgentDir, withFileMutationQueue, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { lock } from "proper-lockfile";
6
+ import { Type } from "typebox";
7
+ import { loadMemoryConfig, type MemoryConfig } from "../src/config.ts";
8
+ import { ENTRY_DELIMITER, MemoryStore, type Target } from "../src/store.ts";
9
+
10
+ const SEPARATOR = "═".repeat(46);
11
+ // Backups and the lock file live OUTSIDE config.directory (which may be
12
+ // iCloud-synced) so the memory dir holds exactly MEMORY.md and USER.md (ADR 005).
13
+ const BACKUP_DIR = () => join(getAgentDir(), "memory-backups");
14
+ // Defense-in-depth against snapshot frame spoofing by poisoned on-disk entries.
15
+ const FRAME_TOKEN_LINE = /^\s*(?:═{3,}|MEMORY \(your personal notes|USER PROFILE \(who the user is)/;
16
+ const FRAME_TOKEN_REPLACEMENT = "[filtered frame token]";
17
+ // @henryqw/pi-herdr-btw does not export internal/core.ts from its package root.
18
+ const BTW_CHILD_PAYLOAD_ARG = "--pi-herdr-btw-payload";
19
+ const CONSOLIDATION_FAILURE = /(?:exceed|over) the limit|would put memory|no entry matched|[Mm]ultiple entries matched|matched multiple distinct/i;
20
+ 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.
21
+
22
+ 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.
23
+
24
+ WHEN: Save proactively when the user states a preference, correction, or personal detail, or you learn a stable fact about their environment, conventions, or workflow. Prioritize user preferences and corrections, then environment facts, then procedures.
25
+
26
+ IF FULL: Reissue one batch that removes or shortens enough stale entries and adds the new entry together.
27
+
28
+ TARGETS: user is who the user is (name, role, preferences, style). memory is your notes (environment, conventions, tool quirks, lessons).
29
+
30
+ EXCLUDE: project- or repository-specific facts (build commands, repo conventions, architecture) do NOT belong here — this store is global across projects; put them in that repository's docs instead.
31
+
32
+ SKIP: trivial or obvious information, easily rediscovered facts, raw dumps, task progress, completed-work logs, and temporary TODO state. Reusable procedures belong in a skill, not memory.`;
33
+
34
+ function sanitizeEntry(entry: string): string {
35
+ return entry.split("\n").map((line) => FRAME_TOKEN_LINE.test(line) ? FRAME_TOKEN_REPLACEMENT : line).join("\n");
36
+ }
37
+
38
+ // Strip control characters so externally-influenced names can't smuggle
39
+ // prompt structure into warnings.
40
+ function sanitizeName(name: string): string {
41
+ return name.replace(/[\p{C}]/gu, "").slice(0, 120);
42
+ }
43
+
44
+ function renderBlock(target: Target, entries: string[], config: MemoryConfig, warnings: string[]): string {
45
+ if (!entries.length) return "";
46
+ const limit = target === "user" ? config.userCharLimit : config.memoryCharLimit;
47
+ // Sanitize BEFORE budgeting: expansion from frame-token replacement must
48
+ // count against the cap, or many short reserved lines could inflate the
49
+ // injected snapshot past it.
50
+ const sanitized = entries.map(sanitizeEntry);
51
+ // Cap the snapshot at the configured char budget even when the on-disk file
52
+ // exceeds it (external edit / sync). Omitted entries stay on disk; the
53
+ // warning tells the model to consolidate before anything new fits.
54
+ const kept: string[] = [];
55
+ let used = 0;
56
+ let omitted = 0;
57
+ for (const entry of sanitized) {
58
+ const cost = entry.length + (kept.length ? ENTRY_DELIMITER.length : 0);
59
+ // No kept.length exemption: a single oversized entry (manual edit or sync)
60
+ // must be omitted too, or it defeats the advertised context cap.
61
+ if (used + cost > limit) {
62
+ omitted = entries.length - kept.length;
63
+ break;
64
+ }
65
+ kept.push(entry);
66
+ used += cost;
67
+ }
68
+ const content = kept.join(ENTRY_DELIMITER);
69
+ if (content.includes(FRAME_TOKEN_REPLACEMENT)) {
70
+ warnings.push(`WARNING: frame-token-like lines were filtered out of the ${target} snapshot (see "${FRAME_TOKEN_REPLACEMENT}").`);
71
+ }
72
+ if (omitted > 0) {
73
+ warnings.push(`WARNING: ${target} store is over its character cap; ${omitted} entr${omitted === 1 ? "y was" : "ies were"} omitted from this snapshot. Consolidate stale entries via a memory batch.`);
74
+ }
75
+ // Everything omitted (e.g. one entry larger than the whole cap): no block,
76
+ // the standalone warning above still reaches the prompt.
77
+ if (!kept.length) return "";
78
+ const usage = `${Math.min(100, Math.floor((used / limit) * 100))}% — ${used.toLocaleString()}/${limit.toLocaleString()} chars`;
79
+ const header = target === "user" ? "USER PROFILE (who the user is)" : "MEMORY (your personal notes)";
80
+ return `${SEPARATOR}\n${header} [${usage}]\n${SEPARATOR}\n${content}`;
81
+ }
82
+
83
+ // Bound aggregate preview size: thousands of short entries over cap must not
84
+ // inject nearly the whole file into one tool error.
85
+ function failureMessage(error: string, entries?: string[]): string {
86
+ if (!entries?.length) return error;
87
+ const MAX_PREVIEW_ENTRIES = 20;
88
+ const MAX_PREVIEW_CHARS = 1500;
89
+ const shown: string[] = [];
90
+ let chars = 0;
91
+ let moreEntries = 0;
92
+ for (const entry of entries) {
93
+ const clipped = entry.length > 120 ? `${entry.slice(0, 120)}...` : entry;
94
+ if (shown.length >= MAX_PREVIEW_ENTRIES || chars + clipped.length > MAX_PREVIEW_CHARS) {
95
+ moreEntries = entries.length - shown.length;
96
+ break;
97
+ }
98
+ shown.push(clipped);
99
+ chars += clipped.length;
100
+ }
101
+ const suffix = moreEntries > 0 ? ` (and ${moreEntries} more entries)` : "";
102
+ return `${error}\nCurrent entries: ${JSON.stringify(shown)}${suffix}`;
103
+ }
104
+
105
+ export default function memoryExtension(pi: ExtensionAPI): void {
106
+ const state: {
107
+ config?: MemoryConfig;
108
+ stores?: Record<Target, MemoryStore>;
109
+ snapshotBlocks?: string[];
110
+ conflictWarnings: string[];
111
+ initError?: string;
112
+ } = { conflictWarnings: [] };
113
+
114
+ pi.on("session_start", async () => {
115
+ state.config = undefined;
116
+ state.stores = undefined;
117
+ state.snapshotBlocks = undefined;
118
+ state.conflictWarnings = [];
119
+ state.initError = undefined;
120
+ try {
121
+ await mkdir(BACKUP_DIR(), { recursive: true });
122
+ const config = loadMemoryConfig();
123
+ await mkdir(config.directory, { recursive: true });
124
+ // The runtime contract keeps backups OUTSIDE the memory directory; reject
125
+ // overlap (equal, ancestor, descendant) so backup cleanup can never eat
126
+ // the store and .bak files can't be mistaken for memory files. Both dirs
127
+ // exist by now — resolve symlinks and '..' components via realpath.
128
+ const [realStore, realBackup] = await Promise.all([realpath(config.directory), realpath(BACKUP_DIR())]);
129
+ if (realStore === realBackup || realStore.startsWith(realBackup + sep) || realBackup.startsWith(realStore + sep)) {
130
+ throw new Error(`Memory directory must not overlap the backup directory (${BACKUP_DIR()}): got ${config.directory}`);
131
+ }
132
+ const backupPath = (target: Target) => join(BACKUP_DIR(), target === "user" ? "USER.md.bak" : "MEMORY.md.bak");
133
+ const stores: Record<Target, MemoryStore> = {
134
+ memory: new MemoryStore({ ...config, backupPath }),
135
+ user: new MemoryStore({ ...config, backupPath }),
136
+ };
137
+ const [memory, user, siblings] = await Promise.all([
138
+ stores.memory.load("memory"),
139
+ stores.user.load("user"),
140
+ readdir(config.directory, { withFileTypes: true }),
141
+ ]);
142
+ const conflictWarnings = [memory.conflictWarning, user.conflictWarning].filter((warning): warning is string => !!warning);
143
+ // Directory contract is exactly MEMORY.md + USER.md — warn on ANY other
144
+ // regular file (iCloud conflict copies, stray edits) without guessing its
145
+ // origin from the name. Bound the list so pointing directory at a large
146
+ // existing folder can't flood the system prompt.
147
+ const MAX_LISTED_FILES = 3;
148
+ const unexpected = siblings.filter((sibling) => sibling.isFile() && sibling.name !== "MEMORY.md" && sibling.name !== "USER.md").map((sibling) => sibling.name).sort();
149
+ if (unexpected.length > 0) {
150
+ const listed = unexpected.slice(0, MAX_LISTED_FILES).map((name) => `"${sanitizeName(name)}"`).join(", ");
151
+ const more = unexpected.length > MAX_LISTED_FILES ? ` and ${unexpected.length - MAX_LISTED_FILES} more` : "";
152
+ conflictWarnings.push(`WARNING: ${unexpected.length} unexpected file${unexpected.length === 1 ? "" : "s"} in the memory directory (${listed}${more}). Only MEMORY.md and USER.md are loaded; reconcile or remove the rest.`);
153
+ }
154
+
155
+ state.config = config;
156
+ state.stores = stores;
157
+ state.snapshotBlocks = [renderBlock("memory", memory.entries, config, conflictWarnings), renderBlock("user", user.entries, config, conflictWarnings)];
158
+ state.conflictWarnings = conflictWarnings;
159
+ } catch (error) {
160
+ // Surface once, disable quietly: no throw-loop every turn.
161
+ state.initError = error instanceof Error ? error.message : String(error);
162
+ }
163
+ });
164
+
165
+ // Tool is registered unconditionally at factory time so a failed init
166
+ // degrades to per-call errors instead of a missing tool.
167
+ pi.registerTool({
168
+ name: "memory",
169
+ label: "Memory",
170
+ description: `${MEMORY_DESCRIPTION}\n\nTo see current live entries, read MEMORY.md in the configured memory directory with the read tool.`,
171
+ promptSnippet: "Save durable facts to persistent memory",
172
+ parameters: Type.Object({
173
+ action: Type.Optional(StringEnum(["add", "replace", "remove"] as const, {
174
+ description: "Single change to perform. Omit when using operations.",
175
+ })),
176
+ target: Type.Optional(StringEnum(["memory", "user"] as const, {
177
+ default: "memory",
178
+ description: "memory for agent notes; user for user profile facts. Defaults to memory.",
179
+ })),
180
+ content: Type.Optional(Type.String({ description: "Entry content for add or replace." })),
181
+ old_text: Type.Optional(Type.String({ description: "Unique substring identifying the entry for replace or remove." })),
182
+ operations: Type.Optional(Type.Array(Type.Object({
183
+ action: StringEnum(["add", "replace", "remove"] as const),
184
+ content: Type.Optional(Type.String()),
185
+ old_text: Type.Optional(Type.String()),
186
+ }), { description: "Preferred atomic batch of memory changes." })),
187
+ }),
188
+
189
+ async execute(_toolCallId, params) {
190
+ if (state.initError) throw new Error(`Memory extension failed to initialize and is disabled: ${state.initError}`);
191
+ if (!state.config || !state.stores) throw new Error("Memory extension is not initialized.");
192
+ const target = params.target ?? "memory";
193
+ const store = state.stores[target];
194
+ // Serialize the entire mutation window against Pi's edit/write tools.
195
+ return withFileMutationQueue(join(state.config.directory, target === "user" ? "USER.md" : "MEMORY.md"), async () => {
196
+ // Recreate before locking: a cleaned-up backup dir would otherwise fail
197
+ // lock-file creation before persist() gets a chance to restore it.
198
+ await mkdir(BACKUP_DIR(), { recursive: true });
199
+ const release = await lock(join(BACKUP_DIR(), ".memory-lock"), {
200
+ realpath: false,
201
+ stale: 10_000,
202
+ retries: { retries: 2, minTimeout: 50, maxTimeout: 200 },
203
+ });
204
+ try {
205
+ let result: Awaited<ReturnType<MemoryStore["add"]>>;
206
+ if (params.operations !== undefined) result = await store.applyBatch(target, params.operations);
207
+ else if (params.action === "add") result = await store.add(target, params.content ?? "");
208
+ else if (params.action === "replace") result = await store.replace(target, params.old_text ?? "", params.content ?? "");
209
+ else if (params.action === "remove") result = await store.remove(target, params.old_text ?? "");
210
+ else result = { success: false, error: "Provide action for a single change or operations for a batch." };
211
+
212
+ if (!result.success) {
213
+ let error = result.error ?? "Memory write failed.";
214
+ // Pi tool errors are plain strings — surface match previews and usage.
215
+ if (result.matches?.length) error += `\nMatching entries: ${JSON.stringify(result.matches)}`;
216
+ if (result.usage) error += `\nUsage: ${result.usage}`;
217
+ if (CONSOLIDATION_FAILURE.test(error) && store.incrementFailure().done) {
218
+ throw new Error("Memory consolidation failed repeatedly this turn. Stop retrying memory calls, continue replying to the user.");
219
+ }
220
+ throw new Error(failureMessage(error, result.currentEntries));
221
+ }
222
+ store.resetOnSuccess();
223
+ return {
224
+ content: [{
225
+ type: "text" as const,
226
+ text: JSON.stringify({
227
+ success: true,
228
+ done: true,
229
+ usage: result.usage,
230
+ entryCount: result.entryCount,
231
+ message: "Write saved. This update is complete — do not repeat it.",
232
+ }),
233
+ }],
234
+ details: {},
235
+ };
236
+ } finally {
237
+ await release();
238
+ }
239
+ });
240
+ },
241
+ });
242
+
243
+ pi.on("before_agent_start", (event) => {
244
+ for (const store of Object.values(state.stores ?? {})) store.resetOnSuccess();
245
+ if (process.argv.includes(BTW_CHILD_PAYLOAD_ARG)) return;
246
+ // Failed init stays visible every turn (correctness-critical config must
247
+ // not vanish silently) but as a warning line, not a per-turn throw-loop.
248
+ if (state.initError) {
249
+ return { systemPrompt: `${event.systemPrompt}\n\nWARNING: persistent memory is DISABLED this session — initialization failed: ${sanitizeName(state.initError)} Fix config/pi-memory.json and restart.` };
250
+ }
251
+ if (!state.config || !state.stores || !state.snapshotBlocks) return;
252
+ const blocks = [...state.snapshotBlocks, ...state.conflictWarnings].filter(Boolean).join("\n\n");
253
+ return { systemPrompt: `${event.systemPrompt}\n\n${blocks}` };
254
+ });
255
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@henryqw/pi-memory",
3
+ "version": "0.1.0",
4
+ "description": "Auto-managed markdown memory for Pi: capped MEMORY.md/USER.md entry stores with frozen session snapshots.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "memory"
9
+ ],
10
+ "type": "module",
11
+ "engines": {
12
+ "node": ">=22.19.0"
13
+ },
14
+ "license": "MIT",
15
+ "files": [
16
+ "extensions",
17
+ "src",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "test": "node --test test/*.test.ts",
23
+ "typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/*.ts src/*.ts test/*.test.ts",
24
+ "pack:check": "npm pack --dry-run"
25
+ },
26
+ "dependencies": {
27
+ "proper-lockfile": "^4.1.2"
28
+ },
29
+ "peerDependencies": {
30
+ "@earendil-works/pi-ai": "^0.84.2",
31
+ "@earendil-works/pi-coding-agent": "^0.84.2",
32
+ "typebox": "^1.3.15"
33
+ },
34
+ "devDependencies": {
35
+ "@types/proper-lockfile": "^4.1.4"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/HenryQW/pi-packages.git",
40
+ "directory": "packages/pi-memory"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/HenryQW/pi-packages/issues"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "pi": {
49
+ "extensions": [
50
+ "./extensions/memory.ts"
51
+ ]
52
+ }
53
+ }
package/src/config.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+
5
+ // Repository-mandated single-extension config path boundary (root AGENTS.md).
6
+ export const configPath = () => join(getAgentDir(), "config", "pi-memory.json");
7
+
8
+ export interface MemoryConfig {
9
+ directory: string;
10
+ memoryCharLimit: number;
11
+ userCharLimit: number;
12
+ }
13
+
14
+ export function DEFAULT_DIRECTORY(): string {
15
+ return join(getAgentDir(), "memory");
16
+ }
17
+
18
+ export const DEFAULT_MEMORY_CHAR_LIMIT = 8800;
19
+ export const DEFAULT_USER_CHAR_LIMIT = 5500;
20
+
21
+ export function loadMemoryConfig(explicitPath?: string): MemoryConfig {
22
+ const path = explicitPath ?? configPath();
23
+ let raw: string;
24
+ try {
25
+ // Bound the read: a config accidentally replaced with (or symlinked to) a
26
+ // huge file must not exhaust memory before validation. Real configs are
27
+ // tiny; 64 KiB is generous.
28
+ const bytes = readFileSync(path);
29
+ if (bytes.length > 64 * 1024) {
30
+ throw new Error(`Memory config at ${path} is too large (${bytes.length} bytes); expected < 64 KiB.`);
31
+ }
32
+ // Fatal decode: invalid UTF-8 must surface as malformed config, not a
33
+ // U+FFFD-replaced view that could pass path checks and silently redirect
34
+ // the memory directory.
35
+ raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
36
+ } catch (error: unknown) {
37
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
38
+ return {
39
+ directory: DEFAULT_DIRECTORY(),
40
+ memoryCharLimit: DEFAULT_MEMORY_CHAR_LIMIT,
41
+ userCharLimit: DEFAULT_USER_CHAR_LIMIT,
42
+ };
43
+ }
44
+ if (error instanceof TypeError) {
45
+ throw new Error(`Malformed memory config at ${path}: invalid UTF-8.`);
46
+ }
47
+ throw error;
48
+ }
49
+
50
+ let parsed: unknown;
51
+ try {
52
+ parsed = JSON.parse(raw);
53
+ } catch (error: unknown) {
54
+ const message = error instanceof Error ? error.message : String(error);
55
+ throw new Error(`Malformed JSON in memory config at ${path}: ${message}`);
56
+ }
57
+
58
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
59
+ throw new Error(`Memory config at ${path} must be a JSON object.`);
60
+ }
61
+
62
+ const obj = parsed as Record<string, unknown>;
63
+
64
+ // Fail fast on typos: an unrecognized key would otherwise silently activate
65
+ // defaults (e.g. "memoryCharLimits": 100), contradicting the fail-fast contract.
66
+ const KNOWN_KEYS = ["directory", "memoryCharLimit", "userCharLimit"];
67
+ for (const key of Object.keys(obj)) {
68
+ if (!KNOWN_KEYS.includes(key)) {
69
+ throw new Error(`Unknown key '${key}' in memory config at ${path}. Expected: ${KNOWN_KEYS.join(", ")}.`);
70
+ }
71
+ }
72
+
73
+ let directory = DEFAULT_DIRECTORY();
74
+ if (obj.directory !== undefined) {
75
+ if (typeof obj.directory !== "string" || obj.directory.trim() === "") {
76
+ throw new Error(`Invalid 'directory' in memory config at ${path}: must be a non-empty string, got ${JSON.stringify(obj.directory)}`);
77
+ }
78
+ if (!isAbsolute(obj.directory)) {
79
+ throw new Error(`Invalid 'directory' in memory config at ${path}: must be an absolute path, got ${JSON.stringify(obj.directory)}`);
80
+ }
81
+ // Untrusted config value gets embedded verbatim in prompt warnings;
82
+ // control characters could forge prompt lines.
83
+ if (/\p{C}/u.test(obj.directory)) {
84
+ throw new Error(`Invalid 'directory' in memory config at ${path}: must not contain control characters.`);
85
+ }
86
+ directory = obj.directory;
87
+ }
88
+
89
+ let memoryCharLimit = DEFAULT_MEMORY_CHAR_LIMIT;
90
+ if (obj.memoryCharLimit !== undefined) {
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
+ }
106
+
107
+ return {
108
+ directory,
109
+ memoryCharLimit,
110
+ userCharLimit,
111
+ };
112
+ }
package/src/store.ts ADDED
@@ -0,0 +1,562 @@
1
+ import { copyFile, lstat, mkdir, open, rename, stat, writeFile, rm } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+
4
+ export const ENTRY_DELIMITER: string = "\n§\n";
5
+
6
+ export type Target = "memory" | "user";
7
+
8
+ export interface StoreConfig {
9
+ directory: string;
10
+ memoryCharLimit: number;
11
+ userCharLimit: number;
12
+ /** Called before every rewrite of an existing file; store copies the old file there. */
13
+ backupPath?: (target: Target) => string;
14
+ /** Test seam: rename implementation. Defaults to fs.rename. */
15
+ renameFn?: (from: string, to: string) => Promise<void>;
16
+ /** Test seam: stat implementation for persistence-time checks. Defaults to fs.stat. */
17
+ statFn?: (path: string) => Promise<import("node:fs").Stats>;
18
+ }
19
+
20
+ export interface LoadResult {
21
+ entries: string[];
22
+ status?: "unreadable" | "oversized";
23
+ conflictWarning?: string;
24
+ }
25
+
26
+ export interface BatchOperation {
27
+ action?: string;
28
+ content?: string;
29
+ new_text?: string;
30
+ old_text?: string;
31
+ }
32
+
33
+ /** Refuse to inject snapshots above this size into context. */
34
+ export const MAX_FILE_BYTES = 1_000_000;
35
+
36
+ export type FileState =
37
+ | { kind: "ok"; raw: string }
38
+ | { kind: "absent" }
39
+ | { kind: "unreadable" }
40
+ | { kind: "oversized"; bytes: number };
41
+
42
+ type Result = {
43
+ success: boolean;
44
+ message?: string;
45
+ error?: string;
46
+ usage?: string;
47
+ entryCount?: number;
48
+ currentEntries?: string[];
49
+ matches?: string[];
50
+ done?: boolean;
51
+ note?: string;
52
+ target?: Target;
53
+ };
54
+
55
+ const PREVIEW_WIDTH = 80;
56
+ const MAX_PREVIEW_ITEMS = 20;
57
+ const MAX_PREVIEW_CHARS = 1500;
58
+
59
+ function previews(entries: string[]): string[] {
60
+ let chars = 0;
61
+ const shown: string[] = [];
62
+ for (const entry of entries) {
63
+ if (shown.length >= MAX_PREVIEW_ITEMS || chars + PREVIEW_WIDTH > MAX_PREVIEW_CHARS) break;
64
+ shown.push(entry.length > PREVIEW_WIDTH ? `${entry.slice(0, PREVIEW_WIDTH)}...` : entry);
65
+ chars += PREVIEW_WIDTH;
66
+ }
67
+ return shown;
68
+ }
69
+
70
+ /**
71
+ * Binding normalization order: strip BOM -> all line terminators to LF -> trim.
72
+ * Delimiter validation, parsing, budgeting, and matching all operate on
73
+ * normalized text so "a\r\n§\r\nb" cannot smuggle a delimiter past us and
74
+ * CR/LF/NEL/VT/FF/U+2028/U+2029 cannot smuggle fake frame lines past line-based
75
+ * filters. ponytail: NFKC/zero-width lookalike spoofing is NOT handled — the
76
+ * frame headers are advisory context, not a security boundary; revisit only if
77
+ * entries start coming from untrusted writers.
78
+ */
79
+ function normalize(raw: string): string {
80
+ return raw.replace(/^\uFEFF/, "").replace(/\r\n?|[\u2028\u2029\u0085\u000B\u000C]/g, "\n").trim();
81
+ }
82
+
83
+ function parseEntries(raw: string): string[] {
84
+ const text = normalize(raw);
85
+ if (!text) return [];
86
+ // Deduplicate, preserving order and first occurrence.
87
+ return [...new Set(text.split(ENTRY_DELIMITER).map((e) => e.trim()).filter(Boolean))];
88
+ }
89
+
90
+ export class MemoryStore {
91
+ private readonly config: StoreConfig;
92
+ private readonly entries = new Map<Target, string[]>([
93
+ ["memory", []],
94
+ ["user", []],
95
+ ]);
96
+ private consolidationFailures = 0;
97
+ // Targets whose file was observed on disk this session — used to detect
98
+ // unexpected mid-session disappearance before a mutation rewrites from an
99
+ // empty view.
100
+ private readonly observedExisting = new Set<Target>();
101
+ private disappearanceDetected = false;
102
+ private unreadableReason: string | undefined;
103
+ // mtime/size of the last successfully loaded file, per target.
104
+ private readonly loadedFingerprints = new Map<Target, { mtimeMs: number; size: number }>();
105
+
106
+ constructor(config: StoreConfig) {
107
+ this.config = config;
108
+ }
109
+
110
+ private limit(target: Target): number {
111
+ return target === "user" ? this.config.userCharLimit : this.config.memoryCharLimit;
112
+ }
113
+
114
+ private pathFor(target: Target): string {
115
+ return join(this.config.directory, target === "user" ? "USER.md" : "MEMORY.md");
116
+ }
117
+
118
+ private charCount(target: Target): number {
119
+ const entries = this.entries.get(target)!;
120
+ return entries.length ? entries.join(ENTRY_DELIMITER).length : 0;
121
+ }
122
+
123
+ private usage(target: Target): string {
124
+ const current = this.charCount(target);
125
+ const limit = this.limit(target);
126
+ const pct = limit > 0 ? Math.min(100, Math.floor((current / limit) * 100)) : 0;
127
+ return `${pct}% — ${current.toLocaleString()}/${limit.toLocaleString()} chars`;
128
+ }
129
+
130
+ private successResponse(target: Target, message?: string): Result {
131
+ this.resetOnSuccess();
132
+ return {
133
+ success: true,
134
+ done: true,
135
+ target,
136
+ message,
137
+ usage: this.usage(target),
138
+ entryCount: this.entries.get(target)!.length,
139
+ note: "Write saved. This update is complete — do not repeat it.",
140
+ };
141
+ }
142
+
143
+ private consolidationFailure(error: string, extra?: Partial<Result>): Result {
144
+ const target = extra?.target ?? "memory";
145
+ return {
146
+ success: false,
147
+ error,
148
+ currentEntries: previews(this.entries.get(target)!),
149
+ usage: this.usage(target),
150
+ ...extra,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Track consecutive consolidation failures. After 3, the model is told to
156
+ * stop retrying ({ done: true }); a successful write resets the count.
157
+ */
158
+ incrementFailure(): { done: boolean } {
159
+ this.consolidationFailures += 1;
160
+ if (this.consolidationFailures >= 3) {
161
+ return { done: true };
162
+ }
163
+ return { done: false };
164
+ }
165
+
166
+ resetOnSuccess(): void {
167
+ this.consolidationFailures = 0;
168
+ }
169
+
170
+ async load(target: Target): Promise<LoadResult> {
171
+ const file = await this.readFileState(target);
172
+ if (file.kind === "ok") this.observedExisting.add(target);
173
+ if (file.kind === "unreadable") {
174
+ return {
175
+ entries: [],
176
+ status: "unreadable",
177
+ conflictWarning: `${this.pathFor(target)} exists but could not be read; refusing to serve a possibly-wrong view.`,
178
+ };
179
+ }
180
+ if (file.kind === "oversized") {
181
+ return {
182
+ entries: [],
183
+ status: "oversized",
184
+ conflictWarning: `${this.pathFor(target)} is ${file.bytes.toLocaleString()} bytes, over the ${MAX_FILE_BYTES.toLocaleString()}-byte injection limit; refusing to serve it. Consolidate the file manually.`,
185
+ };
186
+ }
187
+ return { entries: file.kind === "ok" ? parseEntries(file.raw) : [] };
188
+ }
189
+
190
+ /**
191
+ * Returns file state: "absent" for a missing file, "unreadable" when the file
192
+ * EXISTS but could not be read (permissions or invalid UTF-8), "oversized"
193
+ * when it exceeds MAX_FILE_BYTES. Callers must abort on unreadable/oversized
194
+ * rather than treat it as an empty store.
195
+ */
196
+ private async readFileState(target: Target): Promise<FileState> {
197
+ // Bounded, EOF-complete read: at most MAX_FILE_BYTES + 1 bytes leave the
198
+ // filesystem (so a huge synced file can't exhaust memory), and we keep
199
+ // reading until EOF so a short read from a network/synced filesystem can
200
+ // never be mistaken for the whole file.
201
+ let handle: import("node:fs/promises").FileHandle | undefined;
202
+ try {
203
+ // Symlinked store files are rejected before anything follows the link:
204
+ // tmp+rename would replace the link itself and silently disconnect
205
+ // writes from the intended synced target.
206
+ const ls = await lstat(this.pathFor(target)).catch(() => null);
207
+ if (ls?.isSymbolicLink()) {
208
+ this.unreadableReason = `${this.pathFor(target)} is a symlink; symlinked store files are not supported because atomic rewrites replace the link. Point the memory directory at real files.`;
209
+ return { kind: "unreadable" };
210
+ }
211
+ handle = await open(this.pathFor(target), "r");
212
+ const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
213
+ let total = 0;
214
+ for (;;) {
215
+ if (total > MAX_FILE_BYTES) return { kind: "oversized", bytes: total };
216
+ const { bytesRead } = await handle.read(buffer, total, buffer.length - total, null);
217
+ total += bytesRead;
218
+ if (bytesRead === 0) break; // EOF
219
+ }
220
+ // Fatal decode: invalid UTF-8 counts as unreadable — a lossy replacement
221
+ // view could get persisted back over the real bytes.
222
+ const raw = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, total));
223
+ // Fingerprint for the pre-rename change check: an external sync that
224
+ // lands V2 between this read and persist must not be silently replaced
225
+ // by V1-plus-mutation.
226
+ try {
227
+ const st = await (this.config.statFn ?? stat)(this.pathFor(target));
228
+ this.loadedFingerprints.set(target, { mtimeMs: st.mtimeMs, size: st.size });
229
+ } catch {
230
+ this.loadedFingerprints.delete(target);
231
+ }
232
+ return { kind: "ok", raw };
233
+ } catch (error) {
234
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
235
+ // ENOENT on the FILE is "absent" only if the configured directory
236
+ // itself still exists — a vanished synced/mounted directory must not
237
+ // be mistaken for an empty store and rewritten divergently.
238
+ try {
239
+ await stat(this.config.directory);
240
+ return { kind: "absent" };
241
+ } catch {
242
+ return { kind: "unreadable" };
243
+ }
244
+ }
245
+ return { kind: "unreadable" };
246
+ } finally {
247
+ await handle?.close().catch(() => {});
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Re-read from disk into in-memory state. Returns false when the file is
253
+ * unreadable or oversized — callers must NOT proceed (never rewrite from an
254
+ * assumed-empty view).
255
+ */
256
+ private async reloadTarget(target: Target): Promise<boolean> {
257
+ const file = await this.readFileState(target);
258
+ if (file.kind !== "ok" && file.kind !== "absent") return false;
259
+ if (file.kind === "absent" && this.observedExisting.has(target)) {
260
+ // The store existed earlier this session and has vanished (sync
261
+ // conflict, cleanup, accident). Rewriting from the in-memory view would
262
+ // create a divergent store that hides the original when it reappears.
263
+ this.disappearanceDetected = true;
264
+ return false;
265
+ }
266
+ if (file.kind === "ok") this.observedExisting.add(target);
267
+ this.entries.set(target, file.kind === "ok" ? parseEntries(file.raw) : []);
268
+ return true;
269
+ }
270
+
271
+ private async persist(target: Target): Promise<void> {
272
+ await mkdir(this.config.directory, { recursive: true });
273
+ const path = this.pathFor(target);
274
+ const backup = this.config.backupPath?.(target);
275
+ if (backup) {
276
+ // Recreate the backup parent so a cleaned-up backup directory can't be
277
+ // misread as "source absent" and silently skip the promised backup.
278
+ await mkdir(dirname(backup), { recursive: true });
279
+ try {
280
+ await copyFile(path, backup);
281
+ } catch (error) {
282
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
283
+ // Backup dir now exists, so ENOENT means the SOURCE vanished after
284
+ // reloadTarget saw it — same divergence hazard as mid-session
285
+ // disappearance; never proceed from the stale view.
286
+ if (this.observedExisting.has(target)) {
287
+ this.disappearanceDetected = true;
288
+ throw new Error(`${path} vanished before its backup could be written; aborting to avoid a divergent store.`);
289
+ }
290
+ }
291
+ }
292
+ const content = this.entries.get(target)!.join(ENTRY_DELIMITER);
293
+ const tmp = join(dirname(path), `.mem_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2)}`);
294
+ try {
295
+ // Preserve restrictive modes/ACLs across inode replacement: default
296
+ // umask would otherwise turn a 0600 USER.md into 0644.
297
+ let mode: number | undefined;
298
+ try {
299
+ mode = (await stat(path)).mode & 0o777;
300
+ } catch (error) {
301
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
302
+ }
303
+ await writeFile(tmp, content, mode === undefined ? "utf-8" : { encoding: "utf-8", mode });
304
+ // A creation-assumed write (reload saw the file absent) must not clobber
305
+ // a file that appeared meanwhile (sync race): re-check just before the
306
+ // rename, as close to it as possible.
307
+ if (!this.observedExisting.has(target)) {
308
+ try {
309
+ await (this.config.statFn ?? stat)(path);
310
+ throw new Error(`${path} appeared during this mutation (likely sync); retry to merge its content.`);
311
+ } catch (error) {
312
+ if (!(error as NodeJS.ErrnoException).code && (error as Error).message.includes("appeared during")) throw error;
313
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
314
+ // ENOENT: still absent, proceed.
315
+ }
316
+ }
317
+ // An existing store must not be replaced if the on-disk version changed
318
+ // since reload (external sync landed V2 after we read V1): compare
319
+ // fingerprint immediately before the rename.
320
+ if (this.observedExisting.has(target)) {
321
+ const fingerprint = this.loadedFingerprints.get(target);
322
+ if (fingerprint) {
323
+ const current = await (this.config.statFn ?? stat)(path);
324
+ if (current.mtimeMs !== fingerprint.mtimeMs || current.size !== fingerprint.size) {
325
+ throw new Error(`${path} changed during this mutation (likely sync); retry to merge its content.`);
326
+ }
327
+ }
328
+ }
329
+ await (this.config.renameFn ?? rename)(tmp, path);
330
+ // The store now exists on disk; a later mid-session disappearance is
331
+ // unexpected and must abort, not rewrite from the in-memory view.
332
+ this.observedExisting.add(target);
333
+ } finally {
334
+ await rm(tmp, { force: true }).catch(() => {});
335
+ }
336
+ }
337
+
338
+ private static checkContent(content: string): string | undefined {
339
+ const normalized = normalize(content);
340
+ if (!normalized) return "Content cannot be empty.";
341
+ if (normalized.includes(ENTRY_DELIMITER)) return `Content must not contain the entry delimiter ("${ENTRY_DELIMITER.trim()}”).`;
342
+ // Same predicate as the snapshot sanitizer (leading Unicode whitespace
343
+ // included): anything the sanitizer would filter must be rejected here,
344
+ // or writes report success while vanishing from snapshots.
345
+ for (const line of normalized.split("\n")) {
346
+ if (/^\s*(?:═{3,}|MEMORY \(your personal notes|USER PROFILE \(who the user is)/.test(line)) {
347
+ return "Content must not contain lines starting with '═' separators or the reserved headers 'MEMORY (your personal notes' / 'USER PROFILE (who the user is'.";
348
+ }
349
+ }
350
+ return undefined;
351
+ }
352
+
353
+ private static missingOldTextError(target: Target, action: "replace" | "remove", store: MemoryStore): Result {
354
+ return {
355
+ success: false,
356
+ error: `'${action}' needs old_text -- a short unique substring of the entry to ${action}. None was provided. Reissue the ${action} with old_text set to part of one of the current_entries below.`,
357
+ currentEntries: previews(store.entries.get(target)!),
358
+ usage: store.usage(target),
359
+ };
360
+ }
361
+
362
+ private static ambiguousError(oldText: string, matches: string[]): Result {
363
+ return {
364
+ success: false,
365
+ error: `Multiple entries matched '${oldText}'. Be more specific.`,
366
+ matches: previews(matches),
367
+ };
368
+ }
369
+
370
+ /**
371
+ * Resolve substring matches against entries. Entries are duplicate-free by
372
+ * invariant (dedupe on load + after every mutation), so multiple matches
373
+ * are always distinct entries.
374
+ */
375
+ private static resolveMatch(entries: string[], oldText: string): ["missing"] | ["ambiguous", string[]] | [number] {
376
+ const matches = entries.map((e, i) => (e.includes(oldText) ? i : -1)).filter((i) => i >= 0);
377
+ if (matches.length === 0) return ["missing"];
378
+ if (matches.length > 1) {
379
+ return ["ambiguous", matches.map((i) => entries[i])];
380
+ }
381
+ return [matches[0]];
382
+ }
383
+
384
+ async add(target: Target, content: string): Promise<Result> {
385
+ const contentError = MemoryStore.checkContent(content);
386
+ if (contentError) return { success: false, error: contentError };
387
+ const text = normalize(content);
388
+
389
+ if (!(await this.reloadTarget(target))) {
390
+ return this.unreadableAbort(target);
391
+ }
392
+ const entries = this.entries.get(target)!;
393
+
394
+ if (entries.includes(text)) {
395
+ return this.successResponse(target, "Entry already exists (no duplicate added).");
396
+ }
397
+
398
+ const newTotal = [...entries, text].join(ENTRY_DELIMITER).length;
399
+ if (newTotal > this.limit(target)) {
400
+ return this.consolidationFailure(
401
+ `Memory at ${this.charCount(target).toLocaleString()}/${this.limit(target).toLocaleString()} chars. `
402
+ + `Adding this entry (${text.length} chars) would exceed the limit. Consolidate now: use 'replace' to merge `
403
+ + `overlapping entries into shorter ones or 'remove' stale or less important entries (see current_entries below), `
404
+ + `then retry this add — all in this turn.`,
405
+ { target },
406
+ );
407
+ }
408
+
409
+ entries.push(text);
410
+ await this.persist(target);
411
+ return this.successResponse(target, "Entry added.");
412
+ }
413
+
414
+ private unreadableAbort(target: Target): Result {
415
+ if (this.unreadableReason) {
416
+ const reason = this.unreadableReason;
417
+ this.unreadableReason = undefined;
418
+ return { success: false, error: reason };
419
+ }
420
+ if (this.disappearanceDetected) {
421
+ this.disappearanceDetected = false;
422
+ return {
423
+ success: false,
424
+ error: `${this.pathFor(target)} existed earlier this session but has disappeared (sync conflict, cleanup, or accident). Writing now would create a divergent store that hides the original when it returns. Restore the file (with the entries you want to keep) — once recreated, mutations work normally again.`,
425
+ };
426
+ }
427
+ return {
428
+ success: false,
429
+ error: `${this.pathFor(target)} exists but could not be read (unreadable, or over the ${MAX_FILE_BYTES.toLocaleString()}-byte limit). The on-disk entries are unknown, so writing would `
430
+ + `risk wiping them. Fix the file and retry — nothing was changed.`,
431
+ };
432
+ }
433
+
434
+ async replace(target: Target, oldText: string, newText: string): Promise<Result> {
435
+ const contentError = MemoryStore.checkContent(newText);
436
+ if (contentError) return { success: false, error: contentError };
437
+
438
+ // Reload before validating old_text so failure results reflect DISK state.
439
+ if (!(await this.reloadTarget(target))) return this.unreadableAbort(target);
440
+ const trimmedOld = normalize(oldText ?? "");
441
+ if (!trimmedOld) return MemoryStore.missingOldTextError(target, "replace", this);
442
+ const entries = this.entries.get(target)!;
443
+
444
+ const resolved = MemoryStore.resolveMatch(entries, trimmedOld);
445
+ if (resolved[0] === "missing") {
446
+ return this.consolidationFailure(
447
+ `No entry matched '${trimmedOld}'. Check current_entries below and retry with the exact text of the entry you want to replace.`,
448
+ { target },
449
+ );
450
+ }
451
+ if (resolved[0] === "ambiguous") return MemoryStore.ambiguousError(trimmedOld, resolved[1]);
452
+
453
+ const text = normalize(newText);
454
+ const testEntries = [...entries];
455
+ testEntries[resolved[0]] = text;
456
+ // A replace can create a duplicate; dedupe order-preserving before budget.
457
+ const deduped = [...new Set(testEntries)];
458
+ const newTotal = deduped.join(ENTRY_DELIMITER).length;
459
+ if (newTotal > this.limit(target)) {
460
+ return this.consolidationFailure(
461
+ `Replacement would put memory at ${newTotal.toLocaleString()}/${this.limit(target).toLocaleString()} chars. `
462
+ + `Shorten the new content, or 'remove' other stale or less important entries to make room `
463
+ + `(see current_entries below), then retry — all in this turn.`,
464
+ { target },
465
+ );
466
+ }
467
+
468
+ this.entries.set(target, deduped);
469
+ await this.persist(target);
470
+ return this.successResponse(target, "Entry replaced.");
471
+ }
472
+
473
+ async remove(target: Target, oldText: string): Promise<Result> {
474
+ // Reload before validating old_text so failure results reflect DISK state.
475
+ if (!(await this.reloadTarget(target))) return this.unreadableAbort(target);
476
+ const trimmedOld = normalize(oldText ?? "");
477
+ if (!trimmedOld) return MemoryStore.missingOldTextError(target, "remove", this);
478
+ const entries = this.entries.get(target)!;
479
+
480
+ const resolved = MemoryStore.resolveMatch(entries, trimmedOld);
481
+ if (resolved[0] === "missing") {
482
+ return this.consolidationFailure(
483
+ `No entry matched '${trimmedOld}'. Check current_entries below and retry with the exact text of the entry you want to remove.`,
484
+ { target },
485
+ );
486
+ }
487
+ if (resolved[0] === "ambiguous") return MemoryStore.ambiguousError(trimmedOld, resolved[1]);
488
+
489
+ entries.splice(resolved[0], 1);
490
+ await this.persist(target);
491
+ return this.successResponse(target, "Entry removed.");
492
+ }
493
+
494
+ /**
495
+ * Apply add/replace/remove operations atomically against the FINAL budget:
496
+ * intermediate overflow is fine, only the end state is checked. All-or-nothing.
497
+ */
498
+ async applyBatch(target: Target, operations: BatchOperation[]): Promise<Result> {
499
+ if (!operations || operations.length === 0) {
500
+ return { success: false, error: "operations list is empty." };
501
+ }
502
+ for (const op of operations) {
503
+ const content = op.content ?? op.new_text ?? "";
504
+ if ((op.action === "add" || op.action === "replace") && content) {
505
+ const contentError = MemoryStore.checkContent(content);
506
+ if (contentError) return { success: false, error: contentError };
507
+ }
508
+ }
509
+
510
+ if (!(await this.reloadTarget(target))) return this.unreadableAbort(target);
511
+
512
+ let working = [...this.entries.get(target)!];
513
+ const fail = (message: string): Result =>
514
+ this.consolidationFailure(`${message} No operations were applied (batch is all-or-nothing).`, { target });
515
+
516
+ for (let i = 0; i < operations.length; i++) {
517
+ const op = operations[i] ?? {};
518
+ const action = op.action;
519
+ const content = normalize(op.content ?? op.new_text ?? "");
520
+ const oldText = normalize(op.old_text ?? "");
521
+ const pos = `Operation ${i + 1} (${action ?? "unknown"})`;
522
+
523
+ if (action === "add") {
524
+ if (!content) return fail(`${pos}: content is required.`);
525
+ if (working.includes(normalize(content))) continue; // idempotent duplicate
526
+ working.push(normalize(content));
527
+ } else if (action === "replace") {
528
+ if (!oldText) return fail(`${pos}: old_text is required.`);
529
+ if (!content) return fail(`${pos}: content is required (use action='remove' to delete).`);
530
+ const resolved = MemoryStore.resolveMatch(working, oldText);
531
+ if (resolved[0] === "missing") return fail(`${pos}: no entry matched '${oldText}'.`);
532
+ if (resolved[0] === "ambiguous") return fail(`${pos}: '${oldText}' matched multiple distinct entries -- be more specific.`);
533
+ working[resolved[0]] = content;
534
+ // A replace can create a duplicate; dedupe order-preserving before later ops/budget.
535
+ working = [...new Set(working)];
536
+ } else if (action === "remove") {
537
+ if (!oldText) return fail(`${pos}: old_text is required.`);
538
+ const resolved = MemoryStore.resolveMatch(working, oldText);
539
+ if (resolved[0] === "missing") return fail(`${pos}: no entry matched '${oldText}'.`);
540
+ if (resolved[0] === "ambiguous") return fail(`${pos}: '${oldText}' matched multiple distinct entries -- be more specific.`);
541
+ working.splice(resolved[0], 1);
542
+ } else {
543
+ return fail(`${pos}: unknown action. Use add, replace, or remove.`);
544
+ }
545
+ }
546
+
547
+ const newTotal = working.length ? working.join(ENTRY_DELIMITER).length : 0;
548
+ if (newTotal > this.limit(target)) {
549
+ const current = this.charCount(target);
550
+ return this.consolidationFailure(
551
+ `After applying all ${operations.length} operations, memory would be at ${newTotal.toLocaleString()}/`
552
+ + `${this.limit(target).toLocaleString()} chars -- over the limit. Remove or shorten more entries in the same batch `
553
+ + `(see current_entries below), then retry.`,
554
+ { target, usage: `${current.toLocaleString()}/${this.limit(target).toLocaleString()}` },
555
+ );
556
+ }
557
+
558
+ this.entries.set(target, working);
559
+ await this.persist(target);
560
+ return this.successResponse(target, `Applied ${operations.length} operation(s).`);
561
+ }
562
+ }