@oh-my-pi/pi-coding-agent 14.9.3 → 14.9.7
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/CHANGELOG.md +96 -0
- package/package.json +7 -7
- package/src/async/job-manager.ts +66 -9
- package/src/capability/rule.ts +20 -0
- package/src/cli/setup-cli.ts +14 -161
- package/src/cli/stats-cli.ts +56 -2
- package/src/cli.ts +0 -1
- package/src/config/model-registry.ts +13 -0
- package/src/config/model-resolver.ts +8 -2
- package/src/config/settings-schema.ts +1 -11
- package/src/edit/index.ts +8 -0
- package/src/edit/renderer.ts +6 -1
- package/src/edit/streaming.ts +53 -2
- package/src/eval/eval.lark +30 -10
- package/src/eval/js/context-manager.ts +334 -601
- package/src/eval/js/shared/helpers.ts +237 -0
- package/src/eval/js/shared/indirect-eval.ts +30 -0
- package/src/eval/js/{prelude.txt → shared/prelude.txt} +0 -2
- package/src/eval/js/shared/rewrite-imports.ts +211 -0
- package/src/eval/js/shared/runtime.ts +168 -0
- package/src/eval/js/shared/types.ts +18 -0
- package/src/eval/js/tool-bridge.ts +2 -4
- package/src/eval/js/worker-core.ts +146 -0
- package/src/eval/js/worker-entry.ts +24 -0
- package/src/eval/js/worker-protocol.ts +41 -0
- package/src/eval/parse.ts +218 -49
- package/src/eval/py/display.ts +71 -0
- package/src/eval/py/executor.ts +97 -96
- package/src/eval/py/index.ts +2 -2
- package/src/eval/py/kernel.ts +472 -900
- package/src/eval/py/prelude.py +106 -87
- package/src/eval/py/runner.py +879 -0
- package/src/eval/py/runtime.ts +3 -16
- package/src/eval/py/tool-bridge.ts +137 -0
- package/src/export/html/template.css +12 -0
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +113 -7
- package/src/extensibility/plugins/loader.ts +31 -6
- package/src/extensibility/skills.ts +20 -0
- package/src/internal-urls/agent-protocol.ts +63 -52
- package/src/internal-urls/artifact-protocol.ts +51 -51
- package/src/internal-urls/docs-index.generated.ts +35 -3
- package/src/internal-urls/index.ts +6 -19
- package/src/internal-urls/local-protocol.ts +49 -7
- package/src/internal-urls/mcp-protocol.ts +2 -8
- package/src/internal-urls/memory-protocol.ts +89 -59
- package/src/internal-urls/router.ts +38 -22
- package/src/internal-urls/rule-protocol.ts +2 -20
- package/src/internal-urls/skill-protocol.ts +4 -27
- package/src/main.ts +1 -1
- package/src/mcp/manager.ts +17 -0
- package/src/modes/components/session-observer-overlay.ts +2 -2
- package/src/modes/components/tool-execution.ts +6 -0
- package/src/modes/components/tree-selector.ts +4 -0
- package/src/modes/controllers/command-controller.ts +0 -23
- package/src/modes/controllers/event-controller.ts +23 -2
- package/src/modes/controllers/mcp-command-controller.ts +7 -10
- package/src/modes/interactive-mode.ts +2 -2
- package/src/modes/theme/theme.ts +27 -27
- package/src/modes/types.ts +1 -1
- package/src/modes/utils/ui-helpers.ts +14 -9
- package/src/prompts/commands/orchestrate.md +1 -0
- package/src/prompts/system/project-prompt.md +10 -2
- package/src/prompts/system/subagent-system-prompt.md +8 -8
- package/src/prompts/system/system-prompt.md +13 -7
- package/src/prompts/tools/ask.md +0 -1
- package/src/prompts/tools/bash.md +0 -10
- package/src/prompts/tools/eval.md +15 -30
- package/src/prompts/tools/github.md +6 -5
- package/src/prompts/tools/hashline.md +1 -0
- package/src/prompts/tools/job.md +14 -6
- package/src/prompts/tools/task.md +20 -3
- package/src/registry/agent-registry.ts +2 -1
- package/src/sdk.ts +87 -89
- package/src/session/agent-session.ts +58 -21
- package/src/session/artifacts.ts +7 -4
- package/src/session/history-storage.ts +77 -19
- package/src/session/session-manager.ts +30 -1
- package/src/ssh/connection-manager.ts +32 -16
- package/src/ssh/sshfs-mount.ts +10 -7
- package/src/system-prompt.ts +0 -5
- package/src/task/executor.ts +14 -2
- package/src/task/index.ts +19 -5
- package/src/tool-discovery/tool-index.ts +21 -8
- package/src/tools/ast-edit.ts +3 -2
- package/src/tools/ast-grep.ts +3 -2
- package/src/tools/bash.ts +15 -9
- package/src/tools/browser/tab-protocol.ts +4 -0
- package/src/tools/browser/tab-supervisor.ts +98 -7
- package/src/tools/browser/tab-worker.ts +104 -58
- package/src/tools/eval.ts +49 -11
- package/src/tools/fetch.ts +1 -1
- package/src/tools/gh.ts +140 -4
- package/src/tools/index.ts +12 -11
- package/src/tools/job.ts +48 -12
- package/src/tools/read.ts +5 -4
- package/src/tools/search.ts +3 -2
- package/src/tools/todo-write.ts +1 -1
- package/src/web/scrapers/mastodon.ts +1 -1
- package/src/web/scrapers/repology.ts +7 -7
- package/src/web/search/index.ts +6 -4
- package/src/cli/jupyter-cli.ts +0 -106
- package/src/commands/jupyter.ts +0 -32
- package/src/eval/py/cancellation.ts +0 -28
- package/src/eval/py/gateway-coordinator.ts +0 -424
- package/src/internal-urls/jobs-protocol.ts +0 -120
- package/src/prompts/system/now-prompt.md +0 -7
- /package/src/eval/js/{prelude.ts → shared/prelude.ts} +0 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
import * as Diff from "diff";
|
|
5
|
+
import { ToolError } from "../../../tools/tool-errors";
|
|
6
|
+
import type { JsStatusEvent } from "./types";
|
|
7
|
+
|
|
8
|
+
export interface HelperOptions {
|
|
9
|
+
path?: string;
|
|
10
|
+
hidden?: boolean;
|
|
11
|
+
maxDepth?: number;
|
|
12
|
+
limit?: number;
|
|
13
|
+
offset?: number;
|
|
14
|
+
reverse?: boolean;
|
|
15
|
+
unique?: boolean;
|
|
16
|
+
count?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Inputs the helper factory needs from its host runtime. `cwd` is a getter so the runtime
|
|
21
|
+
* can update it between cells (e.g. when the agent's session cwd changes) without
|
|
22
|
+
* recreating helpers.
|
|
23
|
+
*/
|
|
24
|
+
export interface HelperContext {
|
|
25
|
+
cwd(): string;
|
|
26
|
+
env: Map<string, string>;
|
|
27
|
+
emitStatus(event: JsStatusEvent): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The set of functions exposed to user code via `globalThis.__omp_helpers__`. The JS
|
|
32
|
+
* prelude reads from this bag and attaches short aliases (`read`, `write`, `tree`, ...)
|
|
33
|
+
* onto the global scope.
|
|
34
|
+
*/
|
|
35
|
+
export interface HelperBundle {
|
|
36
|
+
read(rawPath: string, options?: HelperOptions): Promise<string>;
|
|
37
|
+
writeFile(rawPath: string, data: unknown): Promise<string>;
|
|
38
|
+
append(rawPath: string, content: string): Promise<string>;
|
|
39
|
+
sortText(text: string, options?: HelperOptions): string;
|
|
40
|
+
uniqText(text: string, options?: HelperOptions): string | Array<[number, string]>;
|
|
41
|
+
counter(items: string | string[], options?: HelperOptions): Array<[number, string]>;
|
|
42
|
+
diff(rawA: string, rawB: string): Promise<string>;
|
|
43
|
+
tree(searchPath?: string, options?: HelperOptions): Promise<string>;
|
|
44
|
+
env(key?: string, value?: string): string | Record<string, string> | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const utf8Encoder = new TextEncoder();
|
|
48
|
+
|
|
49
|
+
export function createHelpers(ctx: HelperContext): HelperBundle {
|
|
50
|
+
return {
|
|
51
|
+
read: async (rawPath, options = {}) => {
|
|
52
|
+
const { filePath, file, size } = await resolveRegularFile(ctx, rawPath);
|
|
53
|
+
let text = await file.text();
|
|
54
|
+
const offset = typeof options.offset === "number" ? options.offset : 1;
|
|
55
|
+
const limit = typeof options.limit === "number" ? options.limit : undefined;
|
|
56
|
+
if (offset > 1 || limit !== undefined) {
|
|
57
|
+
const lines = text.split(/\r?\n/);
|
|
58
|
+
const start = Math.max(0, offset - 1);
|
|
59
|
+
const end = limit !== undefined ? start + limit : lines.length;
|
|
60
|
+
text = lines.slice(start, end).join("\n");
|
|
61
|
+
}
|
|
62
|
+
ctx.emitStatus({ op: "read", path: filePath, bytes: size, chars: text.length });
|
|
63
|
+
return text;
|
|
64
|
+
},
|
|
65
|
+
writeFile: async (rawPath, data) => {
|
|
66
|
+
if (!isWriteData(data)) {
|
|
67
|
+
throw new ToolError("write() expects string, Blob, ArrayBuffer, or TypedArray data");
|
|
68
|
+
}
|
|
69
|
+
const filePath = resolvePath(ctx, rawPath);
|
|
70
|
+
if (typeof data === "string" || data instanceof Blob || data instanceof ArrayBuffer) {
|
|
71
|
+
await Bun.write(filePath, data);
|
|
72
|
+
} else {
|
|
73
|
+
await Bun.write(filePath, new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
|
74
|
+
}
|
|
75
|
+
ctx.emitStatus({ op: "write", path: filePath, bytes: getDataSize(data) });
|
|
76
|
+
return filePath;
|
|
77
|
+
},
|
|
78
|
+
append: async (rawPath, content) => {
|
|
79
|
+
const target = resolvePath(ctx, rawPath);
|
|
80
|
+
await Bun.write(
|
|
81
|
+
target,
|
|
82
|
+
`${await Bun.file(target)
|
|
83
|
+
.text()
|
|
84
|
+
.catch(() => "")}${content}`,
|
|
85
|
+
);
|
|
86
|
+
ctx.emitStatus({
|
|
87
|
+
op: "append",
|
|
88
|
+
path: target,
|
|
89
|
+
chars: content.length,
|
|
90
|
+
bytes: utf8Encoder.encode(content).byteLength,
|
|
91
|
+
});
|
|
92
|
+
return target;
|
|
93
|
+
},
|
|
94
|
+
sortText: (text, options = {}) => {
|
|
95
|
+
const lines = String(text).split(/\r?\n/);
|
|
96
|
+
const deduped = options.unique ? Array.from(new Set(lines)) : lines;
|
|
97
|
+
const sorted = deduped.sort((a, b) => a.localeCompare(b));
|
|
98
|
+
if (options.reverse) sorted.reverse();
|
|
99
|
+
const result = sorted.join("\n");
|
|
100
|
+
ctx.emitStatus({
|
|
101
|
+
op: "sort",
|
|
102
|
+
lines: sorted.length,
|
|
103
|
+
reverse: options.reverse === true,
|
|
104
|
+
unique: options.unique === true,
|
|
105
|
+
});
|
|
106
|
+
return result;
|
|
107
|
+
},
|
|
108
|
+
uniqText: (text, options = {}) => {
|
|
109
|
+
const lines = String(text)
|
|
110
|
+
.split(/\r?\n/)
|
|
111
|
+
.filter(line => line.length > 0);
|
|
112
|
+
const groups: Array<[number, string]> = [];
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
const last = groups.at(-1);
|
|
115
|
+
if (last && last[1] === line) {
|
|
116
|
+
last[0] += 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
groups.push([1, line]);
|
|
120
|
+
}
|
|
121
|
+
ctx.emitStatus({ op: "uniq", groups: groups.length, count_mode: options.count === true });
|
|
122
|
+
if (options.count) return groups;
|
|
123
|
+
return groups.map(([, line]) => line).join("\n");
|
|
124
|
+
},
|
|
125
|
+
counter: (items, options = {}) => {
|
|
126
|
+
const values = Array.isArray(items) ? items : String(items).split(/\r?\n/).filter(Boolean);
|
|
127
|
+
const counts = new Map<string, number>();
|
|
128
|
+
for (const item of values) counts.set(item, (counts.get(item) ?? 0) + 1);
|
|
129
|
+
const entries = Array.from(counts.entries())
|
|
130
|
+
.map(([item, count]) => [count, item] as [number, string])
|
|
131
|
+
.sort((a, b) => (options.reverse === false ? a[0] - b[0] : b[0] - a[0]) || a[1].localeCompare(b[1]));
|
|
132
|
+
const limited = entries.slice(0, options.limit ?? entries.length);
|
|
133
|
+
ctx.emitStatus({ op: "counter", unique: counts.size, total: values.length, top: limited.slice(0, 10) });
|
|
134
|
+
return limited;
|
|
135
|
+
},
|
|
136
|
+
diff: async (rawA, rawB) => {
|
|
137
|
+
const fileA = resolvePath(ctx, rawA);
|
|
138
|
+
const fileB = resolvePath(ctx, rawB);
|
|
139
|
+
const [a, b] = await Promise.all([Bun.file(fileA).text(), Bun.file(fileB).text()]);
|
|
140
|
+
const result = Diff.createTwoFilesPatch(fileA, fileB, a, b, "", "", { context: 3 });
|
|
141
|
+
ctx.emitStatus({
|
|
142
|
+
op: "diff",
|
|
143
|
+
file_a: fileA,
|
|
144
|
+
file_b: fileB,
|
|
145
|
+
identical: a === b,
|
|
146
|
+
preview: result.slice(0, 500),
|
|
147
|
+
});
|
|
148
|
+
return result;
|
|
149
|
+
},
|
|
150
|
+
tree: async (searchPath = ".", options = {}) => {
|
|
151
|
+
const root = resolvePath(ctx, searchPath);
|
|
152
|
+
const maxDepth = options.maxDepth ?? 3;
|
|
153
|
+
const showHidden = options.hidden ?? false;
|
|
154
|
+
const lines: string[] = [`${root}/`];
|
|
155
|
+
let entryCount = 0;
|
|
156
|
+
const walk = async (dir: string, prefix: string, depth: number): Promise<void> => {
|
|
157
|
+
if (depth > maxDepth) return;
|
|
158
|
+
const entries = (await fs.promises.readdir(dir, { withFileTypes: true }))
|
|
159
|
+
.filter(entry => showHidden || !entry.name.startsWith("."))
|
|
160
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
161
|
+
for (let index = 0; index < entries.length; index++) {
|
|
162
|
+
const entry = entries[index];
|
|
163
|
+
const isLast = index === entries.length - 1;
|
|
164
|
+
const connector = isLast ? "└── " : "├── ";
|
|
165
|
+
const suffix = entry.isDirectory() ? "/" : "";
|
|
166
|
+
lines.push(`${prefix}${connector}${entry.name}${suffix}`);
|
|
167
|
+
entryCount += 1;
|
|
168
|
+
if (entry.isDirectory()) {
|
|
169
|
+
await walk(path.join(dir, entry.name), `${prefix}${isLast ? " " : "│ "}`, depth + 1);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
await walk(root, "", 1);
|
|
174
|
+
const result = lines.join("\n");
|
|
175
|
+
ctx.emitStatus({ op: "tree", path: root, entries: entryCount, preview: result.slice(0, 1000) });
|
|
176
|
+
return result;
|
|
177
|
+
},
|
|
178
|
+
env: (key, value) => {
|
|
179
|
+
if (!key) {
|
|
180
|
+
const merged = Object.fromEntries(Object.entries(getMergedEnv(ctx)).sort(([a], [b]) => a.localeCompare(b)));
|
|
181
|
+
ctx.emitStatus({ op: "env", count: Object.keys(merged).length, keys: Object.keys(merged).slice(0, 20) });
|
|
182
|
+
return merged;
|
|
183
|
+
}
|
|
184
|
+
if (value !== undefined) {
|
|
185
|
+
ctx.env.set(key, value);
|
|
186
|
+
ctx.emitStatus({ op: "env", key, value, action: "set" });
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
const result = ctx.env.get(key) ?? Bun.env[key];
|
|
190
|
+
ctx.emitStatus({ op: "env", key, value: result, action: "get" });
|
|
191
|
+
return result;
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function getMergedEnv(ctx: HelperContext): Record<string, string> {
|
|
197
|
+
const merged: Record<string, string> = {};
|
|
198
|
+
for (const [key, value] of Object.entries(Bun.env)) {
|
|
199
|
+
if (typeof value === "string") merged[key] = value;
|
|
200
|
+
}
|
|
201
|
+
for (const [key, value] of ctx.env) merged[key] = value;
|
|
202
|
+
return merged;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function resolvePath(ctx: HelperContext, value: string): string {
|
|
206
|
+
if (path.isAbsolute(value)) return path.normalize(value);
|
|
207
|
+
return path.resolve(ctx.cwd(), value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function resolveRegularFile(
|
|
211
|
+
ctx: HelperContext,
|
|
212
|
+
rawPath: string,
|
|
213
|
+
): Promise<{ filePath: string; file: Bun.BunFile; size: number }> {
|
|
214
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(rawPath)) {
|
|
215
|
+
throw new ToolError(`Protocol paths are not supported by read(): ${rawPath}`);
|
|
216
|
+
}
|
|
217
|
+
const filePath = resolvePath(ctx, rawPath);
|
|
218
|
+
const file = Bun.file(filePath);
|
|
219
|
+
const stat = await file.stat();
|
|
220
|
+
if (stat.isDirectory()) {
|
|
221
|
+
throw new ToolError(`Directory paths are not supported by read(): ${filePath}`);
|
|
222
|
+
}
|
|
223
|
+
return { filePath, file, size: stat.size };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function getDataSize(data: string | Blob | ArrayBuffer | ArrayBufferView): number {
|
|
227
|
+
if (typeof data === "string") return utf8Encoder.encode(data).byteLength;
|
|
228
|
+
if (data instanceof Blob) return data.size;
|
|
229
|
+
if (data instanceof ArrayBuffer) return data.byteLength;
|
|
230
|
+
return data.byteLength;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function isWriteData(value: unknown): value is string | Blob | ArrayBuffer | ArrayBufferView {
|
|
234
|
+
return (
|
|
235
|
+
typeof value === "string" || value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value)
|
|
236
|
+
);
|
|
237
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Indirect eval — runs in the host's global scope, isolating bindings declared with
|
|
3
|
+
* `const`/`let` from this module's closure. Used by both the JS eval worker and the
|
|
4
|
+
* browser tab worker to execute user-supplied source without `node:vm`.
|
|
5
|
+
*
|
|
6
|
+
* Why not vm.runInContext: Bun crashes the parent process with SIGTRAP when
|
|
7
|
+
* `Worker.terminate()` fires while a worker is mid-`vm.runInContext` synchronous loop.
|
|
8
|
+
* Indirect eval does not trip that bug.
|
|
9
|
+
*
|
|
10
|
+
* The optional `filename` is appended as a `//# sourceURL=...` pragma so V8 attributes
|
|
11
|
+
* stack frames to the user cell instead of `<anonymous>`.
|
|
12
|
+
*/
|
|
13
|
+
export function indirectEval(source: string, filename?: string): unknown {
|
|
14
|
+
const withPragma = filename ? `${source}\n//# sourceURL=${filename}` : source;
|
|
15
|
+
// Read `eval` via a property access so the call site is *indirect* (global scope),
|
|
16
|
+
// not direct (this module's lexical scope). The cast erases the DOM lib return type.
|
|
17
|
+
// We deliberately avoid `node:vm` because Bun crashes the parent with SIGTRAP when
|
|
18
|
+
// Worker.terminate() fires mid-`vm.runInContext` synchronous loop — indirect eval is
|
|
19
|
+
// the executor for user code in the worker.
|
|
20
|
+
// biome-ignore lint/security/noGlobalEval: see comment above — this is the executor.
|
|
21
|
+
const geval = globalThis.eval as (src: string) => unknown;
|
|
22
|
+
return geval(withPragma);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function awaitMaybePromise<T>(value: T | Promise<T>): Promise<T> {
|
|
26
|
+
if (!value || typeof value !== "object" || typeof (value as { then?: unknown }).then !== "function") {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
return await (value as Promise<T>);
|
|
30
|
+
}
|
|
@@ -12,7 +12,6 @@ if (!globalThis.__omp_js_prelude_loaded__) {
|
|
|
12
12
|
const counter = (items, opts = {}) => callHelper("counter", items, toOptions(opts));
|
|
13
13
|
const diff = (a, b) => callHelper("diff", a, b);
|
|
14
14
|
const tree = (path = ".", opts = {}) => callHelper("tree", path, toOptions(opts));
|
|
15
|
-
const run = (cmd, opts = {}) => callHelper("run", cmd, toOptions(opts));
|
|
16
15
|
const env = (key, value) => callHelper("env", key, value);
|
|
17
16
|
|
|
18
17
|
const tool = new Proxy(
|
|
@@ -67,6 +66,5 @@ if (!globalThis.__omp_js_prelude_loaded__) {
|
|
|
67
66
|
globalThis.counter = counter;
|
|
68
67
|
globalThis.diff = diff;
|
|
69
68
|
globalThis.tree = tree;
|
|
70
|
-
globalThis.run = run;
|
|
71
69
|
globalThis.env = env;
|
|
72
70
|
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { parse as babelParse } from "@babel/parser";
|
|
2
|
+
|
|
3
|
+
// Static ESM `import` declarations are not valid inside vm.runInContext (script-mode parsing).
|
|
4
|
+
// We rewrite top-level imports to dynamic-import expressions in the user-supplied source so
|
|
5
|
+
// pasted ESM runs verbatim. A real parser keeps imports embedded in string literals, template
|
|
6
|
+
// literals, or comments intact.
|
|
7
|
+
|
|
8
|
+
type BabelImportDeclaration = {
|
|
9
|
+
type: "ImportDeclaration";
|
|
10
|
+
start: number;
|
|
11
|
+
end: number;
|
|
12
|
+
source: { value: string };
|
|
13
|
+
specifiers: ReadonlyArray<{
|
|
14
|
+
type: "ImportDefaultSpecifier" | "ImportNamespaceSpecifier" | "ImportSpecifier";
|
|
15
|
+
local: { name: string };
|
|
16
|
+
imported?: { type: "Identifier"; name: string } | { type: "StringLiteral"; value: string };
|
|
17
|
+
}>;
|
|
18
|
+
attributes?: ReadonlyArray<{
|
|
19
|
+
key: { type: "Identifier"; name: string } | { type: "StringLiteral"; value: string };
|
|
20
|
+
value: { value: string };
|
|
21
|
+
}>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type BabelLexicalDecl =
|
|
25
|
+
| { type: "VariableDeclaration"; kind: "const" | "let" | "var"; start: number; end: number }
|
|
26
|
+
| { type: "ClassDeclaration"; start: number; end: number; id: { start: number; end: number; name: string } | null };
|
|
27
|
+
|
|
28
|
+
function buildDynamicImportCall(sourceLiteral: string, withClause: string | undefined): string {
|
|
29
|
+
// Route every static import through the worker-injected `__omp_import__` helper so the
|
|
30
|
+
// specifier resolves against the session cwd (and `with`-attribute imports keep working).
|
|
31
|
+
return withClause ? `__omp_import__(${sourceLiteral}, ${withClause})` : `__omp_import__(${sourceLiteral})`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildWithClause(node: BabelImportDeclaration): string | undefined {
|
|
35
|
+
const attrs = node.attributes;
|
|
36
|
+
if (!attrs || attrs.length === 0) return undefined;
|
|
37
|
+
const pairs = attrs.map(attr => {
|
|
38
|
+
const key = attr.key.type === "Identifier" ? attr.key.name : JSON.stringify(attr.key.value);
|
|
39
|
+
return `${key}: ${JSON.stringify(attr.value.value)}`;
|
|
40
|
+
});
|
|
41
|
+
return `{ ${pairs.join(", ")} }`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function rewriteImportNode(node: BabelImportDeclaration): string {
|
|
45
|
+
const sourceLiteral = JSON.stringify(node.source.value);
|
|
46
|
+
const withClause = buildWithClause(node);
|
|
47
|
+
const importCall = buildDynamicImportCall(sourceLiteral, withClause);
|
|
48
|
+
|
|
49
|
+
let defaultName: string | undefined;
|
|
50
|
+
let namespaceName: string | undefined;
|
|
51
|
+
const namedPairs: Array<[string, string]> = [];
|
|
52
|
+
for (const spec of node.specifiers) {
|
|
53
|
+
if (spec.type === "ImportDefaultSpecifier") {
|
|
54
|
+
defaultName = spec.local.name;
|
|
55
|
+
} else if (spec.type === "ImportNamespaceSpecifier") {
|
|
56
|
+
namespaceName = spec.local.name;
|
|
57
|
+
} else if (spec.type === "ImportSpecifier" && spec.imported) {
|
|
58
|
+
const imported = spec.imported.type === "Identifier" ? spec.imported.name : spec.imported.value;
|
|
59
|
+
namedPairs.push([imported, spec.local.name]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (namedPairs.length > 0) {
|
|
64
|
+
const inner = namedPairs.map(([imp, loc]) => (imp === loc ? imp : `${imp}: ${loc}`)).join(", ");
|
|
65
|
+
const props = defaultName ? `default: ${defaultName}, ${inner}` : inner;
|
|
66
|
+
return `const { ${props} } = await ${importCall};`;
|
|
67
|
+
}
|
|
68
|
+
if (namespaceName && defaultName) {
|
|
69
|
+
return `const ${namespaceName} = await ${importCall}; const ${defaultName} = ${namespaceName}.default;`;
|
|
70
|
+
}
|
|
71
|
+
if (namespaceName) return `const ${namespaceName} = await ${importCall};`;
|
|
72
|
+
if (defaultName) return `const ${defaultName} = (await ${importCall}).default;`;
|
|
73
|
+
return `await ${importCall};`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function rewriteStaticImports(code: string): string {
|
|
77
|
+
if (!code.includes("import")) return code;
|
|
78
|
+
|
|
79
|
+
let ast: { program: { body: ReadonlyArray<{ type: string }> } };
|
|
80
|
+
try {
|
|
81
|
+
ast = babelParse(code, {
|
|
82
|
+
sourceType: "module",
|
|
83
|
+
allowAwaitOutsideFunction: true,
|
|
84
|
+
allowReturnOutsideFunction: true,
|
|
85
|
+
allowImportExportEverywhere: true,
|
|
86
|
+
allowNewTargetOutsideFunction: true,
|
|
87
|
+
allowSuperOutsideMethod: true,
|
|
88
|
+
allowUndeclaredExports: true,
|
|
89
|
+
errorRecovery: true,
|
|
90
|
+
}) as unknown as typeof ast;
|
|
91
|
+
} catch {
|
|
92
|
+
// Parser bailed entirely — let the VM surface the real syntax error.
|
|
93
|
+
return code;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const imports: BabelImportDeclaration[] = [];
|
|
97
|
+
for (const node of ast.program.body) {
|
|
98
|
+
if (node.type === "ImportDeclaration") imports.push(node as unknown as BabelImportDeclaration);
|
|
99
|
+
}
|
|
100
|
+
if (imports.length === 0) return code;
|
|
101
|
+
|
|
102
|
+
// Splice from the back so earlier offsets stay valid.
|
|
103
|
+
imports.sort((a, b) => b.start - a.start);
|
|
104
|
+
let result = code;
|
|
105
|
+
for (const node of imports) {
|
|
106
|
+
result = result.slice(0, node.start) + rewriteImportNode(node) + result.slice(node.end);
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Demote top-level `const`/`let`/`class` declarations to `var` so they persist on the
|
|
113
|
+
* worker's globalThis across indirect `eval` calls. Indirect eval gives each call its own
|
|
114
|
+
* lexical environment, so `const x = 1` in one cell would be invisible to the next.
|
|
115
|
+
* `var` and function declarations are stored on the global object and survive across cells.
|
|
116
|
+
*
|
|
117
|
+
* const x = 1; -> var x = 1;
|
|
118
|
+
* let { a, b } = obj; -> var { a, b } = obj;
|
|
119
|
+
* class Foo extends Bar {} -> var Foo = class extends Bar {};
|
|
120
|
+
*
|
|
121
|
+
* Nested declarations (inside functions, blocks, classes) are left alone \u2014 they're
|
|
122
|
+
* scoped to their enclosing function/block regardless of `var` vs `let`/`const`.
|
|
123
|
+
*/
|
|
124
|
+
export function demoteTopLevelLexicals(code: string): string {
|
|
125
|
+
if (!/\b(?:const|let|class)\b/.test(code)) return code;
|
|
126
|
+
|
|
127
|
+
let ast: { program: { body: ReadonlyArray<{ type: string }> } };
|
|
128
|
+
try {
|
|
129
|
+
ast = babelParse(code, {
|
|
130
|
+
sourceType: "module",
|
|
131
|
+
allowAwaitOutsideFunction: true,
|
|
132
|
+
allowReturnOutsideFunction: true,
|
|
133
|
+
allowImportExportEverywhere: true,
|
|
134
|
+
allowNewTargetOutsideFunction: true,
|
|
135
|
+
allowSuperOutsideMethod: true,
|
|
136
|
+
allowUndeclaredExports: true,
|
|
137
|
+
errorRecovery: true,
|
|
138
|
+
}) as unknown as typeof ast;
|
|
139
|
+
} catch {
|
|
140
|
+
return code;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const targets: BabelLexicalDecl[] = [];
|
|
144
|
+
for (const node of ast.program.body) {
|
|
145
|
+
if (node.type === "VariableDeclaration") {
|
|
146
|
+
const decl = node as unknown as BabelLexicalDecl & { kind: string };
|
|
147
|
+
if (decl.kind === "const" || decl.kind === "let") targets.push(decl as BabelLexicalDecl);
|
|
148
|
+
} else if (node.type === "ClassDeclaration") {
|
|
149
|
+
const decl = node as unknown as Extract<BabelLexicalDecl, { type: "ClassDeclaration" }>;
|
|
150
|
+
if (decl.id) targets.push(decl);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (targets.length === 0) return code;
|
|
154
|
+
|
|
155
|
+
targets.sort((a, b) => b.start - a.start);
|
|
156
|
+
let result = code;
|
|
157
|
+
for (const node of targets) {
|
|
158
|
+
const segment = result.slice(node.start, node.end);
|
|
159
|
+
let replacement: string;
|
|
160
|
+
if (node.type === "VariableDeclaration") {
|
|
161
|
+
replacement = `var${segment.slice(node.kind.length)}`;
|
|
162
|
+
} else {
|
|
163
|
+
const id = node.id;
|
|
164
|
+
if (!id) continue;
|
|
165
|
+
const idEndInSegment = id.end - node.start;
|
|
166
|
+
const tail = segment.slice(idEndInSegment);
|
|
167
|
+
const hasTrailingSemi = segment.endsWith(";");
|
|
168
|
+
replacement = `var ${id.name} = class${tail}${hasTrailingSemi ? "" : ";"}`;
|
|
169
|
+
}
|
|
170
|
+
result = result.slice(0, node.start) + replacement + result.slice(node.end);
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Strip TypeScript syntax (type annotations, `interface`, `as`, `satisfies`, generics in
|
|
177
|
+
* call expressions, etc.) before the import/lexical rewriters parse the code. We use Bun's
|
|
178
|
+
* native transpiler in `ts` loader mode — fast, no JSX transforms, preserves `import`/
|
|
179
|
+
* `export` declarations so the downstream Babel rewrites keep working.
|
|
180
|
+
*
|
|
181
|
+
* Skipped when the code parses as plain JavaScript already (Babel can accept it), so the
|
|
182
|
+
* common case avoids an extra transpile pass. We detect "looks like TS" with a cheap regex
|
|
183
|
+
* before invoking the transpiler.
|
|
184
|
+
*/
|
|
185
|
+
export function stripTypeScript(code: string): string {
|
|
186
|
+
if (!LOOKS_LIKE_TS.test(code)) return code;
|
|
187
|
+
try {
|
|
188
|
+
return new Bun.Transpiler({ loader: "ts" }).transformSync(code);
|
|
189
|
+
} catch {
|
|
190
|
+
// Transpiler failed (e.g. unrecoverable syntax). Hand the original source back so the
|
|
191
|
+
// downstream rewriter / VM surfaces the real error to the user.
|
|
192
|
+
return code;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Heuristic: any of the obvious TS-only tokens. Plain JS using `as` only inside strings
|
|
197
|
+
// won't match because we require a leading word boundary plus a colon/keyword neighbor.
|
|
198
|
+
const LOOKS_LIKE_TS =
|
|
199
|
+
/(?:\binterface\s+\w|\btype\s+\w+\s*=|\b(?:as|satisfies)\s+(?:[A-Z]|\bconst\b)|:\s*(?:string|number|boolean|any|unknown|void|never|object|[A-Z]\w*)\b|<\s*[A-Z]\w*\s*[,>])/;
|
|
200
|
+
|
|
201
|
+
export function wrapCode(code: string): { source: string; asyncWrapped: boolean } {
|
|
202
|
+
const rewritten = demoteTopLevelLexicals(rewriteStaticImports(stripTypeScript(code)));
|
|
203
|
+
const needsAsyncWrapper = /\bawait\b|\breturn\b/.test(rewritten);
|
|
204
|
+
if (!needsAsyncWrapper) {
|
|
205
|
+
return { source: rewritten, asyncWrapped: false };
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
source: `(async () => {\n${rewritten}\n})()`,
|
|
209
|
+
asyncWrapped: true,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import * as util from "node:util";
|
|
6
|
+
|
|
7
|
+
import { ToolError } from "../../../tools/tool-errors";
|
|
8
|
+
import { createHelpers, type HelperBundle } from "./helpers";
|
|
9
|
+
import { awaitMaybePromise, indirectEval } from "./indirect-eval";
|
|
10
|
+
import { JAVASCRIPT_PRELUDE_SOURCE } from "./prelude";
|
|
11
|
+
import { wrapCode } from "./rewrite-imports";
|
|
12
|
+
import type { JsDisplayOutput, JsStatusEvent } from "./types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Per-run callbacks. Returned by `getHooks()` on each helper/tool/display invocation so
|
|
16
|
+
* the embedding worker can route emissions to the currently active run. Returning `null`
|
|
17
|
+
* makes status/display/tool calls reject with an error — useful for guarding against
|
|
18
|
+
* helpers being invoked outside a run window.
|
|
19
|
+
*/
|
|
20
|
+
export interface RuntimeHooks {
|
|
21
|
+
onText(chunk: string): void;
|
|
22
|
+
onDisplay(output: JsDisplayOutput): void;
|
|
23
|
+
callTool(name: string, args: unknown): Promise<unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RuntimeOptions {
|
|
27
|
+
initialCwd: string;
|
|
28
|
+
sessionId: string;
|
|
29
|
+
/** Resolve hooks for the run currently in flight, or `null` if nothing is active. */
|
|
30
|
+
getHooks(): RuntimeHooks | null;
|
|
31
|
+
/**
|
|
32
|
+
* Extra globals installed alongside `__omp_helpers__` / prelude. Use for stable, lifetime-
|
|
33
|
+
* of-the-worker bindings (e.g. browser's `page`, `browser`). Per-run scope should be set
|
|
34
|
+
* via `setRunScope()` instead.
|
|
35
|
+
*/
|
|
36
|
+
extraGlobals?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Shared JS runtime for the eval worker and the browser tab worker. Owns the prelude,
|
|
41
|
+
* helper bag, console bridge, and indirect-eval execution. Emits text/display/tool-call
|
|
42
|
+
* back through `RuntimeHooks` that the embedder supplies — wire format is the embedder's
|
|
43
|
+
* concern.
|
|
44
|
+
*/
|
|
45
|
+
export class JsRuntime {
|
|
46
|
+
readonly helpers: HelperBundle;
|
|
47
|
+
#cwd: string;
|
|
48
|
+
readonly sessionId: string;
|
|
49
|
+
#env: Map<string, string>;
|
|
50
|
+
#getHooks: () => RuntimeHooks | null;
|
|
51
|
+
|
|
52
|
+
constructor(opts: RuntimeOptions) {
|
|
53
|
+
this.#cwd = opts.initialCwd;
|
|
54
|
+
this.sessionId = opts.sessionId;
|
|
55
|
+
this.#env = new Map();
|
|
56
|
+
this.#getHooks = opts.getHooks;
|
|
57
|
+
this.helpers = createHelpers({
|
|
58
|
+
cwd: () => this.#cwd,
|
|
59
|
+
env: this.#env,
|
|
60
|
+
emitStatus: event => this.#getHooks()?.onDisplay({ type: "status", event }),
|
|
61
|
+
});
|
|
62
|
+
this.#install(opts.extraGlobals);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get cwd(): string {
|
|
66
|
+
return this.#cwd;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
setCwd(cwd: string): void {
|
|
70
|
+
this.#cwd = cwd;
|
|
71
|
+
const session = (globalThis as { __omp_session__?: { cwd?: string } }).__omp_session__;
|
|
72
|
+
if (session) session.cwd = cwd;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Install per-run globals. Intended for run-scoped state (browser's `tab`, `display`
|
|
77
|
+
* overrides, etc.). Overwrites previous assignments — caller is responsible for any
|
|
78
|
+
* cleanup it wants.
|
|
79
|
+
*/
|
|
80
|
+
setRunScope(scope: Record<string, unknown>): void {
|
|
81
|
+
Object.assign(globalThis, scope);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async run(code: string, filename?: string): Promise<unknown> {
|
|
85
|
+
const wrapped = wrapCode(code);
|
|
86
|
+
const value = indirectEval(wrapped.source, filename);
|
|
87
|
+
return await awaitMaybePromise(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
displayValue(value: unknown): void {
|
|
91
|
+
if (value === undefined) return;
|
|
92
|
+
const hooks = this.#getHooks();
|
|
93
|
+
if (!hooks) return;
|
|
94
|
+
if (value && typeof value === "object") {
|
|
95
|
+
const record = value as Record<string, unknown>;
|
|
96
|
+
if (record.type === "image" && typeof record.data === "string" && typeof record.mimeType === "string") {
|
|
97
|
+
hooks.onDisplay({ type: "image", data: record.data, mimeType: record.mimeType });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
hooks.onDisplay({ type: "json", data: structuredClone(value) });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
hooks.onText(`${String(value)}\n`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#install(extraGlobals: Record<string, unknown> | undefined): void {
|
|
107
|
+
const injected: Record<string, unknown> = {
|
|
108
|
+
__omp_session__: { cwd: this.#cwd, sessionId: this.sessionId },
|
|
109
|
+
__omp_helpers__: this.helpers,
|
|
110
|
+
__omp_call_tool__: async (name: string, args: unknown) => {
|
|
111
|
+
const hooks = this.#getHooks();
|
|
112
|
+
if (!hooks) throw new ToolError("Tool calls are only valid inside an active run");
|
|
113
|
+
return await hooks.callTool(name, args);
|
|
114
|
+
},
|
|
115
|
+
__omp_import__: async (source: string, attrs?: Record<string, string>) => {
|
|
116
|
+
const target = resolveImportSpecifier(this.#cwd, source);
|
|
117
|
+
return attrs ? await import(target, { with: attrs }) : await import(target);
|
|
118
|
+
},
|
|
119
|
+
__omp_emit_status__: (op: string, data: Record<string, unknown> = {}) => {
|
|
120
|
+
const event: JsStatusEvent = { op, ...data };
|
|
121
|
+
this.#getHooks()?.onDisplay({ type: "status", event });
|
|
122
|
+
},
|
|
123
|
+
__omp_log__: (level: string, ...args: unknown[]) => {
|
|
124
|
+
const prefix = level === "error" ? "[error] " : level === "warn" ? "[warn] " : "";
|
|
125
|
+
const text = `${prefix}${formatConsoleArgs(args)}`;
|
|
126
|
+
this.#getHooks()?.onText(text.endsWith("\n") ? text : `${text}\n`);
|
|
127
|
+
},
|
|
128
|
+
__omp_display__: (value: unknown) => this.displayValue(value),
|
|
129
|
+
webcrypto: crypto,
|
|
130
|
+
// `process` is intentionally not overridden — user code gets the host worker's real
|
|
131
|
+
// `process` object. Subsetting it caused segfaults in workers that share state with
|
|
132
|
+
// puppeteer/worker_threads internals.
|
|
133
|
+
require: buildRequire(this.#cwd),
|
|
134
|
+
createRequire,
|
|
135
|
+
fs,
|
|
136
|
+
};
|
|
137
|
+
Object.assign(globalThis, injected, extraGlobals ?? {});
|
|
138
|
+
// Prelude assigns console bridge + short aliases (`read`, `write`, `tool`, `display`, ...)
|
|
139
|
+
// onto globalThis. Must run after helpers are in place.
|
|
140
|
+
indirectEval(JAVASCRIPT_PRELUDE_SOURCE);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function formatConsoleArgs(args: unknown[]): string {
|
|
145
|
+
return args
|
|
146
|
+
.map(arg => (typeof arg === "string" ? arg : util.inspect(arg, { depth: 6, colors: false, breakLength: 120 })))
|
|
147
|
+
.join(" ");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function buildRequire(cwd: string): NodeJS.Require {
|
|
151
|
+
return createRequire(pathToFileURL(path.join(cwd, "[eval]")).href);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve an import specifier emitted by `rewriteStaticImports` against the active session
|
|
156
|
+
* cwd. Relative paths (`./`, `../`, `/`) and bare specifiers (`pkg`, `@scope/pkg`) both go
|
|
157
|
+
* through `Bun.resolveSync` rooted at the cwd so user-pasted ESM behaves as if it lived in
|
|
158
|
+
* the project — not next to the worker module. URL-like specifiers (`file://`, `data:`,
|
|
159
|
+
* `node:`, `http:`) are passed through unchanged.
|
|
160
|
+
*/
|
|
161
|
+
function resolveImportSpecifier(cwd: string, source: string): string {
|
|
162
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(source)) return source;
|
|
163
|
+
try {
|
|
164
|
+
return Bun.resolveSync(source, cwd);
|
|
165
|
+
} catch {
|
|
166
|
+
return source;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured status payload emitted by helpers (`read`, `write`, `tree`, etc.) and the
|
|
3
|
+
* tool-call bridge. Surfaces to the model as part of `displays` so it has machine-readable
|
|
4
|
+
* context about what side effects happened.
|
|
5
|
+
*/
|
|
6
|
+
export interface JsStatusEvent {
|
|
7
|
+
op: string;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One unit of structured output from a JS eval cell. `text` chunks flow through a separate
|
|
13
|
+
* channel.
|
|
14
|
+
*/
|
|
15
|
+
export type JsDisplayOutput =
|
|
16
|
+
| { type: "json"; data: unknown }
|
|
17
|
+
| { type: "image"; data: string; mimeType: string }
|
|
18
|
+
| { type: "status"; event: JsStatusEvent };
|