@trim21/personal-pi-extensions 0.1.678 → 0.1.680
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 -12
- package/package.json +1 -1
- package/src/claude-code/files.ts +12 -2
- package/src/codemode/declarations.ts +35 -12
- package/src/codemode/fs.ts +138 -0
- package/src/codemode/prelude.ts +44 -17
- package/src/codemode/protocol.ts +17 -2
- package/src/codemode/tool.ts +69 -11
- package/src/codemode/worker.js +48 -30
- package/src/codemode/worker.ts +3 -13
- package/src/gh/base.ts +62 -12
- package/src/gh/schemas.ts +230 -0
- package/src/gh/tools/download-release-assets.ts +51 -44
- package/src/gh/tools/get-workflow-jobs.ts +29 -18
- package/src/gh/tools/read-ci-logs.ts +37 -23
- package/src/gh/tools/read-issue-comments.ts +31 -24
- package/src/gh/tools/read-issue.ts +30 -22
- package/src/gh/tools/read-pr-comments.ts +62 -58
- package/src/gh/tools/read-pr-status.ts +31 -20
- package/src/gh/tools/read-pr.ts +30 -22
- package/src/gh/tools/wait-commit-checks.ts +35 -31
- package/src/gh/tools/wait-pr-checks.ts +30 -23
- package/src/index.ts +5 -2
- package/src/lib/tool-bus.ts +128 -8
- package/src/opencode/files.ts +12 -2
package/README.md
CHANGED
|
@@ -31,21 +31,34 @@
|
|
|
31
31
|
|
|
32
32
|
`codemode` 让模型写一段 JavaScript(作为 async 函数体,`await` 与 `return` 都可用),在
|
|
33
33
|
进程内的 worker 线程里用 QuickJS wasm VM 执行。VM 里没有 node、文件系统、网络、timer
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
- **嵌套调用经工具总线**:`
|
|
37
|
-
`execute
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
列表求交,所以 `personalExtensions.disabledTools`、pi 的 `defaultTools` /
|
|
42
|
-
|
|
34
|
+
或模块加载,脚本的出口只有 `call(name, args)` 与 `fs.read` / `fs.write` 两个文件原语。
|
|
35
|
+
|
|
36
|
+
- **嵌套调用经工具总线**:`call("Bash", { command })` 最终执行的是 `Bash` 工具自己的
|
|
37
|
+
`execute`,所以工具的审批照常生效(Bash 沙箱外执行会弹自己的提权确认)。codemode
|
|
38
|
+
不额外加确认层:脚本里连发十次调用就是十次工具自己的审批(需要审批的那些)。
|
|
39
|
+
- **可调用集合** = 总线上实际注册的工具减去排除名单(`codemode` 自身、`spawn-agent`、
|
|
40
|
+
以及两套文件工具集的 `Read`/`Edit`/`Write` 与 `read`/`edit`/`write`),执行时再与当前
|
|
41
|
+
active 列表求交,所以 `personalExtensions.disabledTools`、pi 的 `defaultTools` /
|
|
42
|
+
`--tools`、子代理的工具白名单都同样约束脚本。
|
|
43
|
+
- **文件读写只有 `fs` 一条路**:`fs.read(path)` 返回文件全文的原始 UTF-8 文本(不加行号、
|
|
44
|
+
不截断、不设大小上限,只有内容不是合法 UTF-8 时报错),`fs.write(path, content)` 整体写入并自动创建
|
|
45
|
+
父目录,相对路径相对当前 cwd。`fs.write` 与写类工具共用同一套保护:写前要求「已读且读后
|
|
46
|
+
未变」(记账与文件工具共享,工具读过的文件脚本可以直接写),工作区外写入走 write-guard
|
|
47
|
+
的 diff 审批,headless / Windows / `/bwrap-deny-request` 下直接拒绝。文件读写工具本身
|
|
48
|
+
不在可调用集合里,脚本要改一行内容也走 `fs.read` + `fs.write`(行级替换用 `Edit` 工具
|
|
49
|
+
直接改,不进脚本)。
|
|
43
50
|
- **只有脚本输出进上下文**:`text(value)` / `console.log(...)` 与 `return` 值进入工具结果,
|
|
44
51
|
中间的工具调用与它们的返回内容不会(也不在会话记录里留下工具调用条目)。
|
|
45
|
-
-
|
|
46
|
-
`
|
|
47
|
-
|
|
52
|
+
- **返回值**:声明了 `structuredSchema` 的工具(如 gh-readonly 的读类工具)把结果放在
|
|
53
|
+
`structuredResult` 里,`call()` 解包成对象给脚本;`{ ok: false, error }` 会 reject 成
|
|
54
|
+
`CallFailedError`(脚本可按 `instanceof CallFailedError` 区分调用失败与自身运行期错误)。
|
|
55
|
+
没有声明输出结构的工具回退成工具输出的文本。
|
|
56
|
+
- **脚本接口**:`call` / `CallFailedError` / `ALL_TOOLS` / `fs.read` / `fs.write` /
|
|
57
|
+
`text` / `image` / `exit` / `console.*` / `store.set` / `store.get` / `store.list`
|
|
58
|
+
(会话内持久的键值表);首行可选 `// @options: {"max_output_tokens": 10000}`。
|
|
48
59
|
脚本没有超时:死循环由调用方中止(Esc)结束,等嵌套调用返回(含用户审批弹窗)多久都不算超时。
|
|
60
|
+
- **工具描述**里给出每个可调用工具的 `declare function call(name, args): Promise<T>` 重载,
|
|
61
|
+
参数与返回类型都取自工具自己的 schema,所以模型在写脚本前就知道返回值形状。
|
|
49
62
|
- **store** 记在每次成功调用工具结果的 `details.store` 上(与 `src/lib/file-reads.ts` 的
|
|
50
63
|
已读记账同一套做法),下一次调用从当前分支的 toolResult 重放;输出超过 `max_output_tokens`
|
|
51
64
|
时头尾截断并把全文落到 `$TMPDIR/pi-codemode-*.txt`。
|
package/package.json
CHANGED
package/src/claude-code/files.ts
CHANGED
|
@@ -595,8 +595,14 @@ export function registerFileTools(
|
|
|
595
595
|
// 它们由 manager 的 onEnabled 回调注册,不在这里注册。
|
|
596
596
|
}
|
|
597
597
|
|
|
598
|
-
/**
|
|
599
|
-
|
|
598
|
+
/**
|
|
599
|
+
* 会更新 reads state 并随 details 持久化快照的工具名。
|
|
600
|
+
*
|
|
601
|
+
* 含 `codemode`:脚本的 `fs.read` / `fs.write`(src/codemode/fs.ts)与本工具集共用
|
|
602
|
+
* 同一个 ReadsState,脚本记下的已读落在 codemode 自己的工具结果里,重放分支时要一起
|
|
603
|
+
* 收回来(见 `restoreReads`)。
|
|
604
|
+
*/
|
|
605
|
+
const FILE_TOOL_NAMES = new Set(["Read", "Edit", "Write", "lsp-rename", "codemode"]);
|
|
600
606
|
|
|
601
607
|
export interface ClaudeCodeFileToolOptions extends LspServiceOptions {
|
|
602
608
|
/** 与 bash runtime 共享的非沙盒请求策略;独立入口不传,自建一份。 */
|
|
@@ -619,6 +625,8 @@ export interface FileToolset {
|
|
|
619
625
|
register(bus: ToolBus): void;
|
|
620
626
|
onLspEnabled(bus: ToolBus, service: LspService): void;
|
|
621
627
|
restoreReads(ctx: ExtensionContext): void;
|
|
628
|
+
/** 与工具共用的已读记账:codemode 的 fs 原语拿它做 stale 保护(两边互通)。 */
|
|
629
|
+
readonly reads: ReadsState;
|
|
622
630
|
}
|
|
623
631
|
|
|
624
632
|
/**
|
|
@@ -668,6 +676,8 @@ export function createClaudeCodeFileTools(
|
|
|
668
676
|
restoreReads(ctx) {
|
|
669
677
|
restoreReads(state, ctx.sessionManager, FILE_TOOL_NAMES);
|
|
670
678
|
},
|
|
679
|
+
|
|
680
|
+
reads: state,
|
|
671
681
|
};
|
|
672
682
|
|
|
673
683
|
// 独立入口没有注入 manager 时自建一份(入口共享的那份由入口创建)。
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* 把工具声明渲染成 codemode 脚本侧的 TypeScript:写进 codemode 的工具描述里,模型据此
|
|
3
|
+
* 知道脚本里能写 `await call("Read", { file_path: "..." })`,以及每个工具返回什么。
|
|
4
4
|
*
|
|
5
5
|
* 只处理本仓库工具实际用到的形状(object / array / string / number / boolean /
|
|
6
|
-
* union / enum),其余退化成 `unknown`——宁可少给类型,也不要编出错类型。
|
|
6
|
+
* const / union / enum),其余退化成 `unknown`——宁可少给类型,也不要编出错类型。
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { ScriptTool } from "./protocol.js";
|
|
@@ -12,6 +12,7 @@ interface ToolLike {
|
|
|
12
12
|
name: string;
|
|
13
13
|
description?: string;
|
|
14
14
|
parameters?: unknown;
|
|
15
|
+
structuredSchema?: unknown;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
type JsonSchema = Record<string, unknown>;
|
|
@@ -24,7 +25,7 @@ function literal(value: unknown): string {
|
|
|
24
25
|
return typeof value === "string" ? JSON.stringify(value) : String(value);
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
/**
|
|
28
|
+
/** 对象成员名:非法标识符用引号形式。 */
|
|
28
29
|
function propertyName(name: string): string {
|
|
29
30
|
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
30
31
|
}
|
|
@@ -33,6 +34,10 @@ function renderType(schema: unknown, indent: string): string {
|
|
|
33
34
|
if (!isSchema(schema)) {
|
|
34
35
|
return "unknown";
|
|
35
36
|
}
|
|
37
|
+
// TypeBox 的 Type.Literal / StringEnum 产出 const,而不是 enum
|
|
38
|
+
if ("const" in schema) {
|
|
39
|
+
return literal(schema.const);
|
|
40
|
+
}
|
|
36
41
|
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
|
37
42
|
return schema.enum.map((value) => literal(value)).join(" | ");
|
|
38
43
|
}
|
|
@@ -76,24 +81,42 @@ function renderType(schema: unknown, indent: string): string {
|
|
|
76
81
|
}
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
/**
|
|
80
|
-
function
|
|
84
|
+
/**
|
|
85
|
+
* 单个工具在脚本里的调用重载:`declare function call(name: "X", args: T): Promise<R>;`。
|
|
86
|
+
* 返回类型取自工具声明的输出结构,未声明就是文本(`string`)。
|
|
87
|
+
*/
|
|
88
|
+
function renderOverload(tool: ToolLike): string {
|
|
81
89
|
const summary = tool.description?.split("\n", 1)[0]?.trim();
|
|
82
|
-
const doc = summary ? `/** ${summary} */\n
|
|
83
|
-
|
|
90
|
+
const doc = summary ? `/** ${summary} */\n` : "";
|
|
91
|
+
const output =
|
|
92
|
+
tool.structuredSchema === undefined ? "string" : renderType(tool.structuredSchema, "");
|
|
93
|
+
return `${doc}declare function call(name: ${JSON.stringify(tool.name)}, args: ${renderType(tool.parameters, "")}): Promise<${output}>;`;
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
/** 给脚本用的工具清单(名字 + 说明)。 */
|
|
87
97
|
export function toScriptTools(tools: readonly ToolLike[]): ScriptTool[] {
|
|
88
|
-
return tools.map((tool) => ({
|
|
98
|
+
return tools.map((tool) => ({
|
|
99
|
+
name: tool.name,
|
|
100
|
+
description: tool.description,
|
|
101
|
+
structuredSchema: tool.structuredSchema,
|
|
102
|
+
}));
|
|
89
103
|
}
|
|
90
104
|
|
|
91
|
-
/**
|
|
105
|
+
/** 渲染脚本侧的声明:每个工具一条 `call` 重载,外加 `fs` 原语、`CallFailedError` 与全局辅助函数。 */
|
|
92
106
|
export function renderDeclarations(tools: readonly ToolLike[]): string {
|
|
93
|
-
const members = tools.map((tool) => ` ${renderToolMember(tool)}`).join("\n");
|
|
94
107
|
return [
|
|
95
|
-
|
|
108
|
+
...tools.map((tool) => renderOverload(tool)),
|
|
109
|
+
// 动态名字的兜底重载,必须放最后
|
|
110
|
+
"declare function call(name: string, args?: unknown): Promise<unknown>;",
|
|
96
111
|
"declare const ALL_TOOLS: Array<{ name: string; description?: string }>;",
|
|
112
|
+
// 文件原语:直接读写文件。它们不是工具,所以不在上面的 call 重载里。
|
|
113
|
+
[
|
|
114
|
+
"declare const fs: {",
|
|
115
|
+
" read(path: string): Promise<string>;",
|
|
116
|
+
" write(path: string, content: string): Promise<void>;",
|
|
117
|
+
"};",
|
|
118
|
+
].join("\n"),
|
|
119
|
+
'declare class CallFailedError extends Error { readonly name: "CallFailedError"; }',
|
|
97
120
|
"declare function text(value: unknown): void;",
|
|
98
121
|
"declare function image(value: unknown): void;",
|
|
99
122
|
"declare function exit(): void;",
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codemode 的文件原语(`fs.read` / `fs.write`):宿主用 `node:fs/promises` 直接读写,
|
|
3
|
+
* 不走工具总线——它们不是工具,不出现在工具列表里,也不参与工具开关与 active 工具求交。
|
|
4
|
+
*
|
|
5
|
+
* 两条保护与文件工具完全一致,而且共用同一份状态:
|
|
6
|
+
* - stale 保护:写入前要求目标文件「已读且读后未变」(`file-reads.ts` 的
|
|
7
|
+
* `requireCurrentRead`),文件不存在时允许直接创建;读与写都记账,因此工具读过的
|
|
8
|
+
* 文件脚本可以直接写,反之亦然。
|
|
9
|
+
* - 审批:写入经 write-guard(工作区内与 `/tmp` 放行,区外弹 diff 审批,headless /
|
|
10
|
+
* Windows / 请求策略下直接拒绝)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
14
|
+
import { dirname, resolve } from "node:path";
|
|
15
|
+
|
|
16
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { Type } from "typebox";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
type FileSnapshot,
|
|
21
|
+
type ReadsState,
|
|
22
|
+
recordRead,
|
|
23
|
+
requireCurrentRead,
|
|
24
|
+
snapshotOf,
|
|
25
|
+
} from "../lib/file-reads.js";
|
|
26
|
+
import { parseWithSchema } from "../lib/parse-with-schema.js";
|
|
27
|
+
import type { RequestPolicy } from "../lib/request-policy.js";
|
|
28
|
+
import { guardWriteAccess } from "../lib/write-guard.js";
|
|
29
|
+
|
|
30
|
+
const readArgsSchema = Type.Object({ path: Type.String() });
|
|
31
|
+
const writeArgsSchema = Type.Object({ path: Type.String(), content: Type.String() });
|
|
32
|
+
|
|
33
|
+
const FS_READ = "fs.read";
|
|
34
|
+
const FS_WRITE = "fs.write";
|
|
35
|
+
|
|
36
|
+
export interface FsCallContext {
|
|
37
|
+
ctx: ExtensionContext;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 一次 fs 原语的结果:`value` 回给脚本,`reads` 是本次新增的已读记账(进 details.reads)。 */
|
|
42
|
+
export interface FsCallResult {
|
|
43
|
+
value?: unknown;
|
|
44
|
+
reads?: Record<string, FileSnapshot>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CodemodeFs {
|
|
48
|
+
/** 该名字是否归 fs 原语(`fs.read` / `fs.write`)。 */
|
|
49
|
+
handles(name: string): boolean;
|
|
50
|
+
/** 执行一次 fs 原语;失败抛错,由调用方转成脚本侧的错误。 */
|
|
51
|
+
execute(name: string, args: unknown, call: FsCallContext): Promise<FsCallResult>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CodemodeFsOptions {
|
|
55
|
+
/** 与写类工具共享的请求策略:`/bwrap-deny-request` 生效时工作区外写入直接拒绝。 */
|
|
56
|
+
policy: RequestPolicy;
|
|
57
|
+
/** 与文件工具共享的已读记账。 */
|
|
58
|
+
reads: ReadsState;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 解码 UTF-8:`ignoreBOM: true` 表示不特殊处理 BOM(即保留它),这样内容与磁盘字节
|
|
63
|
+
* 一一对应——记账指纹与审批预览都是按原始字节比对的,丢掉 BOM 会让两者对不上。
|
|
64
|
+
*/
|
|
65
|
+
function decodeUtf8(buffer: Uint8Array, path: string): string {
|
|
66
|
+
try {
|
|
67
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer);
|
|
68
|
+
} catch {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Cannot read "${path}" as UTF-8 text: the file is not valid UTF-8 (binary files are not supported).`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 读文件;不存在时返回 undefined(新建文件免已读),其余错误照常抛出。 */
|
|
76
|
+
async function readIfExists(path: string, signal?: AbortSignal): Promise<Buffer | undefined> {
|
|
77
|
+
try {
|
|
78
|
+
return await readFile(path, { signal });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createCodemodeFs(options: CodemodeFsOptions): CodemodeFs {
|
|
88
|
+
const { policy, reads } = options;
|
|
89
|
+
|
|
90
|
+
async function read(args: unknown, call: FsCallContext): Promise<FsCallResult> {
|
|
91
|
+
const { path } = parseWithSchema(readArgsSchema, args);
|
|
92
|
+
const absolutePath = resolve(call.ctx.cwd, path);
|
|
93
|
+
// 不设大小上限:内容不进模型上下文,放不下时(VM 堆不够)会以错误回到脚本里
|
|
94
|
+
const buffer = await readFile(absolutePath, { signal: call.signal });
|
|
95
|
+
const text = decodeUtf8(buffer, absolutePath);
|
|
96
|
+
// 指纹按磁盘字节算:与文件工具共用记账,两侧的 digest 必须能互相对上
|
|
97
|
+
const recorded = await recordRead(reads, absolutePath, snapshotOf(buffer));
|
|
98
|
+
return { value: text, reads: recorded };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function write(args: unknown, call: FsCallContext): Promise<FsCallResult> {
|
|
102
|
+
const { path, content } = parseWithSchema(writeArgsSchema, args);
|
|
103
|
+
const absolutePath = resolve(call.ctx.cwd, path);
|
|
104
|
+
const existing = await readIfExists(absolutePath, call.signal);
|
|
105
|
+
if (existing !== undefined) {
|
|
106
|
+
await requireCurrentRead(reads, absolutePath, existing);
|
|
107
|
+
}
|
|
108
|
+
await guardWriteAccess(call.ctx, {
|
|
109
|
+
toolName: FS_WRITE,
|
|
110
|
+
absolutePath,
|
|
111
|
+
mutation: {
|
|
112
|
+
contentOld: existing === undefined ? "" : decodeUtf8(existing, absolutePath),
|
|
113
|
+
contentNew: content,
|
|
114
|
+
},
|
|
115
|
+
policy,
|
|
116
|
+
signal: call.signal,
|
|
117
|
+
});
|
|
118
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
119
|
+
await writeFile(absolutePath, content, { encoding: "utf8", signal: call.signal });
|
|
120
|
+
const recorded = await recordRead(reads, absolutePath, snapshotOf(content));
|
|
121
|
+
return { reads: recorded };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
handles(name) {
|
|
126
|
+
return name === FS_READ || name === FS_WRITE;
|
|
127
|
+
},
|
|
128
|
+
async execute(name, args, call) {
|
|
129
|
+
if (name === FS_READ) {
|
|
130
|
+
return await read(args, call);
|
|
131
|
+
}
|
|
132
|
+
if (name === FS_WRITE) {
|
|
133
|
+
return await write(args, call);
|
|
134
|
+
}
|
|
135
|
+
throw new Error(`Unknown fs operation "${name}".`);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/codemode/prelude.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* codemode 脚本侧的 prelude:在 QuickJS VM 里先于脚本求值,构建脚本能看到的全部
|
|
3
|
-
* 能力(`
|
|
4
|
-
*
|
|
3
|
+
* 能力(`call` / `CallFailedError` / `ALL_TOOLS` / `fs` / `text` / `image` / `exit` /
|
|
4
|
+
* `console` / `store`),并把宿主桥接封在闭包里——脚本拿不到 `bridge` 本身。
|
|
5
5
|
*
|
|
6
6
|
* 值与参数过桥时都是 JSON 文本,本侧负责 parse/stringify;异步调用用一个 pending
|
|
7
|
-
* 表把 id 映射到 promise,由宿主在结果到达时 settle
|
|
7
|
+
* 表把 id 映射到 promise,由宿主在结果到达时 settle。每次调用失败(工具或 fs 原语)
|
|
8
|
+
* 都由宿主以 `ok: false` 回报,本侧统一 reject 成 `CallFailedError`(脚本可以按
|
|
9
|
+
* instanceof 区分「调用失败」与自己的运行期错误)。
|
|
8
10
|
*
|
|
9
11
|
* 求值结果是一个函数 `(bridge, toolsJson, storeJson) => { settle, run, stalled }`。
|
|
10
12
|
* `bridge(kind, a, b, c)`:
|
|
11
|
-
* - `"call"`(id, name, argsJson
|
|
13
|
+
* - `"call"`(id, name, argsJson):工具,以及名字为 `fs.read` / `fs.write` 的 fs 原语
|
|
12
14
|
* - `"output"`("text", text)/("image", data, mimeType)
|
|
13
15
|
* - `"done"`(ok, valueJsonOrErrorJson, writesJson)
|
|
14
16
|
*/
|
|
@@ -28,6 +30,13 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
|
|
|
28
30
|
const promiseThen = Promise.prototype.then;
|
|
29
31
|
const ErrorCtor = Error;
|
|
30
32
|
const TypeErrorCtor = TypeError;
|
|
33
|
+
// 嵌套调用失败统一用它 reject:脚本能按 instanceof 区分「工具失败」与自身运行期错误
|
|
34
|
+
class CallFailedError extends ErrorCtor {
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "CallFailedError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
31
40
|
const pending = new Map();
|
|
32
41
|
let nextId = 1;
|
|
33
42
|
let finished = false;
|
|
@@ -89,20 +98,36 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
|
|
|
89
98
|
});
|
|
90
99
|
}
|
|
91
100
|
|
|
92
|
-
const
|
|
101
|
+
const callers = new Map();
|
|
93
102
|
const allTools = [];
|
|
94
|
-
for (const { name,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
tools[jsName] = fn;
|
|
99
|
-
allTools.push(Object.freeze({ name: jsName, description }));
|
|
100
|
-
}
|
|
101
|
-
if (!(name in tools)) tools[name] = fn;
|
|
103
|
+
for (const { name, description } of parse(toolsJson)) {
|
|
104
|
+
if (callers.has(name)) continue;
|
|
105
|
+
callers.set(name, caller(name));
|
|
106
|
+
allTools.push(Object.freeze({ name, description }));
|
|
102
107
|
}
|
|
103
|
-
Object.freeze(tools);
|
|
104
108
|
Object.freeze(allTools);
|
|
105
109
|
|
|
110
|
+
function call(name, args) {
|
|
111
|
+
const fn = callers.get(name);
|
|
112
|
+
if (fn === undefined) {
|
|
113
|
+
return Promise.reject(
|
|
114
|
+
new CallFailedError('Tool "' + String(name) + '" is not available in codemode.'),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return fn(args);
|
|
118
|
+
}
|
|
119
|
+
Object.freeze(call);
|
|
120
|
+
|
|
121
|
+
// 脚本的文件原语:与工具走同一条桥,但它们是内建能力而不是工具——不进 ALL_TOOLS,
|
|
122
|
+
// 也不出现在工具描述的工具重载里(声明单独渲染),写审批与已读记账由宿主负责。
|
|
123
|
+
// 脚本侧是 Node 风格的位置参数,过桥仍是一个可校验的对象。
|
|
124
|
+
const readFile = caller("fs.read");
|
|
125
|
+
const writeFile = caller("fs.write");
|
|
126
|
+
const fs = Object.freeze({
|
|
127
|
+
read: (path) => readFile({ path }),
|
|
128
|
+
write: (path, content) => writeFile({ path, content }),
|
|
129
|
+
});
|
|
130
|
+
|
|
106
131
|
// key -> JSON 文本;容量按 key 与 JSON 的字符数计
|
|
107
132
|
const stored = new Map();
|
|
108
133
|
const writes = new Map();
|
|
@@ -242,8 +267,10 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
|
|
|
242
267
|
}
|
|
243
268
|
Object.freeze(console);
|
|
244
269
|
|
|
245
|
-
Object.defineProperty(globalThis, "
|
|
270
|
+
Object.defineProperty(globalThis, "call", { value: call, enumerable: true });
|
|
271
|
+
Object.defineProperty(globalThis, "CallFailedError", { value: CallFailedError, enumerable: true });
|
|
246
272
|
Object.defineProperty(globalThis, "ALL_TOOLS", { value: allTools, enumerable: true });
|
|
273
|
+
Object.defineProperty(globalThis, "fs", { value: fs, enumerable: true });
|
|
247
274
|
Object.defineProperty(globalThis, "console", { value: console, enumerable: true });
|
|
248
275
|
Object.defineProperty(globalThis, "text", { value: text, enumerable: true });
|
|
249
276
|
Object.defineProperty(globalThis, "image", { value: image, enumerable: true });
|
|
@@ -255,7 +282,7 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
|
|
|
255
282
|
if (!entry) return;
|
|
256
283
|
pending.delete(id);
|
|
257
284
|
if (!ok) {
|
|
258
|
-
entry.reject(new
|
|
285
|
+
entry.reject(new CallFailedError(payload));
|
|
259
286
|
return;
|
|
260
287
|
}
|
|
261
288
|
let value;
|
|
@@ -270,7 +297,7 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
|
|
|
270
297
|
run(fn) {
|
|
271
298
|
let promise;
|
|
272
299
|
try {
|
|
273
|
-
promise = fn(
|
|
300
|
+
promise = fn();
|
|
274
301
|
} catch (error) {
|
|
275
302
|
done(false, describeError(error));
|
|
276
303
|
return;
|
package/src/codemode/protocol.ts
CHANGED
|
@@ -13,6 +13,18 @@
|
|
|
13
13
|
import { type TSchema, Type } from "typebox";
|
|
14
14
|
import { Value } from "typebox/value";
|
|
15
15
|
|
|
16
|
+
// ── 资源上限 ─────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* QuickJS VM 的堆上限。它不是预留(创建 VM 只占几 MiB,按脚本实际分配增长),而是
|
|
20
|
+
* 「超量分配变成脚本里可捕获的 InternalError,而不是拖垮宿主」的那条线。
|
|
21
|
+
*
|
|
22
|
+
* 文件读取没有单独的上限:读进来的内容不进模型上下文,所以不需要按上下文预算裁剪;
|
|
23
|
+
* 真正放不下时(VM 堆不够、或超出宿主字符串/缓冲上限)会以错误回到脚本里,让它自己
|
|
24
|
+
* 决定怎么办。
|
|
25
|
+
*/
|
|
26
|
+
export const MEMORY_LIMIT_BYTES = 2 * 1024 ** 3;
|
|
27
|
+
|
|
16
28
|
// ── 消息 schema ──────────────────────────────────────────────────────────────
|
|
17
29
|
|
|
18
30
|
const outputItemSchema = Type.Union([
|
|
@@ -23,6 +35,8 @@ const outputItemSchema = Type.Union([
|
|
|
23
35
|
const toolDeclSchema = Type.Object({
|
|
24
36
|
name: Type.String(),
|
|
25
37
|
description: Type.Optional(Type.String()),
|
|
38
|
+
/** 工具的 structuredSchema(TypeBox schema):脚本侧据此渲染 call() 的返回类型。 */
|
|
39
|
+
structuredSchema: Type.Optional(Type.Unknown()),
|
|
26
40
|
});
|
|
27
41
|
|
|
28
42
|
const startSchema = Type.Object({
|
|
@@ -54,7 +68,7 @@ const callSchema = Type.Object({
|
|
|
54
68
|
args: Type.Unknown(),
|
|
55
69
|
});
|
|
56
70
|
|
|
57
|
-
const
|
|
71
|
+
const outputFrameSchema = Type.Object({
|
|
58
72
|
t: Type.Literal("output"),
|
|
59
73
|
items: Type.Array(outputItemSchema),
|
|
60
74
|
});
|
|
@@ -87,7 +101,7 @@ const doneSchema = Type.Union([
|
|
|
87
101
|
]);
|
|
88
102
|
|
|
89
103
|
const hostMessageSchema = Type.Union([startSchema, resultSchema]);
|
|
90
|
-
const workerMessageSchema = Type.Union([callSchema,
|
|
104
|
+
const workerMessageSchema = Type.Union([callSchema, outputFrameSchema, doneSchema]);
|
|
91
105
|
|
|
92
106
|
/** worker 启动数据:注册时编译好的 wasm 模块(结构化克隆可以带它跨线程)。 */
|
|
93
107
|
export interface WorkerBootstrap {
|
|
@@ -117,6 +131,7 @@ export interface StoreWrites {
|
|
|
117
131
|
export interface ScriptTool {
|
|
118
132
|
name: string;
|
|
119
133
|
description?: string;
|
|
134
|
+
structuredSchema?: unknown;
|
|
120
135
|
}
|
|
121
136
|
|
|
122
137
|
export type HostMessage =
|
package/src/codemode/tool.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* codemode 工具:模型写一段 JavaScript,脚本在 QuickJS VM(worker
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* codemode 工具:模型写一段 JavaScript,脚本在 QuickJS VM(worker 线程)里执行,脚本的
|
|
3
|
+
* 能力有两条:`call(name, args)` 调用工具(每个嵌套调用都由主线程经本仓库的工具总线执行,
|
|
4
|
+
* 因此工具实现内部的审批——工作区外写入、Bash 沙箱提权等——照常生效;codemode 不再加
|
|
5
|
+
* 自己的确认层),以及 `fs.read` / `fs.write` 两个文件原语(同样由主线程执行,与文件工具
|
|
6
|
+
* 共用写审批与已读记账,见 fs.ts)。
|
|
5
7
|
*
|
|
6
|
-
* 可调用集合:总线上实际注册的工具减去
|
|
8
|
+
* 可调用集合:总线上实际注册的工具减去 `EXCLUDED_TOOL_NAMES`,执行时再与 active
|
|
7
9
|
* 列表求交——pi 自己的 `defaultTools` / `--tools` / 子代理白名单的排除因此同样生效。
|
|
8
|
-
* spawn-agent 被排除是因为它启动一个新的隔离会话、成本与运行时长都不适合放进脚本编排。
|
|
9
10
|
*
|
|
10
11
|
* wasm 在注册这个工具时编译一次(`createCodemodeSandbox`),worker 复用编译结果。
|
|
11
12
|
*/
|
|
@@ -18,33 +19,59 @@ import { join } from "node:path";
|
|
|
18
19
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
20
|
import { Type } from "typebox";
|
|
20
21
|
|
|
22
|
+
import type { FileSnapshot, ReadsState } from "../lib/file-reads.js";
|
|
21
23
|
import type { ToolPendant } from "../lib/pendant.js";
|
|
24
|
+
import type { RequestPolicy } from "../lib/request-policy.js";
|
|
22
25
|
import { type ToolBus, toolResultText } from "../lib/tool-bus.js";
|
|
23
26
|
import { renderDeclarations, toScriptTools } from "./declarations.js";
|
|
27
|
+
import { createCodemodeFs } from "./fs.js";
|
|
24
28
|
import type { CodemodeOutputItem, ScriptError, StoreWrites } from "./protocol.js";
|
|
25
29
|
import { type CodemodeSandbox, createCodemodeSandbox, type ScriptCall } from "./sandbox.js";
|
|
26
30
|
import { CODEMODE_SOURCE_GRAMMAR, DEFAULT_OUTPUT_TOKENS, parseCodemodeSource } from "./source.js";
|
|
27
31
|
|
|
28
32
|
export const CODEMODE_TOOL_NAME = "codemode";
|
|
29
33
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
34
|
+
/**
|
|
35
|
+
* 不暴露给脚本的工具:
|
|
36
|
+
* - codemode 自身(防递归);
|
|
37
|
+
* - spawn-agent:它启动一个新的隔离会话,成本与运行时长都不适合放进脚本编排;
|
|
38
|
+
* - 两套文件工具集的读写工具:脚本用 `fs.read` / `fs.write`(原文、不截断、按路径整体
|
|
39
|
+
* 写入),不重复给一套为 LLM 上下文设计的行号/锚点语义。
|
|
40
|
+
*/
|
|
41
|
+
const EXCLUDED_TOOL_NAMES: ReadonlySet<string> = new Set([
|
|
42
|
+
CODEMODE_TOOL_NAME,
|
|
43
|
+
"spawn-agent",
|
|
44
|
+
"Read",
|
|
45
|
+
"Edit",
|
|
46
|
+
"Write",
|
|
47
|
+
"read",
|
|
48
|
+
"edit",
|
|
49
|
+
"write",
|
|
50
|
+
]);
|
|
32
51
|
|
|
33
52
|
/** 估计 token 用的字符数(与 pi 一致)。 */
|
|
34
53
|
const CHARS_PER_TOKEN = 4;
|
|
35
54
|
|
|
55
|
+
export interface CodemodeToolDeps {
|
|
56
|
+
/** 与写类工具共享的非沙盒请求策略(写审批要用)。 */
|
|
57
|
+
policy: RequestPolicy;
|
|
58
|
+
/** 与文件工具共享的已读记账:脚本的 fs 原语据此做 stale 保护(两边互通)。 */
|
|
59
|
+
reads: ReadsState;
|
|
60
|
+
}
|
|
61
|
+
|
|
36
62
|
export interface CodemodeTools {
|
|
37
63
|
/**
|
|
38
64
|
* 注册 codemode 工具。注册时编译 quickjs.wasm(编译失败则注册失败,由入口记成
|
|
39
65
|
* 警告),之后每次执行复用同一份编译结果。
|
|
40
66
|
*/
|
|
41
|
-
register(bus: ToolBus): Promise<void>;
|
|
67
|
+
register(bus: ToolBus, deps: CodemodeToolDeps): Promise<void>;
|
|
42
68
|
}
|
|
43
69
|
|
|
44
70
|
interface CallableTool {
|
|
45
71
|
name: string;
|
|
46
72
|
description?: string;
|
|
47
73
|
parameters?: unknown;
|
|
74
|
+
structuredSchema?: unknown;
|
|
48
75
|
}
|
|
49
76
|
|
|
50
77
|
/** 只在 pi 有 active 工具概念时才求交(子代理、`--tools` 等场景)。 */
|
|
@@ -66,6 +93,7 @@ function collectTools(bus: ToolBus, allowed: Set<string> | undefined): CallableT
|
|
|
66
93
|
name: definition.name,
|
|
67
94
|
description: definition.description,
|
|
68
95
|
parameters: definition.parameters,
|
|
96
|
+
structuredSchema: definition.structuredSchema,
|
|
69
97
|
}));
|
|
70
98
|
}
|
|
71
99
|
|
|
@@ -73,8 +101,8 @@ function buildDescription(tools: readonly CallableTool[]): string {
|
|
|
73
101
|
return [
|
|
74
102
|
"Run JavaScript code that orchestrates tool calls in a QuickJS sandbox.",
|
|
75
103
|
"- The code is the body of an async function: top-level `await` and `return` both work.",
|
|
76
|
-
"- Call tools with `await
|
|
77
|
-
" and a failing tool rejects with
|
|
104
|
+
"- Call tools with `await call(name, args)`. Arguments and results make a JSON round trip,",
|
|
105
|
+
" and a failing tool rejects with a `CallFailedError` you can catch.",
|
|
78
106
|
"- Only what the script passes to `text(value)` / `console.log(...)` and its `return` value enter",
|
|
79
107
|
" this conversation; nested calls and their results stay out of it.",
|
|
80
108
|
"- `store.set(key, value)`, `store.get(key)` and `store.list()` are a small key/value store that",
|
|
@@ -124,6 +152,11 @@ function readStore(ctx: ExtensionContext): Record<string, unknown> {
|
|
|
124
152
|
return Object.fromEntries(store);
|
|
125
153
|
}
|
|
126
154
|
|
|
155
|
+
/** 本次脚本有没有通过 fs 读写过文件(决定 details 里要不要带 reads 记账)。 */
|
|
156
|
+
function hasReads(reads: Record<string, FileSnapshot>): boolean {
|
|
157
|
+
return Object.keys(reads).length > 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
127
160
|
function errorText(error: ScriptError): string {
|
|
128
161
|
const head = error.name ? `${error.name}: ${error.message}` : error.message;
|
|
129
162
|
return `${head}${error.stack && error.stack !== head ? `\n${error.stack}` : ""}`;
|
|
@@ -222,8 +255,9 @@ function formatCallSummary(calls: readonly ScriptCall[]): string {
|
|
|
222
255
|
|
|
223
256
|
export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
|
|
224
257
|
return {
|
|
225
|
-
async register(bus) {
|
|
258
|
+
async register(bus, deps) {
|
|
226
259
|
const sandbox: CodemodeSandbox = await createCodemodeSandbox();
|
|
260
|
+
const fs = createCodemodeFs(deps);
|
|
227
261
|
const tools = collectTools(bus, allowedToolNames(pi));
|
|
228
262
|
const callable = new Set(tools.map((tool) => tool.name));
|
|
229
263
|
const scriptTools = toScriptTools(tools);
|
|
@@ -267,6 +301,9 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
|
|
|
267
301
|
};
|
|
268
302
|
}
|
|
269
303
|
|
|
304
|
+
// 脚本的 fs 原语读到的文件也进同一份记账,随结果持久化(与 details.store 同一处)
|
|
305
|
+
const recordedReads: Record<string, FileSnapshot> = {};
|
|
306
|
+
|
|
270
307
|
const outcome = await sandbox.run({
|
|
271
308
|
code,
|
|
272
309
|
tools: scriptTools,
|
|
@@ -293,10 +330,29 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
|
|
|
293
330
|
});
|
|
294
331
|
},
|
|
295
332
|
onCall: async ({ name, args }) => {
|
|
333
|
+
if (fs.handles(name)) {
|
|
334
|
+
try {
|
|
335
|
+
const result = await fs.execute(name, args, { ctx, signal });
|
|
336
|
+
if (result.reads !== undefined) {
|
|
337
|
+
Object.assign(recordedReads, result.reads);
|
|
338
|
+
}
|
|
339
|
+
return { ok: true, value: result.value };
|
|
340
|
+
} catch (error) {
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
error: error instanceof Error ? error.message : String(error),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
}
|
|
296
347
|
if (!callable.has(name)) {
|
|
297
348
|
return { ok: false, error: `Tool "${name}" is not available in codemode.` };
|
|
298
349
|
}
|
|
299
350
|
const result = await bus.executeTool(name, args, { ctx, signal });
|
|
351
|
+
if (result.structuredResult) {
|
|
352
|
+
return result.structuredResult.ok
|
|
353
|
+
? { ok: true, value: result.structuredResult.value }
|
|
354
|
+
: { ok: false, error: result.structuredResult.error };
|
|
355
|
+
}
|
|
300
356
|
const text = toolResultText(result);
|
|
301
357
|
if (result.isError) {
|
|
302
358
|
return { ok: false, error: text };
|
|
@@ -329,6 +385,7 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
|
|
|
329
385
|
calls: outcome.calls,
|
|
330
386
|
// store 的写入随工具结果持久化,下一次调用从这里重放恢复
|
|
331
387
|
...(hasWrites && { store: writes }),
|
|
388
|
+
...(hasReads(recordedReads) && { reads: recordedReads }),
|
|
332
389
|
pendant: scriptPendant(code, `${outcome.calls.length} tool call(s)`),
|
|
333
390
|
...(truncated.fullOutputPath && { fullOutputPath: truncated.fullOutputPath }),
|
|
334
391
|
},
|
|
@@ -349,6 +406,7 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
|
|
|
349
406
|
details: {
|
|
350
407
|
calls: outcome.calls,
|
|
351
408
|
error: outcome.error.kind,
|
|
409
|
+
...(hasReads(recordedReads) && { reads: recordedReads }),
|
|
352
410
|
pendant: scriptPendant(code, `failed (${outcome.error.kind})`),
|
|
353
411
|
},
|
|
354
412
|
};
|