@y0shua1ee/dsh-auto-memory 0.0.0-stage → 0.3.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 +21 -0
- package/README.md +292 -2
- package/assets/icon.svg +21 -0
- package/cordis.patch.yml +8 -0
- package/lib/config.js +134 -0
- package/lib/guidance.js +59 -0
- package/lib/index.js +117 -0
- package/lib/inject.js +114 -0
- package/lib/memory-frontmatter.js +95 -0
- package/lib/paths.js +97 -0
- package/lib/render.js +76 -0
- package/lib/rule-frontmatter.js +95 -0
- package/lib/rule-glob.js +163 -0
- package/lib/rules-inject.js +130 -0
- package/lib/rules.js +264 -0
- package/lib/store.js +646 -0
- package/lib/tool.js +246 -0
- package/package.json +74 -3
package/lib/store.js
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
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";
|
|
11
|
+
import { basename, dirname, extname, join, resolve, sep } from "node:path";
|
|
12
|
+
import { memoryFrontmatterProblems, parseMemory } from "./memory-frontmatter.js";
|
|
13
|
+
|
|
14
|
+
/** Name of the index file loaded into every session. */
|
|
15
|
+
export const INDEX_FILE = "MEMORY.md";
|
|
16
|
+
|
|
17
|
+
/** Virtual root exposed to the model. */
|
|
18
|
+
export const VIRTUAL_ROOT = "/memories";
|
|
19
|
+
|
|
20
|
+
/** Header written when the index is created automatically. */
|
|
21
|
+
export const INDEX_HEADER = `# Project memory
|
|
22
|
+
|
|
23
|
+
<!-- One line per memory: - [name](name.md) — description. Keep this index short; details live in the memory files. -->
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const MAX_PATH_SEGMENTS = 4;
|
|
27
|
+
const MAX_RELATIVE_PATH_LENGTH = 200;
|
|
28
|
+
const MAX_LIST_DEPTH = MAX_PATH_SEGMENTS;
|
|
29
|
+
const SAFE_SEGMENT = /^[^\\/\u0000-\u001f\u007f:*?"<>|#%\[\]()]+$/u;
|
|
30
|
+
|
|
31
|
+
/** A memory operation failed with a model-actionable reason. */
|
|
32
|
+
export class MemoryError extends Error {
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} message - human-readable explanation.
|
|
35
|
+
* @param {string} code - stable uppercase code returned to the model.
|
|
36
|
+
*/
|
|
37
|
+
constructor(message, code = "MEMORY_ERROR") {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "MemoryError";
|
|
40
|
+
this.code = code;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function byteLength(value) {
|
|
45
|
+
return Buffer.byteLength(value, "utf8");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function lineCount(value) {
|
|
49
|
+
return value.length === 0 ? 0 : value.split("\n").length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function virtual(relativePath) {
|
|
53
|
+
return relativePath.length === 0 ? VIRTUAL_ROOT : `${VIRTUAL_ROOT}/${relativePath}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read and write one project's memory directory.
|
|
58
|
+
*
|
|
59
|
+
* Every path is virtual: `/memories` is the directory and
|
|
60
|
+
* `/memories/MEMORY.md` is the index. The store refuses traversal, symlinks,
|
|
61
|
+
* non-Markdown files, and writes that would exceed the configured limits.
|
|
62
|
+
* Mutations are serialized per store instance, so two tool calls from the same
|
|
63
|
+
* process cannot interleave a read-modify-write cycle.
|
|
64
|
+
*/
|
|
65
|
+
export class MemoryStore {
|
|
66
|
+
#queue = Promise.resolve();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {object} options - store limits and the absolute project directory.
|
|
70
|
+
* @param {string} options.root - absolute project memory directory.
|
|
71
|
+
* @param {number} options.maxFileBytes - maximum bytes per file.
|
|
72
|
+
* @param {number} options.maxIndexBytes - injected index budget.
|
|
73
|
+
* @param {number} options.maxFiles - maximum number of `.md` files.
|
|
74
|
+
*/
|
|
75
|
+
constructor({ root, maxFileBytes, maxIndexBytes, maxFiles }) {
|
|
76
|
+
this.root = resolve(root);
|
|
77
|
+
this.maxFileBytes = maxFileBytes;
|
|
78
|
+
this.maxIndexBytes = maxIndexBytes;
|
|
79
|
+
this.maxFiles = maxFiles;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Run one mutation after every earlier mutation and return its result. */
|
|
83
|
+
#run(operation) {
|
|
84
|
+
const result = this.#queue.then(operation, operation);
|
|
85
|
+
this.#queue = result.then(
|
|
86
|
+
() => undefined,
|
|
87
|
+
() => undefined
|
|
88
|
+
);
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Parse a model-supplied virtual path.
|
|
94
|
+
*
|
|
95
|
+
* @param {unknown} value - path such as `/memories/MEMORY.md`.
|
|
96
|
+
* @returns {string} project-relative path, or `''` for the root.
|
|
97
|
+
*/
|
|
98
|
+
static parseVirtualPath(value) {
|
|
99
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
100
|
+
throw new MemoryError("path is required and must be a string", "MEMORY_INVALID_PATH");
|
|
101
|
+
}
|
|
102
|
+
const raw = value.trim();
|
|
103
|
+
if (raw === VIRTUAL_ROOT || raw === `${VIRTUAL_ROOT}/`) return "";
|
|
104
|
+
if (!raw.startsWith(`${VIRTUAL_ROOT}/`)) {
|
|
105
|
+
throw new MemoryError(
|
|
106
|
+
`path must start with ${VIRTUAL_ROOT} (received ${JSON.stringify(value)})`,
|
|
107
|
+
"MEMORY_INVALID_PATH"
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const relativePath = raw.slice(VIRTUAL_ROOT.length + 1);
|
|
111
|
+
const segments = relativePath.split("/");
|
|
112
|
+
if (segments.length === 0 || segments.length > MAX_PATH_SEGMENTS) {
|
|
113
|
+
throw new MemoryError(`path may have at most ${MAX_PATH_SEGMENTS} segments`, "MEMORY_INVALID_PATH");
|
|
114
|
+
}
|
|
115
|
+
for (const segment of segments) {
|
|
116
|
+
if (segment.length === 0) {
|
|
117
|
+
throw new MemoryError("path must not contain empty segments or a trailing slash", "MEMORY_INVALID_PATH");
|
|
118
|
+
}
|
|
119
|
+
if (segment === "." || segment === ".." || segment.startsWith(".")) {
|
|
120
|
+
throw new MemoryError("path must not contain relative or hidden segments", "MEMORY_INVALID_PATH");
|
|
121
|
+
}
|
|
122
|
+
if (/[. ]$/u.test(segment)) {
|
|
123
|
+
throw new MemoryError("path segments must not end with a dot or space", "MEMORY_INVALID_PATH");
|
|
124
|
+
}
|
|
125
|
+
if (!SAFE_SEGMENT.test(segment)) {
|
|
126
|
+
throw new MemoryError(
|
|
127
|
+
`path segment ${JSON.stringify(segment)} contains unsupported characters`,
|
|
128
|
+
"MEMORY_INVALID_PATH"
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (relativePath.length > MAX_RELATIVE_PATH_LENGTH) {
|
|
133
|
+
throw new MemoryError("path is too long", "MEMORY_INVALID_PATH");
|
|
134
|
+
}
|
|
135
|
+
return segments.join("/");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#assertFile(relativePath) {
|
|
139
|
+
if (relativePath.length === 0) {
|
|
140
|
+
throw new MemoryError("this command requires a file path under /memories", "MEMORY_INVALID_ARGUMENT");
|
|
141
|
+
}
|
|
142
|
+
if (extname(relativePath).toLowerCase() !== ".md") {
|
|
143
|
+
throw new MemoryError("memory files must use the .md extension", "MEMORY_INVALID_PATH");
|
|
144
|
+
}
|
|
145
|
+
return relativePath;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#absolute(relativePath) {
|
|
149
|
+
const target = resolve(this.root, relativePath);
|
|
150
|
+
if (target !== this.root && !target.startsWith(this.root + sep)) {
|
|
151
|
+
throw new MemoryError("path escapes the memory directory", "MEMORY_INVALID_PATH");
|
|
152
|
+
}
|
|
153
|
+
return target;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async #lstat(absolutePath) {
|
|
157
|
+
try {
|
|
158
|
+
return await lstat(absolutePath);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return undefined;
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async #assertSafeAncestors(relativePath) {
|
|
166
|
+
const segments = relativePath.split("/").slice(0, -1);
|
|
167
|
+
let current = this.root;
|
|
168
|
+
for (const segment of segments) {
|
|
169
|
+
current = join(current, segment);
|
|
170
|
+
const info = await this.#lstat(current);
|
|
171
|
+
if (info === undefined) return;
|
|
172
|
+
if (info.isSymbolicLink()) {
|
|
173
|
+
throw new MemoryError("path traverses a symbolic link", "MEMORY_SYMLINK");
|
|
174
|
+
}
|
|
175
|
+
if (!info.isDirectory()) {
|
|
176
|
+
throw new MemoryError("a path component is not a directory", "MEMORY_NOT_A_DIRECTORY");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async #readText(relativePath, limit = this.maxFileBytes) {
|
|
182
|
+
const absolutePath = this.#absolute(relativePath);
|
|
183
|
+
await this.#assertSafeAncestors(relativePath);
|
|
184
|
+
const info = await this.#lstat(absolutePath);
|
|
185
|
+
if (info === undefined) {
|
|
186
|
+
throw new MemoryError(`${virtual(relativePath)} does not exist`, "MEMORY_NOT_FOUND");
|
|
187
|
+
}
|
|
188
|
+
if (info.isSymbolicLink()) {
|
|
189
|
+
throw new MemoryError(`${virtual(relativePath)} is a symbolic link`, "MEMORY_SYMLINK");
|
|
190
|
+
}
|
|
191
|
+
if (!info.isFile()) {
|
|
192
|
+
throw new MemoryError(`${virtual(relativePath)} is not a file`, "MEMORY_NOT_A_FILE");
|
|
193
|
+
}
|
|
194
|
+
if (info.size > limit) {
|
|
195
|
+
throw new MemoryError(
|
|
196
|
+
`${virtual(relativePath)} is ${info.size} bytes, above the ${limit}-byte read limit`,
|
|
197
|
+
"MEMORY_TOO_LARGE"
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const content = await readFile(absolutePath, "utf8");
|
|
201
|
+
if (Buffer.byteLength(content, "utf8") > limit) {
|
|
202
|
+
throw new MemoryError(
|
|
203
|
+
`${virtual(relativePath)} grew above the ${limit}-byte read limit`,
|
|
204
|
+
"MEMORY_TOO_LARGE"
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (content.includes("\0")) {
|
|
208
|
+
throw new MemoryError(`${virtual(relativePath)} is not a text file`, "MEMORY_NOT_A_FILE");
|
|
209
|
+
}
|
|
210
|
+
return { content, size: info.size, mtimeMs: info.mtimeMs };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async #atomicWrite(relativePath, content) {
|
|
214
|
+
if (content.includes("\0")) {
|
|
215
|
+
throw new MemoryError("memory files must be text without NUL bytes", "MEMORY_INVALID_ARGUMENT");
|
|
216
|
+
}
|
|
217
|
+
const absolutePath = this.#absolute(relativePath);
|
|
218
|
+
await this.#assertSafeAncestors(relativePath);
|
|
219
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
220
|
+
const temporary = join(dirname(absolutePath), `.${basename(absolutePath)}.${randomUUID()}.tmp`);
|
|
221
|
+
await writeFile(temporary, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
222
|
+
try {
|
|
223
|
+
await renameFile(temporary, absolutePath);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
await rm(temporary, { force: true });
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
return { bytes: byteLength(content), lines: lineCount(content) };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#assertWithinFileBudget(relativePath, content) {
|
|
232
|
+
const bytes = byteLength(content);
|
|
233
|
+
if (bytes > this.maxFileBytes) {
|
|
234
|
+
throw new MemoryError(
|
|
235
|
+
`${virtual(relativePath)} would be ${bytes} bytes, above the ${this.maxFileBytes}-byte limit; split it into topic files`,
|
|
236
|
+
"MEMORY_TOO_LARGE"
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return bytes;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async #list(relativePath) {
|
|
243
|
+
const absolutePath = this.#absolute(relativePath);
|
|
244
|
+
await this.#assertSafeAncestors(relativePath);
|
|
245
|
+
const info = await this.#lstat(absolutePath);
|
|
246
|
+
if (info === undefined) {
|
|
247
|
+
if (relativePath.length === 0) return [];
|
|
248
|
+
throw new MemoryError(`${virtual(relativePath)} does not exist`, "MEMORY_NOT_FOUND");
|
|
249
|
+
}
|
|
250
|
+
if (info.isSymbolicLink() && relativePath.length > 0) {
|
|
251
|
+
throw new MemoryError(`${virtual(relativePath)} is a symbolic link`, "MEMORY_SYMLINK");
|
|
252
|
+
}
|
|
253
|
+
if (!info.isDirectory()) {
|
|
254
|
+
throw new MemoryError(`${virtual(relativePath)} is not a directory`, "MEMORY_NOT_A_DIRECTORY");
|
|
255
|
+
}
|
|
256
|
+
const entries = [];
|
|
257
|
+
const walk = async (directory, prefix, depth) => {
|
|
258
|
+
if (depth > MAX_LIST_DEPTH) return;
|
|
259
|
+
const children = await readdir(directory, { withFileTypes: true });
|
|
260
|
+
children.sort((left, right) => left.name.localeCompare(right.name));
|
|
261
|
+
for (const child of children) {
|
|
262
|
+
if (child.name.startsWith(".")) continue;
|
|
263
|
+
const childAbsolute = join(directory, child.name);
|
|
264
|
+
const childRelative = prefix.length === 0 ? child.name : `${prefix}/${child.name}`;
|
|
265
|
+
const childInfo = await this.#lstat(childAbsolute);
|
|
266
|
+
if (childInfo === undefined || childInfo.isSymbolicLink()) continue;
|
|
267
|
+
if (childInfo.isDirectory()) {
|
|
268
|
+
entries.push({ path: virtual(childRelative), kind: "directory", bytes: 0 });
|
|
269
|
+
await walk(childAbsolute, childRelative, depth + 1);
|
|
270
|
+
} else if (childInfo.isFile()) {
|
|
271
|
+
entries.push({ path: virtual(childRelative), kind: "file", bytes: childInfo.size });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
await walk(absolutePath, relativePath, 0);
|
|
276
|
+
return entries;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async #fileCount() {
|
|
280
|
+
const entries = await this.#list("");
|
|
281
|
+
return entries.filter((entry) => entry.kind === "file").length;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Read the index, if it exists.
|
|
286
|
+
*
|
|
287
|
+
* @returns {Promise<{exists: boolean, content?: string, bytes?: number}>}
|
|
288
|
+
*/
|
|
289
|
+
async index() {
|
|
290
|
+
const absolutePath = this.#absolute(INDEX_FILE);
|
|
291
|
+
const info = await this.#lstat(absolutePath);
|
|
292
|
+
if (info === undefined) return { exists: false };
|
|
293
|
+
if (info.isSymbolicLink()) {
|
|
294
|
+
throw new MemoryError(`${INDEX_FILE} is a symbolic link`, "MEMORY_SYMLINK");
|
|
295
|
+
}
|
|
296
|
+
if (!info.isFile()) return { exists: false };
|
|
297
|
+
const limit = Math.max(this.maxFileBytes, this.maxIndexBytes);
|
|
298
|
+
if (info.size > limit) {
|
|
299
|
+
throw new MemoryError(
|
|
300
|
+
`${INDEX_FILE} is ${info.size} bytes, above the ${limit}-byte read limit`,
|
|
301
|
+
"MEMORY_TOO_LARGE"
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
const content = await readFile(absolutePath, "utf8");
|
|
305
|
+
if (Buffer.byteLength(content, "utf8") > limit) {
|
|
306
|
+
throw new MemoryError(`${INDEX_FILE} grew above the ${limit}-byte read limit`, "MEMORY_TOO_LARGE");
|
|
307
|
+
}
|
|
308
|
+
if (content.includes("\0")) {
|
|
309
|
+
throw new MemoryError(`${INDEX_FILE} is not a text file`, "MEMORY_NOT_A_FILE");
|
|
310
|
+
}
|
|
311
|
+
return { exists: true, content, bytes: info.size, mtimeMs: info.mtimeMs };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* List the root or one subtree.
|
|
316
|
+
*
|
|
317
|
+
* @param {string} relativePath - project-relative directory.
|
|
318
|
+
* @returns {Promise<Array<{path: string, kind: string, bytes: number}>>}
|
|
319
|
+
*/
|
|
320
|
+
async list(relativePath = "") {
|
|
321
|
+
return this.#list(relativePath);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Read one file or list one directory.
|
|
326
|
+
*
|
|
327
|
+
* @param {string} relativePath - project-relative path.
|
|
328
|
+
* @returns {Promise<object>} either `{kind: 'directory', entries}` or
|
|
329
|
+
* `{kind: 'file', content, bytes, lines}`.
|
|
330
|
+
*/
|
|
331
|
+
async view(relativePath = "") {
|
|
332
|
+
if (relativePath.length === 0) {
|
|
333
|
+
return { kind: "directory", entries: await this.#list("") };
|
|
334
|
+
}
|
|
335
|
+
const absolutePath = this.#absolute(relativePath);
|
|
336
|
+
await this.#assertSafeAncestors(relativePath);
|
|
337
|
+
const info = await this.#lstat(absolutePath);
|
|
338
|
+
if (info === undefined) {
|
|
339
|
+
throw new MemoryError(`${virtual(relativePath)} does not exist`, "MEMORY_NOT_FOUND");
|
|
340
|
+
}
|
|
341
|
+
if (info.isSymbolicLink()) {
|
|
342
|
+
throw new MemoryError(`${virtual(relativePath)} is a symbolic link`, "MEMORY_SYMLINK");
|
|
343
|
+
}
|
|
344
|
+
if (info.isDirectory()) {
|
|
345
|
+
return { kind: "directory", entries: await this.#list(relativePath) };
|
|
346
|
+
}
|
|
347
|
+
const read = await this.#readText(
|
|
348
|
+
relativePath,
|
|
349
|
+
relativePath === INDEX_FILE ? Math.max(this.maxFileBytes, this.maxIndexBytes) : this.maxFileBytes
|
|
350
|
+
);
|
|
351
|
+
return {
|
|
352
|
+
kind: "file",
|
|
353
|
+
content: read.content,
|
|
354
|
+
bytes: read.size,
|
|
355
|
+
lines: lineCount(read.content)
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Create a new file. Creating a topic file also appends an index entry when
|
|
361
|
+
* the index exists and does not already link the file, and reports what its
|
|
362
|
+
* frontmatter is missing without refusing the write.
|
|
363
|
+
*
|
|
364
|
+
* @param {string} relativePath - file path ending in `.md`.
|
|
365
|
+
* @param {string} [text] - complete file content.
|
|
366
|
+
* @returns {Promise<object>} written file metadata plus optional index and frontmatter warnings.
|
|
367
|
+
*/
|
|
368
|
+
async create(relativePath, text) {
|
|
369
|
+
this.#assertFile(relativePath);
|
|
370
|
+
return this.#run(async () => {
|
|
371
|
+
const absolutePath = this.#absolute(relativePath);
|
|
372
|
+
await this.#assertSafeAncestors(relativePath);
|
|
373
|
+
if (await this.#lstat(absolutePath)) {
|
|
374
|
+
throw new MemoryError(`${virtual(relativePath)} already exists; use str_replace or insert`, "MEMORY_ALREADY_EXISTS");
|
|
375
|
+
}
|
|
376
|
+
if ((await this.#fileCount()) >= this.maxFiles) {
|
|
377
|
+
throw new MemoryError(
|
|
378
|
+
`memory already holds ${this.maxFiles} files; delete or merge some before creating another`,
|
|
379
|
+
"MEMORY_TOO_MANY_FILES"
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
const content = typeof text === "string" ? text : "";
|
|
383
|
+
const next = relativePath === INDEX_FILE && content.trim().length === 0 ? INDEX_HEADER : content;
|
|
384
|
+
this.#assertWithinFileBudget(relativePath, next);
|
|
385
|
+
const written = await this.#atomicWrite(relativePath, next);
|
|
386
|
+
let indexWarning;
|
|
387
|
+
let indexed = false;
|
|
388
|
+
let frontmatterProblems = [];
|
|
389
|
+
if (relativePath !== INDEX_FILE) {
|
|
390
|
+
frontmatterProblems = memoryFrontmatterProblems(parseMemory(next));
|
|
391
|
+
try {
|
|
392
|
+
indexed = (await this.#linkIndex(relativePath, next)).updated;
|
|
393
|
+
} catch (error) {
|
|
394
|
+
indexWarning = `created the file, but could not update MEMORY.md: ${error.message}`;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return { path: virtual(relativePath), ...written, indexed, indexWarning, frontmatterProblems };
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Replace exactly one occurrence of `oldStr`. Editing a topic file also
|
|
403
|
+
* refreshes its index line when that line still matches the old frontmatter.
|
|
404
|
+
*
|
|
405
|
+
* @param {string} relativePath - file path.
|
|
406
|
+
* @param {string} oldStr - non-empty text that must occur exactly once.
|
|
407
|
+
* @param {string} [newStr] - replacement text, possibly empty.
|
|
408
|
+
* @returns {Promise<object>} written file metadata plus optional index status.
|
|
409
|
+
*/
|
|
410
|
+
async strReplace(relativePath, oldStr, newStr = "") {
|
|
411
|
+
this.#assertFile(relativePath);
|
|
412
|
+
if (typeof oldStr !== "string" || oldStr.length === 0) {
|
|
413
|
+
throw new MemoryError("old_str must be a non-empty string", "MEMORY_INVALID_ARGUMENT");
|
|
414
|
+
}
|
|
415
|
+
if (typeof newStr !== "string") {
|
|
416
|
+
throw new MemoryError("new_str must be a string", "MEMORY_INVALID_ARGUMENT");
|
|
417
|
+
}
|
|
418
|
+
return this.#run(async () => {
|
|
419
|
+
const current = await this.#readText(relativePath);
|
|
420
|
+
const occurrences = current.content.split(oldStr).length - 1;
|
|
421
|
+
if (occurrences === 0) {
|
|
422
|
+
throw new MemoryError("old_str was not found; read the file again before editing", "MEMORY_NO_MATCH");
|
|
423
|
+
}
|
|
424
|
+
if (occurrences > 1) {
|
|
425
|
+
throw new MemoryError(
|
|
426
|
+
`old_str occurs ${occurrences} times; include more surrounding text so it is unique`,
|
|
427
|
+
"MEMORY_MULTIPLE_MATCHES"
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
const next = current.content.replace(oldStr, () => newStr);
|
|
431
|
+
this.#assertWithinFileBudget(relativePath, next);
|
|
432
|
+
const written = await this.#atomicWrite(relativePath, next);
|
|
433
|
+
return { path: virtual(relativePath), ...written, ...(await this.#syncIndex(relativePath, current.content, next)) };
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Insert text at a zero-based line index. Like `strReplace`, it refreshes a
|
|
439
|
+
* topic file's index line that still matches the old frontmatter.
|
|
440
|
+
*
|
|
441
|
+
* @param {string} relativePath - file path.
|
|
442
|
+
* @param {number} line - 0-based line index, from 0 through the line count.
|
|
443
|
+
* @param {string} text - text inserted as lines.
|
|
444
|
+
* @returns {Promise<object>} written file metadata plus optional index status.
|
|
445
|
+
*/
|
|
446
|
+
async insert(relativePath, line, text) {
|
|
447
|
+
this.#assertFile(relativePath);
|
|
448
|
+
if (!Number.isSafeInteger(line) || line < 0) {
|
|
449
|
+
throw new MemoryError("insert_line must be a non-negative integer", "MEMORY_INVALID_ARGUMENT");
|
|
450
|
+
}
|
|
451
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
452
|
+
throw new MemoryError("insert_text must be a non-empty string", "MEMORY_INVALID_ARGUMENT");
|
|
453
|
+
}
|
|
454
|
+
return this.#run(async () => {
|
|
455
|
+
const current = await this.#readText(relativePath);
|
|
456
|
+
const lines = current.content.split("\n");
|
|
457
|
+
const at = Math.min(line, lines.length);
|
|
458
|
+
lines.splice(at, 0, ...text.split("\n"));
|
|
459
|
+
const next = lines.join("\n");
|
|
460
|
+
this.#assertWithinFileBudget(relativePath, next);
|
|
461
|
+
const written = await this.#atomicWrite(relativePath, next);
|
|
462
|
+
return {
|
|
463
|
+
path: virtual(relativePath),
|
|
464
|
+
...written,
|
|
465
|
+
insertedAt: at,
|
|
466
|
+
...(await this.#syncIndex(relativePath, current.content, next))
|
|
467
|
+
};
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Delete one file. Deleting a topic file also removes index lines that link it.
|
|
473
|
+
*
|
|
474
|
+
* @param {string} relativePath - file path.
|
|
475
|
+
* @returns {Promise<object>} deletion metadata.
|
|
476
|
+
*/
|
|
477
|
+
async delete(relativePath) {
|
|
478
|
+
this.#assertFile(relativePath);
|
|
479
|
+
return this.#run(async () => {
|
|
480
|
+
const absolutePath = this.#absolute(relativePath);
|
|
481
|
+
await this.#assertSafeAncestors(relativePath);
|
|
482
|
+
const info = await this.#lstat(absolutePath);
|
|
483
|
+
if (info === undefined) {
|
|
484
|
+
throw new MemoryError(`${virtual(relativePath)} does not exist`, "MEMORY_NOT_FOUND");
|
|
485
|
+
}
|
|
486
|
+
if (info.isSymbolicLink()) {
|
|
487
|
+
throw new MemoryError(`${virtual(relativePath)} is a symbolic link`, "MEMORY_SYMLINK");
|
|
488
|
+
}
|
|
489
|
+
if (!info.isFile()) {
|
|
490
|
+
throw new MemoryError(`${virtual(relativePath)} is not a file`, "MEMORY_NOT_A_FILE");
|
|
491
|
+
}
|
|
492
|
+
await rm(absolutePath);
|
|
493
|
+
let indexWarning;
|
|
494
|
+
let indexed = false;
|
|
495
|
+
if (relativePath !== INDEX_FILE) {
|
|
496
|
+
try {
|
|
497
|
+
indexed = await this.#unlinkIndex(relativePath);
|
|
498
|
+
} catch (error) {
|
|
499
|
+
indexWarning = `deleted the file, but could not update MEMORY.md: ${error.message}`;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return { path: virtual(relativePath), changed: true, indexed, indexWarning };
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Rename one file. Links in the index follow the file.
|
|
508
|
+
*
|
|
509
|
+
* @param {string} oldPath - existing file path.
|
|
510
|
+
* @param {string} newPath - new file path.
|
|
511
|
+
* @returns {Promise<object>} rename metadata.
|
|
512
|
+
*/
|
|
513
|
+
async rename(oldPath, newPath) {
|
|
514
|
+
this.#assertFile(oldPath);
|
|
515
|
+
this.#assertFile(newPath);
|
|
516
|
+
if (oldPath === INDEX_FILE || newPath === INDEX_FILE) {
|
|
517
|
+
throw new MemoryError("MEMORY.md is the index and cannot be renamed; edit it in place", "MEMORY_INVALID_ARGUMENT");
|
|
518
|
+
}
|
|
519
|
+
return this.#run(async () => {
|
|
520
|
+
const absoluteOld = this.#absolute(oldPath);
|
|
521
|
+
const absoluteNew = this.#absolute(newPath);
|
|
522
|
+
await this.#assertSafeAncestors(oldPath);
|
|
523
|
+
await this.#assertSafeAncestors(newPath);
|
|
524
|
+
const oldInfo = await this.#lstat(absoluteOld);
|
|
525
|
+
if (oldInfo === undefined) {
|
|
526
|
+
throw new MemoryError(`${virtual(oldPath)} does not exist`, "MEMORY_NOT_FOUND");
|
|
527
|
+
}
|
|
528
|
+
if (oldInfo.isSymbolicLink()) {
|
|
529
|
+
throw new MemoryError(`${virtual(oldPath)} is a symbolic link`, "MEMORY_SYMLINK");
|
|
530
|
+
}
|
|
531
|
+
if (!oldInfo.isFile()) {
|
|
532
|
+
throw new MemoryError(`${virtual(oldPath)} is not a file`, "MEMORY_NOT_A_FILE");
|
|
533
|
+
}
|
|
534
|
+
if (await this.#lstat(absoluteNew)) {
|
|
535
|
+
throw new MemoryError(`${virtual(newPath)} already exists`, "MEMORY_ALREADY_EXISTS");
|
|
536
|
+
}
|
|
537
|
+
await mkdir(dirname(absoluteNew), { recursive: true });
|
|
538
|
+
await renameFile(absoluteOld, absoluteNew);
|
|
539
|
+
let indexWarning;
|
|
540
|
+
let indexed = false;
|
|
541
|
+
try {
|
|
542
|
+
indexed = await this.#relinkIndex(oldPath, newPath);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
indexWarning = `renamed the file, but could not update MEMORY.md: ${error.message}`;
|
|
545
|
+
}
|
|
546
|
+
return { path: virtual(newPath), previousPath: virtual(oldPath), changed: true, indexed, indexWarning };
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
#indexFields(relativePath, text) {
|
|
551
|
+
const parsed = parseMemory(text ?? "");
|
|
552
|
+
const title = parsed.name ?? basename(relativePath, extname(relativePath));
|
|
553
|
+
const firstLine = parsed.body
|
|
554
|
+
.split("\n")
|
|
555
|
+
.map((line) => line.trim())
|
|
556
|
+
.find((line) => line.length > 0 && !line.startsWith("<!--"));
|
|
557
|
+
const hook = (parsed.description ?? firstLine ?? "")
|
|
558
|
+
.replace(/^#+\s*/u, "")
|
|
559
|
+
.replace(/\s+/gu, " ")
|
|
560
|
+
.slice(0, 110);
|
|
561
|
+
return { title, hook };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
#indexEntry(relativePath, text) {
|
|
565
|
+
const { title, hook } = this.#indexFields(relativePath, text);
|
|
566
|
+
return `- [${title}](${relativePath})${hook.length > 0 ? ` — ${hook}` : ""}`;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Refresh the index line of an edited topic file. A title or hook is
|
|
571
|
+
* replaced only while it still equals what the old content would have
|
|
572
|
+
* generated, so a line the model rewrote by hand is left alone.
|
|
573
|
+
*/
|
|
574
|
+
async #syncIndex(relativePath, before, after) {
|
|
575
|
+
if (relativePath === INDEX_FILE) return {};
|
|
576
|
+
const previous = this.#indexFields(relativePath, before);
|
|
577
|
+
const current = this.#indexFields(relativePath, after);
|
|
578
|
+
if (previous.title === current.title && previous.hook === current.hook) return {};
|
|
579
|
+
try {
|
|
580
|
+
const index = await this.index();
|
|
581
|
+
if (!index.exists) return { indexed: false };
|
|
582
|
+
const link = `](${relativePath})`;
|
|
583
|
+
let changed = false;
|
|
584
|
+
const lines = index.content.split("\n").map((line) => {
|
|
585
|
+
const at = line.indexOf(link);
|
|
586
|
+
const open = line.lastIndexOf("[", at);
|
|
587
|
+
if (at === -1 || open === -1 || !/^\s*-\s*$/u.test(line.slice(0, open))) return line;
|
|
588
|
+
const title = line.slice(open + 1, at);
|
|
589
|
+
const rest = line.slice(at + link.length);
|
|
590
|
+
const hook = rest.startsWith(" — ") ? rest.slice(3) : rest.length === 0 ? "" : undefined;
|
|
591
|
+
if (hook === undefined) return line;
|
|
592
|
+
const nextTitle = title === previous.title ? current.title : title;
|
|
593
|
+
const nextHook = hook === previous.hook ? current.hook : hook;
|
|
594
|
+
if (nextTitle === title && nextHook === hook) return line;
|
|
595
|
+
changed = true;
|
|
596
|
+
return `${line.slice(0, open)}[${nextTitle}${link}${nextHook.length > 0 ? ` — ${nextHook}` : ""}`;
|
|
597
|
+
});
|
|
598
|
+
if (!changed) return { indexed: false };
|
|
599
|
+
const next = lines.join("\n");
|
|
600
|
+
if (byteLength(next) > this.maxFileBytes) {
|
|
601
|
+
throw new MemoryError("MEMORY.md is full; remove or merge entries before adding more", "MEMORY_INDEX_FULL");
|
|
602
|
+
}
|
|
603
|
+
await this.#atomicWrite(INDEX_FILE, next);
|
|
604
|
+
return { indexed: true };
|
|
605
|
+
} catch (error) {
|
|
606
|
+
return { indexed: false, indexWarning: `edited the file, but could not update MEMORY.md: ${error.message}` };
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async #linkIndex(relativePath, text) {
|
|
611
|
+
const index = await this.index();
|
|
612
|
+
const link = `](${relativePath})`;
|
|
613
|
+
if (index.exists && index.content.includes(link)) return { updated: false };
|
|
614
|
+
const entry = this.#indexEntry(relativePath, text);
|
|
615
|
+
const next = index.exists
|
|
616
|
+
? `${index.content.replace(/\n*$/u, "")}\n${entry}\n`
|
|
617
|
+
: `${INDEX_HEADER.replace(/\n*$/u, "")}\n\n${entry}\n`;
|
|
618
|
+
if (byteLength(next) > this.maxFileBytes) {
|
|
619
|
+
throw new MemoryError("MEMORY.md is full; remove or merge entries before adding more", "MEMORY_INDEX_FULL");
|
|
620
|
+
}
|
|
621
|
+
await this.#atomicWrite(INDEX_FILE, next);
|
|
622
|
+
return { updated: true };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async #unlinkIndex(relativePath) {
|
|
626
|
+
const index = await this.index();
|
|
627
|
+
if (!index.exists) return false;
|
|
628
|
+
const needle = `](${relativePath})`;
|
|
629
|
+
const lines = index.content.split("\n");
|
|
630
|
+
const kept = lines.filter((line) => !line.includes(needle));
|
|
631
|
+
if (kept.length === lines.length) return false;
|
|
632
|
+
const next = kept.join("\n");
|
|
633
|
+
await this.#atomicWrite(INDEX_FILE, next);
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async #relinkIndex(oldPath, newPath) {
|
|
638
|
+
const index = await this.index();
|
|
639
|
+
if (!index.exists) return false;
|
|
640
|
+
const needle = `](${oldPath})`;
|
|
641
|
+
if (!index.content.includes(needle)) return false;
|
|
642
|
+
const next = index.content.split(needle).join(`](${newPath})`);
|
|
643
|
+
await this.#atomicWrite(INDEX_FILE, next);
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
}
|