@y0shua1ee/dsh-auto-memory 0.3.2 → 0.4.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/lib/store.js CHANGED
@@ -1,196 +1,167 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import {
3
- lstat,
4
- mkdir,
5
- readFile,
6
- readdir,
7
- rename as renameFile,
8
- rm,
9
- writeFile
10
- } from "node:fs/promises";
2
+ import { lstat, mkdir, open, readFile, readdir, rename as renameFile, rm, writeFile } from "node:fs/promises";
11
3
  import { basename, dirname, extname, join, resolve, sep } from "node:path";
12
- import { MEMORY_NAME, memoryFrontmatterIssues, parseMemory } from "./memory-frontmatter.js";
13
-
4
+ import { MEMORY_NAME, memoryFrontmatterIssues, parseMemory, touchModified } from "./memory-frontmatter.js";
14
5
  /** Name of the index file loaded into every session. */
15
6
  export const INDEX_FILE = "MEMORY.md";
16
-
17
7
  /** Virtual root exposed to the model. */
18
8
  export const VIRTUAL_ROOT = "/memories";
19
-
20
9
  /** Header written when the index is created automatically. */
21
10
  export const INDEX_HEADER = `# Project memory
22
11
 
23
12
  <!-- One line per memory: - [name](name.md) — description. Keep this index short; details live in the memory files. -->
24
13
  `;
25
-
26
14
  const MAX_PATH_SEGMENTS = 4;
27
15
  const MAX_RELATIVE_PATH_LENGTH = 200;
28
16
  const MAX_LIST_DEPTH = MAX_PATH_SEGMENTS;
29
- const SAFE_SEGMENT = /^[^\\/\u0000-\u001f\u007f:*?"<>|#%\[\]()]+$/u;
17
+ /** Safety cap for reading and rewriting the index; the injected budget is separate. */
18
+ const MAX_INDEX_SOURCE_BYTES = 4 * 1024 * 1024;
19
+ const NEAR_LIMIT_LINES = 10;
20
+ const NEAR_LIMIT_RATIO = 0.9;
21
+ const SAFE_SEGMENT = /^[^\\/\u0000-\u001f\u007f:*?"<>|#%[\]()]+$/u;
30
22
  const INDEX_BULLET = /^(\s*[-*]\s*)/u;
31
23
  const CHECKBOX = /^\s*(?:[xX])?\s*$/u;
32
-
33
24
  /** A memory operation failed with a model-actionable reason. */
34
25
  export class MemoryError extends Error {
35
- /**
36
- * @param {string} message - human-readable explanation.
37
- * @param {string} code - stable uppercase code returned to the model.
38
- */
39
- constructor(message, code = "MEMORY_ERROR") {
40
- super(message);
41
- this.name = "MemoryError";
42
- this.code = code;
43
- }
26
+ code;
27
+ constructor(message, code = "MEMORY_ERROR") {
28
+ super(message);
29
+ this.name = "MemoryError";
30
+ this.code = code;
31
+ }
44
32
  }
45
-
46
33
  function byteLength(value) {
47
- return Buffer.byteLength(value, "utf8");
34
+ return Buffer.byteLength(value, "utf8");
48
35
  }
49
-
50
36
  function lineCount(value) {
51
- return value.length === 0 ? 0 : value.split("\n").length;
37
+ return value.length === 0 ? 0 : value.split("\n").length;
52
38
  }
53
-
54
39
  function needsAngle(target) {
55
- return /\s/u.test(target);
40
+ return /\s/u.test(target);
56
41
  }
57
-
58
42
  /**
59
43
  * Read a Markdown link destination. A destination with whitespace is written
60
44
  * as `<target>`; the bare form is kept for older index lines and for targets
61
45
  * without whitespace.
62
- *
63
- * @param {string} line - index line without its trailing `\r`.
64
- * @param {number} start - index of the first destination character or `<`.
65
- * @returns {{target: string, end: number} | undefined} target and the index
66
- * after the closing `)`, or undefined when the link is malformed.
67
46
  */
68
47
  function readTarget(line, start) {
69
- if (line[start] === "<") {
70
- const close = line.indexOf(">", start + 1);
71
- if (close === -1 || line[close + 1] !== ")") return undefined;
72
- return { target: line.slice(start + 1, close), end: close + 2 };
73
- }
74
- const close = line.indexOf(")", start);
75
- if (close === -1) return undefined;
76
- return { target: line.slice(start, close), end: close + 1 };
48
+ if (line[start] === "<") {
49
+ const close = line.indexOf(">", start + 1);
50
+ if (close === -1 || line[close + 1] !== ")")
51
+ return undefined;
52
+ return { target: line.slice(start + 1, close), end: close + 2 };
53
+ }
54
+ const close = line.indexOf(")", start);
55
+ if (close === -1)
56
+ return undefined;
57
+ return { target: line.slice(start, close), end: close + 1 };
77
58
  }
78
-
79
59
  /**
80
60
  * Find the title of the first link, matching nested brackets. This keeps a
81
61
  * link embedded in a title from becoming the entry target.
82
- *
83
- * @param {string} line - index line without its trailing `\r`.
84
- * @param {number} open - index of the opening `[`.
85
- * @returns {{title: string, targetStart: number} | undefined} the title and
86
- * the index after `](`, or undefined when the bracket is not followed by a
87
- * destination.
88
62
  */
89
63
  function leadingLink(line, open) {
90
- let depth = 0;
91
- for (let index = open; index < line.length; index += 1) {
92
- const char = line[index];
93
- if (char === "\\") {
94
- index += 1;
95
- continue;
96
- }
97
- if (char === "[") {
98
- depth += 1;
99
- } else if (char === "]") {
100
- depth -= 1;
101
- if (depth === 0) {
102
- if (line[index + 1] !== "(") return undefined;
103
- return { title: line.slice(open + 1, index), targetStart: index + 2 };
104
- }
105
- }
106
- }
107
- return undefined;
64
+ let depth = 0;
65
+ for (let index = open; index < line.length; index += 1) {
66
+ const char = line[index];
67
+ if (char === "\\") {
68
+ index += 1;
69
+ continue;
70
+ }
71
+ if (char === "[") {
72
+ depth += 1;
73
+ }
74
+ else if (char === "]") {
75
+ depth -= 1;
76
+ if (depth === 0) {
77
+ if (line[index + 1] !== "(")
78
+ return undefined;
79
+ return { title: line.slice(open + 1, index), targetStart: index + 2 };
80
+ }
81
+ }
82
+ }
83
+ return undefined;
108
84
  }
109
-
110
85
  /**
111
86
  * Parse one index line that links `target`. Only the leading link counts, so
112
87
  * links inside a hook never identify the line. A line whose title contains
113
88
  * nested brackets is handled by bracket matching; v0.3.0 wrote unescaped
114
89
  * names as titles, so a malformed legacy title can still be recognized by the
115
90
  * first link after the opening `[`.
116
- *
117
- * @param {string} line - one `MEMORY.md` line, possibly ending in `\r`.
118
- * @param {string} target - exact project-relative path to match.
119
- * @returns {{bullet: string, title: string, target: string, rest: string, cr: string} | undefined}
120
91
  */
121
92
  function parseIndexLine(line, target) {
122
- const cr = line.endsWith("\r") ? "\r" : "";
123
- const text = cr.length > 0 ? line.slice(0, -1) : line;
124
- const bullet = INDEX_BULLET.exec(text);
125
- if (bullet === null) return undefined;
126
- const prefix = bullet[1];
127
- const open = prefix.length;
128
- if (text[open] !== "[") return undefined;
129
-
130
- const leading = leadingLink(text, open);
131
- if (leading !== undefined) {
132
- const destination = readTarget(text, leading.targetStart);
133
- if (destination === undefined || destination.target !== target) return undefined;
93
+ const cr = line.endsWith("\r") ? "\r" : "";
94
+ const text = cr.length > 0 ? line.slice(0, -1) : line;
95
+ const bullet = INDEX_BULLET.exec(text);
96
+ if (bullet === null)
97
+ return undefined;
98
+ const prefix = bullet[1];
99
+ const open = prefix.length;
100
+ if (text[open] !== "[")
101
+ return undefined;
102
+ const leading = leadingLink(text, open);
103
+ if (leading !== undefined) {
104
+ const destination = readTarget(text, leading.targetStart);
105
+ if (destination === undefined || destination.target !== target)
106
+ return undefined;
107
+ return {
108
+ bullet: prefix,
109
+ title: leading.title,
110
+ target: destination.target,
111
+ rest: text.slice(destination.end),
112
+ cr
113
+ };
114
+ }
115
+ // `- [ ] [name](target)` is a task list, not an index line.
116
+ const firstClose = text.indexOf("]", open + 1);
117
+ if (firstClose !== -1) {
118
+ const box = text.slice(open + 1, firstClose);
119
+ if (CHECKBOX.test(box) && text.indexOf("[", firstClose + 1) !== -1)
120
+ return undefined;
121
+ if (text.slice(firstClose + 1).trimStart().startsWith("["))
122
+ return undefined;
123
+ }
124
+ // A legacy title such as `- [a]b](target)` lacks a bracket before the
125
+ // destination; fall back to the first link, which is still the entry link.
126
+ const firstLink = text.indexOf("](", open + 1);
127
+ if (firstLink === -1)
128
+ return undefined;
129
+ const hookAt = text.indexOf(" — ", open + 1);
130
+ if (hookAt !== -1 && firstLink > hookAt)
131
+ return undefined;
132
+ const destination = readTarget(text, firstLink + 2);
133
+ if (destination === undefined || destination.target !== target)
134
+ return undefined;
134
135
  return {
135
- bullet: prefix,
136
- title: leading.title,
137
- target: destination.target,
138
- rest: text.slice(destination.end),
139
- cr
136
+ bullet: prefix,
137
+ title: text.slice(open + 1, firstLink),
138
+ target: destination.target,
139
+ rest: text.slice(destination.end),
140
+ cr
140
141
  };
141
- }
142
-
143
- // `- [ ] [name](target)` is a task list, not an index line.
144
- const firstClose = text.indexOf("]", open + 1);
145
- if (firstClose !== -1) {
146
- const box = text.slice(open + 1, firstClose);
147
- if (CHECKBOX.test(box) && text.indexOf("[", firstClose + 1) !== -1) return undefined;
148
- if (text.slice(firstClose + 1).trimStart().startsWith("[")) return undefined;
149
- }
150
-
151
- // A legacy title such as `- [a]b](target)` lacks a bracket before the
152
- // destination; fall back to the first link, which is still the entry link.
153
- const firstLink = text.indexOf("](", open + 1);
154
- if (firstLink === -1) return undefined;
155
- const hookAt = text.indexOf(" — ", open + 1);
156
- if (hookAt !== -1 && firstLink > hookAt) return undefined;
157
- const destination = readTarget(text, firstLink + 2);
158
- if (destination === undefined || destination.target !== target) return undefined;
159
- return {
160
- bullet: prefix,
161
- title: text.slice(open + 1, firstLink),
162
- target: destination.target,
163
- rest: text.slice(destination.end),
164
- cr
165
- };
166
142
  }
167
-
168
143
  function formatIndexLine({ bullet, title, target, rest, cr }) {
169
- const destination = needsAngle(target) ? `<${target}>` : target;
170
- return `${bullet}[${title}](${destination})${rest}${cr}`;
144
+ const destination = needsAngle(target) ? `<${target}>` : target;
145
+ return `${bullet}[${title}](${destination})${rest}${cr}`;
171
146
  }
172
-
173
147
  function stem(relativePath) {
174
- return basename(relativePath, extname(relativePath));
148
+ return basename(relativePath, extname(relativePath));
175
149
  }
176
-
177
150
  function frontmatterIssues(relativePath, content) {
178
- return memoryFrontmatterIssues(parseMemory(content), stem(relativePath), relativePath);
151
+ return memoryFrontmatterIssues(parseMemory(content), stem(relativePath), relativePath);
179
152
  }
180
-
181
153
  /** Frontmatter problems present after an edit or rename that were not present before it. */
182
154
  function introducedProblems(relativePath, before, after, previousPath = relativePath) {
183
- if (relativePath === INDEX_FILE) return [];
184
- const known = new Set(frontmatterIssues(previousPath, before).map((issue) => issue.code));
185
- return frontmatterIssues(relativePath, after)
186
- .filter((issue) => !known.has(issue.code))
187
- .map((issue) => issue.message);
155
+ if (relativePath === INDEX_FILE)
156
+ return [];
157
+ const known = new Set(frontmatterIssues(previousPath, before).map((issue) => issue.code));
158
+ return frontmatterIssues(relativePath, after)
159
+ .filter((issue) => !known.has(issue.code))
160
+ .map((issue) => issue.message);
188
161
  }
189
-
190
162
  function virtual(relativePath) {
191
- return relativePath.length === 0 ? VIRTUAL_ROOT : `${VIRTUAL_ROOT}/${relativePath}`;
163
+ return relativePath.length === 0 ? VIRTUAL_ROOT : `${VIRTUAL_ROOT}/${relativePath}`;
192
164
  }
193
-
194
165
  /**
195
166
  * Read and write one project's memory directory.
196
167
  *
@@ -201,637 +172,671 @@ function virtual(relativePath) {
201
172
  * process cannot interleave a read-modify-write cycle.
202
173
  */
203
174
  export class MemoryStore {
204
- #queue = Promise.resolve();
205
-
206
- /**
207
- * @param {object} options - store limits and the absolute project directory.
208
- * @param {string} options.root - absolute project memory directory.
209
- * @param {number} options.maxFileBytes - maximum bytes per file.
210
- * @param {number} options.maxIndexBytes - injected index budget.
211
- * @param {number} options.maxFiles - maximum number of `.md` files.
212
- */
213
- constructor({ root, maxFileBytes, maxIndexBytes, maxFiles }) {
214
- this.root = resolve(root);
215
- this.maxFileBytes = maxFileBytes;
216
- this.maxIndexBytes = maxIndexBytes;
217
- this.maxFiles = maxFiles;
218
- }
219
-
220
- /** Run one mutation after every earlier mutation and return its result. */
221
- #run(operation) {
222
- const result = this.#queue.then(operation, operation);
223
- this.#queue = result.then(
224
- () => undefined,
225
- () => undefined
226
- );
227
- return result;
228
- }
229
-
230
- /**
231
- * Parse a model-supplied virtual path.
232
- *
233
- * @param {unknown} value - path such as `/memories/MEMORY.md`.
234
- * @returns {string} project-relative path, or `''` for the root.
235
- */
236
- static parseVirtualPath(value) {
237
- if (typeof value !== "string" || value.trim().length === 0) {
238
- throw new MemoryError("path is required and must be a string", "MEMORY_INVALID_PATH");
239
- }
240
- const raw = value.trim();
241
- if (raw === VIRTUAL_ROOT || raw === `${VIRTUAL_ROOT}/`) return "";
242
- if (!raw.startsWith(`${VIRTUAL_ROOT}/`)) {
243
- throw new MemoryError(
244
- `path must start with ${VIRTUAL_ROOT} (received ${JSON.stringify(value)})`,
245
- "MEMORY_INVALID_PATH"
246
- );
247
- }
248
- const relativePath = raw.slice(VIRTUAL_ROOT.length + 1);
249
- const segments = relativePath.split("/");
250
- if (segments.length === 0 || segments.length > MAX_PATH_SEGMENTS) {
251
- throw new MemoryError(`path may have at most ${MAX_PATH_SEGMENTS} segments`, "MEMORY_INVALID_PATH");
252
- }
253
- for (const segment of segments) {
254
- if (segment.length === 0) {
255
- throw new MemoryError("path must not contain empty segments or a trailing slash", "MEMORY_INVALID_PATH");
256
- }
257
- if (segment === "." || segment === ".." || segment.startsWith(".")) {
258
- throw new MemoryError("path must not contain relative or hidden segments", "MEMORY_INVALID_PATH");
259
- }
260
- if (/[. ]$/u.test(segment)) {
261
- throw new MemoryError("path segments must not end with a dot or space", "MEMORY_INVALID_PATH");
262
- }
263
- if (!SAFE_SEGMENT.test(segment)) {
264
- throw new MemoryError(
265
- `path segment ${JSON.stringify(segment)} contains unsupported characters`,
266
- "MEMORY_INVALID_PATH"
267
- );
268
- }
269
- }
270
- if (relativePath.length > MAX_RELATIVE_PATH_LENGTH) {
271
- throw new MemoryError("path is too long", "MEMORY_INVALID_PATH");
272
- }
273
- return segments.join("/");
274
- }
275
-
276
- #assertFile(relativePath) {
277
- if (relativePath.length === 0) {
278
- throw new MemoryError("this command requires a file path under /memories", "MEMORY_INVALID_ARGUMENT");
175
+ root;
176
+ maxFileBytes;
177
+ maxIndexBytes;
178
+ maxIndexLines;
179
+ maxFiles;
180
+ #queue = Promise.resolve();
181
+ constructor({ root, maxFileBytes, maxIndexBytes, maxIndexLines = 200, maxFiles }) {
182
+ this.root = resolve(root);
183
+ this.maxFileBytes = maxFileBytes;
184
+ this.maxIndexBytes = maxIndexBytes;
185
+ this.maxIndexLines = maxIndexLines;
186
+ this.maxFiles = maxFiles;
187
+ }
188
+ /** Run one mutation after every earlier mutation and return its result. */
189
+ #run(operation) {
190
+ const result = this.#queue.then(operation, operation);
191
+ this.#queue = result.then(() => undefined, () => undefined);
192
+ return result;
193
+ }
194
+ /**
195
+ * Parse a model-supplied virtual path.
196
+ *
197
+ * @param value - path such as `/memories/MEMORY.md`.
198
+ * @returns project-relative path, or `''` for the root.
199
+ */
200
+ static parseVirtualPath(value) {
201
+ if (typeof value !== "string" || value.trim().length === 0) {
202
+ throw new MemoryError("path is required and must be a string", "MEMORY_INVALID_PATH");
203
+ }
204
+ const raw = value.trim();
205
+ if (raw === VIRTUAL_ROOT || raw === `${VIRTUAL_ROOT}/`)
206
+ return "";
207
+ if (!raw.startsWith(`${VIRTUAL_ROOT}/`)) {
208
+ throw new MemoryError(`path must start with ${VIRTUAL_ROOT} (received ${JSON.stringify(value)})`, "MEMORY_INVALID_PATH");
209
+ }
210
+ const relativePath = raw.slice(VIRTUAL_ROOT.length + 1);
211
+ const segments = relativePath.split("/");
212
+ if (segments.length === 0 || segments.length > MAX_PATH_SEGMENTS) {
213
+ throw new MemoryError(`path may have at most ${MAX_PATH_SEGMENTS} segments`, "MEMORY_INVALID_PATH");
214
+ }
215
+ for (const segment of segments) {
216
+ if (segment.length === 0) {
217
+ throw new MemoryError("path must not contain empty segments or a trailing slash", "MEMORY_INVALID_PATH");
218
+ }
219
+ if (segment === "." || segment === ".." || segment.startsWith(".")) {
220
+ throw new MemoryError("path must not contain relative or hidden segments", "MEMORY_INVALID_PATH");
221
+ }
222
+ if (/[. ]$/u.test(segment)) {
223
+ throw new MemoryError("path segments must not end with a dot or space", "MEMORY_INVALID_PATH");
224
+ }
225
+ if (!SAFE_SEGMENT.test(segment)) {
226
+ throw new MemoryError(`path segment ${JSON.stringify(segment)} contains unsupported characters`, "MEMORY_INVALID_PATH");
227
+ }
228
+ }
229
+ if (relativePath.length > MAX_RELATIVE_PATH_LENGTH) {
230
+ throw new MemoryError("path is too long", "MEMORY_INVALID_PATH");
231
+ }
232
+ return segments.join("/");
279
233
  }
280
- if (extname(relativePath).toLowerCase() !== ".md") {
281
- throw new MemoryError("memory files must use the .md extension", "MEMORY_INVALID_PATH");
234
+ #assertFile(relativePath) {
235
+ if (relativePath.length === 0) {
236
+ throw new MemoryError("this command requires a file path under /memories", "MEMORY_INVALID_ARGUMENT");
237
+ }
238
+ if (extname(relativePath).toLowerCase() !== ".md") {
239
+ throw new MemoryError("memory files must use the .md extension", "MEMORY_INVALID_PATH");
240
+ }
241
+ return relativePath;
282
242
  }
283
- return relativePath;
284
- }
285
-
286
- #absolute(relativePath) {
287
- const target = resolve(this.root, relativePath);
288
- if (target !== this.root && !target.startsWith(this.root + sep)) {
289
- throw new MemoryError("path escapes the memory directory", "MEMORY_INVALID_PATH");
243
+ #readLimit(relativePath) {
244
+ return relativePath === INDEX_FILE ? MAX_INDEX_SOURCE_BYTES : this.maxFileBytes;
290
245
  }
291
- return target;
292
- }
293
-
294
- async #lstat(absolutePath) {
295
- try {
296
- return await lstat(absolutePath);
297
- } catch (error) {
298
- if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return undefined;
299
- throw error;
300
- }
301
- }
302
-
303
- async #assertSafeAncestors(relativePath) {
304
- const segments = relativePath.split("/").slice(0, -1);
305
- let current = this.root;
306
- for (const segment of segments) {
307
- current = join(current, segment);
308
- const info = await this.#lstat(current);
309
- if (info === undefined) return;
310
- if (info.isSymbolicLink()) {
311
- throw new MemoryError("path traverses a symbolic link", "MEMORY_SYMLINK");
312
- }
313
- if (!info.isDirectory()) {
314
- throw new MemoryError("a path component is not a directory", "MEMORY_NOT_A_DIRECTORY");
315
- }
316
- }
317
- }
318
-
319
- async #readText(relativePath, limit = this.maxFileBytes) {
320
- const absolutePath = this.#absolute(relativePath);
321
- await this.#assertSafeAncestors(relativePath);
322
- const info = await this.#lstat(absolutePath);
323
- if (info === undefined) {
324
- throw new MemoryError(`${virtual(relativePath)} does not exist`, "MEMORY_NOT_FOUND");
325
- }
326
- if (info.isSymbolicLink()) {
327
- throw new MemoryError(`${virtual(relativePath)} is a symbolic link`, "MEMORY_SYMLINK");
328
- }
329
- if (!info.isFile()) {
330
- throw new MemoryError(`${virtual(relativePath)} is not a file`, "MEMORY_NOT_A_FILE");
331
- }
332
- if (info.size > limit) {
333
- throw new MemoryError(
334
- `${virtual(relativePath)} is ${info.size} bytes, above the ${limit}-byte read limit`,
335
- "MEMORY_TOO_LARGE"
336
- );
337
- }
338
- const content = await readFile(absolutePath, "utf8");
339
- if (Buffer.byteLength(content, "utf8") > limit) {
340
- throw new MemoryError(
341
- `${virtual(relativePath)} grew above the ${limit}-byte read limit`,
342
- "MEMORY_TOO_LARGE"
343
- );
344
- }
345
- if (content.includes("\0")) {
346
- throw new MemoryError(`${virtual(relativePath)} is not a text file`, "MEMORY_NOT_A_FILE");
347
- }
348
- return { content, size: info.size, mtimeMs: info.mtimeMs };
349
- }
350
-
351
- async #atomicWrite(relativePath, content) {
352
- if (content.includes("\0")) {
353
- throw new MemoryError("memory files must be text without NUL bytes", "MEMORY_INVALID_ARGUMENT");
354
- }
355
- const absolutePath = this.#absolute(relativePath);
356
- await this.#assertSafeAncestors(relativePath);
357
- await mkdir(dirname(absolutePath), { recursive: true });
358
- const temporary = join(dirname(absolutePath), `.${basename(absolutePath)}.${randomUUID()}.tmp`);
359
- await writeFile(temporary, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
360
- try {
361
- await renameFile(temporary, absolutePath);
362
- } catch (error) {
363
- await rm(temporary, { force: true });
364
- throw error;
365
- }
366
- return { bytes: byteLength(content), lines: lineCount(content) };
367
- }
368
-
369
- #assertWithinFileBudget(relativePath, content) {
370
- const bytes = byteLength(content);
371
- if (bytes > this.maxFileBytes) {
372
- throw new MemoryError(
373
- `${virtual(relativePath)} would be ${bytes} bytes, above the ${this.maxFileBytes}-byte limit; split it into topic files`,
374
- "MEMORY_TOO_LARGE"
375
- );
376
- }
377
- return bytes;
378
- }
379
-
380
- async #list(relativePath) {
381
- const absolutePath = this.#absolute(relativePath);
382
- await this.#assertSafeAncestors(relativePath);
383
- const info = await this.#lstat(absolutePath);
384
- if (info === undefined) {
385
- if (relativePath.length === 0) return [];
386
- throw new MemoryError(`${virtual(relativePath)} does not exist`, "MEMORY_NOT_FOUND");
387
- }
388
- if (info.isSymbolicLink() && relativePath.length > 0) {
389
- throw new MemoryError(`${virtual(relativePath)} is a symbolic link`, "MEMORY_SYMLINK");
390
- }
391
- if (!info.isDirectory()) {
392
- throw new MemoryError(`${virtual(relativePath)} is not a directory`, "MEMORY_NOT_A_DIRECTORY");
393
- }
394
- const entries = [];
395
- const walk = async (directory, prefix, depth) => {
396
- if (depth > MAX_LIST_DEPTH) return;
397
- const children = await readdir(directory, { withFileTypes: true });
398
- children.sort((left, right) => left.name.localeCompare(right.name));
399
- for (const child of children) {
400
- if (child.name.startsWith(".")) continue;
401
- const childAbsolute = join(directory, child.name);
402
- const childRelative = prefix.length === 0 ? child.name : `${prefix}/${child.name}`;
403
- const childInfo = await this.#lstat(childAbsolute);
404
- if (childInfo === undefined || childInfo.isSymbolicLink()) continue;
405
- if (childInfo.isDirectory()) {
406
- entries.push({ path: virtual(childRelative), kind: "directory", bytes: 0 });
407
- await walk(childAbsolute, childRelative, depth + 1);
408
- } else if (childInfo.isFile()) {
409
- entries.push({ path: virtual(childRelative), kind: "file", bytes: childInfo.size });
410
- }
411
- }
412
- };
413
- await walk(absolutePath, relativePath, 0);
414
- return entries;
415
- }
416
-
417
- async #fileCount() {
418
- const entries = await this.#list("");
419
- return entries.filter((entry) => entry.kind === "file").length;
420
- }
421
-
422
- /**
423
- * Read the index, if it exists.
424
- *
425
- * @returns {Promise<{exists: boolean, content?: string, bytes?: number}>}
426
- */
427
- async index() {
428
- const absolutePath = this.#absolute(INDEX_FILE);
429
- const info = await this.#lstat(absolutePath);
430
- if (info === undefined) return { exists: false };
431
- if (info.isSymbolicLink()) {
432
- throw new MemoryError(`${INDEX_FILE} is a symbolic link`, "MEMORY_SYMLINK");
433
- }
434
- if (!info.isFile()) return { exists: false };
435
- const limit = Math.max(this.maxFileBytes, this.maxIndexBytes);
436
- if (info.size > limit) {
437
- throw new MemoryError(
438
- `${INDEX_FILE} is ${info.size} bytes, above the ${limit}-byte read limit`,
439
- "MEMORY_TOO_LARGE"
440
- );
441
- }
442
- const content = await readFile(absolutePath, "utf8");
443
- if (Buffer.byteLength(content, "utf8") > limit) {
444
- throw new MemoryError(`${INDEX_FILE} grew above the ${limit}-byte read limit`, "MEMORY_TOO_LARGE");
445
- }
446
- if (content.includes("\0")) {
447
- throw new MemoryError(`${INDEX_FILE} is not a text file`, "MEMORY_NOT_A_FILE");
448
- }
449
- return { exists: true, content, bytes: info.size, mtimeMs: info.mtimeMs };
450
- }
451
-
452
- /**
453
- * List the root or one subtree.
454
- *
455
- * @param {string} relativePath - project-relative directory.
456
- * @returns {Promise<Array<{path: string, kind: string, bytes: number}>>}
457
- */
458
- async list(relativePath = "") {
459
- return this.#list(relativePath);
460
- }
461
-
462
- /**
463
- * Read one file or list one directory.
464
- *
465
- * @param {string} relativePath - project-relative path.
466
- * @returns {Promise<object>} either `{kind: 'directory', entries}` or
467
- * `{kind: 'file', content, bytes, lines}`.
468
- */
469
- async view(relativePath = "") {
470
- if (relativePath.length === 0) {
471
- return { kind: "directory", entries: await this.#list("") };
472
- }
473
- const absolutePath = this.#absolute(relativePath);
474
- await this.#assertSafeAncestors(relativePath);
475
- const info = await this.#lstat(absolutePath);
476
- if (info === undefined) {
477
- throw new MemoryError(`${virtual(relativePath)} does not exist`, "MEMORY_NOT_FOUND");
478
- }
479
- if (info.isSymbolicLink()) {
480
- throw new MemoryError(`${virtual(relativePath)} is a symbolic link`, "MEMORY_SYMLINK");
481
- }
482
- if (info.isDirectory()) {
483
- return { kind: "directory", entries: await this.#list(relativePath) };
484
- }
485
- const read = await this.#readText(
486
- relativePath,
487
- relativePath === INDEX_FILE ? Math.max(this.maxFileBytes, this.maxIndexBytes) : this.maxFileBytes
488
- );
489
- return {
490
- kind: "file",
491
- content: read.content,
492
- bytes: read.size,
493
- lines: lineCount(read.content)
494
- };
495
- }
496
-
497
- /**
498
- * Create a new file. Creating a topic file also appends an index entry when
499
- * the index exists and does not already link the file, and reports what its
500
- * frontmatter is missing without refusing the write.
501
- *
502
- * @param {string} relativePath - file path ending in `.md`.
503
- * @param {string} [text] - complete file content.
504
- * @returns {Promise<object>} written file metadata plus optional index and frontmatter warnings.
505
- */
506
- async create(relativePath, text) {
507
- this.#assertFile(relativePath);
508
- return this.#run(async () => {
509
- const absolutePath = this.#absolute(relativePath);
510
- await this.#assertSafeAncestors(relativePath);
511
- if (await this.#lstat(absolutePath)) {
512
- throw new MemoryError(`${virtual(relativePath)} already exists; use str_replace or insert`, "MEMORY_ALREADY_EXISTS");
513
- }
514
- if ((await this.#fileCount()) >= this.maxFiles) {
515
- throw new MemoryError(
516
- `memory already holds ${this.maxFiles} files; delete or merge some before creating another`,
517
- "MEMORY_TOO_MANY_FILES"
518
- );
519
- }
520
- const content = typeof text === "string" ? text : "";
521
- const next = relativePath === INDEX_FILE && content.trim().length === 0 ? INDEX_HEADER : content;
522
- this.#assertWithinFileBudget(relativePath, next);
523
- const written = await this.#atomicWrite(relativePath, next);
524
- let indexWarning;
525
- let indexed = false;
526
- let frontmatterProblems = [];
527
- if (relativePath !== INDEX_FILE) {
528
- frontmatterProblems = frontmatterIssues(relativePath, next).map((issue) => issue.message);
246
+ #absolute(relativePath) {
247
+ const target = resolve(this.root, relativePath);
248
+ if (target !== this.root && !target.startsWith(this.root + sep)) {
249
+ throw new MemoryError("path escapes the memory directory", "MEMORY_INVALID_PATH");
250
+ }
251
+ return target;
252
+ }
253
+ async #lstat(absolutePath) {
529
254
  try {
530
- indexed = (await this.#linkIndex(relativePath, next)).updated;
531
- } catch (error) {
532
- indexWarning = `created the file, but could not update MEMORY.md: ${error.message}`;
533
- }
534
- }
535
- return { path: virtual(relativePath), ...written, indexed, indexWarning, frontmatterProblems };
536
- });
537
- }
538
-
539
- /**
540
- * Replace exactly one occurrence of `oldStr`. Editing a topic file also
541
- * refreshes its index line when that line still matches the old frontmatter.
542
- *
543
- * @param {string} relativePath - file path.
544
- * @param {string} oldStr - non-empty text that must occur exactly once.
545
- * @param {string} [newStr] - replacement text, possibly empty.
546
- * @returns {Promise<object>} written file metadata plus optional index status.
547
- */
548
- async strReplace(relativePath, oldStr, newStr = "") {
549
- this.#assertFile(relativePath);
550
- if (typeof oldStr !== "string" || oldStr.length === 0) {
551
- throw new MemoryError("old_str must be a non-empty string", "MEMORY_INVALID_ARGUMENT");
552
- }
553
- if (typeof newStr !== "string") {
554
- throw new MemoryError("new_str must be a string", "MEMORY_INVALID_ARGUMENT");
555
- }
556
- return this.#run(async () => {
557
- const current = await this.#readText(relativePath);
558
- const occurrences = current.content.split(oldStr).length - 1;
559
- if (occurrences === 0) {
560
- throw new MemoryError("old_str was not found; read the file again before editing", "MEMORY_NO_MATCH");
561
- }
562
- if (occurrences > 1) {
563
- throw new MemoryError(
564
- `old_str occurs ${occurrences} times; include more surrounding text so it is unique`,
565
- "MEMORY_MULTIPLE_MATCHES"
566
- );
567
- }
568
- const next = current.content.replace(oldStr, () => newStr);
569
- this.#assertWithinFileBudget(relativePath, next);
570
- const written = await this.#atomicWrite(relativePath, next);
571
- return {
572
- path: virtual(relativePath),
573
- ...written,
574
- ...(await this.#syncIndex(relativePath, current.content, next)),
575
- frontmatterProblems: introducedProblems(relativePath, current.content, next)
576
- };
577
- });
578
- }
579
-
580
- /**
581
- * Insert text at a zero-based line index. Like `strReplace`, it refreshes a
582
- * topic file's index line that still matches the old frontmatter.
583
- *
584
- * @param {string} relativePath - file path.
585
- * @param {number} line - 0-based line index, from 0 through the line count.
586
- * @param {string} text - text inserted as lines.
587
- * @returns {Promise<object>} written file metadata plus optional index status.
588
- */
589
- async insert(relativePath, line, text) {
590
- this.#assertFile(relativePath);
591
- if (!Number.isSafeInteger(line) || line < 0) {
592
- throw new MemoryError("insert_line must be a non-negative integer", "MEMORY_INVALID_ARGUMENT");
593
- }
594
- if (typeof text !== "string" || text.length === 0) {
595
- throw new MemoryError("insert_text must be a non-empty string", "MEMORY_INVALID_ARGUMENT");
596
- }
597
- return this.#run(async () => {
598
- const current = await this.#readText(relativePath);
599
- const lines = current.content.split("\n");
600
- const at = Math.min(line, lines.length);
601
- lines.splice(at, 0, ...text.split("\n"));
602
- const next = lines.join("\n");
603
- this.#assertWithinFileBudget(relativePath, next);
604
- const written = await this.#atomicWrite(relativePath, next);
605
- return {
606
- path: virtual(relativePath),
607
- ...written,
608
- insertedAt: at,
609
- ...(await this.#syncIndex(relativePath, current.content, next)),
610
- frontmatterProblems: introducedProblems(relativePath, current.content, next)
611
- };
612
- });
613
- }
614
-
615
- /**
616
- * Delete one file. Deleting a topic file also removes index lines that link it.
617
- *
618
- * @param {string} relativePath - file path.
619
- * @returns {Promise<object>} deletion metadata.
620
- */
621
- async delete(relativePath) {
622
- this.#assertFile(relativePath);
623
- return this.#run(async () => {
624
- const absolutePath = this.#absolute(relativePath);
625
- await this.#assertSafeAncestors(relativePath);
626
- const info = await this.#lstat(absolutePath);
627
- if (info === undefined) {
628
- throw new MemoryError(`${virtual(relativePath)} does not exist`, "MEMORY_NOT_FOUND");
629
- }
630
- if (info.isSymbolicLink()) {
631
- throw new MemoryError(`${virtual(relativePath)} is a symbolic link`, "MEMORY_SYMLINK");
632
- }
633
- if (!info.isFile()) {
634
- throw new MemoryError(`${virtual(relativePath)} is not a file`, "MEMORY_NOT_A_FILE");
635
- }
636
- await rm(absolutePath);
637
- let indexWarning;
638
- let indexed = false;
639
- if (relativePath !== INDEX_FILE) {
255
+ return await lstat(absolutePath);
256
+ }
257
+ catch (error) {
258
+ const code = error.code;
259
+ if (code === "ENOENT" || code === "ENOTDIR")
260
+ return undefined;
261
+ throw error;
262
+ }
263
+ }
264
+ async #assertSafeAncestors(relativePath) {
265
+ const segments = relativePath.split("/").slice(0, -1);
266
+ let current = this.root;
267
+ for (const segment of segments) {
268
+ current = join(current, segment);
269
+ const info = await this.#lstat(current);
270
+ if (info === undefined)
271
+ return;
272
+ if (info.isSymbolicLink()) {
273
+ throw new MemoryError("path traverses a symbolic link", "MEMORY_SYMLINK");
274
+ }
275
+ if (!info.isDirectory()) {
276
+ throw new MemoryError("a path component is not a directory", "MEMORY_NOT_A_DIRECTORY");
277
+ }
278
+ }
279
+ }
280
+ async #readText(relativePath, limit = this.maxFileBytes) {
281
+ const absolutePath = this.#absolute(relativePath);
282
+ await this.#assertSafeAncestors(relativePath);
283
+ const info = await this.#lstat(absolutePath);
284
+ if (info === undefined) {
285
+ throw new MemoryError(`${virtual(relativePath)} does not exist`, "MEMORY_NOT_FOUND");
286
+ }
287
+ if (info.isSymbolicLink()) {
288
+ throw new MemoryError(`${virtual(relativePath)} is a symbolic link`, "MEMORY_SYMLINK");
289
+ }
290
+ if (!info.isFile()) {
291
+ throw new MemoryError(`${virtual(relativePath)} is not a file`, "MEMORY_NOT_A_FILE");
292
+ }
293
+ if (info.size > limit) {
294
+ throw new MemoryError(`${virtual(relativePath)} is ${info.size} bytes, above the ${limit}-byte read limit`, "MEMORY_TOO_LARGE");
295
+ }
296
+ const content = await readFile(absolutePath, "utf8");
297
+ if (Buffer.byteLength(content, "utf8") > limit) {
298
+ throw new MemoryError(`${virtual(relativePath)} grew above the ${limit}-byte read limit`, "MEMORY_TOO_LARGE");
299
+ }
300
+ if (content.includes("\0")) {
301
+ throw new MemoryError(`${virtual(relativePath)} is not a text file`, "MEMORY_NOT_A_FILE");
302
+ }
303
+ return { content, size: info.size, mtimeMs: info.mtimeMs };
304
+ }
305
+ async #atomicWrite(relativePath, content) {
306
+ if (content.includes("\0")) {
307
+ throw new MemoryError("memory files must be text without NUL bytes", "MEMORY_INVALID_ARGUMENT");
308
+ }
309
+ const absolutePath = this.#absolute(relativePath);
310
+ await this.#assertSafeAncestors(relativePath);
311
+ await mkdir(dirname(absolutePath), { recursive: true });
312
+ const temporary = join(dirname(absolutePath), `.${basename(absolutePath)}.${randomUUID()}.tmp`);
313
+ await writeFile(temporary, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
640
314
  try {
641
- indexed = await this.#unlinkIndex(relativePath);
642
- } catch (error) {
643
- indexWarning = `deleted the file, but could not update MEMORY.md: ${error.message}`;
644
- }
645
- }
646
- return { path: virtual(relativePath), changed: true, indexed, indexWarning };
647
- });
648
- }
649
-
650
- /**
651
- * Rename one file. Links in the index follow the file, and a title still
652
- * generated from the old file name follows the new one.
653
- *
654
- * @param {string} oldPath - existing file path.
655
- * @param {string} newPath - new file path.
656
- * @returns {Promise<object>} rename metadata.
657
- */
658
- async rename(oldPath, newPath) {
659
- this.#assertFile(oldPath);
660
- this.#assertFile(newPath);
661
- if (oldPath === INDEX_FILE || newPath === INDEX_FILE) {
662
- throw new MemoryError("MEMORY.md is the index and cannot be renamed; edit it in place", "MEMORY_INVALID_ARGUMENT");
663
- }
664
- return this.#run(async () => {
665
- const absoluteOld = this.#absolute(oldPath);
666
- const absoluteNew = this.#absolute(newPath);
667
- await this.#assertSafeAncestors(oldPath);
668
- await this.#assertSafeAncestors(newPath);
669
- const oldInfo = await this.#lstat(absoluteOld);
670
- if (oldInfo === undefined) {
671
- throw new MemoryError(`${virtual(oldPath)} does not exist`, "MEMORY_NOT_FOUND");
672
- }
673
- if (oldInfo.isSymbolicLink()) {
674
- throw new MemoryError(`${virtual(oldPath)} is a symbolic link`, "MEMORY_SYMLINK");
675
- }
676
- if (!oldInfo.isFile()) {
677
- throw new MemoryError(`${virtual(oldPath)} is not a file`, "MEMORY_NOT_A_FILE");
678
- }
679
- if (await this.#lstat(absoluteNew)) {
680
- throw new MemoryError(`${virtual(newPath)} already exists`, "MEMORY_ALREADY_EXISTS");
681
- }
682
- const content = await this.#readText(oldPath).then(
683
- (read) => read.content,
684
- () => undefined
685
- );
686
- await mkdir(dirname(absoluteNew), { recursive: true });
687
- await renameFile(absoluteOld, absoluteNew);
688
- let indexWarning;
689
- let indexed = false;
690
- const previousFields = this.#indexFields(oldPath, content);
691
- const nextFields = this.#indexFields(newPath, content);
692
- try {
693
- indexed = await this.#rewriteIndex(oldPath, (entry) => ({
694
- ...entry,
695
- target: newPath,
696
- title:
697
- entry.title === previousFields.title || entry.title === previousFields.legacyTitle
698
- ? nextFields.title
699
- : entry.title
700
- }));
701
- } catch (error) {
702
- indexWarning = `renamed the file, but could not update MEMORY.md: ${error.message}`;
703
- }
704
- const frontmatterProblems = content === undefined ? [] : introducedProblems(newPath, content, content, oldPath);
705
- return {
706
- path: virtual(newPath),
707
- previousPath: virtual(oldPath),
708
- changed: true,
709
- indexed,
710
- indexWarning,
711
- frontmatterProblems
712
- };
713
- });
714
- }
715
-
716
- #indexFields(relativePath, text) {
717
- const parsed = parseMemory(text ?? "");
718
- const title = parsed.name !== undefined && MEMORY_NAME.test(parsed.name) ? parsed.name : stem(relativePath);
719
- const firstLine = parsed.body
720
- .split("\n")
721
- .map((line) => line.trim())
722
- .find((line) => line.length > 0 && !line.startsWith("<!--"));
723
- const hook = (parsed.description ?? firstLine ?? "")
724
- .replace(/^#+\s*/u, "")
725
- .replace(/\s+/gu, " ")
726
- .trim()
727
- .slice(0, 110);
728
- return { title, hook, legacyTitle: parsed.name };
729
- }
730
-
731
- #indexEntry(relativePath, text) {
732
- const { title, hook } = this.#indexFields(relativePath, text);
733
- return formatIndexLine({
734
- bullet: "- ",
735
- title,
736
- target: relativePath,
737
- rest: hook.length > 0 ? ` — ${hook}` : "",
738
- cr: ""
739
- });
740
- }
741
-
742
- /**
743
- * Rewrite the index lines that link `relativePath`.
744
- *
745
- * @param {string} relativePath - link target to match.
746
- * @param {(entry: object) => object | null | undefined} update - returns the
747
- * replacement entry, `null` to drop the line, or `undefined` to keep it.
748
- * @returns {Promise<boolean>} whether the index changed.
749
- */
750
- async #rewriteIndex(relativePath, update) {
751
- const index = await this.index();
752
- if (!index.exists) return false;
753
- let changed = false;
754
- const lines = [];
755
- for (const line of index.content.split("\n")) {
756
- const entry = parseIndexLine(line, relativePath);
757
- const next = entry === undefined ? undefined : update(entry);
758
- if (next === undefined) {
759
- lines.push(line);
760
- } else if (next !== null) {
761
- const formatted = formatIndexLine(next);
762
- changed ||= formatted !== line;
763
- lines.push(formatted);
764
- } else {
765
- changed = true;
766
- }
767
- }
768
- if (!changed) return false;
769
- const content = lines.join("\n");
770
- if (byteLength(content) > this.maxFileBytes) {
771
- throw new MemoryError("MEMORY.md is full; remove or merge entries before adding more", "MEMORY_INDEX_FULL");
772
- }
773
- await this.#atomicWrite(INDEX_FILE, content);
774
- return true;
775
- }
776
-
777
- /**
778
- * Refresh the index line of an edited topic file. A title or hook is replaced
779
- * only while it still equals what the old content would have generated, so a
780
- * line the model rewrote by hand is left alone. An invalid name from v0.3.0
781
- * was used as the title then; it is recognized as the legacy generated title
782
- * and normalized to the current fallback.
783
- */
784
- async #syncIndex(relativePath, before, after) {
785
- if (relativePath === INDEX_FILE) return {};
786
- const previous = this.#indexFields(relativePath, before);
787
- const current = this.#indexFields(relativePath, after);
788
- const legacyStale =
789
- previous.legacyTitle !== undefined && previous.legacyTitle !== previous.title;
790
- if (previous.title === current.title && previous.hook === current.hook && !legacyStale) return {};
791
- try {
792
- const indexed = await this.#rewriteIndex(relativePath, (entry) => {
793
- const hook = entry.rest.startsWith(" — ") ? entry.rest.slice(3) : entry.rest.length === 0 ? "" : undefined;
794
- if (hook === undefined) return undefined;
795
- const title =
796
- entry.title === previous.title || entry.title === previous.legacyTitle
797
- ? current.title
798
- : entry.title;
799
- const nextHook = hook === previous.hook ? current.hook : hook;
800
- return { ...entry, title, rest: nextHook.length > 0 ? ` — ${nextHook}` : "" };
801
- });
802
- return { indexed };
803
- } catch (error) {
804
- return { indexed: false, indexWarning: `edited the file, but could not update MEMORY.md: ${error.message}` };
805
- }
806
- }
807
-
808
- async #linkIndex(relativePath, text) {
809
- const index = await this.index();
810
- if (
811
- index.exists &&
812
- index.content.split("\n").some((line) => parseIndexLine(line, relativePath) !== undefined)
813
- ) {
814
- return { updated: false };
815
- }
816
- const entry = this.#indexEntry(relativePath, text);
817
- let next;
818
- if (!index.exists || index.content.trim().length === 0) {
819
- next = `${INDEX_HEADER.replace(/\n*$/u, "")}\n\n${entry}\n`;
820
- } else {
821
- const content = index.content.replace(/[ \t]+$/u, "");
822
- const eol = content.endsWith("\r\n") ? "\r\n" : content.endsWith("\r") ? "\r" : "\n";
823
- const separator =
824
- content.length === 0 || content.endsWith("\n") || content.endsWith("\r") ? "" : eol;
825
- next = `${content}${separator}${entry}${eol}`;
826
- }
827
- if (byteLength(next) > this.maxFileBytes) {
828
- throw new MemoryError("MEMORY.md is full; remove or merge entries before adding more", "MEMORY_INDEX_FULL");
829
- }
830
- await this.#atomicWrite(INDEX_FILE, next);
831
- return { updated: true };
832
- }
833
-
834
- async #unlinkIndex(relativePath) {
835
- return this.#rewriteIndex(relativePath, () => null);
836
- }
315
+ await renameFile(temporary, absolutePath);
316
+ }
317
+ catch (error) {
318
+ await rm(temporary, { force: true });
319
+ throw error;
320
+ }
321
+ return { bytes: byteLength(content), lines: lineCount(content) };
322
+ }
323
+ #assertWithinFileBudget(relativePath, content) {
324
+ const bytes = byteLength(content);
325
+ if (relativePath === INDEX_FILE) {
326
+ if (bytes > MAX_INDEX_SOURCE_BYTES) {
327
+ throw new MemoryError(`${INDEX_FILE} would be ${bytes} bytes, above the ${MAX_INDEX_SOURCE_BYTES}-byte safety limit; rewrite the index`, "MEMORY_INDEX_FULL");
328
+ }
329
+ return bytes;
330
+ }
331
+ if (bytes > this.maxFileBytes) {
332
+ throw new MemoryError(`${virtual(relativePath)} would be ${bytes} bytes, above the ${this.maxFileBytes}-byte limit; split it into topic files`, "MEMORY_TOO_LARGE");
333
+ }
334
+ return bytes;
335
+ }
336
+ async #list(relativePath) {
337
+ const absolutePath = this.#absolute(relativePath);
338
+ await this.#assertSafeAncestors(relativePath);
339
+ const info = await this.#lstat(absolutePath);
340
+ if (info === undefined) {
341
+ if (relativePath.length === 0)
342
+ return [];
343
+ throw new MemoryError(`${virtual(relativePath)} does not exist`, "MEMORY_NOT_FOUND");
344
+ }
345
+ if (info.isSymbolicLink() && relativePath.length > 0) {
346
+ throw new MemoryError(`${virtual(relativePath)} is a symbolic link`, "MEMORY_SYMLINK");
347
+ }
348
+ if (!info.isDirectory()) {
349
+ throw new MemoryError(`${virtual(relativePath)} is not a directory`, "MEMORY_NOT_A_DIRECTORY");
350
+ }
351
+ const entries = [];
352
+ const walk = async (directory, prefix, depth) => {
353
+ if (depth > MAX_LIST_DEPTH)
354
+ return;
355
+ const children = await readdir(directory, { withFileTypes: true });
356
+ children.sort((left, right) => left.name.localeCompare(right.name));
357
+ for (const child of children) {
358
+ if (child.name.startsWith("."))
359
+ continue;
360
+ const childAbsolute = join(directory, child.name);
361
+ const childRelative = prefix.length === 0 ? child.name : `${prefix}/${child.name}`;
362
+ const childInfo = await this.#lstat(childAbsolute);
363
+ if (childInfo === undefined || childInfo.isSymbolicLink())
364
+ continue;
365
+ if (childInfo.isDirectory()) {
366
+ entries.push({ path: virtual(childRelative), kind: "directory", bytes: 0 });
367
+ await walk(childAbsolute, childRelative, depth + 1);
368
+ }
369
+ else if (childInfo.isFile()) {
370
+ entries.push({ path: virtual(childRelative), kind: "file", bytes: childInfo.size });
371
+ }
372
+ }
373
+ };
374
+ await walk(absolutePath, relativePath, 0);
375
+ return entries;
376
+ }
377
+ async #fileCount() {
378
+ const entries = await this.#list("");
379
+ return entries.filter((entry) => entry.kind === "file").length;
380
+ }
381
+ /**
382
+ * Read the index, if it exists.
383
+ */
384
+ async index() {
385
+ const absolutePath = this.#absolute(INDEX_FILE);
386
+ const info = await this.#lstat(absolutePath);
387
+ if (info === undefined)
388
+ return { exists: false };
389
+ if (info.isSymbolicLink()) {
390
+ throw new MemoryError(`${INDEX_FILE} is a symbolic link`, "MEMORY_SYMLINK");
391
+ }
392
+ if (!info.isFile())
393
+ return { exists: false };
394
+ if (info.size > MAX_INDEX_SOURCE_BYTES) {
395
+ const handle = await open(absolutePath, "r");
396
+ try {
397
+ const buffer = Buffer.alloc(MAX_INDEX_SOURCE_BYTES);
398
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
399
+ const content = buffer.subarray(0, bytesRead).toString("utf8");
400
+ if (content.includes("\0")) {
401
+ throw new MemoryError(`${INDEX_FILE} is not a text file`, "MEMORY_NOT_A_FILE");
402
+ }
403
+ return {
404
+ exists: true,
405
+ content,
406
+ bytes: info.size,
407
+ mtimeMs: info.mtimeMs,
408
+ truncated: true
409
+ };
410
+ }
411
+ finally {
412
+ await handle.close();
413
+ }
414
+ }
415
+ const content = await readFile(absolutePath, "utf8");
416
+ if (Buffer.byteLength(content, "utf8") > MAX_INDEX_SOURCE_BYTES) {
417
+ throw new MemoryError(`${INDEX_FILE} grew above the ${MAX_INDEX_SOURCE_BYTES}-byte read limit`, "MEMORY_TOO_LARGE");
418
+ }
419
+ if (content.includes("\0")) {
420
+ throw new MemoryError(`${INDEX_FILE} is not a text file`, "MEMORY_NOT_A_FILE");
421
+ }
422
+ return { exists: true, content, bytes: info.size, mtimeMs: info.mtimeMs, truncated: false };
423
+ }
424
+ /**
425
+ * Compare the index against the 200-line and 25KB injection budget.
426
+ *
427
+ * The budget is a read limit, not a hard write limit: a write may leave the
428
+ * index over budget, but the caller must tell the model to rewrite it because
429
+ * everything past the limit is dropped on the next load.
430
+ */
431
+ indexBudget(content, truncated = false) {
432
+ const lines = lineCount(content);
433
+ const bytes = byteLength(content);
434
+ const reasons = [];
435
+ if (lines > this.maxIndexLines)
436
+ reasons.push("lines");
437
+ if (bytes > this.maxIndexBytes)
438
+ reasons.push("bytes");
439
+ if (truncated)
440
+ reasons.push("source");
441
+ const overLimit = reasons.length > 0;
442
+ const nearLimit = !overLimit &&
443
+ (this.maxIndexLines - lines <= NEAR_LIMIT_LINES ||
444
+ bytes >= Math.floor(this.maxIndexBytes * NEAR_LIMIT_RATIO));
445
+ return {
446
+ lines,
447
+ bytes,
448
+ lineLimit: this.maxIndexLines,
449
+ byteLimit: this.maxIndexBytes,
450
+ overLimit,
451
+ nearLimit,
452
+ reasons
453
+ };
454
+ }
455
+ async #checkIndexBudget() {
456
+ const index = await this.index();
457
+ if (!index.exists)
458
+ return {};
459
+ const budget = this.indexBudget(index.content, index.truncated);
460
+ if (budget.overLimit) {
461
+ const where = `${INDEX_FILE} is now ${budget.lines} lines / ${budget.bytes} bytes, over its ${budget.lineLimit}-line / ${budget.byteLimit}-byte read limit`;
462
+ throw new MemoryError(`${where}. The change was applied. Rewrite the index: keep one line per memory, move detail into topic files, and merge or drop stale entries.`, "MEMORY_INDEX_OVER_LIMIT");
463
+ }
464
+ return {
465
+ indexBudget: budget,
466
+ ...(budget.nearLimit
467
+ ? {
468
+ indexBudgetWarning: `${INDEX_FILE} is near its read limit at ${budget.lines}/${budget.lineLimit} lines and ` +
469
+ `${budget.bytes}/${budget.byteLimit} bytes; keep one line per memory, move detail into topic files, ` +
470
+ "and merge or drop stale entries."
471
+ }
472
+ : {})
473
+ };
474
+ }
475
+ /**
476
+ * List the root or one subtree.
477
+ */
478
+ async list(relativePath = "") {
479
+ return this.#list(relativePath);
480
+ }
481
+ /**
482
+ * Read one file or list one directory.
483
+ */
484
+ async view(relativePath = "") {
485
+ if (relativePath.length === 0) {
486
+ return { kind: "directory", entries: await this.#list("") };
487
+ }
488
+ const absolutePath = this.#absolute(relativePath);
489
+ await this.#assertSafeAncestors(relativePath);
490
+ const info = await this.#lstat(absolutePath);
491
+ if (info === undefined) {
492
+ throw new MemoryError(`${virtual(relativePath)} does not exist`, "MEMORY_NOT_FOUND");
493
+ }
494
+ if (info.isSymbolicLink()) {
495
+ throw new MemoryError(`${virtual(relativePath)} is a symbolic link`, "MEMORY_SYMLINK");
496
+ }
497
+ if (info.isDirectory()) {
498
+ return { kind: "directory", entries: await this.#list(relativePath) };
499
+ }
500
+ const read = await this.#readText(relativePath, this.#readLimit(relativePath));
501
+ return {
502
+ kind: "file",
503
+ content: read.content,
504
+ bytes: read.size,
505
+ lines: lineCount(read.content)
506
+ };
507
+ }
508
+ /**
509
+ * Create a new file. Creating a topic file also appends an index entry when
510
+ * the index exists and does not already link the file, and reports what its
511
+ * frontmatter is missing without refusing the write.
512
+ */
513
+ async create(relativePath, text) {
514
+ this.#assertFile(relativePath);
515
+ return this.#run(async () => {
516
+ const absolutePath = this.#absolute(relativePath);
517
+ await this.#assertSafeAncestors(relativePath);
518
+ if (await this.#lstat(absolutePath)) {
519
+ throw new MemoryError(`${virtual(relativePath)} already exists; use str_replace or insert`, "MEMORY_ALREADY_EXISTS");
520
+ }
521
+ if ((await this.#fileCount()) >= this.maxFiles) {
522
+ throw new MemoryError(`memory already holds ${this.maxFiles} files; delete or merge some before creating another`, "MEMORY_TOO_MANY_FILES");
523
+ }
524
+ const content = typeof text === "string" ? text : "";
525
+ const next = relativePath === INDEX_FILE
526
+ ? content.trim().length === 0
527
+ ? INDEX_HEADER
528
+ : content
529
+ : touchModified(content);
530
+ this.#assertWithinFileBudget(relativePath, next);
531
+ const written = await this.#atomicWrite(relativePath, next);
532
+ let indexWarning;
533
+ let indexed = false;
534
+ let frontmatterProblems = [];
535
+ if (relativePath !== INDEX_FILE) {
536
+ frontmatterProblems = frontmatterIssues(relativePath, next).map((issue) => issue.message);
537
+ try {
538
+ indexed = (await this.#linkIndex(relativePath, next)).updated;
539
+ }
540
+ catch (error) {
541
+ indexWarning = `created the file, but could not update MEMORY.md: ${error.message}`;
542
+ }
543
+ }
544
+ const budget = await this.#checkIndexBudget();
545
+ return {
546
+ path: virtual(relativePath),
547
+ ...written,
548
+ indexed,
549
+ indexWarning,
550
+ ...budget,
551
+ frontmatterProblems
552
+ };
553
+ });
554
+ }
555
+ /**
556
+ * Replace exactly one occurrence of `oldStr`. Editing a topic file also
557
+ * refreshes its index line when that line still matches the old frontmatter.
558
+ */
559
+ async strReplace(relativePath, oldStr, newStr = "") {
560
+ this.#assertFile(relativePath);
561
+ if (typeof oldStr !== "string" || oldStr.length === 0) {
562
+ throw new MemoryError("old_str must be a non-empty string", "MEMORY_INVALID_ARGUMENT");
563
+ }
564
+ if (typeof newStr !== "string") {
565
+ throw new MemoryError("new_str must be a string", "MEMORY_INVALID_ARGUMENT");
566
+ }
567
+ return this.#run(async () => {
568
+ const current = await this.#readText(relativePath, this.#readLimit(relativePath));
569
+ const occurrences = current.content.split(oldStr).length - 1;
570
+ if (occurrences === 0) {
571
+ throw new MemoryError("old_str was not found; read the file again before editing", "MEMORY_NO_MATCH");
572
+ }
573
+ if (occurrences > 1) {
574
+ throw new MemoryError(`old_str occurs ${occurrences} times; include more surrounding text so it is unique`, "MEMORY_MULTIPLE_MATCHES");
575
+ }
576
+ const next = current.content.replace(oldStr, () => newStr);
577
+ const stamped = relativePath === INDEX_FILE ? next : touchModified(next);
578
+ this.#assertWithinFileBudget(relativePath, stamped);
579
+ const written = await this.#atomicWrite(relativePath, stamped);
580
+ const sync = await this.#syncIndex(relativePath, current.content, stamped);
581
+ const budget = await this.#checkIndexBudget();
582
+ return {
583
+ path: virtual(relativePath),
584
+ ...written,
585
+ ...sync,
586
+ ...budget,
587
+ frontmatterProblems: introducedProblems(relativePath, current.content, stamped)
588
+ };
589
+ });
590
+ }
591
+ /**
592
+ * Insert text at a zero-based line index. Like `strReplace`, it refreshes a
593
+ * topic file's index line that still matches the old frontmatter.
594
+ */
595
+ async insert(relativePath, line, text) {
596
+ this.#assertFile(relativePath);
597
+ if (!Number.isSafeInteger(line) || line < 0) {
598
+ throw new MemoryError("insert_line must be a non-negative integer", "MEMORY_INVALID_ARGUMENT");
599
+ }
600
+ if (typeof text !== "string" || text.length === 0) {
601
+ throw new MemoryError("insert_text must be a non-empty string", "MEMORY_INVALID_ARGUMENT");
602
+ }
603
+ return this.#run(async () => {
604
+ const current = await this.#readText(relativePath, this.#readLimit(relativePath));
605
+ const lines = current.content.split("\n");
606
+ const at = Math.min(line, lines.length);
607
+ lines.splice(at, 0, ...text.split("\n"));
608
+ const next = lines.join("\n");
609
+ const stamped = relativePath === INDEX_FILE ? next : touchModified(next);
610
+ this.#assertWithinFileBudget(relativePath, stamped);
611
+ const written = await this.#atomicWrite(relativePath, stamped);
612
+ const sync = await this.#syncIndex(relativePath, current.content, stamped);
613
+ const budget = await this.#checkIndexBudget();
614
+ return {
615
+ path: virtual(relativePath),
616
+ ...written,
617
+ insertedAt: at,
618
+ ...sync,
619
+ ...budget,
620
+ frontmatterProblems: introducedProblems(relativePath, current.content, stamped)
621
+ };
622
+ });
623
+ }
624
+ /**
625
+ * Delete one file. Deleting a topic file also removes index lines that link it.
626
+ */
627
+ async delete(relativePath) {
628
+ this.#assertFile(relativePath);
629
+ return this.#run(async () => {
630
+ const absolutePath = this.#absolute(relativePath);
631
+ await this.#assertSafeAncestors(relativePath);
632
+ const info = await this.#lstat(absolutePath);
633
+ if (info === undefined) {
634
+ throw new MemoryError(`${virtual(relativePath)} does not exist`, "MEMORY_NOT_FOUND");
635
+ }
636
+ if (info.isSymbolicLink()) {
637
+ throw new MemoryError(`${virtual(relativePath)} is a symbolic link`, "MEMORY_SYMLINK");
638
+ }
639
+ if (!info.isFile()) {
640
+ throw new MemoryError(`${virtual(relativePath)} is not a file`, "MEMORY_NOT_A_FILE");
641
+ }
642
+ await rm(absolutePath);
643
+ let indexWarning;
644
+ let indexed = false;
645
+ if (relativePath !== INDEX_FILE) {
646
+ try {
647
+ indexed = await this.#unlinkIndex(relativePath);
648
+ }
649
+ catch (error) {
650
+ indexWarning = `deleted the file, but could not update MEMORY.md: ${error.message}`;
651
+ }
652
+ }
653
+ const budget = await this.#checkIndexBudget();
654
+ return { path: virtual(relativePath), changed: true, indexed, indexWarning, ...budget };
655
+ });
656
+ }
657
+ /**
658
+ * Rename one file. Links in the index follow the file, and a title still
659
+ * generated from the old file name follows the new one.
660
+ */
661
+ async rename(oldPath, newPath) {
662
+ this.#assertFile(oldPath);
663
+ this.#assertFile(newPath);
664
+ if (oldPath === INDEX_FILE || newPath === INDEX_FILE) {
665
+ throw new MemoryError("MEMORY.md is the index and cannot be renamed; edit it in place", "MEMORY_INVALID_ARGUMENT");
666
+ }
667
+ return this.#run(async () => {
668
+ const absoluteOld = this.#absolute(oldPath);
669
+ const absoluteNew = this.#absolute(newPath);
670
+ await this.#assertSafeAncestors(oldPath);
671
+ await this.#assertSafeAncestors(newPath);
672
+ const oldInfo = await this.#lstat(absoluteOld);
673
+ if (oldInfo === undefined) {
674
+ throw new MemoryError(`${virtual(oldPath)} does not exist`, "MEMORY_NOT_FOUND");
675
+ }
676
+ if (oldInfo.isSymbolicLink()) {
677
+ throw new MemoryError(`${virtual(oldPath)} is a symbolic link`, "MEMORY_SYMLINK");
678
+ }
679
+ if (!oldInfo.isFile()) {
680
+ throw new MemoryError(`${virtual(oldPath)} is not a file`, "MEMORY_NOT_A_FILE");
681
+ }
682
+ if (await this.#lstat(absoluteNew)) {
683
+ throw new MemoryError(`${virtual(newPath)} already exists`, "MEMORY_ALREADY_EXISTS");
684
+ }
685
+ const content = await this.#readText(oldPath, this.#readLimit(oldPath)).then((read) => read.content, () => undefined);
686
+ await mkdir(dirname(absoluteNew), { recursive: true });
687
+ await renameFile(absoluteOld, absoluteNew);
688
+ let indexWarning;
689
+ let indexed = false;
690
+ const previousFields = this.#indexFields(oldPath, content);
691
+ const nextFields = this.#indexFields(newPath, content);
692
+ try {
693
+ indexed = await this.#rewriteIndex(oldPath, (entry) => ({
694
+ ...entry,
695
+ target: newPath,
696
+ title: entry.title === previousFields.title || entry.title === previousFields.legacyTitle
697
+ ? nextFields.title
698
+ : entry.title
699
+ }));
700
+ }
701
+ catch (error) {
702
+ indexWarning = `renamed the file, but could not update MEMORY.md: ${error.message}`;
703
+ }
704
+ const frontmatterProblems = content === undefined ? [] : introducedProblems(newPath, content, content, oldPath);
705
+ const budget = await this.#checkIndexBudget();
706
+ return {
707
+ path: virtual(newPath),
708
+ previousPath: virtual(oldPath),
709
+ changed: true,
710
+ indexed,
711
+ indexWarning,
712
+ ...budget,
713
+ frontmatterProblems
714
+ };
715
+ });
716
+ }
717
+ #indexFields(relativePath, text) {
718
+ const parsed = parseMemory(text ?? "");
719
+ const title = parsed.name !== undefined && MEMORY_NAME.test(parsed.name) ? parsed.name : stem(relativePath);
720
+ const firstLine = parsed.body
721
+ .split("\n")
722
+ .map((line) => line.trim())
723
+ .find((line) => line.length > 0 && !line.startsWith("<!--"));
724
+ const hook = (parsed.description ?? firstLine ?? "")
725
+ .replace(/^#+\s*/u, "")
726
+ .replace(/\s+/gu, " ")
727
+ .trim()
728
+ .slice(0, 110);
729
+ return { title, hook, legacyTitle: parsed.name };
730
+ }
731
+ #indexEntry(relativePath, text) {
732
+ const { title, hook } = this.#indexFields(relativePath, text);
733
+ return formatIndexLine({
734
+ bullet: "- ",
735
+ title,
736
+ target: relativePath,
737
+ rest: hook.length > 0 ? ` — ${hook}` : "",
738
+ cr: ""
739
+ });
740
+ }
741
+ /**
742
+ * Rewrite the index lines that link `relativePath`.
743
+ *
744
+ * @param update - returns the replacement entry, `null` to drop the line, or
745
+ * `undefined` to keep it.
746
+ * @returns whether the index changed.
747
+ */
748
+ async #rewriteIndex(relativePath, update) {
749
+ const index = await this.index();
750
+ if (!index.exists)
751
+ return false;
752
+ if (index.truncated) {
753
+ throw new MemoryError(`${INDEX_FILE} is too large to rewrite; rewrite the index before editing more entries`, "MEMORY_INDEX_FULL");
754
+ }
755
+ let changed = false;
756
+ const lines = [];
757
+ for (const line of index.content.split("\n")) {
758
+ const entry = parseIndexLine(line, relativePath);
759
+ const next = entry === undefined ? undefined : update(entry);
760
+ if (next === undefined) {
761
+ lines.push(line);
762
+ }
763
+ else if (next !== null) {
764
+ const formatted = formatIndexLine(next);
765
+ changed ||= formatted !== line;
766
+ lines.push(formatted);
767
+ }
768
+ else {
769
+ changed = true;
770
+ }
771
+ }
772
+ if (!changed)
773
+ return false;
774
+ const content = lines.join("\n");
775
+ if (byteLength(content) > MAX_INDEX_SOURCE_BYTES) {
776
+ throw new MemoryError("MEMORY.md is too large to rewrite; remove or merge entries before adding more", "MEMORY_INDEX_FULL");
777
+ }
778
+ await this.#atomicWrite(INDEX_FILE, content);
779
+ return true;
780
+ }
781
+ /**
782
+ * Refresh the index line of an edited topic file. A title or hook is replaced
783
+ * only while it still equals what the old content would have generated, so a
784
+ * line the model rewrote by hand is left alone. An invalid name from v0.3.0
785
+ * was used as the title then; it is recognized as the legacy generated title
786
+ * and normalized to the current fallback.
787
+ */
788
+ async #syncIndex(relativePath, before, after) {
789
+ if (relativePath === INDEX_FILE)
790
+ return {};
791
+ const previous = this.#indexFields(relativePath, before);
792
+ const current = this.#indexFields(relativePath, after);
793
+ const legacyStale = previous.legacyTitle !== undefined && previous.legacyTitle !== previous.title;
794
+ if (previous.title === current.title && previous.hook === current.hook && !legacyStale)
795
+ return {};
796
+ try {
797
+ const indexed = await this.#rewriteIndex(relativePath, (entry) => {
798
+ const hook = entry.rest.startsWith(" — ") ? entry.rest.slice(3) : entry.rest.length === 0 ? "" : undefined;
799
+ if (hook === undefined)
800
+ return undefined;
801
+ const title = entry.title === previous.title || entry.title === previous.legacyTitle
802
+ ? current.title
803
+ : entry.title;
804
+ const nextHook = hook === previous.hook ? current.hook : hook;
805
+ return { ...entry, title, rest: nextHook.length > 0 ? ` — ${nextHook}` : "" };
806
+ });
807
+ return { indexed };
808
+ }
809
+ catch (error) {
810
+ return { indexed: false, indexWarning: `edited the file, but could not update MEMORY.md: ${error.message}` };
811
+ }
812
+ }
813
+ async #linkIndex(relativePath, text) {
814
+ const index = await this.index();
815
+ if (index.exists && index.truncated) {
816
+ throw new MemoryError(`${INDEX_FILE} is too large to rewrite; rewrite the index before adding more entries`, "MEMORY_INDEX_FULL");
817
+ }
818
+ if (index.exists &&
819
+ index.content.split("\n").some((line) => parseIndexLine(line, relativePath) !== undefined)) {
820
+ return { updated: false };
821
+ }
822
+ const entry = this.#indexEntry(relativePath, text);
823
+ let next;
824
+ if (!index.exists || index.content.trim().length === 0) {
825
+ next = `${INDEX_HEADER.replace(/\n*$/u, "")}\n\n${entry}\n`;
826
+ }
827
+ else {
828
+ const content = index.content.replace(/[ \t]+$/u, "");
829
+ const eol = content.endsWith("\r\n") ? "\r\n" : content.endsWith("\r") ? "\r" : "\n";
830
+ const separator = content.length === 0 || content.endsWith("\n") || content.endsWith("\r") ? "" : eol;
831
+ next = `${content}${separator}${entry}${eol}`;
832
+ }
833
+ if (byteLength(next) > MAX_INDEX_SOURCE_BYTES) {
834
+ throw new MemoryError("MEMORY.md is too large to rewrite; remove or merge entries before adding more", "MEMORY_INDEX_FULL");
835
+ }
836
+ await this.#atomicWrite(INDEX_FILE, next);
837
+ return { updated: true };
838
+ }
839
+ async #unlinkIndex(relativePath) {
840
+ return this.#rewriteIndex(relativePath, () => null);
841
+ }
837
842
  }