@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/README.md +28 -16
- package/lib/config.d.ts +47 -0
- package/lib/config.js +89 -97
- package/lib/guidance.d.ts +9 -0
- package/lib/guidance.js +5 -9
- package/lib/index.d.ts +13 -0
- package/lib/index.js +68 -89
- package/lib/inject.d.ts +36 -0
- package/lib/inject.js +80 -82
- package/lib/memory-frontmatter.d.ts +60 -0
- package/lib/memory-frontmatter.js +169 -102
- package/lib/paths.d.ts +38 -0
- package/lib/paths.js +117 -66
- package/lib/render.d.ts +24 -0
- package/lib/render.js +44 -61
- package/lib/rule-frontmatter.d.ts +15 -0
- package/lib/rule-frontmatter.js +141 -74
- package/lib/rule-glob.d.ts +21 -0
- package/lib/rule-glob.js +149 -124
- package/lib/rules-inject.d.ts +14 -0
- package/lib/rules-inject.js +119 -111
- package/lib/rules.d.ts +62 -0
- package/lib/rules.js +235 -227
- package/lib/store.d.ts +169 -0
- package/lib/store.js +766 -761
- package/lib/tool.d.ts +63 -0
- package/lib/tool.js +224 -224
- package/lib/types.d.ts +122 -0
- package/lib/types.js +1 -0
- package/package.json +17 -10
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
34
|
+
return Buffer.byteLength(value, "utf8");
|
|
48
35
|
}
|
|
49
|
-
|
|
50
36
|
function lineCount(value) {
|
|
51
|
-
|
|
37
|
+
return value.length === 0 ? 0 : value.split("\n").length;
|
|
52
38
|
}
|
|
53
|
-
|
|
54
39
|
function needsAngle(target) {
|
|
55
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
170
|
-
|
|
144
|
+
const destination = needsAngle(target) ? `<${target}>` : target;
|
|
145
|
+
return `${bullet}[${title}](${destination})${rest}${cr}`;
|
|
171
146
|
}
|
|
172
|
-
|
|
173
147
|
function stem(relativePath) {
|
|
174
|
-
|
|
148
|
+
return basename(relativePath, extname(relativePath));
|
|
175
149
|
}
|
|
176
|
-
|
|
177
150
|
function frontmatterIssues(relativePath, content) {
|
|
178
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
|
|
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
|
-
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
}
|