@frontendxlab/opencode-computer-use 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +25 -0
- package/README.md +285 -0
- package/SECURITY.md +36 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/bin/opencode-computer-use-repl.js +12 -0
- package/docs/_headers +12 -0
- package/docs/favicon.svg +4 -0
- package/docs/index.html +733 -0
- package/docs/llms-full.txt +146 -0
- package/docs/llms.txt +30 -0
- package/docs/og-card.png +0 -0
- package/docs/robots.txt +7 -0
- package/docs/site.webmanifest +10 -0
- package/docs/sitemap.xml +9 -0
- package/docs/webmcp.js +135 -0
- package/index.js +78 -0
- package/lib/backend.js +190 -0
- package/lib/json-line-peer.mjs +182 -0
- package/lib/options.js +111 -0
- package/lib/output.mjs +212 -0
- package/lib/repl-kernel.mjs +41 -0
- package/lib/repl.mjs +615 -0
- package/lib/sandbox.mjs +291 -0
- package/package.json +57 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
6
|
+
const MAX_FRAME_BYTES = 32 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
function safeNativeErrorMessage(error) {
|
|
9
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
10
|
+
return message
|
|
11
|
+
.replace(/\b(token|secret|password|api[_-]?key|authorization)\s*[:=]\s*(?:bearer|basic)?[^\n\r,;]*/giu, "$1=[redacted]")
|
|
12
|
+
.slice(0, 4_000);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class JsonLinePeer extends EventEmitter {
|
|
16
|
+
constructor({ command, args = [], cwd, env = process.env, child } = {}) {
|
|
17
|
+
super();
|
|
18
|
+
this.child = child ?? spawn(command, args, {
|
|
19
|
+
cwd,
|
|
20
|
+
env,
|
|
21
|
+
shell: false,
|
|
22
|
+
detached: process.platform !== "win32",
|
|
23
|
+
windowsHide: true,
|
|
24
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
25
|
+
});
|
|
26
|
+
this.pid = this.child.pid;
|
|
27
|
+
this.nextId = 1;
|
|
28
|
+
this.pending = new Map();
|
|
29
|
+
this.buffer = "";
|
|
30
|
+
this.stderr = "";
|
|
31
|
+
this.closed = false;
|
|
32
|
+
this.exited = false;
|
|
33
|
+
this.terminationStarted = false;
|
|
34
|
+
this.exitPromise = new Promise((resolve) => { this.resolveExit = resolve; });
|
|
35
|
+
this.child.stdout.setEncoding("utf8");
|
|
36
|
+
this.child.stderr.setEncoding("utf8");
|
|
37
|
+
this.child.stdout.on("data", (chunk) => this.#onData(chunk));
|
|
38
|
+
this.child.stderr.on("data", (chunk) => { this.stderr = (this.stderr + chunk).slice(-8192); });
|
|
39
|
+
this.child.once("error", (error) => this.#close(error, true));
|
|
40
|
+
this.child.once("exit", (code, signal) => {
|
|
41
|
+
this.exited = true;
|
|
42
|
+
this.resolveExit();
|
|
43
|
+
const detail = this.stderr ? `: ${safeNativeErrorMessage(new Error(this.stderr.trim()))}` : "";
|
|
44
|
+
this.#close(new Error(`native MCP exited (code=${code ?? "null"}, signal=${signal ?? "null"})${detail}`));
|
|
45
|
+
});
|
|
46
|
+
this.child.once("close", () => {
|
|
47
|
+
if (!this.exited) {
|
|
48
|
+
this.exited = true;
|
|
49
|
+
this.resolveExit();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#onData(chunk) {
|
|
55
|
+
this.buffer += chunk;
|
|
56
|
+
if (Buffer.byteLength(this.buffer, "utf8") > MAX_FRAME_BYTES) {
|
|
57
|
+
this.#close(new Error("native MCP response buffer exceeded limit"), true);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (;;) {
|
|
61
|
+
const newline = this.buffer.indexOf("\n");
|
|
62
|
+
if (newline < 0) break;
|
|
63
|
+
const line = this.buffer.slice(0, newline).trim();
|
|
64
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
65
|
+
if (!line) continue;
|
|
66
|
+
let message;
|
|
67
|
+
try {
|
|
68
|
+
message = JSON.parse(line);
|
|
69
|
+
} catch {
|
|
70
|
+
this.#close(new Error(`invalid native MCP JSON: ${line.slice(0, 200)}`), true);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
|
74
|
+
this.#close(new Error("invalid native MCP message"), true);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (message.id !== undefined && this.pending.has(String(message.id))) {
|
|
78
|
+
const pending = this.pending.get(String(message.id));
|
|
79
|
+
this.pending.delete(String(message.id));
|
|
80
|
+
clearTimeout(pending.timer);
|
|
81
|
+
if (message.error) {
|
|
82
|
+
const messageText = typeof message.error?.message === "string" ? message.error.message : JSON.stringify(message.error);
|
|
83
|
+
pending.reject(new Error(safeNativeErrorMessage(new Error(messageText))));
|
|
84
|
+
} else {
|
|
85
|
+
pending.resolve(message.result);
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
this.emit("message", message);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#close(error, terminate = false) {
|
|
94
|
+
if (!this.closed) {
|
|
95
|
+
this.closed = true;
|
|
96
|
+
for (const pending of this.pending.values()) {
|
|
97
|
+
clearTimeout(pending.timer);
|
|
98
|
+
pending.reject(error);
|
|
99
|
+
}
|
|
100
|
+
this.pending.clear();
|
|
101
|
+
this.emit("close", error);
|
|
102
|
+
}
|
|
103
|
+
if (terminate) void this.#terminateChild();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#killChild(force) {
|
|
107
|
+
const signal = force ? "SIGKILL" : "SIGTERM";
|
|
108
|
+
if (process.platform === "win32") {
|
|
109
|
+
if (force && this.pid) {
|
|
110
|
+
try {
|
|
111
|
+
const killer = spawn("taskkill", ["/PID", String(this.pid), "/T", "/F"], {
|
|
112
|
+
shell: false,
|
|
113
|
+
windowsHide: true,
|
|
114
|
+
stdio: "ignore",
|
|
115
|
+
});
|
|
116
|
+
killer.unref();
|
|
117
|
+
} catch {
|
|
118
|
+
// Fall through to the direct child termination below.
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
try { this.child.kill("SIGTERM"); } catch {}
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (this.pid) {
|
|
126
|
+
try {
|
|
127
|
+
process.kill(-this.pid, signal);
|
|
128
|
+
return;
|
|
129
|
+
} catch {
|
|
130
|
+
// Fall through to the direct child termination below.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!this.exited) {
|
|
134
|
+
try { this.child.kill(signal); } catch {}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#terminateChild() {
|
|
139
|
+
if (this.terminationStarted) return this.exitPromise;
|
|
140
|
+
this.terminationStarted = true;
|
|
141
|
+
try { this.child.stdin.end(); } catch {}
|
|
142
|
+
this.#killChild(false);
|
|
143
|
+
const timer = setTimeout(() => this.#killChild(true), 1_000);
|
|
144
|
+
timer.unref();
|
|
145
|
+
return this.exitPromise.finally(() => clearTimeout(timer));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
request(method, params = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
149
|
+
if (this.closed) return Promise.reject(new Error("native MCP is closed"));
|
|
150
|
+
const id = this.nextId++;
|
|
151
|
+
return new Promise((resolve, reject) => {
|
|
152
|
+
const timer = timeoutMs > 0
|
|
153
|
+
? setTimeout(() => { this.pending.delete(String(id)); reject(new Error(`native MCP ${method} timed out after ${timeoutMs} ms`)); }, timeoutMs)
|
|
154
|
+
: undefined;
|
|
155
|
+
this.pending.set(String(id), { resolve, reject, timer });
|
|
156
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`, (error) => {
|
|
157
|
+
if (!error) return;
|
|
158
|
+
if (timer) clearTimeout(timer);
|
|
159
|
+
this.pending.delete(String(id));
|
|
160
|
+
reject(error);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
notify(method, params = {}) {
|
|
166
|
+
if (!this.closed) this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async initialize() {
|
|
170
|
+
const result = await this.request("initialize", {
|
|
171
|
+
protocolVersion: "2025-06-18",
|
|
172
|
+
capabilities: {},
|
|
173
|
+
clientInfo: { name: "open-computer-use-node-repl", version: "1" },
|
|
174
|
+
});
|
|
175
|
+
this.notify("notifications/initialized", {});
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async close() {
|
|
180
|
+
await this.#terminateChild();
|
|
181
|
+
}
|
|
182
|
+
}
|
package/lib/options.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const BACKENDS = new Set(["auto", "native", "custom"]);
|
|
2
|
+
const DEFAULT_SERVER_NAME = "cua_repl";
|
|
3
|
+
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function stringOption(value, fallback) {
|
|
9
|
+
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function booleanOption(value, fallback) {
|
|
13
|
+
return typeof value === "boolean" ? value : fallback;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function boundedNumber(value, fallback, minimum, maximum) {
|
|
17
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
18
|
+
return Math.min(maximum, Math.max(minimum, Math.round(value)));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function commandOption(value) {
|
|
22
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
23
|
+
if (value.some((item) => typeof item !== "string" || !item)) return undefined;
|
|
24
|
+
return [...value];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function serverNameOption(value) {
|
|
28
|
+
const name = stringOption(value, DEFAULT_SERVER_NAME);
|
|
29
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
|
|
30
|
+
throw new Error("serverName must contain only letters, numbers, underscores, or hyphens");
|
|
31
|
+
}
|
|
32
|
+
return name;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function normalizeOptions(input = {}) {
|
|
36
|
+
const raw = isRecord(input) ? input : {};
|
|
37
|
+
const backend = stringOption(raw.backend, "auto");
|
|
38
|
+
if (!BACKENDS.has(backend)) {
|
|
39
|
+
throw new Error(`backend must be one of: ${[...BACKENDS].join(", ")}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const command = commandOption(raw.command);
|
|
43
|
+
if (backend === "custom" && !command) {
|
|
44
|
+
throw new Error("backend 'custom' requires a non-empty command array");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
backend,
|
|
49
|
+
command,
|
|
50
|
+
disabled: booleanOption(raw.disabled, false),
|
|
51
|
+
allowGlobalPointerFallbacks: booleanOption(raw.allowGlobalPointerFallbacks, false),
|
|
52
|
+
override: booleanOption(raw.override, false),
|
|
53
|
+
serverName: serverNameOption(raw.serverName),
|
|
54
|
+
timeoutMs: boundedNumber(raw.timeoutMs, 30_000, 1_000, 300_000),
|
|
55
|
+
maxTextChars: boundedNumber(raw.maxTextChars, 200_000, 1_000, 2_000_000),
|
|
56
|
+
maxImageBytes: boundedNumber(raw.maxImageBytes, 2 * 1024 * 1024, 16_384, 16 * 1024 * 1024),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function optionsToEnvironment(options) {
|
|
61
|
+
const normalized = normalizeOptions(options);
|
|
62
|
+
const environment = {
|
|
63
|
+
OPENCODE_COMPUTER_USE_BACKEND: normalized.backend,
|
|
64
|
+
OPENCODE_COMPUTER_USE_SERVER_NAME: normalized.serverName,
|
|
65
|
+
OPENCODE_COMPUTER_USE_TIMEOUT_MS: String(normalized.timeoutMs),
|
|
66
|
+
OPENCODE_COMPUTER_USE_MAX_TEXT_CHARS: String(normalized.maxTextChars),
|
|
67
|
+
OPENCODE_COMPUTER_USE_MAX_IMAGE_BYTES: String(normalized.maxImageBytes),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (normalized.command) {
|
|
71
|
+
environment.OPENCODE_COMPUTER_USE_COMMAND = JSON.stringify(normalized.command);
|
|
72
|
+
}
|
|
73
|
+
if (normalized.allowGlobalPointerFallbacks) {
|
|
74
|
+
environment.OPEN_COMPUTER_USE_ALLOW_GLOBAL_POINTER_FALLBACKS = "1";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return environment;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function optionsFromEnvironment(environment = process.env) {
|
|
81
|
+
const commandText = environment.OPENCODE_COMPUTER_USE_COMMAND;
|
|
82
|
+
let command;
|
|
83
|
+
if (commandText) {
|
|
84
|
+
try {
|
|
85
|
+
command = commandOption(JSON.parse(commandText));
|
|
86
|
+
} catch {
|
|
87
|
+
command = undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return normalizeOptions({
|
|
92
|
+
backend: environment.OPENCODE_COMPUTER_USE_BACKEND,
|
|
93
|
+
command,
|
|
94
|
+
allowGlobalPointerFallbacks: environment.OPEN_COMPUTER_USE_ALLOW_GLOBAL_POINTER_FALLBACKS === "1",
|
|
95
|
+
serverName: environment.OPENCODE_COMPUTER_USE_SERVER_NAME,
|
|
96
|
+
timeoutMs: Number(environment.OPENCODE_COMPUTER_USE_TIMEOUT_MS),
|
|
97
|
+
maxTextChars: Number(environment.OPENCODE_COMPUTER_USE_MAX_TEXT_CHARS),
|
|
98
|
+
maxImageBytes: Number(environment.OPENCODE_COMPUTER_USE_MAX_IMAGE_BYTES),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const defaults = Object.freeze({
|
|
103
|
+
backend: "auto",
|
|
104
|
+
disabled: false,
|
|
105
|
+
allowGlobalPointerFallbacks: false,
|
|
106
|
+
override: false,
|
|
107
|
+
serverName: DEFAULT_SERVER_NAME,
|
|
108
|
+
timeoutMs: 30_000,
|
|
109
|
+
maxTextChars: 200_000,
|
|
110
|
+
maxImageBytes: 2 * 1024 * 1024,
|
|
111
|
+
});
|
package/lib/output.mjs
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { inspect } from "node:util";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_MAX_TEXT_CHARS = 200_000;
|
|
4
|
+
export const DEFAULT_MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
|
5
|
+
const MAX_CONTENT_ITEMS = 128;
|
|
6
|
+
const MAX_META_ID_CHARS = 128;
|
|
7
|
+
const MAX_IMAGE_INPUT_BYTES = 16 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
function isPlainObject(value) {
|
|
10
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatValue(value) {
|
|
14
|
+
if (typeof value === "string") return value;
|
|
15
|
+
return inspect(value, { depth: 6, colors: false, breakLength: 100 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sniffMimeType(bytes) {
|
|
19
|
+
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
|
|
20
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
|
21
|
+
if (bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") return "image/webp";
|
|
22
|
+
throw new Error("nodeRepl.emitImage could not infer PNG, JPEG, or WebP MIME type");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
|
|
26
|
+
|
|
27
|
+
function decodeImageBase64(value) {
|
|
28
|
+
if (typeof value !== "string" || value.length === 0 || value.length > Math.ceil(MAX_IMAGE_INPUT_BYTES * 4 / 3) + 4 || value.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(value)) {
|
|
29
|
+
throw new Error("nodeRepl.emitImage expected valid base64 image data");
|
|
30
|
+
}
|
|
31
|
+
const bytes = Buffer.from(value, "base64");
|
|
32
|
+
const normalizedInput = value.replace(/=+$/u, "");
|
|
33
|
+
const normalizedOutput = bytes.toString("base64").replace(/=+$/u, "");
|
|
34
|
+
if (normalizedInput !== normalizedOutput) throw new Error("nodeRepl.emitImage expected canonical base64 image data");
|
|
35
|
+
return bytes;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function validateImage(mimeType, bytes) {
|
|
39
|
+
if (bytes.length > MAX_IMAGE_INPUT_BYTES) throw new Error("nodeRepl.emitImage image exceeds the 16 MiB input limit");
|
|
40
|
+
const normalizedMime = typeof mimeType === "string" ? mimeType.toLowerCase() : "";
|
|
41
|
+
if (!IMAGE_MIME_TYPES.has(normalizedMime) || sniffMimeType(bytes) !== normalizedMime) {
|
|
42
|
+
throw new Error("nodeRepl.emitImage expected a PNG, JPEG, or WebP image");
|
|
43
|
+
}
|
|
44
|
+
return normalizedMime;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isArrayBuffer(value) {
|
|
48
|
+
return value instanceof ArrayBuffer || Object.prototype.toString.call(value) === "[object ArrayBuffer]";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function normalizeImage(value) {
|
|
52
|
+
let bytes;
|
|
53
|
+
let mimeType;
|
|
54
|
+
if (typeof value === "string") {
|
|
55
|
+
if (!value.startsWith("data:image/")) throw new Error("nodeRepl.emitImage accepts image data URLs or bytes");
|
|
56
|
+
const match = /^data:([^;,]+);base64,(.*)$/s.exec(value);
|
|
57
|
+
if (!match) throw new Error("nodeRepl.emitImage expected a base64 image data URL");
|
|
58
|
+
mimeType = match[1];
|
|
59
|
+
bytes = decodeImageBase64(match[2]);
|
|
60
|
+
} else if (Buffer.isBuffer(value) || isArrayBuffer(value) || ArrayBuffer.isView(value)) {
|
|
61
|
+
const byteLength = value.byteLength;
|
|
62
|
+
if (byteLength > MAX_IMAGE_INPUT_BYTES) throw new Error("nodeRepl.emitImage image exceeds the 16 MiB input limit");
|
|
63
|
+
bytes = Buffer.isBuffer(value)
|
|
64
|
+
? value
|
|
65
|
+
: isArrayBuffer(value)
|
|
66
|
+
? Buffer.from(value)
|
|
67
|
+
: Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
68
|
+
mimeType = sniffMimeType(bytes);
|
|
69
|
+
} else if (isPlainObject(value) && value.bytes !== undefined) {
|
|
70
|
+
const rawBytes = value.bytes;
|
|
71
|
+
const byteLength = typeof rawBytes === "string" ? rawBytes.length : rawBytes?.byteLength;
|
|
72
|
+
if (Number.isFinite(byteLength) && byteLength > MAX_IMAGE_INPUT_BYTES) {
|
|
73
|
+
throw new Error("nodeRepl.emitImage image exceeds the 16 MiB input limit");
|
|
74
|
+
}
|
|
75
|
+
bytes = Buffer.from(rawBytes);
|
|
76
|
+
mimeType = typeof value.mimeType === "string" ? value.mimeType : sniffMimeType(bytes);
|
|
77
|
+
} else {
|
|
78
|
+
throw new Error("nodeRepl.emitImage received an unsupported value");
|
|
79
|
+
}
|
|
80
|
+
mimeType = validateImage(mimeType, bytes);
|
|
81
|
+
return { mimeType, data: bytes.toString("base64"), bytes };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function normalizeNativeImage(image) {
|
|
85
|
+
return normalizeImage({ bytes: decodeImageBase64(image.data), mimeType: image.mimeType });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function outputLimits(limits = {}) {
|
|
89
|
+
const maxTextChars = Number.isFinite(limits.maxTextChars)
|
|
90
|
+
? Math.min(Math.max(Math.floor(limits.maxTextChars), 1_000), 2_000_000)
|
|
91
|
+
: DEFAULT_MAX_TEXT_CHARS;
|
|
92
|
+
const maxImageBytes = Number.isFinite(limits.maxImageBytes)
|
|
93
|
+
? Math.min(Math.max(Math.floor(limits.maxImageBytes), 16_384), 16 * 1024 * 1024)
|
|
94
|
+
: DEFAULT_MAX_IMAGE_BYTES;
|
|
95
|
+
return { maxTextChars, maxImageBytes };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createOutputCollector(limits) {
|
|
99
|
+
const { maxTextChars, maxImageBytes } = outputLimits(limits);
|
|
100
|
+
const content = [];
|
|
101
|
+
let textChars = 0;
|
|
102
|
+
let imageBytes = 0;
|
|
103
|
+
let truncationAdded = false;
|
|
104
|
+
const truncationMarker = "[output truncated]";
|
|
105
|
+
|
|
106
|
+
function markTruncated() {
|
|
107
|
+
if (truncationAdded) return;
|
|
108
|
+
const last = content.at(-1);
|
|
109
|
+
if (last?.type !== "text" || last.text.length < truncationMarker.length) return;
|
|
110
|
+
const previousLength = last.text.length;
|
|
111
|
+
last.text = `${last.text.slice(0, -truncationMarker.length)}${truncationMarker}`;
|
|
112
|
+
textChars = textChars - previousLength + last.text.length;
|
|
113
|
+
truncationAdded = true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function appendText(value, itemId) {
|
|
117
|
+
if (content.length >= MAX_CONTENT_ITEMS) {
|
|
118
|
+
markTruncated();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
let text = formatValue(value);
|
|
122
|
+
const remaining = maxTextChars - textChars;
|
|
123
|
+
if (remaining <= 0) {
|
|
124
|
+
markTruncated();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (text.length > remaining) {
|
|
128
|
+
const marker = remaining >= truncationMarker.length ? truncationMarker : "";
|
|
129
|
+
text = `${text.slice(0, Math.max(0, remaining - marker.length))}${marker}`;
|
|
130
|
+
truncationAdded = Boolean(marker);
|
|
131
|
+
}
|
|
132
|
+
if (!text) return;
|
|
133
|
+
const item = { type: "text", text };
|
|
134
|
+
if (typeof itemId === "string" || typeof itemId === "number") {
|
|
135
|
+
item._meta = { id: String(itemId).slice(0, MAX_META_ID_CHARS) };
|
|
136
|
+
}
|
|
137
|
+
content.push(item);
|
|
138
|
+
textChars += text.length;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function appendImage(value) {
|
|
142
|
+
if (content.length >= MAX_CONTENT_ITEMS) {
|
|
143
|
+
appendText("[image omitted because the content item limit was reached]");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const image = normalizeImage(await value);
|
|
147
|
+
if (imageBytes + image.bytes.length > maxImageBytes) {
|
|
148
|
+
appendText("[image omitted because it exceeded the configured size limit]");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (content.length >= MAX_CONTENT_ITEMS) {
|
|
152
|
+
appendText("[image omitted because the content item limit was reached]");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
content.push({ type: "image", mimeType: image.mimeType, data: image.data });
|
|
156
|
+
imageBytes += image.bytes.length;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { content, write: appendText, emitImage: appendImage };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function boundToolResult(result, limits) {
|
|
163
|
+
const { maxTextChars, maxImageBytes } = outputLimits(limits);
|
|
164
|
+
const content = [];
|
|
165
|
+
let textChars = 0;
|
|
166
|
+
let imageBytes = 0;
|
|
167
|
+
const truncationMarker = "[output truncated]";
|
|
168
|
+
const imageMarker = "[image omitted because it exceeded the configured size limit]";
|
|
169
|
+
|
|
170
|
+
function appendText(text, metadata) {
|
|
171
|
+
if (content.length >= MAX_CONTENT_ITEMS) return;
|
|
172
|
+
const remaining = maxTextChars - textChars;
|
|
173
|
+
if (remaining <= 0) return;
|
|
174
|
+
let bounded = text;
|
|
175
|
+
if (bounded.length > remaining) {
|
|
176
|
+
const marker = remaining >= truncationMarker.length ? truncationMarker : "";
|
|
177
|
+
bounded = `${bounded.slice(0, Math.max(0, remaining - marker.length))}${marker}`;
|
|
178
|
+
}
|
|
179
|
+
if (!bounded) return;
|
|
180
|
+
const item = { type: "text", text: bounded };
|
|
181
|
+
if (metadata && typeof metadata.id === "string") {
|
|
182
|
+
item._meta = { id: metadata.id.slice(0, MAX_META_ID_CHARS) };
|
|
183
|
+
}
|
|
184
|
+
content.push(item);
|
|
185
|
+
textChars += bounded.length;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const item of result?.content ?? []) {
|
|
189
|
+
if (content.length >= MAX_CONTENT_ITEMS) break;
|
|
190
|
+
if (item?.type === "text" && typeof item.text === "string") {
|
|
191
|
+
appendText(item.text, item._meta);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (item?.type !== "image" || typeof item.data !== "string") continue;
|
|
195
|
+
const estimatedBytes = Math.floor(item.data.length * 3 / 4);
|
|
196
|
+
if (imageBytes + estimatedBytes > maxImageBytes) {
|
|
197
|
+
appendText(imageMarker);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (content.length >= MAX_CONTENT_ITEMS) {
|
|
201
|
+
appendText(imageMarker);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
content.push({
|
|
205
|
+
type: "image",
|
|
206
|
+
mimeType: typeof item.mimeType === "string" ? item.mimeType : "image/png",
|
|
207
|
+
data: item.data,
|
|
208
|
+
});
|
|
209
|
+
imageBytes += estimatedBytes;
|
|
210
|
+
}
|
|
211
|
+
return { ...result, content };
|
|
212
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
2
|
+
import { PersistentJavaScriptSession } from "./repl.mjs";
|
|
3
|
+
|
|
4
|
+
let nextNativeId = 1;
|
|
5
|
+
let activeTimeoutMs;
|
|
6
|
+
let activeRequestId;
|
|
7
|
+
const pendingNative = new Map();
|
|
8
|
+
const native = {
|
|
9
|
+
request(method, params, timeoutMs = activeTimeoutMs) {
|
|
10
|
+
const id = nextNativeId++;
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
pendingNative.set(id, { resolve, reject });
|
|
13
|
+
parentPort.postMessage({ type: "native_request", id, method, params, timeoutMs, requestId: activeRequestId });
|
|
14
|
+
});
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
const session = new PersistentJavaScriptSession({ native, limits: workerData });
|
|
18
|
+
|
|
19
|
+
parentPort.on("message", async message => {
|
|
20
|
+
if (message.type === "native_response") {
|
|
21
|
+
const pending = pendingNative.get(message.id);
|
|
22
|
+
if (!pending) return;
|
|
23
|
+
pendingNative.delete(message.id);
|
|
24
|
+
if (message.error) pending.reject(new Error(message.error));
|
|
25
|
+
else pending.resolve(message.result);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (message.type === "exec") {
|
|
29
|
+
let result;
|
|
30
|
+
activeRequestId = message.id;
|
|
31
|
+
try {
|
|
32
|
+
activeTimeoutMs = message.timeoutMs;
|
|
33
|
+
result = await session.run(message.code, 0, message.requestMeta);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
result = { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
|
|
36
|
+
}
|
|
37
|
+
activeTimeoutMs = undefined;
|
|
38
|
+
activeRequestId = undefined;
|
|
39
|
+
parentPort.postMessage({ type: "result", id: message.id, result });
|
|
40
|
+
}
|
|
41
|
+
});
|