@trim21/personal-pi-extensions 0.1.679 → 0.1.681
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 +24 -14
- package/package.json +2 -2
- package/src/claude-code/files.ts +12 -2
- package/src/codemode/declarations.ts +8 -1
- package/src/codemode/fs.ts +138 -0
- package/src/codemode/prelude.ts +16 -5
- package/src/codemode/protocol.ts +12 -0
- package/src/codemode/tool.ts +60 -9
- package/src/codemode/worker.js +8533 -53
- package/src/codemode/worker.ts +1 -3
- package/src/index.ts +5 -2
- package/src/opencode/files.ts +12 -2
package/README.md
CHANGED
|
@@ -31,24 +31,31 @@
|
|
|
31
31
|
|
|
32
32
|
`codemode` 让模型写一段 JavaScript(作为 async 函数体,`await` 与 `return` 都可用),在
|
|
33
33
|
进程内的 worker 线程里用 QuickJS wasm VM 执行。VM 里没有 node、文件系统、网络、timer
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
- **嵌套调用经工具总线**:`call("
|
|
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
52
|
- **返回值**:声明了 `structuredSchema` 的工具(如 gh-readonly 的读类工具)把结果放在
|
|
46
53
|
`structuredResult` 里,`call()` 解包成对象给脚本;`{ ok: false, error }` 会 reject 成
|
|
47
|
-
`CallFailedError`(脚本可按 `instanceof CallFailedError`
|
|
54
|
+
`CallFailedError`(脚本可按 `instanceof CallFailedError` 区分调用失败与自身运行期错误)。
|
|
48
55
|
没有声明输出结构的工具回退成工具输出的文本。
|
|
49
|
-
- **脚本接口**:`call` / `CallFailedError` / `ALL_TOOLS` / `
|
|
50
|
-
`console.*` / `store.set` / `store.get` / `store.list
|
|
51
|
-
`// @options: {"max_output_tokens": 10000}`。
|
|
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}`。
|
|
52
59
|
脚本没有超时:死循环由调用方中止(Esc)结束,等嵌套调用返回(含用户审批弹窗)多久都不算超时。
|
|
53
60
|
- **工具描述**里给出每个可调用工具的 `declare function call(name, args): Promise<T>` 重载,
|
|
54
61
|
参数与返回类型都取自工具自己的 schema,所以模型在写脚本前就知道返回值形状。
|
|
@@ -57,7 +64,10 @@
|
|
|
57
64
|
时头尾截断并把全文落到 `$TMPDIR/pi-codemode-*.txt`。
|
|
58
65
|
- **构建**:worker 入口是 esbuild 产物 `src/codemode/worker.js`(随仓库提交,
|
|
59
66
|
`pnpm run build:codemode-worker` 重新生成,pre-commit 会自动跑);`quickjs-wasi` 的
|
|
60
|
-
wasm
|
|
67
|
+
wasm 在注册工具时编译一次,之后每次执行复用。产物必须**自包含**:它由 `new Worker(url)`
|
|
68
|
+
作为普通 Node 模块加载,不像主线程那样经 jiti 从 pi 的 `node_modules` 解析裸包名,所以
|
|
69
|
+
除了 `quickjs-wasi`(我们自己的 dependency)之外的依赖都要打包进去——否则用户装好包后
|
|
70
|
+
worker 会在启动时报 `Cannot find package`。
|
|
61
71
|
|
|
62
72
|
## 工具启用配置
|
|
63
73
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.681",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"aft-search": "jiti bin/aft-search.ts",
|
|
29
29
|
"build:holder": "esbuild src/bwrap/holder.ts --format=esm --target=node24 --outfile=src/bwrap/holder.js",
|
|
30
30
|
"prepare": "husky",
|
|
31
|
-
"build:codemode-worker": "esbuild src/codemode/worker.ts --bundle --
|
|
31
|
+
"build:codemode-worker": "esbuild src/codemode/worker.ts --bundle --platform=node --external:quickjs-wasi --format=esm --target=node24 --outfile=src/codemode/worker.js"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"@earendil-works/pi-agent-core": ">=0.84.1",
|
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 时自建一份(入口共享的那份由入口创建)。
|
|
@@ -102,13 +102,20 @@ export function toScriptTools(tools: readonly ToolLike[]): ScriptTool[] {
|
|
|
102
102
|
}));
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
/** 渲染脚本侧的声明:每个工具一条 `call` 重载,外加 `CallFailedError` 与全局辅助函数。 */
|
|
105
|
+
/** 渲染脚本侧的声明:每个工具一条 `call` 重载,外加 `fs` 原语、`CallFailedError` 与全局辅助函数。 */
|
|
106
106
|
export function renderDeclarations(tools: readonly ToolLike[]): string {
|
|
107
107
|
return [
|
|
108
108
|
...tools.map((tool) => renderOverload(tool)),
|
|
109
109
|
// 动态名字的兜底重载,必须放最后
|
|
110
110
|
"declare function call(name: string, args?: unknown): Promise<unknown>;",
|
|
111
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"),
|
|
112
119
|
'declare class CallFailedError extends Error { readonly name: "CallFailedError"; }',
|
|
113
120
|
"declare function text(value: unknown): void;",
|
|
114
121
|
"declare function image(value: unknown): 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,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* codemode 脚本侧的 prelude:在 QuickJS VM 里先于脚本求值,构建脚本能看到的全部
|
|
3
|
-
* 能力(`call` / `CallFailedError` / `ALL_TOOLS` / `text` / `image` / `exit` /
|
|
3
|
+
* 能力(`call` / `CallFailedError` / `ALL_TOOLS` / `fs` / `text` / `image` / `exit` /
|
|
4
4
|
* `console` / `store`),并把宿主桥接封在闭包里——脚本拿不到 `bridge` 本身。
|
|
5
5
|
*
|
|
6
6
|
* 值与参数过桥时都是 JSON 文本,本侧负责 parse/stringify;异步调用用一个 pending
|
|
7
|
-
* 表把 id 映射到 promise,由宿主在结果到达时 settle
|
|
8
|
-
* `ok: false` 回报,本侧统一 reject 成 `CallFailedError`(脚本可以按
|
|
9
|
-
*
|
|
7
|
+
* 表把 id 映射到 promise,由宿主在结果到达时 settle。每次调用失败(工具或 fs 原语)
|
|
8
|
+
* 都由宿主以 `ok: false` 回报,本侧统一 reject 成 `CallFailedError`(脚本可以按
|
|
9
|
+
* instanceof 区分「调用失败」与自己的运行期错误)。
|
|
10
10
|
*
|
|
11
11
|
* 求值结果是一个函数 `(bridge, toolsJson, storeJson) => { settle, run, stalled }`。
|
|
12
12
|
* `bridge(kind, a, b, c)`:
|
|
13
|
-
* - `"call"`(id, name, argsJson
|
|
13
|
+
* - `"call"`(id, name, argsJson):工具,以及名字为 `fs.read` / `fs.write` 的 fs 原语
|
|
14
14
|
* - `"output"`("text", text)/("image", data, mimeType)
|
|
15
15
|
* - `"done"`(ok, valueJsonOrErrorJson, writesJson)
|
|
16
16
|
*/
|
|
@@ -118,6 +118,16 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
|
|
|
118
118
|
}
|
|
119
119
|
Object.freeze(call);
|
|
120
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
|
+
|
|
121
131
|
// key -> JSON 文本;容量按 key 与 JSON 的字符数计
|
|
122
132
|
const stored = new Map();
|
|
123
133
|
const writes = new Map();
|
|
@@ -260,6 +270,7 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
|
|
|
260
270
|
Object.defineProperty(globalThis, "call", { value: call, enumerable: true });
|
|
261
271
|
Object.defineProperty(globalThis, "CallFailedError", { value: CallFailedError, enumerable: true });
|
|
262
272
|
Object.defineProperty(globalThis, "ALL_TOOLS", { value: allTools, enumerable: true });
|
|
273
|
+
Object.defineProperty(globalThis, "fs", { value: fs, enumerable: true });
|
|
263
274
|
Object.defineProperty(globalThis, "console", { value: console, enumerable: true });
|
|
264
275
|
Object.defineProperty(globalThis, "text", { value: text, enumerable: true });
|
|
265
276
|
Object.defineProperty(globalThis, "image", { value: image, enumerable: true });
|
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([
|
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,27 +19,52 @@ 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 {
|
|
@@ -126,6 +152,11 @@ function readStore(ctx: ExtensionContext): Record<string, unknown> {
|
|
|
126
152
|
return Object.fromEntries(store);
|
|
127
153
|
}
|
|
128
154
|
|
|
155
|
+
/** 本次脚本有没有通过 fs 读写过文件(决定 details 里要不要带 reads 记账)。 */
|
|
156
|
+
function hasReads(reads: Record<string, FileSnapshot>): boolean {
|
|
157
|
+
return Object.keys(reads).length > 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
129
160
|
function errorText(error: ScriptError): string {
|
|
130
161
|
const head = error.name ? `${error.name}: ${error.message}` : error.message;
|
|
131
162
|
return `${head}${error.stack && error.stack !== head ? `\n${error.stack}` : ""}`;
|
|
@@ -224,8 +255,9 @@ function formatCallSummary(calls: readonly ScriptCall[]): string {
|
|
|
224
255
|
|
|
225
256
|
export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
|
|
226
257
|
return {
|
|
227
|
-
async register(bus) {
|
|
258
|
+
async register(bus, deps) {
|
|
228
259
|
const sandbox: CodemodeSandbox = await createCodemodeSandbox();
|
|
260
|
+
const fs = createCodemodeFs(deps);
|
|
229
261
|
const tools = collectTools(bus, allowedToolNames(pi));
|
|
230
262
|
const callable = new Set(tools.map((tool) => tool.name));
|
|
231
263
|
const scriptTools = toScriptTools(tools);
|
|
@@ -269,6 +301,9 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
|
|
|
269
301
|
};
|
|
270
302
|
}
|
|
271
303
|
|
|
304
|
+
// 脚本的 fs 原语读到的文件也进同一份记账,随结果持久化(与 details.store 同一处)
|
|
305
|
+
const recordedReads: Record<string, FileSnapshot> = {};
|
|
306
|
+
|
|
272
307
|
const outcome = await sandbox.run({
|
|
273
308
|
code,
|
|
274
309
|
tools: scriptTools,
|
|
@@ -295,6 +330,20 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
|
|
|
295
330
|
});
|
|
296
331
|
},
|
|
297
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
|
+
}
|
|
298
347
|
if (!callable.has(name)) {
|
|
299
348
|
return { ok: false, error: `Tool "${name}" is not available in codemode.` };
|
|
300
349
|
}
|
|
@@ -336,6 +385,7 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
|
|
|
336
385
|
calls: outcome.calls,
|
|
337
386
|
// store 的写入随工具结果持久化,下一次调用从这里重放恢复
|
|
338
387
|
...(hasWrites && { store: writes }),
|
|
388
|
+
...(hasReads(recordedReads) && { reads: recordedReads }),
|
|
339
389
|
pendant: scriptPendant(code, `${outcome.calls.length} tool call(s)`),
|
|
340
390
|
...(truncated.fullOutputPath && { fullOutputPath: truncated.fullOutputPath }),
|
|
341
391
|
},
|
|
@@ -356,6 +406,7 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
|
|
|
356
406
|
details: {
|
|
357
407
|
calls: outcome.calls,
|
|
358
408
|
error: outcome.error.kind,
|
|
409
|
+
...(hasReads(recordedReads) && { reads: recordedReads }),
|
|
359
410
|
pendant: scriptPendant(code, `failed (${outcome.error.kind})`),
|
|
360
411
|
},
|
|
361
412
|
};
|