@trim21/personal-pi-extensions 0.1.654 → 0.1.658

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.
@@ -0,0 +1,279 @@
1
+ /**
2
+ * codemode 的 worker 线程入口:一次执行一个 worker,跑完或被 `terminate()` 结束。
3
+ *
4
+ * 为什么用 worker 而不是主线程:脚本是模型写的、可能死循环,只有 worker 能被
5
+ * `terminate()` 干净地丢掉(连同它那份 wasm 实例)。wasm 模块在主线程注册时编译好,
6
+ * 经 workerData 结构化克隆传进来——模块可以跨线程传,编译结果不必重复付出。
7
+ *
8
+ * 线程内只有一个 QuickJS VM:没有 node、没有文件、没有网络、没有 timer,脚本唯一的
9
+ * 能力是调用注入的工具,而工具由主线程执行。
10
+ */
11
+
12
+ import { parentPort, workerData } from "node:worker_threads";
13
+
14
+ import { JSException, type JSValueHandle, MAX_STACK_SIZE, QuickJS } from "quickjs-wasi";
15
+
16
+ import { PRELUDE_SOURCE } from "./prelude.js";
17
+ import {
18
+ decodeHostMessage,
19
+ type HostMessage,
20
+ type ScriptError,
21
+ type StoreWrites,
22
+ type WorkerBootstrap,
23
+ type WorkerMessage,
24
+ } from "./protocol.js";
25
+
26
+ /** QuickJS VM 的堆上限:超量分配在脚本里变成 InternalError,而不是拖垮宿主。 */
27
+ const MEMORY_LIMIT_BYTES = 512 * 1024 * 1024;
28
+
29
+ /** 工具名到脚本标识符的归一化:非法字符换成 `_`,数字开头补 `_`。 */
30
+ export function toScriptIdentifier(name: string): string {
31
+ return name.replaceAll(/[^A-Za-z0-9_$]/g, "_").replaceAll(/^\d/g, "_");
32
+ }
33
+
34
+ /**
35
+ * QuickJS 把引擎诊断写到 fd 1 / 2,那会直接进 pi 的 TUI;按写入长度回报并丢弃内容,
36
+ * 避免 libc 重试。
37
+ */
38
+ function discardOutput(memory: { readonly buffer: ArrayBufferLike }) {
39
+ return {
40
+ fd_write(_fd: number, iovsPtr: number, iovsLen: number, nwrittenPtr: number): number {
41
+ const view = new DataView(memory.buffer);
42
+ let written = 0;
43
+ for (let index = 0; index < iovsLen; index++) {
44
+ written += view.getUint32(iovsPtr + index * 8 + 4, true);
45
+ }
46
+ view.setUint32(nwrittenPtr, written, true);
47
+ return 0;
48
+ },
49
+ };
50
+ }
51
+
52
+ function post(message: WorkerMessage): void {
53
+ parentPort?.postMessage(message);
54
+ }
55
+
56
+ function emptyWrites(): StoreWrites {
57
+ return { set: {}, delete: [] };
58
+ }
59
+
60
+ /** prelude 传来的 writes 是 `[[key, json?], ...]`。 */
61
+ function parseWrites(json: string): StoreWrites {
62
+ try {
63
+ const entries = JSON.parse(json) as [string, string?][];
64
+ const writes: StoreWrites = { set: {}, delete: [] };
65
+ for (const [key, value] of entries) {
66
+ if (value === undefined) {
67
+ writes.delete.push(key);
68
+ } else {
69
+ writes.set[key] = JSON.parse(value) as unknown;
70
+ }
71
+ }
72
+ return writes;
73
+ } catch {
74
+ return emptyWrites();
75
+ }
76
+ }
77
+
78
+ /** 把 prelude 的 error 描述 JSON 转成可上报的错误。 */
79
+ function scriptError(payload: string | undefined, kind: ScriptError["kind"]): ScriptError {
80
+ if (payload === undefined) {
81
+ return { kind, message: "the script failed without an error message" };
82
+ }
83
+ try {
84
+ const parsed = JSON.parse(payload) as { name?: string; message?: string; stack?: string };
85
+ return { kind, name: parsed.name, message: parsed.message ?? payload, stack: parsed.stack };
86
+ } catch {
87
+ return { kind, message: payload };
88
+ }
89
+ }
90
+
91
+ async function runScript(wasm: object, start: Extract<HostMessage, { t: "start" }>): Promise<void> {
92
+ const port = parentPort;
93
+ if (!port) {
94
+ return;
95
+ }
96
+
97
+ const vm = await QuickJS.create({
98
+ wasm,
99
+ memoryLimit: MEMORY_LIMIT_BYTES,
100
+ // 没有这个上限时深递归会打穿 wasm 栈变成 trap,而不是脚本里可捕获的 RangeError
101
+ maxStackSize: MAX_STACK_SIZE,
102
+ wasi: discardOutput,
103
+ });
104
+
105
+ // prelude 只传原始值过桥:kind 是字符串,其余按需从字符串解析
106
+ const bridge = vm.newFunction(
107
+ "bridge",
108
+ (
109
+ kind: JSValueHandle,
110
+ a: JSValueHandle,
111
+ b: JSValueHandle | undefined,
112
+ c: JSValueHandle | undefined,
113
+ ) => {
114
+ const stringOr = (value: JSValueHandle | undefined, fallback: string): string =>
115
+ value === undefined || value.isUndefined ? fallback : value.toString();
116
+ switch (kind.toString()) {
117
+ case "call": {
118
+ post({
119
+ t: "call",
120
+ id: a.toNumber(),
121
+ name: stringOr(b, ""),
122
+ args:
123
+ c === undefined || c.isUndefined ? undefined : (JSON.parse(c.toString()) as unknown),
124
+ });
125
+ break;
126
+ }
127
+ case "output": {
128
+ post({
129
+ t: "output",
130
+ items:
131
+ a.toString() === "image"
132
+ ? [
133
+ {
134
+ type: "image",
135
+ data: stringOr(b, ""),
136
+ mimeType: stringOr(c, "application/octet-stream"),
137
+ },
138
+ ]
139
+ : [{ type: "text", text: stringOr(b, "") }],
140
+ });
141
+ break;
142
+ }
143
+ case "done": {
144
+ const writes =
145
+ c === undefined || c.isUndefined ? emptyWrites() : parseWrites(c.toString());
146
+ if (a.toBoolean()) {
147
+ post({
148
+ t: "done",
149
+ ok: true,
150
+ value:
151
+ b === undefined || b.isUndefined
152
+ ? undefined
153
+ : (JSON.parse(b.toString()) as unknown),
154
+ writes,
155
+ });
156
+ } else {
157
+ post({
158
+ t: "done",
159
+ ok: false,
160
+ error: scriptError(b === undefined ? undefined : b.toString(), "script"),
161
+ writes,
162
+ });
163
+ }
164
+ break;
165
+ }
166
+ }
167
+ return vm.undefined;
168
+ },
169
+ );
170
+
171
+ const api = vm.withScope((scope) =>
172
+ scope.escape(
173
+ vm.callFunction(
174
+ vm.evalCode(PRELUDE_SOURCE, "codemode-prelude.js"),
175
+ vm.undefined,
176
+ bridge,
177
+ vm.newString(
178
+ JSON.stringify(
179
+ start.tools.map((tool) => ({
180
+ name: tool.name,
181
+ jsName: toScriptIdentifier(tool.name),
182
+ description: tool.description,
183
+ })),
184
+ ),
185
+ ),
186
+ vm.newString(JSON.stringify(start.store)),
187
+ ),
188
+ ),
189
+ );
190
+ const settle = api.getProp("settle");
191
+ const run = api.getProp("run");
192
+ const stalled = api.getProp("stalled");
193
+
194
+ /** 跑完排队的 job,再判定「等一个永远不会 settle 的 promise」。 */
195
+ const drain = (): void => {
196
+ vm.executePendingJobs();
197
+ vm.callFunction(stalled, api).dispose();
198
+ };
199
+
200
+ port.on("message", (value: unknown) => {
201
+ const message = decodeHostMessage(value);
202
+ if (!message.ok || message.frame.t !== "result") {
203
+ return;
204
+ }
205
+ const result = message.frame;
206
+ try {
207
+ vm.withScope(() => {
208
+ vm.callFunction(
209
+ settle,
210
+ api,
211
+ vm.newNumber(result.id),
212
+ result.ok ? vm.true : vm.false,
213
+ result.ok
214
+ ? result.value === undefined
215
+ ? vm.undefined
216
+ : vm.newString(JSON.stringify(result.value))
217
+ : vm.newString(result.error),
218
+ );
219
+ });
220
+ } catch {
221
+ /* VM 已中止 */
222
+ }
223
+ drain();
224
+ });
225
+
226
+ try {
227
+ // 前缀与脚本首行共用一行,报错行号与用户写的脚本一致
228
+ const fn: JSValueHandle = vm.evalCode(
229
+ `(async (tools, console) => {${start.code}\n})`,
230
+ "codemode.js",
231
+ );
232
+ vm.callFunction(run, api, fn).dispose();
233
+ fn.dispose();
234
+ drain();
235
+ } catch (error) {
236
+ if (error instanceof JSException) {
237
+ post({
238
+ t: "done",
239
+ ok: false,
240
+ error: scriptError(
241
+ JSON.stringify({ name: error.name, message: error.message, stack: error.stack }),
242
+ "script",
243
+ ),
244
+ writes: emptyWrites(),
245
+ });
246
+ } else {
247
+ post({
248
+ t: "done",
249
+ ok: false,
250
+ error: { kind: "sandbox", message: error instanceof Error ? error.message : String(error) },
251
+ writes: emptyWrites(),
252
+ });
253
+ }
254
+ }
255
+ }
256
+
257
+ function main(): void {
258
+ const port = parentPort;
259
+ if (!port) {
260
+ return;
261
+ }
262
+ const { wasm } = workerData as WorkerBootstrap;
263
+ // 主线程先发 start;worker 收到后才实例化 VM(wasm 已在主线程编译好)
264
+ port.once("message", (value: unknown) => {
265
+ const message = decodeHostMessage(value);
266
+ if (!message.ok || message.frame.t !== "start") {
267
+ post({
268
+ t: "done",
269
+ ok: false,
270
+ error: { kind: "sandbox", message: "codemode worker: expected a start message first" },
271
+ writes: emptyWrites(),
272
+ });
273
+ return;
274
+ }
275
+ void runScript(wasm, message.frame);
276
+ });
277
+ }
278
+
279
+ main();
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
19
 
20
20
  import { createAftTools } from "./aft/index.js";
21
21
  import { createClaudeCodeFileTools } from "./claude-code/files.js";
22
+ import { createCodemodeTools } from "./codemode/tool.js";
22
23
  import { createGithubTools } from "./gh/index.js";
23
24
  import type { ToolBus } from "./lib/tool-bus.js";
24
25
  import { createToolRegistration } from "./lib/tool-registration.js";
@@ -57,6 +58,7 @@ export default function personalExtensions(pi: ExtensionAPI): void {
57
58
  addModule("web_search", createWebSearchTool());
58
59
  addModule("web_fetch", createWebFetch(pi));
59
60
  const aft = createAftTools(pi);
61
+ const codemode = createCodemodeTools(pi);
60
62
 
61
63
  // 注册期的失败攒起来,在会话启动时一次性上报(那里才有 UI)。
62
64
  const warnings: string[] = [];
@@ -71,6 +73,15 @@ export default function personalExtensions(pi: ExtensionAPI): void {
71
73
  }
72
74
  }
73
75
 
76
+ async function runModuleAsync<T>(name: string, run: () => Promise<T>): Promise<T | undefined> {
77
+ try {
78
+ return await run();
79
+ } catch (error) {
80
+ warnings.push(`${name}: ${error instanceof Error ? error.message : String(error)}`);
81
+ return undefined;
82
+ }
83
+ }
84
+
74
85
  registration.onSessionStart(async (bus, ctx) => {
75
86
  // 配置解析期的警告是稳定的,注册期的失败每个会话重新收集(避免跨会话重复上报)。
76
87
  warnings.length = 0;
@@ -109,6 +120,8 @@ export default function personalExtensions(pi: ExtensionAPI): void {
109
120
  for (const { name, module } of modules) {
110
121
  runModule(name, () => module.register(bus));
111
122
  }
123
+ // codemode 最后注册:它把总线上已有的工具写进自己的描述,并在注册时编译 wasm
124
+ await runModuleAsync("codemode", () => codemode.register(bus));
112
125
 
113
126
  for (const warning of warnings) {
114
127
  ctx.ui.notify(warning, "warning");
@@ -124,3 +124,10 @@ export function createToolBus(pi: ExtensionAPI, options: ToolBusOptions = {}): T
124
124
  },
125
125
  };
126
126
  }
127
+
128
+ /** 工具结果里的文本内容拼接,便于调用方展示或回传给脚本。 */
129
+ export function toolResultText(result: ToolExecutionResult): string {
130
+ return result.content
131
+ .map((part) => (part.type === "text" ? part.text : `[image ${part.mimeType}]`))
132
+ .join("\n");
133
+ }