@anusornneal/chat-relay 0.7.2 → 0.8.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.
@@ -5,6 +5,19 @@ import { randomUUID } from "node:crypto";
5
5
  const MAX_READ_BYTES = 256 * 1024;
6
6
  const DEFAULT_READ_RESPONSE_BYTES = 32 * 1024;
7
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
+
8
21
  const MAX_READ_RESPONSE_BYTES = 48 * 1024;
9
22
  const MIN_READ_RESPONSE_BYTES = 1024;
10
23
  const MAX_WRITE_BYTES = 256 * 1024;
@@ -101,12 +114,23 @@ export class FileManager {
101
114
  await this.#walkList(target, 0, maxDepth, entries, start + pageLimit + 1);
102
115
 
103
116
  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");
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");
106
132
 
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 };
133
+ return payloadFor(selected);
110
134
  }
111
135
 
112
136
  async #walkList(current, level, maxDepth, output, stopAfter = 1001) {
@@ -244,6 +268,127 @@ export class FileManager {
244
268
  };
245
269
  }
246
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
+
247
392
  async write(input, content, mode = "rewrite") {
248
393
  if (typeof content !== "string" || Buffer.byteLength(content, "utf8") > MAX_WRITE_BYTES) {
249
394
  throw new Error("content_too_large");
@@ -0,0 +1,101 @@
1
+ const ALLOWED_WHILE_DRAINING = new Set([
2
+ "ping",
3
+ "agent.config",
4
+ "agent.recentCalls",
5
+ "agent.lifecycle.status",
6
+ "agent.lifecycle.drain",
7
+ "agent.lifecycle.resume",
8
+ "agent.lifecycle.restart",
9
+ "fs.stat",
10
+ "fs.list",
11
+ "fs.read",
12
+ "fs.readMany",
13
+ "fs.batch",
14
+ "fs.artifact",
15
+ "fs.search.start",
16
+ "fs.search.results",
17
+ "process.list",
18
+ "process.kill",
19
+ "desktop.screenshot",
20
+ "desktop.clipboard.read",
21
+ "desktop.window.list",
22
+ "terminal.batch.status",
23
+ "terminal.batch.read",
24
+ "terminal.batch.cancel",
25
+ "terminal.read",
26
+ "terminal.write",
27
+ "terminal.kill",
28
+ "terminal.list",
29
+ "terminal.observability",
30
+ ]);
31
+
32
+ export const AGENT_RESTART_EXIT_CODE = 4;
33
+
34
+ function normalizeWork(value = {}) {
35
+ const count = (key) => Math.max(0, Number(value[key]) || 0);
36
+ const work = {
37
+ activeSessions: count("activeSessions"),
38
+ activeBatchJobs: count("activeBatchJobs"),
39
+ queuedBatchJobs: count("queuedBatchJobs"),
40
+ activeTerminalExecs: count("activeTerminalExecs"),
41
+ queuedTerminalExecs: count("queuedTerminalExecs"),
42
+ };
43
+ work.total = Object.values(work).reduce((sum, item) => sum + item, 0);
44
+ return work;
45
+ }
46
+
47
+ export class AgentLifecycle {
48
+ constructor(options = {}) {
49
+ this.state = "running";
50
+ this.drainStartedAt = null;
51
+ this.restartRequestedAt = null;
52
+ this.now = options.now ?? (() => Date.now());
53
+ this.workSummary = options.workSummary ?? (() => ({}));
54
+ }
55
+
56
+ snapshot() {
57
+ const work = normalizeWork(this.workSummary());
58
+ return {
59
+ state: this.state,
60
+ drainStartedAt: this.drainStartedAt,
61
+ restartRequestedAt: this.restartRequestedAt,
62
+ readyToRestart: this.state === "draining" && work.total === 0,
63
+ work,
64
+ };
65
+ }
66
+
67
+ drain() {
68
+ if (this.state !== "restart-pending") {
69
+ this.state = "draining";
70
+ this.drainStartedAt ??= new Date(this.now()).toISOString();
71
+ }
72
+ return { ok: true, lifecycle: this.snapshot() };
73
+ }
74
+
75
+ resume() {
76
+ if (this.state === "restart-pending") {
77
+ return { ok: false, error: "restart_pending", lifecycle: this.snapshot() };
78
+ }
79
+ this.state = "running";
80
+ this.drainStartedAt = null;
81
+ return { ok: true, lifecycle: this.snapshot() };
82
+ }
83
+
84
+ guard(action) {
85
+ if (this.state === "running" || ALLOWED_WHILE_DRAINING.has(String(action))) return null;
86
+ return { ok: false, error: "agent_draining", lifecycle: this.snapshot() };
87
+ }
88
+
89
+ requestRestart() {
90
+ if (this.state !== "draining") {
91
+ return { ok: false, error: "drain_required", lifecycle: this.snapshot() };
92
+ }
93
+ const snapshot = this.snapshot();
94
+ if (snapshot.work.total > 0) {
95
+ return { ok: false, error: "active_work_remaining", lifecycle: snapshot };
96
+ }
97
+ this.state = "restart-pending";
98
+ this.restartRequestedAt = new Date(this.now()).toISOString();
99
+ return { ok: true, restart: true, lifecycle: this.snapshot() };
100
+ }
101
+ }