@trim21/personal-pi-extensions 0.1.654 → 0.1.655
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/README.md +25 -0
- package/package.json +4 -2
- package/src/codemode/declarations.ts +103 -0
- package/src/codemode/prelude.ts +298 -0
- package/src/codemode/protocol.ts +156 -0
- package/src/codemode/sandbox.ts +192 -0
- package/src/codemode/source.ts +91 -0
- package/src/codemode/tool.ts +353 -0
- package/src/codemode/wasm.ts +20 -0
- package/src/codemode/worker.js +583 -0
- package/src/codemode/worker.ts +279 -0
- package/src/index.ts +13 -0
- package/src/lib/tool-bus.ts +7 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codemode 的沙箱客户端:主线程侧管理 worker 线程的一次执行与回收。
|
|
3
|
+
*
|
|
4
|
+
* 沙箱就是 QuickJS VM 本身(VM 里没有 node、文件、网络、timer),脚本唯一的出口是
|
|
5
|
+
* 注入的工具,而这些工具由主线程执行——所以这里只做三件事:把注册时编译好的 wasm
|
|
6
|
+
* 与脚本交给 worker、转发脚本的嵌套调用与输出、在结束/超时/中止时 `terminate()`。
|
|
7
|
+
*
|
|
8
|
+
* worker 一次执行一个:死循环脚本由主线程 terminate 掉,连同它那份 VM 一起丢弃,
|
|
9
|
+
* 不会污染后续执行,也不需要取消协议。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Worker } from "node:worker_threads";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type CodemodeOutputItem,
|
|
16
|
+
decodeWorkerMessage,
|
|
17
|
+
type ScriptError,
|
|
18
|
+
type ScriptTool,
|
|
19
|
+
type StoreWrites,
|
|
20
|
+
type WorkerMessage,
|
|
21
|
+
} from "./protocol.js";
|
|
22
|
+
import { compileQuickJSWasm } from "./wasm.js";
|
|
23
|
+
|
|
24
|
+
/** 脚本侧一次嵌套调用的记录(主线程是执行方,所以由它记录)。 */
|
|
25
|
+
export interface ScriptCall {
|
|
26
|
+
name: string;
|
|
27
|
+
status: "ok" | "error";
|
|
28
|
+
durationMs: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type SandboxCallOutcome = { ok: true; value: unknown } | { ok: false; error: string };
|
|
32
|
+
|
|
33
|
+
export type SandboxOutcome = {
|
|
34
|
+
output: CodemodeOutputItem[];
|
|
35
|
+
calls: ScriptCall[];
|
|
36
|
+
} & (
|
|
37
|
+
| { ok: true; value: unknown; writes: StoreWrites }
|
|
38
|
+
| { ok: false; error: ScriptError; writes: StoreWrites }
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
export interface SandboxRunOptions {
|
|
42
|
+
code: string;
|
|
43
|
+
tools: readonly ScriptTool[];
|
|
44
|
+
store: Record<string, unknown>;
|
|
45
|
+
timeoutMs: number;
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
/** 每次嵌套调用:由调用方执行工具并把结果回给脚本。 */
|
|
48
|
+
onCall(request: { id: number; name: string; args: unknown }): Promise<SandboxCallOutcome>;
|
|
49
|
+
/** 脚本流式产生的输出项,用于 toolcall 进度。 */
|
|
50
|
+
onOutput?(items: readonly CodemodeOutputItem[]): void;
|
|
51
|
+
/** 嵌套调用的开始与结束,用于 toolcall 进度。 */
|
|
52
|
+
onCallProgress?(event: {
|
|
53
|
+
phase: "start" | "finish";
|
|
54
|
+
name: string;
|
|
55
|
+
args?: unknown;
|
|
56
|
+
status?: string;
|
|
57
|
+
}): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CodemodeSandbox {
|
|
61
|
+
run(options: SandboxRunOptions): Promise<SandboxOutcome>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** worker 入口是 esbuild 构建产物 `worker.js`(prelude 与消息类型已打进去),随仓库提交。 */
|
|
65
|
+
function workerUrl(): URL {
|
|
66
|
+
return new URL("worker.js", import.meta.url);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const NO_WRITES: StoreWrites = { set: {}, delete: [] };
|
|
70
|
+
|
|
71
|
+
/** 注册工具时调用一次:编译 wasm 并返回可复用的沙箱。 */
|
|
72
|
+
export async function createCodemodeSandbox(): Promise<CodemodeSandbox> {
|
|
73
|
+
const wasm = await compileQuickJSWasm();
|
|
74
|
+
return {
|
|
75
|
+
run: (options) => runInWorker(wasm, options),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function runInWorker(wasm: object, options: SandboxRunOptions): Promise<SandboxOutcome> {
|
|
80
|
+
return new Promise<SandboxOutcome>((resolve) => {
|
|
81
|
+
const worker = new Worker(workerUrl(), { workerData: { wasm } });
|
|
82
|
+
|
|
83
|
+
const output: CodemodeOutputItem[] = [];
|
|
84
|
+
const calls: ScriptCall[] = [];
|
|
85
|
+
const started = new Map<number, { name: string; at: number }>();
|
|
86
|
+
let settled = false;
|
|
87
|
+
let deadline: NodeJS.Timeout | undefined;
|
|
88
|
+
|
|
89
|
+
const finish = (
|
|
90
|
+
body:
|
|
91
|
+
| { ok: true; value: unknown; writes: StoreWrites }
|
|
92
|
+
| { ok: false; error: ScriptError; writes: StoreWrites },
|
|
93
|
+
): void => {
|
|
94
|
+
if (settled) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
settled = true;
|
|
98
|
+
if (deadline) {
|
|
99
|
+
clearTimeout(deadline);
|
|
100
|
+
}
|
|
101
|
+
void worker.terminate();
|
|
102
|
+
resolve({ ...body, output, calls });
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const fail = (message: string, kind: ScriptError["kind"] = "sandbox"): void => {
|
|
106
|
+
finish({ ok: false, error: { kind, message }, writes: NO_WRITES });
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// 默认没有超时(Infinity):setTimeout 会把 Infinity 当成 1ms,必须显式判断
|
|
110
|
+
if (Number.isFinite(options.timeoutMs)) {
|
|
111
|
+
deadline = setTimeout(() => {
|
|
112
|
+
fail(`the script exceeded its ${options.timeoutMs}ms deadline`, "timeout");
|
|
113
|
+
}, options.timeoutMs);
|
|
114
|
+
deadline.unref();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (options.signal) {
|
|
118
|
+
if (options.signal.aborted) {
|
|
119
|
+
fail("codemode execution was aborted", "aborted");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
options.signal.addEventListener(
|
|
123
|
+
"abort",
|
|
124
|
+
() => fail("codemode execution was aborted", "aborted"),
|
|
125
|
+
{
|
|
126
|
+
once: true,
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const handleCall = async (id: number, name: string, args: unknown): Promise<void> => {
|
|
132
|
+
started.set(id, { name, at: Date.now() });
|
|
133
|
+
options.onCallProgress?.({ phase: "start", name, args });
|
|
134
|
+
let outcome: SandboxCallOutcome;
|
|
135
|
+
try {
|
|
136
|
+
outcome = await options.onCall({ id, name, args });
|
|
137
|
+
} catch (error) {
|
|
138
|
+
outcome = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
139
|
+
}
|
|
140
|
+
const entry = started.get(id);
|
|
141
|
+
started.delete(id);
|
|
142
|
+
calls.push({
|
|
143
|
+
name,
|
|
144
|
+
status: outcome.ok ? "ok" : "error",
|
|
145
|
+
durationMs: entry ? Date.now() - entry.at : 0,
|
|
146
|
+
});
|
|
147
|
+
options.onCallProgress?.({ phase: "finish", name, status: outcome.ok ? "ok" : "error" });
|
|
148
|
+
if (!settled) {
|
|
149
|
+
worker.postMessage({ t: "result", id, ...outcome });
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const handleMessage = (frame: WorkerMessage): void => {
|
|
154
|
+
if (frame.t === "call") {
|
|
155
|
+
void handleCall(frame.id, frame.name, frame.args);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (frame.t === "output") {
|
|
159
|
+
output.push(...frame.items);
|
|
160
|
+
options.onOutput?.(frame.items);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (frame.ok) {
|
|
164
|
+
finish({ ok: true, value: frame.value, writes: frame.writes });
|
|
165
|
+
} else {
|
|
166
|
+
finish({ ok: false, error: frame.error, writes: frame.writes });
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
worker.on("message", (value: unknown) => {
|
|
171
|
+
const decoded = decodeWorkerMessage(value);
|
|
172
|
+
if (!decoded.ok) {
|
|
173
|
+
fail(`codemode worker sent an invalid message (${decoded.error})`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
handleMessage(decoded.frame);
|
|
177
|
+
});
|
|
178
|
+
worker.on("error", (error: Error) => fail(`codemode worker failed: ${error.message}`));
|
|
179
|
+
worker.on("exit", (code) => {
|
|
180
|
+
if (!settled) {
|
|
181
|
+
fail(`codemode worker exited early with code ${code}`);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
worker.postMessage({
|
|
186
|
+
t: "start",
|
|
187
|
+
code: options.code,
|
|
188
|
+
tools: [...options.tools],
|
|
189
|
+
store: options.store,
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codemode 脚本源码的解析:首行可选的 `// @options: {"max_output_tokens":…, "timeout_ms":…}`。
|
|
3
|
+
*
|
|
4
|
+
* 选项行与脚本首行共用一行,解析后原样保留为空行,脚本里报错的行号因此与用户写的
|
|
5
|
+
* 一致。解析失败(空输入、JSON 非法、未知字段、只有选项行没有代码)直接报错。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const OPTIONS_PREFIX = "// @options:";
|
|
9
|
+
const SUPPORTED_FIELDS = ["max_output_tokens", "timeout_ms"] as const;
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_OUTPUT_TOKENS = 10_000;
|
|
12
|
+
/**
|
|
13
|
+
* 脚本默认没有超时:编排一批工具调用本来就可能跑几分钟(例如批量读大文件)。
|
|
14
|
+
* 调用方中止与 `// @options: {"timeout_ms": …}` 都能提前结束。
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_TIMEOUT_MS = Infinity;
|
|
17
|
+
|
|
18
|
+
export class CodemodeSourceError extends Error {
|
|
19
|
+
constructor(message: string) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "CodemodeSourceError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CodemodeSourceOptions {
|
|
26
|
+
maxOutputTokens: number;
|
|
27
|
+
timeoutMs: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ParsedCodemodeSource {
|
|
31
|
+
code: string;
|
|
32
|
+
options: Partial<CodemodeSourceOptions>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseOptions(json: string): Partial<CodemodeSourceOptions> {
|
|
36
|
+
let parsed: unknown;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(json);
|
|
39
|
+
} catch {
|
|
40
|
+
throw new CodemodeSourceError(
|
|
41
|
+
`${OPTIONS_PREFIX} expects a JSON object, got ${JSON.stringify(json)}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
45
|
+
throw new CodemodeSourceError(`${OPTIONS_PREFIX} expects a JSON object`);
|
|
46
|
+
}
|
|
47
|
+
const options: Partial<CodemodeSourceOptions> = {};
|
|
48
|
+
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
49
|
+
if (!(SUPPORTED_FIELDS as readonly string[]).includes(key)) {
|
|
50
|
+
throw new CodemodeSourceError(
|
|
51
|
+
`${OPTIONS_PREFIX} does not support ${JSON.stringify(key)}; supported fields are ${SUPPORTED_FIELDS.map((field) => `\`${field}\``).join(" and ")}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
55
|
+
throw new CodemodeSourceError(`${OPTIONS_PREFIX} ${key} must be a positive number`);
|
|
56
|
+
}
|
|
57
|
+
if (key === "max_output_tokens") {
|
|
58
|
+
options.maxOutputTokens = value;
|
|
59
|
+
} else {
|
|
60
|
+
options.timeoutMs = value;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return options;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseCodemodeSource(input: string): ParsedCodemodeSource {
|
|
67
|
+
if (input.trim() === "") {
|
|
68
|
+
throw new CodemodeSourceError("Expected JavaScript source text (non-empty).");
|
|
69
|
+
}
|
|
70
|
+
const newline = input.indexOf("\n");
|
|
71
|
+
const firstLine = (newline === -1 ? input : input.slice(0, newline)).replace(/\r$/, "");
|
|
72
|
+
const trimmed = firstLine.trimStart();
|
|
73
|
+
if (!trimmed.startsWith(OPTIONS_PREFIX)) {
|
|
74
|
+
return { code: input, options: {} };
|
|
75
|
+
}
|
|
76
|
+
const code = newline === -1 ? "" : input.slice(newline);
|
|
77
|
+
if (code.trim() === "") {
|
|
78
|
+
throw new CodemodeSourceError(
|
|
79
|
+
"The @options line must be followed by JavaScript source on subsequent lines",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return { code, options: parseOptions(trimmed.slice(OPTIONS_PREFIX.length).trim()) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 只允许「首行可选的 @options + 任意 JS」的 Lark 语法,供支持语法约束采样的 provider
|
|
87
|
+
* 使用(`constrainedSampling`)。
|
|
88
|
+
*/
|
|
89
|
+
export const CODEMODE_SOURCE_GRAMMAR = String.raw`start: options? source
|
|
90
|
+
options: /\/\/ @options: \{[^\n]*\}\n/
|
|
91
|
+
source: /(?s:.)+/`;
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codemode 工具:模型写一段 JavaScript,脚本在 QuickJS VM(worker 线程)里执行,脚本唯一
|
|
3
|
+
* 的能力是调用 `tools.*`——每个嵌套调用都由主线程经本仓库的工具总线执行,因此工具实现
|
|
4
|
+
* 内部的审批(工作区外写入、Bash 沙箱提权等)照常生效;codemode 不再加自己的确认层。
|
|
5
|
+
*
|
|
6
|
+
* 可调用集合:总线上实际注册的工具减去 codemode 自身,执行时再与 active 列表求交——
|
|
7
|
+
* pi 自己的 `defaultTools` / `--tools` / 子代理白名单的排除因此同样生效。
|
|
8
|
+
*
|
|
9
|
+
* wasm 在注册这个工具时编译一次(`createCodemodeSandbox`),worker 复用编译结果。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { writeFile } from "node:fs/promises";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { Type } from "typebox";
|
|
19
|
+
|
|
20
|
+
import type { ToolPendant } from "../lib/pendant.js";
|
|
21
|
+
import { type ToolBus, toolResultText } from "../lib/tool-bus.js";
|
|
22
|
+
import { renderDeclarations, toScriptTools } from "./declarations.js";
|
|
23
|
+
import type { CodemodeOutputItem, ScriptError, StoreWrites } from "./protocol.js";
|
|
24
|
+
import { type CodemodeSandbox, createCodemodeSandbox, type ScriptCall } from "./sandbox.js";
|
|
25
|
+
import {
|
|
26
|
+
CODEMODE_SOURCE_GRAMMAR,
|
|
27
|
+
DEFAULT_OUTPUT_TOKENS,
|
|
28
|
+
DEFAULT_TIMEOUT_MS,
|
|
29
|
+
parseCodemodeSource,
|
|
30
|
+
} from "./source.js";
|
|
31
|
+
|
|
32
|
+
export const CODEMODE_TOOL_NAME = "codemode";
|
|
33
|
+
|
|
34
|
+
/** store 落在 session 的自定义 entry 上:随分支保存与恢复。 */
|
|
35
|
+
const STORE_ENTRY_TYPE = "codemode-store";
|
|
36
|
+
|
|
37
|
+
/** 估计 token 用的字符数(与 pi 一致)。 */
|
|
38
|
+
const CHARS_PER_TOKEN = 4;
|
|
39
|
+
|
|
40
|
+
export interface CodemodeTools {
|
|
41
|
+
/**
|
|
42
|
+
* 注册 codemode 工具。注册时编译 quickjs.wasm(编译失败则注册失败,由入口记成
|
|
43
|
+
* 警告),之后每次执行复用同一份编译结果。
|
|
44
|
+
*/
|
|
45
|
+
register(bus: ToolBus): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface CallableTool {
|
|
49
|
+
name: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
parameters?: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 只在 pi 有 active 工具概念时才求交(子代理、`--tools` 等场景)。 */
|
|
55
|
+
function allowedToolNames(pi: ExtensionAPI): Set<string> | undefined {
|
|
56
|
+
try {
|
|
57
|
+
const active = pi.getActiveTools();
|
|
58
|
+
return active.length > 0 ? new Set(active) : undefined;
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function collectTools(bus: ToolBus, allowed: Set<string> | undefined): CallableTool[] {
|
|
65
|
+
return bus
|
|
66
|
+
.list()
|
|
67
|
+
.filter((definition) => definition.name !== CODEMODE_TOOL_NAME)
|
|
68
|
+
.filter((definition) => allowed === undefined || allowed.has(definition.name))
|
|
69
|
+
.map((definition) => ({
|
|
70
|
+
name: definition.name,
|
|
71
|
+
description: definition.description,
|
|
72
|
+
parameters: definition.parameters,
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildDescription(tools: readonly CallableTool[]): string {
|
|
77
|
+
return [
|
|
78
|
+
"Run JavaScript code that orchestrates tool calls in a QuickJS sandbox.",
|
|
79
|
+
"- The code is the body of an async function: top-level `await` and `return` both work.",
|
|
80
|
+
"- Call tools with `await tools.<name>(args)`. Arguments and results make a JSON round trip,",
|
|
81
|
+
" and a failing tool rejects with an `Error` you can catch.",
|
|
82
|
+
"- Only what the script passes to `text(value)` / `console.log(...)` and its `return` value enter",
|
|
83
|
+
" this conversation; nested calls and their results stay out of it.",
|
|
84
|
+
"- `store(key, value)` and `load(key)` keep values across codemode calls in this session.",
|
|
85
|
+
"- The sandbox has no file system, network, timers or Node APIs: calling tools is its only",
|
|
86
|
+
" capability. Tool calls still go through each tool's own approvals, so a call that needs the",
|
|
87
|
+
" user's consent will ask for it.",
|
|
88
|
+
"- Prefer one script over many round trips: batch independent calls with `Promise.all`, filter in",
|
|
89
|
+
" JavaScript, and print only what matters.",
|
|
90
|
+
"",
|
|
91
|
+
"```ts",
|
|
92
|
+
renderDeclarations(tools),
|
|
93
|
+
"```",
|
|
94
|
+
].join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readStore(ctx: ExtensionContext): Record<string, unknown> {
|
|
98
|
+
const store = new Map<string, unknown>();
|
|
99
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
100
|
+
if (entry.type !== "custom" || entry.customType !== STORE_ENTRY_TYPE) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const data = (entry as { data?: unknown }).data;
|
|
104
|
+
if (typeof data !== "object" || data === null) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const writes = data as Partial<StoreWrites>;
|
|
108
|
+
for (const key of writes.delete ?? []) {
|
|
109
|
+
store.delete(key);
|
|
110
|
+
}
|
|
111
|
+
for (const [key, value] of Object.entries(writes.set ?? {})) {
|
|
112
|
+
store.set(key, value);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return Object.fromEntries(store);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function errorText(error: ScriptError): string {
|
|
119
|
+
const head = error.name ? `${error.name}: ${error.message}` : error.message;
|
|
120
|
+
return `${head}${error.stack && error.stack !== head ? `\n${error.stack}` : ""}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 文本项拼接:脚本的 `text()` 输出与返回值。 */
|
|
124
|
+
function textOf(items: readonly CodemodeOutputItem[]): string {
|
|
125
|
+
return items
|
|
126
|
+
.filter((item): item is { type: "text"; text: string } => item.type === "text")
|
|
127
|
+
.map((item) => item.text)
|
|
128
|
+
.join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function imagesOf(
|
|
132
|
+
items: readonly CodemodeOutputItem[],
|
|
133
|
+
): { type: "image"; data: string; mimeType: string }[] {
|
|
134
|
+
return items.filter(
|
|
135
|
+
(item): item is { type: "image"; data: string; mimeType: string } => item.type === "image",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 输出太长时把全文写到临时文件,返回路径(写失败返回错误文本)。 */
|
|
140
|
+
async function spillOutput(text: string): Promise<{ path: string } | { error: string }> {
|
|
141
|
+
const path = join(tmpdir(), `pi-codemode-${randomBytes(8).toString("hex")}.txt`);
|
|
142
|
+
try {
|
|
143
|
+
await writeFile(path, text);
|
|
144
|
+
return { path };
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 套用输出预算:文本超过 `maxTokens * 4` 个字符时保留头尾、写全文到临时文件。
|
|
152
|
+
* 图片不受影响,始终保留。
|
|
153
|
+
*/
|
|
154
|
+
async function truncateOutput(
|
|
155
|
+
text: string,
|
|
156
|
+
images: readonly { type: "image"; data: string; mimeType: string }[],
|
|
157
|
+
maxTokens: number,
|
|
158
|
+
): Promise<{ text: string; fullOutputPath?: string }> {
|
|
159
|
+
const budget = maxTokens * CHARS_PER_TOKEN;
|
|
160
|
+
if (text.length <= budget) {
|
|
161
|
+
return { text };
|
|
162
|
+
}
|
|
163
|
+
const headChars = Math.floor(budget / 2);
|
|
164
|
+
const tailChars = budget - headChars;
|
|
165
|
+
const removed = text.length - headChars - tailChars;
|
|
166
|
+
const spilled = await spillOutput(text);
|
|
167
|
+
const notice =
|
|
168
|
+
"path" in spilled
|
|
169
|
+
? `[Full output: ${spilled.path} (read with offset/limit)]`
|
|
170
|
+
: `[Could not save the full output: ${spilled.error}]`;
|
|
171
|
+
return {
|
|
172
|
+
text: [
|
|
173
|
+
`Warning: truncated output (original token count: ${Math.ceil(text.length / CHARS_PER_TOKEN)})`,
|
|
174
|
+
`Total output lines: ${text.split("\n").length}`,
|
|
175
|
+
"",
|
|
176
|
+
`${text.slice(0, headChars)}…${Math.ceil(removed / CHARS_PER_TOKEN)} tokens truncated…${
|
|
177
|
+
tailChars > 0 ? text.slice(-tailChars) : ""
|
|
178
|
+
}`,
|
|
179
|
+
"",
|
|
180
|
+
notice,
|
|
181
|
+
images.length > 0
|
|
182
|
+
? `(${images.length} image output${images.length === 1 ? "" : "s"} not shown here)`
|
|
183
|
+
: "",
|
|
184
|
+
]
|
|
185
|
+
.filter((line) => line !== "")
|
|
186
|
+
.join("\n"),
|
|
187
|
+
...("path" in spilled && { fullOutputPath: spilled.path }),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* TUI 面板:脚本原文放在 js 代码块里,用比脚本里最长反引号串更长的围栏包住,
|
|
193
|
+
* 免得脚本里的 ``` 把面板截断。
|
|
194
|
+
*/
|
|
195
|
+
function scriptPendant(code: string, subtitle: string): ToolPendant {
|
|
196
|
+
const longest = (code.match(/`+/g) ?? []).reduce((max, run) => Math.max(max, run.length), 0);
|
|
197
|
+
const fence = "`".repeat(Math.max(3, longest + 1));
|
|
198
|
+
return {
|
|
199
|
+
title: CODEMODE_TOOL_NAME,
|
|
200
|
+
subtitle,
|
|
201
|
+
markdown: `${fence}js\n${code.trimEnd()}\n${fence}`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function formatCallSummary(calls: readonly ScriptCall[]): string {
|
|
206
|
+
if (calls.length === 0) {
|
|
207
|
+
return "No tool calls were made.";
|
|
208
|
+
}
|
|
209
|
+
return `Tool calls made before the failure (they are not undone): ${calls
|
|
210
|
+
.map((call) => `${call.name} (${call.status})`)
|
|
211
|
+
.join(", ")}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
|
|
215
|
+
return {
|
|
216
|
+
async register(bus) {
|
|
217
|
+
const sandbox: CodemodeSandbox = await createCodemodeSandbox();
|
|
218
|
+
const tools = collectTools(bus, allowedToolNames(pi));
|
|
219
|
+
const callable = new Set(tools.map((tool) => tool.name));
|
|
220
|
+
const scriptTools = toScriptTools(tools);
|
|
221
|
+
|
|
222
|
+
bus.register({
|
|
223
|
+
name: CODEMODE_TOOL_NAME,
|
|
224
|
+
label: "codemode",
|
|
225
|
+
description: buildDescription(tools),
|
|
226
|
+
promptSnippet: "codemode: run JavaScript that calls tools in parallel",
|
|
227
|
+
promptGuidelines: [
|
|
228
|
+
"Use codemode to batch or chain several tool calls, or to filter large tool output down to what matters, instead of issuing many individual tool calls.",
|
|
229
|
+
],
|
|
230
|
+
parameters: Type.Object({
|
|
231
|
+
code: Type.String({
|
|
232
|
+
description:
|
|
233
|
+
'Raw JavaScript source. Top-level await and return work. May start with a `// @options: {"timeout_ms": 30000}` line.',
|
|
234
|
+
}),
|
|
235
|
+
}),
|
|
236
|
+
constrainedSampling: {
|
|
237
|
+
type: "grammar",
|
|
238
|
+
variants: { openai_lark: CODEMODE_SOURCE_GRAMMAR },
|
|
239
|
+
},
|
|
240
|
+
executionMode: "sequential",
|
|
241
|
+
|
|
242
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
243
|
+
let code: string;
|
|
244
|
+
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
245
|
+
let maxOutputTokens = DEFAULT_OUTPUT_TOKENS;
|
|
246
|
+
try {
|
|
247
|
+
const parsed = parseCodemodeSource(params.code);
|
|
248
|
+
code = parsed.code;
|
|
249
|
+
timeoutMs = parsed.options.timeoutMs ?? timeoutMs;
|
|
250
|
+
maxOutputTokens = parsed.options.maxOutputTokens ?? maxOutputTokens;
|
|
251
|
+
} catch (error) {
|
|
252
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
253
|
+
return {
|
|
254
|
+
isError: true,
|
|
255
|
+
content: [{ type: "text", text: message }],
|
|
256
|
+
details: {
|
|
257
|
+
error: message,
|
|
258
|
+
pendant: scriptPendant(params.code, "invalid @options"),
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const outcome = await sandbox.run({
|
|
264
|
+
code,
|
|
265
|
+
tools: scriptTools,
|
|
266
|
+
store: readStore(ctx),
|
|
267
|
+
timeoutMs,
|
|
268
|
+
signal,
|
|
269
|
+
onOutput: (items) => {
|
|
270
|
+
const text = textOf(items);
|
|
271
|
+
if (text) {
|
|
272
|
+
onUpdate?.({
|
|
273
|
+
content: [{ type: "text", text }],
|
|
274
|
+
details: { pendant: scriptPendant(code, text.slice(0, 80)) },
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
onCallProgress: ({ phase, name, args }) => {
|
|
279
|
+
const detail =
|
|
280
|
+
phase === "start" && args !== undefined
|
|
281
|
+
? ` ${JSON.stringify(args).slice(0, 200)}`
|
|
282
|
+
: "";
|
|
283
|
+
const line = `${phase === "start" ? "→" : "←"} ${name}${detail}`;
|
|
284
|
+
onUpdate?.({
|
|
285
|
+
content: [{ type: "text", text: line }],
|
|
286
|
+
details: { pendant: scriptPendant(code, line.slice(0, 120)) },
|
|
287
|
+
});
|
|
288
|
+
},
|
|
289
|
+
onCall: async ({ name, args }) => {
|
|
290
|
+
if (!callable.has(name)) {
|
|
291
|
+
return { ok: false, error: `Tool "${name}" is not available in codemode.` };
|
|
292
|
+
}
|
|
293
|
+
const result = await bus.executeTool(name, args, { ctx, signal });
|
|
294
|
+
const text = toolResultText(result);
|
|
295
|
+
if (result.isError) {
|
|
296
|
+
return { ok: false, error: text };
|
|
297
|
+
}
|
|
298
|
+
return { ok: true, value: text };
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const images = imagesOf(outcome.output);
|
|
303
|
+
if (outcome.ok) {
|
|
304
|
+
const writes = outcome.writes;
|
|
305
|
+
if (Object.keys(writes.set).length > 0 || writes.delete.length > 0) {
|
|
306
|
+
pi.appendEntry(STORE_ENTRY_TYPE, writes);
|
|
307
|
+
}
|
|
308
|
+
const value =
|
|
309
|
+
outcome.value === undefined ? "" : `\n\n${JSON.stringify(outcome.value, null, 2)}`;
|
|
310
|
+
const truncated = await truncateOutput(
|
|
311
|
+
textOf(outcome.output) + value,
|
|
312
|
+
images,
|
|
313
|
+
maxOutputTokens,
|
|
314
|
+
);
|
|
315
|
+
return {
|
|
316
|
+
content: [
|
|
317
|
+
{ type: "text" as const, text: truncated.text },
|
|
318
|
+
...images.map((image) => ({
|
|
319
|
+
type: "image" as const,
|
|
320
|
+
data: image.data,
|
|
321
|
+
mimeType: image.mimeType,
|
|
322
|
+
})),
|
|
323
|
+
],
|
|
324
|
+
details: {
|
|
325
|
+
calls: outcome.calls,
|
|
326
|
+
pendant: scriptPendant(code, `${outcome.calls.length} tool call(s)`),
|
|
327
|
+
...(truncated.fullOutputPath && { fullOutputPath: truncated.fullOutputPath }),
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const body = textOf(outcome.output);
|
|
333
|
+
const text = [
|
|
334
|
+
`Script failed (${outcome.error.kind}):\n${errorText(outcome.error)}`,
|
|
335
|
+
body,
|
|
336
|
+
formatCallSummary(outcome.calls),
|
|
337
|
+
]
|
|
338
|
+
.filter((part) => part !== "")
|
|
339
|
+
.join("\n\n");
|
|
340
|
+
return {
|
|
341
|
+
isError: true,
|
|
342
|
+
content: [{ type: "text", text }],
|
|
343
|
+
details: {
|
|
344
|
+
calls: outcome.calls,
|
|
345
|
+
error: outcome.error.kind,
|
|
346
|
+
pendant: scriptPendant(code, `failed (${outcome.error.kind})`),
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `quickjs.wasm` 的编译:codemode 注册工具时做一次,得到 `WebAssembly.Module`,之后
|
|
3
|
+
* 每次执行把它交给 worker 实例化 VM —— 编译只发生一次,不是每次执行都付出。
|
|
4
|
+
*
|
|
5
|
+
* wasm 由 `quickjs-wasi` 包分发(vercel-labs 的 QuickJS-NG wasi 构建),这里只负责
|
|
6
|
+
* 读文件并编译。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
|
|
12
|
+
interface WebAssemblyGlobal {
|
|
13
|
+
compile(bytes: Uint8Array): Promise<object>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function compileQuickJSWasm(): Promise<object> {
|
|
17
|
+
const resolved = createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm");
|
|
18
|
+
const { WebAssembly } = globalThis as unknown as { WebAssembly: WebAssemblyGlobal };
|
|
19
|
+
return await WebAssembly.compile(await readFile(resolved));
|
|
20
|
+
}
|