@anusornneal/chat-relay 0.6.0 → 0.7.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.
@@ -1,265 +1,369 @@
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 MAX_WRITE_BYTES = 256 * 1024;
7
- const MAX_SEARCH_FILES = 5000;
8
- const SKIP_DIRS = new Set([".git", "node_modules", ".gradle", ".idea", ".wrangler"]);
9
-
10
- function splitRoots(spec) {
11
- if (!spec) return [process.cwd()];
12
- return spec.split(";").map((value) => value.trim()).filter(Boolean).map((value) => path.resolve(value));
13
- }
14
-
15
- function within(root, target) {
16
- const relative = path.relative(root, target);
17
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
18
- }
19
-
20
- async function existingCanonical(target) {
21
- return fs.realpath(target);
22
- }
23
-
24
- export class FileManager {
25
- constructor(rootSpec) {
26
- this.roots = splitRoots(rootSpec);
27
- this.searches = new Map();
28
- }
29
-
30
- getRoots() {
31
- return [...this.roots];
32
- }
33
-
34
- async #resolveExisting(input) {
35
- const target = path.resolve(String(input));
36
- const canonical = await existingCanonical(target);
37
- for (const root of this.roots) {
38
- const canonicalRoot = await existingCanonical(root).catch(() => path.resolve(root));
39
- if (within(canonicalRoot, canonical)) return canonical;
40
- }
41
- throw new Error("path_not_allowed");
42
- }
43
-
44
- async #resolveTarget(input) {
45
- const target = path.resolve(String(input));
46
- const parent = await existingCanonical(path.dirname(target));
47
- for (const root of this.roots) {
48
- const canonicalRoot = await existingCanonical(root).catch(() => path.resolve(root));
49
- if (within(canonicalRoot, parent)) return target;
50
- }
51
- throw new Error("path_not_allowed");
52
- }
53
-
54
- async stat(input) {
55
- const target = await this.#resolveExisting(input);
56
- const info = await fs.stat(target);
57
- return {
58
- path: target,
59
- type: info.isDirectory() ? "directory" : info.isFile() ? "file" : "other",
60
- size: info.size,
61
- modifiedAt: info.mtime.toISOString(),
62
- createdAt: info.birthtime.toISOString(),
63
- };
64
- }
65
-
66
- async list(input, depth = 1) {
67
- const target = await this.#resolveExisting(input);
68
- const maxDepth = Math.min(Math.max(Number(depth) || 1, 0), 3);
69
- const entries = [];
70
- await this.#walkList(target, 0, maxDepth, entries);
71
- return { path: target, entries };
72
- }
73
-
74
- async #walkList(current, level, maxDepth, output) {
75
- const items = await fs.readdir(current, { withFileTypes: true });
76
- items.sort((a, b) => a.name.localeCompare(b.name));
77
- for (const item of items) {
78
- const fullPath = path.join(current, item.name);
79
- let size = null;
80
- if (item.isFile()) size = (await fs.stat(fullPath)).size;
81
- output.push({
82
- path: fullPath,
83
- name: item.name,
84
- type: item.isDirectory() ? "directory" : item.isFile() ? "file" : "other",
85
- size,
86
- });
87
- if (output.length >= 1000) return;
88
- if (item.isDirectory() && level < maxDepth && !SKIP_DIRS.has(item.name)) {
89
- await this.#walkList(fullPath, level + 1, maxDepth, output);
90
- if (output.length >= 1000) return;
91
- }
92
- }
93
- }
94
-
95
- async read(input, offset = 0, length = 200) {
96
- const target = await this.#resolveExisting(input);
97
- const info = await fs.stat(target);
98
- if (!info.isFile()) throw new Error("not_a_file");
99
- if (info.size > MAX_READ_BYTES * 8) throw new Error("file_too_large");
100
-
101
- const text = await fs.readFile(target, "utf8");
102
- const lines = text.split(/\r?\n/);
103
- const start = Math.max(Number(offset) || 0, 0);
104
- const count = Math.min(Math.max(Number(length) || 200, 1), 1000);
105
- return {
106
- path: target,
107
- offset: start,
108
- length: Math.min(count, Math.max(lines.length - start, 0)),
109
- totalLines: lines.length,
110
- content: lines.slice(start, start + count).join("\n"),
111
- };
112
- }
113
-
114
- async readMany(paths) {
115
- if (!Array.isArray(paths) || paths.length === 0 || paths.length > 20) {
116
- throw new Error("invalid_paths");
117
- }
118
- const files = [];
119
- for (const input of paths) {
120
- try {
121
- files.push({ ok: true, ...(await this.read(input, 0, 1000)) });
122
- } catch (error) {
123
- files.push({ ok: false, path: String(input), error: error instanceof Error ? error.message : "read_failed" });
124
- }
125
- }
126
- return { files };
127
- }
128
-
129
- async write(input, content, mode = "rewrite") {
130
- if (typeof content !== "string" || Buffer.byteLength(content, "utf8") > MAX_WRITE_BYTES) {
131
- throw new Error("content_too_large");
132
- }
133
- const target = await this.#resolveTarget(input);
134
- if (mode === "append") await fs.appendFile(target, content, "utf8");
135
- else if (mode === "rewrite") await fs.writeFile(target, content, "utf8");
136
- else throw new Error("invalid_write_mode");
137
- return this.stat(target);
138
- }
139
-
140
- async edit(input, oldText, newText, expectedReplacements = 1) {
141
- if (typeof oldText !== "string" || oldText.length === 0 || typeof newText !== "string") {
142
- throw new Error("invalid_edit");
143
- }
144
- const target = await this.#resolveExisting(input);
145
- const text = await fs.readFile(target, "utf8");
146
- const parts = text.split(oldText);
147
- const matches = parts.length - 1;
148
- const expected = Number(expectedReplacements) || 1;
149
- if (matches !== expected) {
150
- throw new Error(`replacement_count_mismatch:${matches}`);
151
- }
152
- const updated = parts.join(newText);
153
- if (Buffer.byteLength(updated, "utf8") > MAX_WRITE_BYTES * 8) throw new Error("file_too_large");
154
- await fs.writeFile(target, updated, "utf8");
155
- return { path: target, replacements: matches };
156
- }
157
-
158
- async mkdir(input) {
159
- const target = await this.#resolveTarget(input);
160
- await fs.mkdir(target, { recursive: true });
161
- return this.stat(target);
162
- }
163
-
164
- async move(source, destination) {
165
- const from = await this.#resolveExisting(source);
166
- const to = await this.#resolveTarget(destination);
167
- await fs.rename(from, to);
168
- return { from, to };
169
- }
170
-
171
- async remove(input, recursive = false) {
172
- const target = await this.#resolveExisting(input);
173
- if (this.roots.some((root) => path.resolve(root) === path.resolve(target))) {
174
- throw new Error("cannot_delete_root");
175
- }
176
- const info = await fs.stat(target);
177
- if (info.isDirectory()) await fs.rm(target, { recursive: Boolean(recursive), force: false });
178
- else await fs.unlink(target);
179
- return { ok: true, path: target };
180
- }
181
-
182
- async startSearch(input, pattern, searchType = "files", maxResults = 100) {
183
- const target = await this.#resolveExisting(input);
184
- if (typeof pattern !== "string" || pattern.length === 0 || pattern.length > 300) {
185
- throw new Error("invalid_pattern");
186
- }
187
- if (!["files", "content"].includes(searchType)) throw new Error("invalid_search_type");
188
-
189
- const sessionId = randomUUID();
190
- const session = {
191
- id: sessionId,
192
- status: "RUNNING",
193
- createdAt: Date.now(),
194
- results: [],
195
- error: null,
196
- };
197
- this.searches.set(sessionId, session);
198
-
199
- try {
200
- const limit = Math.min(Math.max(Number(maxResults) || 100, 1), 500);
201
- const needle = pattern.toLowerCase();
202
- let scanned = 0;
203
-
204
- const walk = async (current) => {
205
- if (session.results.length >= limit || scanned >= MAX_SEARCH_FILES) return;
206
- const items = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
207
- for (const item of items) {
208
- if (session.results.length >= limit || scanned >= MAX_SEARCH_FILES) break;
209
- if (item.isDirectory() && SKIP_DIRS.has(item.name)) continue;
210
- const fullPath = path.join(current, item.name);
211
- scanned++;
212
-
213
- if (searchType === "files" && item.name.toLowerCase().includes(needle)) {
214
- session.results.push({ path: fullPath, name: item.name, type: item.isDirectory() ? "directory" : "file" });
215
- } else if (searchType === "content" && item.isFile()) {
216
- const info = await fs.stat(fullPath).catch(() => null);
217
- if (info && info.size <= 1024 * 1024) {
218
- const text = await fs.readFile(fullPath, "utf8").catch(() => null);
219
- if (text != null) {
220
- const index = text.toLowerCase().indexOf(needle);
221
- if (index >= 0) {
222
- const before = text.slice(0, index);
223
- const line = before.split(/\r?\n/).length;
224
- session.results.push({ path: fullPath, line, preview: text.slice(index, index + 240) });
225
- }
226
- }
227
- }
228
- }
229
-
230
- if (item.isDirectory()) await walk(fullPath);
231
- }
232
- };
233
-
234
- await walk(target);
235
- session.status = "COMPLETED";
236
- session.scanned = scanned;
237
- } catch (error) {
238
- session.status = "ERROR";
239
- session.error = error instanceof Error ? error.message : "search_failed";
240
- }
241
-
242
- return {
243
- sessionId,
244
- status: session.status,
245
- totalResults: session.results.length,
246
- scanned: session.scanned ?? 0,
247
- error: session.error,
248
- };
249
- }
250
-
251
- getSearchResults(sessionId, offset = 0, length = 50) {
252
- const session = this.searches.get(String(sessionId));
253
- if (!session) throw new Error("search_not_found");
254
- const start = Math.max(Number(offset) || 0, 0);
255
- const count = Math.min(Math.max(Number(length) || 50, 1), 100);
256
- return {
257
- sessionId: session.id,
258
- status: session.status,
259
- totalResults: session.results.length,
260
- offset: start,
261
- results: session.results.slice(start, start + count),
262
- error: session.error,
263
- };
264
- }
265
- }
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 SKIP_DIRS = new Set([".git", "node_modules", ".gradle", ".idea", ".wrangler"]);
13
+
14
+ function splitRoots(spec) {
15
+ if (!spec) return [process.cwd()];
16
+ return spec.split(";").map((value) => value.trim()).filter(Boolean).map((value) => path.resolve(value));
17
+ }
18
+
19
+ function within(root, target) {
20
+ const relative = path.relative(root, target);
21
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
22
+ }
23
+
24
+ async function existingCanonical(target) {
25
+ return fs.realpath(target);
26
+ }
27
+
28
+ function byteSize(value) {
29
+ if (typeof value === "string") return Buffer.byteLength(value, "utf8");
30
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
31
+ }
32
+
33
+ function boundedReadBytes(value, fallback, minimum = MIN_READ_RESPONSE_BYTES) {
34
+ const numeric = Number(value);
35
+ if (!Number.isFinite(numeric)) return fallback;
36
+ return Math.min(Math.max(Math.floor(numeric), minimum), MAX_READ_RESPONSE_BYTES);
37
+ }
38
+
39
+ function normalizeReadRequest(input) {
40
+ return typeof input === "string" ? { path: input } : { ...(input || {}) };
41
+ }
42
+
43
+ export class FileManager {
44
+ constructor(rootSpec) {
45
+ this.roots = splitRoots(rootSpec);
46
+ this.searches = new Map();
47
+ }
48
+
49
+ getRoots() {
50
+ return [...this.roots];
51
+ }
52
+
53
+ async #resolveExisting(input) {
54
+ const target = path.resolve(String(input));
55
+ const canonical = await existingCanonical(target);
56
+ for (const root of this.roots) {
57
+ const canonicalRoot = await existingCanonical(root).catch(() => path.resolve(root));
58
+ if (within(canonicalRoot, canonical)) return canonical;
59
+ }
60
+ throw new Error("path_not_allowed");
61
+ }
62
+
63
+ async #resolveTarget(input) {
64
+ const target = path.resolve(String(input));
65
+ const parent = await existingCanonical(path.dirname(target));
66
+ for (const root of this.roots) {
67
+ const canonicalRoot = await existingCanonical(root).catch(() => path.resolve(root));
68
+ if (within(canonicalRoot, parent)) return target;
69
+ }
70
+ throw new Error("path_not_allowed");
71
+ }
72
+
73
+ async stat(input) {
74
+ const target = await this.#resolveExisting(input);
75
+ const info = await fs.stat(target);
76
+ return {
77
+ path: target,
78
+ type: info.isDirectory() ? "directory" : info.isFile() ? "file" : "other",
79
+ size: info.size,
80
+ modifiedAt: info.mtime.toISOString(),
81
+ createdAt: info.birthtime.toISOString(),
82
+ };
83
+ }
84
+
85
+ async list(input, depth = 1) {
86
+ const target = await this.#resolveExisting(input);
87
+ const maxDepth = Math.min(Math.max(Number(depth) || 1, 0), 3);
88
+ const entries = [];
89
+ await this.#walkList(target, 0, maxDepth, entries);
90
+ return { path: target, entries };
91
+ }
92
+
93
+ async #walkList(current, level, maxDepth, output) {
94
+ const items = await fs.readdir(current, { withFileTypes: true });
95
+ items.sort((a, b) => a.name.localeCompare(b.name));
96
+ for (const item of items) {
97
+ const fullPath = path.join(current, item.name);
98
+ let size = null;
99
+ if (item.isFile()) size = (await fs.stat(fullPath)).size;
100
+ output.push({
101
+ path: fullPath,
102
+ name: item.name,
103
+ type: item.isDirectory() ? "directory" : item.isFile() ? "file" : "other",
104
+ size,
105
+ });
106
+ if (output.length >= 1000) return;
107
+ if (item.isDirectory() && level < maxDepth && !SKIP_DIRS.has(item.name)) {
108
+ await this.#walkList(fullPath, level + 1, maxDepth, output);
109
+ if (output.length >= 1000) return;
110
+ }
111
+ }
112
+ }
113
+
114
+ async read(input, offset = 0, length = 200, maxBytes = DEFAULT_READ_RESPONSE_BYTES) {
115
+ const target = await this.#resolveExisting(input);
116
+ const info = await fs.stat(target);
117
+ if (!info.isFile()) throw new Error("not_a_file");
118
+ if (info.size > MAX_READ_BYTES * 8) throw new Error("file_too_large");
119
+
120
+ const text = await fs.readFile(target, "utf8");
121
+ const lines = text.split(/\r?\n/);
122
+ const start = Math.max(Number(offset) || 0, 0);
123
+ const count = Math.min(Math.max(Number(length) || 200, 1), 1000);
124
+ const contentBudget = boundedReadBytes(maxBytes, DEFAULT_READ_RESPONSE_BYTES);
125
+ const end = Math.min(start + count, lines.length);
126
+ const selected = [];
127
+ let contentBytes = 0;
128
+
129
+ for (let index = start; index < end; index++) {
130
+ const line = lines[index];
131
+ const nextBytes = byteSize(line) + (selected.length === 0 ? 0 : 1);
132
+ if (contentBytes + nextBytes > contentBudget) {
133
+ if (selected.length === 0) throw new Error("line_too_large");
134
+ break;
135
+ }
136
+ selected.push(line);
137
+ contentBytes += nextBytes;
138
+ }
139
+
140
+ const nextOffset = start + selected.length;
141
+ const truncated = nextOffset < lines.length;
142
+ return {
143
+ path: target,
144
+ offset: start,
145
+ length: selected.length,
146
+ totalLines: lines.length,
147
+ content: selected.join("\n"),
148
+ contentBytes,
149
+ truncated,
150
+ nextOffset: truncated ? nextOffset : null,
151
+ };
152
+ }
153
+
154
+ async readMany(paths, maxTotalBytes = DEFAULT_MULTI_READ_RESPONSE_BYTES) {
155
+ if (!Array.isArray(paths) || paths.length === 0 || paths.length > 20) {
156
+ throw new Error("invalid_paths");
157
+ }
158
+
159
+ const requests = paths.map(normalizeReadRequest);
160
+ if (requests.some((entry) => typeof entry.path !== "string" || entry.path.length === 0)) {
161
+ throw new Error("invalid_paths");
162
+ }
163
+
164
+ const totalBudget = boundedReadBytes(
165
+ maxTotalBytes,
166
+ DEFAULT_MULTI_READ_RESPONSE_BYTES,
167
+ 4 * 1024,
168
+ );
169
+ const files = [];
170
+ let nextIndex = null;
171
+
172
+ for (let index = 0; index < requests.length; index++) {
173
+ const request = requests[index];
174
+ const start = Math.max(Number(request.offset) || 0, 0);
175
+ const length = Math.min(Math.max(Number(request.length) || 1000, 1), 1000);
176
+ let fileBudget = boundedReadBytes(request.maxBytes, DEFAULT_READ_RESPONSE_BYTES);
177
+ let candidate;
178
+
179
+ try {
180
+ for (let attempt = 0; attempt < 8; attempt++) {
181
+ const result = await this.read(request.path, start, length, fileBudget);
182
+ candidate = { ok: true, ...result };
183
+ const projected = {
184
+ files: [...files, candidate],
185
+ totalFiles: requests.length,
186
+ nextIndex: index + 1 < requests.length ? index + 1 : null,
187
+ truncated: false,
188
+ maxTotalBytes: totalBudget,
189
+ };
190
+ if (byteSize(projected) <= totalBudget) break;
191
+ fileBudget = Math.floor(fileBudget * 0.65);
192
+ candidate = undefined;
193
+ if (fileBudget < MIN_READ_RESPONSE_BYTES) break;
194
+ }
195
+ } catch (error) {
196
+ candidate = {
197
+ ok: false,
198
+ path: String(request.path),
199
+ error: error instanceof Error ? error.message : "read_failed",
200
+ };
201
+ }
202
+
203
+ if (!candidate) {
204
+ nextIndex = index;
205
+ break;
206
+ }
207
+
208
+ const projected = {
209
+ files: [...files, candidate],
210
+ totalFiles: requests.length,
211
+ nextIndex: index + 1 < requests.length ? index + 1 : null,
212
+ truncated: false,
213
+ maxTotalBytes: totalBudget,
214
+ };
215
+ if (byteSize(projected) > totalBudget) {
216
+ nextIndex = index;
217
+ break;
218
+ }
219
+ files.push(candidate);
220
+ }
221
+
222
+ const fileTruncated = files.some((entry) => entry.ok && entry.truncated);
223
+ const truncated = nextIndex !== null || fileTruncated;
224
+ return {
225
+ files,
226
+ totalFiles: requests.length,
227
+ nextIndex,
228
+ truncated,
229
+ maxTotalBytes: totalBudget,
230
+ };
231
+ }
232
+
233
+ async write(input, content, mode = "rewrite") {
234
+ if (typeof content !== "string" || Buffer.byteLength(content, "utf8") > MAX_WRITE_BYTES) {
235
+ throw new Error("content_too_large");
236
+ }
237
+ const target = await this.#resolveTarget(input);
238
+ if (mode === "append") await fs.appendFile(target, content, "utf8");
239
+ else if (mode === "rewrite") await fs.writeFile(target, content, "utf8");
240
+ else throw new Error("invalid_write_mode");
241
+ return this.stat(target);
242
+ }
243
+
244
+ async edit(input, oldText, newText, expectedReplacements = 1) {
245
+ if (typeof oldText !== "string" || oldText.length === 0 || typeof newText !== "string") {
246
+ throw new Error("invalid_edit");
247
+ }
248
+ const target = await this.#resolveExisting(input);
249
+ const text = await fs.readFile(target, "utf8");
250
+ const parts = text.split(oldText);
251
+ const matches = parts.length - 1;
252
+ const expected = Number(expectedReplacements) || 1;
253
+ if (matches !== expected) {
254
+ throw new Error(`replacement_count_mismatch:${matches}`);
255
+ }
256
+ const updated = parts.join(newText);
257
+ if (Buffer.byteLength(updated, "utf8") > MAX_WRITE_BYTES * 8) throw new Error("file_too_large");
258
+ await fs.writeFile(target, updated, "utf8");
259
+ return { path: target, replacements: matches };
260
+ }
261
+
262
+ async mkdir(input) {
263
+ const target = await this.#resolveTarget(input);
264
+ await fs.mkdir(target, { recursive: true });
265
+ return this.stat(target);
266
+ }
267
+
268
+ async move(source, destination) {
269
+ const from = await this.#resolveExisting(source);
270
+ const to = await this.#resolveTarget(destination);
271
+ await fs.rename(from, to);
272
+ return { from, to };
273
+ }
274
+
275
+ async remove(input, recursive = false) {
276
+ const target = await this.#resolveExisting(input);
277
+ if (this.roots.some((root) => path.resolve(root) === path.resolve(target))) {
278
+ throw new Error("cannot_delete_root");
279
+ }
280
+ const info = await fs.stat(target);
281
+ if (info.isDirectory()) await fs.rm(target, { recursive: Boolean(recursive), force: false });
282
+ else await fs.unlink(target);
283
+ return { ok: true, path: target };
284
+ }
285
+
286
+ async startSearch(input, pattern, searchType = "files", maxResults = 100) {
287
+ const target = await this.#resolveExisting(input);
288
+ if (typeof pattern !== "string" || pattern.length === 0 || pattern.length > 300) {
289
+ throw new Error("invalid_pattern");
290
+ }
291
+ if (!["files", "content"].includes(searchType)) throw new Error("invalid_search_type");
292
+
293
+ const sessionId = randomUUID();
294
+ const session = {
295
+ id: sessionId,
296
+ status: "RUNNING",
297
+ createdAt: Date.now(),
298
+ results: [],
299
+ error: null,
300
+ };
301
+ this.searches.set(sessionId, session);
302
+
303
+ try {
304
+ const limit = Math.min(Math.max(Number(maxResults) || 100, 1), 500);
305
+ const needle = pattern.toLowerCase();
306
+ let scanned = 0;
307
+
308
+ const walk = async (current) => {
309
+ if (session.results.length >= limit || scanned >= MAX_SEARCH_FILES) return;
310
+ const items = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
311
+ for (const item of items) {
312
+ if (session.results.length >= limit || scanned >= MAX_SEARCH_FILES) break;
313
+ if (item.isDirectory() && SKIP_DIRS.has(item.name)) continue;
314
+ const fullPath = path.join(current, item.name);
315
+ scanned++;
316
+
317
+ if (searchType === "files" && item.name.toLowerCase().includes(needle)) {
318
+ session.results.push({ path: fullPath, name: item.name, type: item.isDirectory() ? "directory" : "file" });
319
+ } else if (searchType === "content" && item.isFile()) {
320
+ const info = await fs.stat(fullPath).catch(() => null);
321
+ if (info && info.size <= 1024 * 1024) {
322
+ const text = await fs.readFile(fullPath, "utf8").catch(() => null);
323
+ if (text != null) {
324
+ const index = text.toLowerCase().indexOf(needle);
325
+ if (index >= 0) {
326
+ const before = text.slice(0, index);
327
+ const line = before.split(/\r?\n/).length;
328
+ session.results.push({ path: fullPath, line, preview: text.slice(index, index + 240) });
329
+ }
330
+ }
331
+ }
332
+ }
333
+
334
+ if (item.isDirectory()) await walk(fullPath);
335
+ }
336
+ };
337
+
338
+ await walk(target);
339
+ session.status = "COMPLETED";
340
+ session.scanned = scanned;
341
+ } catch (error) {
342
+ session.status = "ERROR";
343
+ session.error = error instanceof Error ? error.message : "search_failed";
344
+ }
345
+
346
+ return {
347
+ sessionId,
348
+ status: session.status,
349
+ totalResults: session.results.length,
350
+ scanned: session.scanned ?? 0,
351
+ error: session.error,
352
+ };
353
+ }
354
+
355
+ getSearchResults(sessionId, offset = 0, length = 50) {
356
+ const session = this.searches.get(String(sessionId));
357
+ if (!session) throw new Error("search_not_found");
358
+ const start = Math.max(Number(offset) || 0, 0);
359
+ const count = Math.min(Math.max(Number(length) || 50, 1), 100);
360
+ return {
361
+ sessionId: session.id,
362
+ status: session.status,
363
+ totalResults: session.results.length,
364
+ offset: start,
365
+ results: session.results.slice(start, start + count),
366
+ error: session.error,
367
+ };
368
+ }
369
+ }