@anusornneal/chat-relay 0.7.2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,382 +1,527 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- import { randomUUID } from "node:crypto";
4
-
5
- const MAX_READ_BYTES = 256 * 1024;
6
- const DEFAULT_READ_RESPONSE_BYTES = 32 * 1024;
7
- const DEFAULT_MULTI_READ_RESPONSE_BYTES = 48 * 1024;
8
- const MAX_READ_RESPONSE_BYTES = 48 * 1024;
9
- const MIN_READ_RESPONSE_BYTES = 1024;
10
- const MAX_WRITE_BYTES = 256 * 1024;
11
- const MAX_SEARCH_FILES = 5000;
12
- const DEFAULT_LIST_LIMIT = 200;
13
- const MAX_LIST_LIMIT = 500;
14
- const DEFAULT_LIST_RESPONSE_BYTES = 48 * 1024;
15
- const SKIP_DIRS = new Set([".git", "node_modules", ".gradle", ".idea", ".wrangler"]);
16
- const ALLOWED_DOT_DIRS = new Set([".github"]);
17
-
18
- function skipWalkDirectory(name) {
19
- return SKIP_DIRS.has(name) || (name.startsWith(".") && !ALLOWED_DOT_DIRS.has(name));
20
- }
21
-
22
- function splitRoots(spec) {
23
- if (!spec) return [process.cwd()];
24
- return spec.split(";").map((value) => value.trim()).filter(Boolean).map((value) => path.resolve(value));
25
- }
26
-
27
- function within(root, target) {
28
- const relative = path.relative(root, target);
29
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
30
- }
31
-
32
- async function existingCanonical(target) {
33
- return fs.realpath(target);
34
- }
35
-
36
- function byteSize(value) {
37
- if (typeof value === "string") return Buffer.byteLength(value, "utf8");
38
- return Buffer.byteLength(JSON.stringify(value), "utf8");
39
- }
40
-
41
- function boundedReadBytes(value, fallback, minimum = MIN_READ_RESPONSE_BYTES) {
42
- const numeric = Number(value);
43
- if (!Number.isFinite(numeric)) return fallback;
44
- return Math.min(Math.max(Math.floor(numeric), minimum), MAX_READ_RESPONSE_BYTES);
45
- }
46
-
47
- function normalizeReadRequest(input) {
48
- return typeof input === "string" ? { path: input } : { ...(input || {}) };
49
- }
50
-
51
- export class FileManager {
52
- constructor(rootSpec) {
53
- this.roots = splitRoots(rootSpec);
54
- this.searches = new Map();
55
- }
56
-
57
- getRoots() {
58
- return [...this.roots];
59
- }
60
-
61
- async #resolveExisting(input) {
62
- const target = path.resolve(String(input));
63
- const canonical = await existingCanonical(target);
64
- for (const root of this.roots) {
65
- const canonicalRoot = await existingCanonical(root).catch(() => path.resolve(root));
66
- if (within(canonicalRoot, canonical)) return canonical;
67
- }
68
- throw new Error("path_not_allowed");
69
- }
70
-
71
- async #resolveTarget(input) {
72
- const target = path.resolve(String(input));
73
- const parent = await existingCanonical(path.dirname(target));
74
- for (const root of this.roots) {
75
- const canonicalRoot = await existingCanonical(root).catch(() => path.resolve(root));
76
- if (within(canonicalRoot, parent)) return target;
77
- }
78
- throw new Error("path_not_allowed");
79
- }
80
-
81
- async stat(input) {
82
- const target = await this.#resolveExisting(input);
83
- const info = await fs.stat(target);
84
- return {
85
- path: target,
86
- type: info.isDirectory() ? "directory" : info.isFile() ? "file" : "other",
87
- size: info.size,
88
- modifiedAt: info.mtime.toISOString(),
89
- createdAt: info.birthtime.toISOString(),
90
- };
91
- }
92
-
93
- async list(input, depth = 1, offset = 0, limit = DEFAULT_LIST_LIMIT, maxBytes = DEFAULT_LIST_RESPONSE_BYTES) {
94
- const target = await this.#resolveExisting(input);
95
- const numericDepth = Number(depth);
96
- const maxDepth = Number.isFinite(numericDepth) ? Math.min(Math.max(Math.trunc(numericDepth), 0), 3) : 1;
97
- const start = Math.max(Math.trunc(Number(offset) || 0), 0);
98
- const pageLimit = Math.min(Math.max(Math.trunc(Number(limit) || DEFAULT_LIST_LIMIT), 1), MAX_LIST_LIMIT);
99
- const responseBudget = boundedReadBytes(maxBytes, DEFAULT_LIST_RESPONSE_BYTES, 4 * 1024);
100
- const entries = [];
101
- await this.#walkList(target, 0, maxDepth, entries, start + pageLimit + 1);
102
-
103
- const selected = entries.slice(start, start + pageLimit);
104
- while (selected.length > 1 && byteSize({ path: target, entries: selected, offset: start }) > responseBudget) selected.pop();
105
- if (selected.length === 1 && byteSize({ path: target, entries: selected, offset: start }) > responseBudget) throw new Error("list_entry_too_large");
106
-
107
- const nextOffset = start + selected.length;
108
- const hasMore = nextOffset < entries.length;
109
- return { path: target, entries: selected, offset: start, limit: pageLimit, maxBytes: responseBudget, truncated: hasMore, nextOffset: hasMore ? nextOffset : null };
110
- }
111
-
112
- async #walkList(current, level, maxDepth, output, stopAfter = 1001) {
113
- const items = await fs.readdir(current, { withFileTypes: true });
114
- items.sort((a, b) => a.name.localeCompare(b.name));
115
- for (const item of items) {
116
- const fullPath = path.join(current, item.name);
117
- let size = null;
118
- if (item.isFile()) size = (await fs.stat(fullPath)).size;
119
- output.push({ path: fullPath, name: item.name, type: item.isDirectory() ? "directory" : item.isFile() ? "file" : "other", size });
120
- if (output.length >= stopAfter) return;
121
- if (item.isDirectory() && level < maxDepth && !skipWalkDirectory(item.name)) {
122
- await this.#walkList(fullPath, level + 1, maxDepth, output, stopAfter);
123
- if (output.length >= stopAfter) return;
124
- }
125
- }
126
- }
127
-
128
- async read(input, offset = 0, length = 200, maxBytes = DEFAULT_READ_RESPONSE_BYTES) {
129
- const target = await this.#resolveExisting(input);
130
- const info = await fs.stat(target);
131
- if (!info.isFile()) throw new Error("not_a_file");
132
- if (info.size > MAX_READ_BYTES * 8) throw new Error("file_too_large");
133
-
134
- const text = await fs.readFile(target, "utf8");
135
- const lines = text.split(/\r?\n/);
136
- const start = Math.max(Number(offset) || 0, 0);
137
- const count = Math.min(Math.max(Number(length) || 200, 1), 1000);
138
- const contentBudget = boundedReadBytes(maxBytes, DEFAULT_READ_RESPONSE_BYTES);
139
- const end = Math.min(start + count, lines.length);
140
- const selected = [];
141
- let contentBytes = 0;
142
-
143
- for (let index = start; index < end; index++) {
144
- const line = lines[index];
145
- const nextBytes = byteSize(line) + (selected.length === 0 ? 0 : 1);
146
- if (contentBytes + nextBytes > contentBudget) {
147
- if (selected.length === 0) throw new Error("line_too_large");
148
- break;
149
- }
150
- selected.push(line);
151
- contentBytes += nextBytes;
152
- }
153
-
154
- const nextOffset = start + selected.length;
155
- const truncated = nextOffset < lines.length;
156
- return {
157
- path: target,
158
- offset: start,
159
- length: selected.length,
160
- totalLines: lines.length,
161
- content: selected.join("\n"),
162
- contentBytes,
163
- truncated,
164
- nextOffset: truncated ? nextOffset : null,
165
- };
166
- }
167
-
168
- async readMany(paths, maxTotalBytes = DEFAULT_MULTI_READ_RESPONSE_BYTES) {
169
- if (!Array.isArray(paths) || paths.length === 0 || paths.length > 20) {
170
- throw new Error("invalid_paths");
171
- }
172
-
173
- const requests = paths.map(normalizeReadRequest);
174
- if (requests.some((entry) => typeof entry.path !== "string" || entry.path.length === 0)) {
175
- throw new Error("invalid_paths");
176
- }
177
-
178
- const totalBudget = boundedReadBytes(
179
- maxTotalBytes,
180
- DEFAULT_MULTI_READ_RESPONSE_BYTES,
181
- 4 * 1024,
182
- );
183
- const files = [];
184
- let nextIndex = null;
185
-
186
- for (let index = 0; index < requests.length; index++) {
187
- const request = requests[index];
188
- const start = Math.max(Number(request.offset) || 0, 0);
189
- const length = Math.min(Math.max(Number(request.length) || 1000, 1), 1000);
190
- let fileBudget = boundedReadBytes(request.maxBytes, DEFAULT_READ_RESPONSE_BYTES);
191
- let candidate;
192
-
193
- try {
194
- for (let attempt = 0; attempt < 8; attempt++) {
195
- const result = await this.read(request.path, start, length, fileBudget);
196
- candidate = { ok: true, ...result };
197
- const projected = {
198
- files: [...files, candidate],
199
- totalFiles: requests.length,
200
- nextIndex: index + 1 < requests.length ? index + 1 : null,
201
- truncated: false,
202
- maxTotalBytes: totalBudget,
203
- };
204
- if (byteSize(projected) <= totalBudget) break;
205
- fileBudget = Math.floor(fileBudget * 0.65);
206
- candidate = undefined;
207
- if (fileBudget < MIN_READ_RESPONSE_BYTES) break;
208
- }
209
- } catch (error) {
210
- candidate = {
211
- ok: false,
212
- path: String(request.path),
213
- error: error instanceof Error ? error.message : "read_failed",
214
- };
215
- }
216
-
217
- if (!candidate) {
218
- nextIndex = index;
219
- break;
220
- }
221
-
222
- const projected = {
223
- files: [...files, candidate],
224
- totalFiles: requests.length,
225
- nextIndex: index + 1 < requests.length ? index + 1 : null,
226
- truncated: false,
227
- maxTotalBytes: totalBudget,
228
- };
229
- if (byteSize(projected) > totalBudget) {
230
- nextIndex = index;
231
- break;
232
- }
233
- files.push(candidate);
234
- }
235
-
236
- const fileTruncated = files.some((entry) => entry.ok && entry.truncated);
237
- const truncated = nextIndex !== null || fileTruncated;
238
- return {
239
- files,
240
- totalFiles: requests.length,
241
- nextIndex,
242
- truncated,
243
- maxTotalBytes: totalBudget,
244
- };
245
- }
246
-
247
- async write(input, content, mode = "rewrite") {
248
- if (typeof content !== "string" || Buffer.byteLength(content, "utf8") > MAX_WRITE_BYTES) {
249
- throw new Error("content_too_large");
250
- }
251
- const target = await this.#resolveTarget(input);
252
- if (mode === "append") await fs.appendFile(target, content, "utf8");
253
- else if (mode === "rewrite") await fs.writeFile(target, content, "utf8");
254
- else throw new Error("invalid_write_mode");
255
- return this.stat(target);
256
- }
257
-
258
- async edit(input, oldText, newText, expectedReplacements = 1) {
259
- if (typeof oldText !== "string" || oldText.length === 0 || typeof newText !== "string") {
260
- throw new Error("invalid_edit");
261
- }
262
- const target = await this.#resolveExisting(input);
263
- const text = await fs.readFile(target, "utf8");
264
- const parts = text.split(oldText);
265
- const matches = parts.length - 1;
266
- const expected = Number(expectedReplacements) || 1;
267
- if (matches !== expected) {
268
- throw new Error(`replacement_count_mismatch:${matches}`);
269
- }
270
- const updated = parts.join(newText);
271
- if (Buffer.byteLength(updated, "utf8") > MAX_WRITE_BYTES * 8) throw new Error("file_too_large");
272
- await fs.writeFile(target, updated, "utf8");
273
- return { path: target, replacements: matches };
274
- }
275
-
276
- async mkdir(input) {
277
- const target = await this.#resolveTarget(input);
278
- await fs.mkdir(target, { recursive: true });
279
- return this.stat(target);
280
- }
281
-
282
- async move(source, destination) {
283
- const from = await this.#resolveExisting(source);
284
- const to = await this.#resolveTarget(destination);
285
- await fs.rename(from, to);
286
- return { from, to };
287
- }
288
-
289
- async remove(input, recursive = false) {
290
- const target = await this.#resolveExisting(input);
291
- if (this.roots.some((root) => path.resolve(root) === path.resolve(target))) {
292
- throw new Error("cannot_delete_root");
293
- }
294
- const info = await fs.stat(target);
295
- if (info.isDirectory()) await fs.rm(target, { recursive: Boolean(recursive), force: false });
296
- else await fs.unlink(target);
297
- return { ok: true, path: target };
298
- }
299
-
300
- async startSearch(input, pattern, searchType = "files", maxResults = 100) {
301
- const target = await this.#resolveExisting(input);
302
- if (typeof pattern !== "string" || pattern.length === 0 || pattern.length > 300) {
303
- throw new Error("invalid_pattern");
304
- }
305
- if (!["files", "content"].includes(searchType)) throw new Error("invalid_search_type");
306
-
307
- const sessionId = randomUUID();
308
- const session = {
309
- id: sessionId,
310
- status: "RUNNING",
311
- createdAt: Date.now(),
312
- results: [],
313
- error: null,
314
- };
315
- this.searches.set(sessionId, session);
316
-
317
- try {
318
- const limit = Math.min(Math.max(Number(maxResults) || 100, 1), 500);
319
- const needle = pattern.toLowerCase();
320
- let scanned = 0;
321
-
322
- const queue = [target];
323
- while (queue.length > 0 && session.results.length < limit && scanned < MAX_SEARCH_FILES) {
324
- const current = queue.shift();
325
- const items = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
326
- items.sort((a, b) => a.name.localeCompare(b.name));
327
- for (const item of items) {
328
- if (session.results.length >= limit || scanned >= MAX_SEARCH_FILES) break;
329
- if (item.isDirectory() && skipWalkDirectory(item.name)) continue;
330
- const fullPath = path.join(current, item.name);
331
- scanned++;
332
-
333
- if (searchType === "files" && item.name.toLowerCase().includes(needle)) {
334
- session.results.push({ path: fullPath, name: item.name, type: item.isDirectory() ? "directory" : "file" });
335
- } else if (searchType === "content" && item.isFile()) {
336
- const info = await fs.stat(fullPath).catch(() => null);
337
- if (info && info.size <= 1024 * 1024) {
338
- const text = await fs.readFile(fullPath, "utf8").catch(() => null);
339
- if (text != null) {
340
- const index = text.toLowerCase().indexOf(needle);
341
- if (index >= 0) {
342
- const before = text.slice(0, index);
343
- const line = before.split(/\r?\n/).length;
344
- session.results.push({ path: fullPath, line, preview: text.slice(index, index + 240) });
345
- }
346
- }
347
- }
348
- }
349
- if (item.isDirectory()) queue.push(fullPath);
350
- }
351
- }
352
- session.status = "COMPLETED";
353
- session.scanned = scanned;
354
- } catch (error) {
355
- session.status = "ERROR";
356
- session.error = error instanceof Error ? error.message : "search_failed";
357
- }
358
-
359
- return {
360
- sessionId,
361
- status: session.status,
362
- totalResults: session.results.length,
363
- scanned: session.scanned ?? 0,
364
- error: session.error,
365
- };
366
- }
367
-
368
- getSearchResults(sessionId, offset = 0, length = 50) {
369
- const session = this.searches.get(String(sessionId));
370
- if (!session) throw new Error("search_not_found");
371
- const start = Math.max(Number(offset) || 0, 0);
372
- const count = Math.min(Math.max(Number(length) || 50, 1), 100);
373
- return {
374
- sessionId: session.id,
375
- status: session.status,
376
- totalResults: session.results.length,
377
- offset: start,
378
- results: session.results.slice(start, start + count),
379
- error: session.error,
380
- };
381
- }
382
- }
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ const MAX_READ_BYTES = 256 * 1024;
6
+ const DEFAULT_READ_RESPONSE_BYTES = 32 * 1024;
7
+ const DEFAULT_MULTI_READ_RESPONSE_BYTES = 48 * 1024;
8
+ const DEFAULT_BATCH_RESPONSE_BYTES = 48 * 1024;
9
+ const MAX_BATCH_OPERATIONS = 20;
10
+ const MAX_ARTIFACT_BYTES = 40 * 1024;
11
+ const ARTIFACT_MIME_TYPES = new Map([
12
+ [".txt", "text/plain; charset=utf-8"],
13
+ [".log", "text/plain; charset=utf-8"],
14
+ [".md", "text/markdown; charset=utf-8"],
15
+ [".json", "application/json"],
16
+ [".png", "image/png"],
17
+ [".jpg", "image/jpeg"],
18
+ [".jpeg", "image/jpeg"],
19
+ ]);
20
+
21
+ const MAX_READ_RESPONSE_BYTES = 48 * 1024;
22
+ const MIN_READ_RESPONSE_BYTES = 1024;
23
+ const MAX_WRITE_BYTES = 256 * 1024;
24
+ const MAX_SEARCH_FILES = 5000;
25
+ const DEFAULT_LIST_LIMIT = 200;
26
+ const MAX_LIST_LIMIT = 500;
27
+ const DEFAULT_LIST_RESPONSE_BYTES = 48 * 1024;
28
+ const SKIP_DIRS = new Set([".git", "node_modules", ".gradle", ".idea", ".wrangler"]);
29
+ const ALLOWED_DOT_DIRS = new Set([".github"]);
30
+
31
+ function skipWalkDirectory(name) {
32
+ return SKIP_DIRS.has(name) || (name.startsWith(".") && !ALLOWED_DOT_DIRS.has(name));
33
+ }
34
+
35
+ function splitRoots(spec) {
36
+ if (!spec) return [process.cwd()];
37
+ return spec.split(";").map((value) => value.trim()).filter(Boolean).map((value) => path.resolve(value));
38
+ }
39
+
40
+ function within(root, target) {
41
+ const relative = path.relative(root, target);
42
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
43
+ }
44
+
45
+ async function existingCanonical(target) {
46
+ return fs.realpath(target);
47
+ }
48
+
49
+ function byteSize(value) {
50
+ if (typeof value === "string") return Buffer.byteLength(value, "utf8");
51
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
52
+ }
53
+
54
+ function boundedReadBytes(value, fallback, minimum = MIN_READ_RESPONSE_BYTES) {
55
+ const numeric = Number(value);
56
+ if (!Number.isFinite(numeric)) return fallback;
57
+ return Math.min(Math.max(Math.floor(numeric), minimum), MAX_READ_RESPONSE_BYTES);
58
+ }
59
+
60
+ function normalizeReadRequest(input) {
61
+ return typeof input === "string" ? { path: input } : { ...(input || {}) };
62
+ }
63
+
64
+ export class FileManager {
65
+ constructor(rootSpec) {
66
+ this.roots = splitRoots(rootSpec);
67
+ this.searches = new Map();
68
+ }
69
+
70
+ getRoots() {
71
+ return [...this.roots];
72
+ }
73
+
74
+ async #resolveExisting(input) {
75
+ const target = path.resolve(String(input));
76
+ const canonical = await existingCanonical(target);
77
+ for (const root of this.roots) {
78
+ const canonicalRoot = await existingCanonical(root).catch(() => path.resolve(root));
79
+ if (within(canonicalRoot, canonical)) return canonical;
80
+ }
81
+ throw new Error("path_not_allowed");
82
+ }
83
+
84
+ async #resolveTarget(input) {
85
+ const target = path.resolve(String(input));
86
+ const parent = await existingCanonical(path.dirname(target));
87
+ for (const root of this.roots) {
88
+ const canonicalRoot = await existingCanonical(root).catch(() => path.resolve(root));
89
+ if (within(canonicalRoot, parent)) return target;
90
+ }
91
+ throw new Error("path_not_allowed");
92
+ }
93
+
94
+ async stat(input) {
95
+ const target = await this.#resolveExisting(input);
96
+ const info = await fs.stat(target);
97
+ return {
98
+ path: target,
99
+ type: info.isDirectory() ? "directory" : info.isFile() ? "file" : "other",
100
+ size: info.size,
101
+ modifiedAt: info.mtime.toISOString(),
102
+ createdAt: info.birthtime.toISOString(),
103
+ };
104
+ }
105
+
106
+ async list(input, depth = 1, offset = 0, limit = DEFAULT_LIST_LIMIT, maxBytes = DEFAULT_LIST_RESPONSE_BYTES) {
107
+ const target = await this.#resolveExisting(input);
108
+ const numericDepth = Number(depth);
109
+ const maxDepth = Number.isFinite(numericDepth) ? Math.min(Math.max(Math.trunc(numericDepth), 0), 3) : 1;
110
+ const start = Math.max(Math.trunc(Number(offset) || 0), 0);
111
+ const pageLimit = Math.min(Math.max(Math.trunc(Number(limit) || DEFAULT_LIST_LIMIT), 1), MAX_LIST_LIMIT);
112
+ const responseBudget = boundedReadBytes(maxBytes, DEFAULT_LIST_RESPONSE_BYTES, 4 * 1024);
113
+ const entries = [];
114
+ await this.#walkList(target, 0, maxDepth, entries, start + pageLimit + 1);
115
+
116
+ const selected = entries.slice(start, start + pageLimit);
117
+ const payloadFor = (items) => {
118
+ const nextOffset = start + items.length;
119
+ const hasMore = nextOffset < entries.length;
120
+ return {
121
+ path: target,
122
+ entries: items,
123
+ offset: start,
124
+ limit: pageLimit,
125
+ maxBytes: responseBudget,
126
+ truncated: hasMore,
127
+ nextOffset: hasMore ? nextOffset : null,
128
+ };
129
+ };
130
+ while (selected.length > 1 && byteSize(payloadFor(selected)) > responseBudget) selected.pop();
131
+ if (selected.length === 1 && byteSize(payloadFor(selected)) > responseBudget) throw new Error("list_entry_too_large");
132
+
133
+ return payloadFor(selected);
134
+ }
135
+
136
+ async #walkList(current, level, maxDepth, output, stopAfter = 1001) {
137
+ const items = await fs.readdir(current, { withFileTypes: true });
138
+ items.sort((a, b) => a.name.localeCompare(b.name));
139
+ for (const item of items) {
140
+ const fullPath = path.join(current, item.name);
141
+ let size = null;
142
+ if (item.isFile()) size = (await fs.stat(fullPath)).size;
143
+ output.push({ path: fullPath, name: item.name, type: item.isDirectory() ? "directory" : item.isFile() ? "file" : "other", size });
144
+ if (output.length >= stopAfter) return;
145
+ if (item.isDirectory() && level < maxDepth && !skipWalkDirectory(item.name)) {
146
+ await this.#walkList(fullPath, level + 1, maxDepth, output, stopAfter);
147
+ if (output.length >= stopAfter) return;
148
+ }
149
+ }
150
+ }
151
+
152
+ async read(input, offset = 0, length = 200, maxBytes = DEFAULT_READ_RESPONSE_BYTES) {
153
+ const target = await this.#resolveExisting(input);
154
+ const info = await fs.stat(target);
155
+ if (!info.isFile()) throw new Error("not_a_file");
156
+ if (info.size > MAX_READ_BYTES * 8) throw new Error("file_too_large");
157
+
158
+ const text = await fs.readFile(target, "utf8");
159
+ const lines = text.split(/\r?\n/);
160
+ const start = Math.max(Number(offset) || 0, 0);
161
+ const count = Math.min(Math.max(Number(length) || 200, 1), 1000);
162
+ const contentBudget = boundedReadBytes(maxBytes, DEFAULT_READ_RESPONSE_BYTES);
163
+ const end = Math.min(start + count, lines.length);
164
+ const selected = [];
165
+ let contentBytes = 0;
166
+
167
+ for (let index = start; index < end; index++) {
168
+ const line = lines[index];
169
+ const nextBytes = byteSize(line) + (selected.length === 0 ? 0 : 1);
170
+ if (contentBytes + nextBytes > contentBudget) {
171
+ if (selected.length === 0) throw new Error("line_too_large");
172
+ break;
173
+ }
174
+ selected.push(line);
175
+ contentBytes += nextBytes;
176
+ }
177
+
178
+ const nextOffset = start + selected.length;
179
+ const truncated = nextOffset < lines.length;
180
+ return {
181
+ path: target,
182
+ offset: start,
183
+ length: selected.length,
184
+ totalLines: lines.length,
185
+ content: selected.join("\n"),
186
+ contentBytes,
187
+ truncated,
188
+ nextOffset: truncated ? nextOffset : null,
189
+ };
190
+ }
191
+
192
+ async readMany(paths, maxTotalBytes = DEFAULT_MULTI_READ_RESPONSE_BYTES) {
193
+ if (!Array.isArray(paths) || paths.length === 0 || paths.length > 20) {
194
+ throw new Error("invalid_paths");
195
+ }
196
+
197
+ const requests = paths.map(normalizeReadRequest);
198
+ if (requests.some((entry) => typeof entry.path !== "string" || entry.path.length === 0)) {
199
+ throw new Error("invalid_paths");
200
+ }
201
+
202
+ const totalBudget = boundedReadBytes(
203
+ maxTotalBytes,
204
+ DEFAULT_MULTI_READ_RESPONSE_BYTES,
205
+ 4 * 1024,
206
+ );
207
+ const files = [];
208
+ let nextIndex = null;
209
+
210
+ for (let index = 0; index < requests.length; index++) {
211
+ const request = requests[index];
212
+ const start = Math.max(Number(request.offset) || 0, 0);
213
+ const length = Math.min(Math.max(Number(request.length) || 1000, 1), 1000);
214
+ let fileBudget = boundedReadBytes(request.maxBytes, DEFAULT_READ_RESPONSE_BYTES);
215
+ let candidate;
216
+
217
+ try {
218
+ for (let attempt = 0; attempt < 8; attempt++) {
219
+ const result = await this.read(request.path, start, length, fileBudget);
220
+ candidate = { ok: true, ...result };
221
+ const projected = {
222
+ files: [...files, candidate],
223
+ totalFiles: requests.length,
224
+ nextIndex: index + 1 < requests.length ? index + 1 : null,
225
+ truncated: false,
226
+ maxTotalBytes: totalBudget,
227
+ };
228
+ if (byteSize(projected) <= totalBudget) break;
229
+ fileBudget = Math.floor(fileBudget * 0.65);
230
+ candidate = undefined;
231
+ if (fileBudget < MIN_READ_RESPONSE_BYTES) break;
232
+ }
233
+ } catch (error) {
234
+ candidate = {
235
+ ok: false,
236
+ path: String(request.path),
237
+ error: error instanceof Error ? error.message : "read_failed",
238
+ };
239
+ }
240
+
241
+ if (!candidate) {
242
+ nextIndex = index;
243
+ break;
244
+ }
245
+
246
+ const projected = {
247
+ files: [...files, candidate],
248
+ totalFiles: requests.length,
249
+ nextIndex: index + 1 < requests.length ? index + 1 : null,
250
+ truncated: false,
251
+ maxTotalBytes: totalBudget,
252
+ };
253
+ if (byteSize(projected) > totalBudget) {
254
+ nextIndex = index;
255
+ break;
256
+ }
257
+ files.push(candidate);
258
+ }
259
+
260
+ const fileTruncated = files.some((entry) => entry.ok && entry.truncated);
261
+ const truncated = nextIndex !== null || fileTruncated;
262
+ return {
263
+ files,
264
+ totalFiles: requests.length,
265
+ nextIndex,
266
+ truncated,
267
+ maxTotalBytes: totalBudget,
268
+ };
269
+ }
270
+
271
+
272
+ async batch(operations, maxTotalBytes = DEFAULT_BATCH_RESPONSE_BYTES) {
273
+ if (!Array.isArray(operations) || operations.length === 0 || operations.length > MAX_BATCH_OPERATIONS) {
274
+ throw new Error("invalid_batch");
275
+ }
276
+
277
+ const totalBudget = boundedReadBytes(maxTotalBytes, DEFAULT_BATCH_RESPONSE_BYTES, 4 * 1024);
278
+ const results = [];
279
+ let nextIndex = null;
280
+
281
+ for (let index = 0; index < operations.length; index++) {
282
+ const operation = operations[index] || {};
283
+ const op = String(operation.op || "");
284
+ const base = {
285
+ index,
286
+ ...(typeof operation.id === "string" && operation.id ? { id: operation.id.slice(0, 128) } : {}),
287
+ op,
288
+ ...(typeof operation.path === "string" ? { path: operation.path } : {}),
289
+ };
290
+ const minimumBudget = op === "list" ? 4 * 1024 : MIN_READ_RESPONSE_BYTES;
291
+ const fallbackBudget = op === "list" ? DEFAULT_LIST_RESPONSE_BYTES : DEFAULT_READ_RESPONSE_BYTES;
292
+ let operationBudget = boundedReadBytes(operation.maxBytes, fallbackBudget, minimumBudget);
293
+ let candidate;
294
+
295
+ try {
296
+ if (typeof operation.path !== "string" || operation.path.length === 0) {
297
+ throw new Error("invalid_path");
298
+ }
299
+
300
+ for (let attempt = 0; attempt < 8; attempt++) {
301
+ let result;
302
+ if (op === "stat") {
303
+ result = await this.stat(operation.path);
304
+ } else if (op === "read") {
305
+ result = await this.read(operation.path, operation.offset, operation.length, operationBudget);
306
+ } else if (op === "list") {
307
+ result = await this.list(operation.path, operation.depth, operation.offset, operation.limit, operationBudget);
308
+ } else {
309
+ throw new Error("invalid_batch_operation");
310
+ }
311
+
312
+ candidate = { ...base, ok: true, result };
313
+ const projected = {
314
+ results: [...results, candidate],
315
+ totalOperations: operations.length,
316
+ nextIndex: index + 1 < operations.length ? index + 1 : null,
317
+ truncated: false,
318
+ maxTotalBytes: totalBudget,
319
+ };
320
+ if (byteSize(projected) <= totalBudget) break;
321
+ if (op === "stat") {
322
+ candidate = undefined;
323
+ break;
324
+ }
325
+
326
+ operationBudget = Math.floor(operationBudget * 0.65);
327
+ candidate = undefined;
328
+ if (operationBudget < minimumBudget) break;
329
+ }
330
+ } catch (error) {
331
+ candidate = {
332
+ ...base,
333
+ ok: false,
334
+ error: error instanceof Error ? error.message : "batch_operation_failed",
335
+ };
336
+ }
337
+
338
+ if (!candidate) {
339
+ nextIndex = index;
340
+ break;
341
+ }
342
+
343
+ const projected = {
344
+ results: [...results, candidate],
345
+ totalOperations: operations.length,
346
+ nextIndex: index + 1 < operations.length ? index + 1 : null,
347
+ truncated: false,
348
+ maxTotalBytes: totalBudget,
349
+ };
350
+ if (byteSize(projected) > totalBudget) {
351
+ nextIndex = index;
352
+ break;
353
+ }
354
+ results.push(candidate);
355
+ }
356
+
357
+ const nestedTruncated = results.some((entry) => entry.ok && entry.result?.truncated === true);
358
+ return {
359
+ results,
360
+ totalOperations: operations.length,
361
+ nextIndex,
362
+ truncated: nextIndex !== null || nestedTruncated,
363
+ maxTotalBytes: totalBudget,
364
+ };
365
+ }
366
+
367
+
368
+ async exportArtifact(input, maxBytes = MAX_ARTIFACT_BYTES) {
369
+ const target = await this.#resolveExisting(input);
370
+ const info = await fs.stat(target);
371
+ if (!info.isFile()) throw new Error("not_a_file");
372
+
373
+ const requested = Number(maxBytes);
374
+ const byteLimit = Number.isFinite(requested)
375
+ ? Math.min(Math.max(Math.trunc(requested), 1024), MAX_ARTIFACT_BYTES)
376
+ : MAX_ARTIFACT_BYTES;
377
+ if (info.size <= 0) throw new Error("artifact_empty");
378
+ if (info.size > byteLimit) throw new Error("artifact_too_large");
379
+
380
+ const bytes = await fs.readFile(target);
381
+ const extension = path.extname(target).toLowerCase();
382
+ const mimeType = ARTIFACT_MIME_TYPES.get(extension) || "application/octet-stream";
383
+ return {
384
+ ok: true,
385
+ filename: path.basename(target).slice(0, 180),
386
+ mimeType,
387
+ byteLength: bytes.byteLength,
388
+ data: bytes.toString("base64"),
389
+ };
390
+ }
391
+
392
+ async write(input, content, mode = "rewrite") {
393
+ if (typeof content !== "string" || Buffer.byteLength(content, "utf8") > MAX_WRITE_BYTES) {
394
+ throw new Error("content_too_large");
395
+ }
396
+ const target = await this.#resolveTarget(input);
397
+ if (mode === "append") await fs.appendFile(target, content, "utf8");
398
+ else if (mode === "rewrite") await fs.writeFile(target, content, "utf8");
399
+ else throw new Error("invalid_write_mode");
400
+ return this.stat(target);
401
+ }
402
+
403
+ async edit(input, oldText, newText, expectedReplacements = 1) {
404
+ if (typeof oldText !== "string" || oldText.length === 0 || typeof newText !== "string") {
405
+ throw new Error("invalid_edit");
406
+ }
407
+ const target = await this.#resolveExisting(input);
408
+ const text = await fs.readFile(target, "utf8");
409
+ const parts = text.split(oldText);
410
+ const matches = parts.length - 1;
411
+ const expected = Number(expectedReplacements) || 1;
412
+ if (matches !== expected) {
413
+ throw new Error(`replacement_count_mismatch:${matches}`);
414
+ }
415
+ const updated = parts.join(newText);
416
+ if (Buffer.byteLength(updated, "utf8") > MAX_WRITE_BYTES * 8) throw new Error("file_too_large");
417
+ await fs.writeFile(target, updated, "utf8");
418
+ return { path: target, replacements: matches };
419
+ }
420
+
421
+ async mkdir(input) {
422
+ const target = await this.#resolveTarget(input);
423
+ await fs.mkdir(target, { recursive: true });
424
+ return this.stat(target);
425
+ }
426
+
427
+ async move(source, destination) {
428
+ const from = await this.#resolveExisting(source);
429
+ const to = await this.#resolveTarget(destination);
430
+ await fs.rename(from, to);
431
+ return { from, to };
432
+ }
433
+
434
+ async remove(input, recursive = false) {
435
+ const target = await this.#resolveExisting(input);
436
+ if (this.roots.some((root) => path.resolve(root) === path.resolve(target))) {
437
+ throw new Error("cannot_delete_root");
438
+ }
439
+ const info = await fs.stat(target);
440
+ if (info.isDirectory()) await fs.rm(target, { recursive: Boolean(recursive), force: false });
441
+ else await fs.unlink(target);
442
+ return { ok: true, path: target };
443
+ }
444
+
445
+ async startSearch(input, pattern, searchType = "files", maxResults = 100) {
446
+ const target = await this.#resolveExisting(input);
447
+ if (typeof pattern !== "string" || pattern.length === 0 || pattern.length > 300) {
448
+ throw new Error("invalid_pattern");
449
+ }
450
+ if (!["files", "content"].includes(searchType)) throw new Error("invalid_search_type");
451
+
452
+ const sessionId = randomUUID();
453
+ const session = {
454
+ id: sessionId,
455
+ status: "RUNNING",
456
+ createdAt: Date.now(),
457
+ results: [],
458
+ error: null,
459
+ };
460
+ this.searches.set(sessionId, session);
461
+
462
+ try {
463
+ const limit = Math.min(Math.max(Number(maxResults) || 100, 1), 500);
464
+ const needle = pattern.toLowerCase();
465
+ let scanned = 0;
466
+
467
+ const queue = [target];
468
+ while (queue.length > 0 && session.results.length < limit && scanned < MAX_SEARCH_FILES) {
469
+ const current = queue.shift();
470
+ const items = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
471
+ items.sort((a, b) => a.name.localeCompare(b.name));
472
+ for (const item of items) {
473
+ if (session.results.length >= limit || scanned >= MAX_SEARCH_FILES) break;
474
+ if (item.isDirectory() && skipWalkDirectory(item.name)) continue;
475
+ const fullPath = path.join(current, item.name);
476
+ scanned++;
477
+
478
+ if (searchType === "files" && item.name.toLowerCase().includes(needle)) {
479
+ session.results.push({ path: fullPath, name: item.name, type: item.isDirectory() ? "directory" : "file" });
480
+ } else if (searchType === "content" && item.isFile()) {
481
+ const info = await fs.stat(fullPath).catch(() => null);
482
+ if (info && info.size <= 1024 * 1024) {
483
+ const text = await fs.readFile(fullPath, "utf8").catch(() => null);
484
+ if (text != null) {
485
+ const index = text.toLowerCase().indexOf(needle);
486
+ if (index >= 0) {
487
+ const before = text.slice(0, index);
488
+ const line = before.split(/\r?\n/).length;
489
+ session.results.push({ path: fullPath, line, preview: text.slice(index, index + 240) });
490
+ }
491
+ }
492
+ }
493
+ }
494
+ if (item.isDirectory()) queue.push(fullPath);
495
+ }
496
+ }
497
+ session.status = "COMPLETED";
498
+ session.scanned = scanned;
499
+ } catch (error) {
500
+ session.status = "ERROR";
501
+ session.error = error instanceof Error ? error.message : "search_failed";
502
+ }
503
+
504
+ return {
505
+ sessionId,
506
+ status: session.status,
507
+ totalResults: session.results.length,
508
+ scanned: session.scanned ?? 0,
509
+ error: session.error,
510
+ };
511
+ }
512
+
513
+ getSearchResults(sessionId, offset = 0, length = 50) {
514
+ const session = this.searches.get(String(sessionId));
515
+ if (!session) throw new Error("search_not_found");
516
+ const start = Math.max(Number(offset) || 0, 0);
517
+ const count = Math.min(Math.max(Number(length) || 50, 1), 100);
518
+ return {
519
+ sessionId: session.id,
520
+ status: session.status,
521
+ totalResults: session.results.length,
522
+ offset: start,
523
+ results: session.results.slice(start, start + count),
524
+ error: session.error,
525
+ };
526
+ }
527
+ }