@trim21/personal-pi-extensions 0.0.192 → 0.0.194

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 CHANGED
@@ -13,6 +13,7 @@
13
13
  | [vision-agent](#vision-agent) | 视觉代理:主模型不支持视觉时,spawn 子 agent 识别图片 |
14
14
  | [todowrite](#todowrite) | opencode 风格的任务列表工具,完整列表替换语义 |
15
15
  | [question](#question) | opencode 风格的提问工具,阻塞式询问用户选择 |
16
+ | [talk](#talk) | session 间消息传递,SQLite 邮箱 + 双向 ask 时间戳仲裁 |
16
17
 
17
18
  ---
18
19
 
@@ -187,7 +188,7 @@ opencode 风格的任务列表工具,参数与语义和 opencode 的 [`todowri
187
188
  ### 使用
188
189
 
189
190
  ```bash
190
- pi -e ./src/todowrite.ts
191
+ pi -e ./src/opencode-todo.ts
191
192
  ```
192
193
 
193
194
  ---
@@ -213,6 +214,68 @@ pi -e ./src/question.ts
213
214
 
214
215
  ---
215
216
 
217
+ ## talk
218
+
219
+ session 间消息传递:不同 pi session(同一台机器)通过一个共享的 SQLite 邮箱互相发送消息、提问并等待回复。
220
+
221
+ ### 架构(三层)
222
+
223
+ ```
224
+ storage.ts —— 存储层:TalkStorage 接口 + SqliteTalkStorage 实现(node:sqlite,零 npm 依赖)
225
+ core.ts —— talk 核心:registry/mailbox/policy/format + TalkCore 协调器,只依赖存储层,通过回调 yield 投递/通知
226
+ index.ts —— pi adapter:把 core 接到 pi 的 sendMessage / 生命周期事件 / 工具注册
227
+ ```
228
+
229
+ 存储层抽象成接口是为了后续可换成 HTTP / remote 后端,talk 核心无需改动。
230
+
231
+ ### 工具(LLM 可见)
232
+
233
+ | 工具 | 作用 |
234
+ | -------------------- | ----------------------------------------------------------------- |
235
+ | `talk-list-sessions` | 列出其他 session(presence:idle/working/not responding/offline) |
236
+ | `talk-read-messages` | 主动读收件箱(读即消费) |
237
+ | `talk-ask` | 向某个 session 提问并阻塞等待回复(默认 120s 超时) |
238
+ | `talk-wait` | 阻塞等待新消息到达 |
239
+ | `talk-send` | 发送纯文本消息(`to: "*"` 广播所有,`to: "cwd"` 广播同 cwd) |
240
+ | `talk-reply` | 回复一个 ask(`replyTo` 为 ask id,显式关联、不推断) |
241
+
242
+ 对端消息在模型工作过程中以 steer 方式注入上下文;模型也可主动 `talk-read-messages` 拉取。
243
+
244
+ ### 关键设计
245
+
246
+ - **投递成功才消费**:信件只在成功交给 `sendMessage` 后才从 inbox 删除,投递失败留在 inbox 下次重试——不会因 `sendMessage` 吞异常而静默丢信。
247
+ - **双向 ask 仲裁**:`talk-ask` 发起前先检查收件箱(有对方消息就先读/先回);阻塞等待期间若收到对方的 ask(而非 reply),按两个 ask 的 `ts` 字段仲裁——先 ask 者主导继续等,后 ask 者让位并先回复对方。`ts` 是信件内固定字段,双方读到同一对值,结论天然对称;同毫秒碰撞用 `session dir + session id` 字符串比较兜底。
248
+ - **typebox runtime 验证**:所有从存储读出的值经 TypeBox schema 校验,损坏/伪造数据被拒绝,不做 `as T` 强转。
249
+ - **安全**:纯文本 ≤32KB;10s 去重 / 30s 限速 8 条 / 50 积压上限(防环);每条投递带「来自其他 session、无权威」声明。
250
+
251
+ ### 配置
252
+
253
+ sqlite 文件路径按优先级取第一个可用值:
254
+
255
+ 1. 环境变量 `PI_TALK_DB`
256
+ 2. global `~/.pi/agent/settings.json` 里的 `talk.db_path`
257
+ 3. 默认 `~/.pi/agent/talk.db`
258
+
259
+ ```jsonc
260
+ // ~/.pi/agent/settings.json
261
+ {
262
+ "talk": { "db_path": "/path/to/talk.db" },
263
+ }
264
+ ```
265
+
266
+ | 变量 | 默认 | 含义 |
267
+ | ----------------- | --------------------------------- | ----------------------------- |
268
+ | `PI_TALK_DB` | settings 或 `~/.pi/agent/talk.db` | SQLite 邮箱数据库路径 |
269
+ | `PI_TALK_INBOUND` | `accept` | `refuse` 时丢弃所有 peer 消息 |
270
+
271
+ ### 使用
272
+
273
+ ```bash
274
+ pi -e ./src/talk/index.ts
275
+ ```
276
+
277
+ ---
278
+
216
279
  ## 安装
217
280
 
218
281
  ### 通过 npm/git 包
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.192",
3
+ "version": "0.0.194",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -64,8 +64,9 @@
64
64
  "src/bash-default-timeout.ts",
65
65
  "src/gh-readonly.ts",
66
66
  "src/spawn-agent.ts",
67
- "src/todowrite.ts",
68
- "src/question.ts"
67
+ "src/opencode-todo.ts",
68
+ "src/question.ts",
69
+ "src/talk/index.ts"
69
70
  ]
70
71
  },
71
72
  "lint-staged": {
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Opencode edit matching engine.
3
3
  *
4
- * The core replacers and replace() function are copied directly from
5
- * https://github.com/anomalyco/opencode (packages/opencode/src/tool/edit.ts)
4
+ * The core replacers and replace() function are copied directly from opencode
5
+ * commit 999be62662 (v1.2.25-1672-g999be62662, 2026-08-12):
6
+ * https://github.com/anomalyco/opencode/blob/999be62662/packages/opencode/src/tool/edit.ts
6
7
  * and wrapped in a pi extension so the behaviour is identical to opencode.
7
8
  *
8
9
  * Shared by:
@@ -2,6 +2,14 @@
2
2
  * Opencode Edit Extension — Replaces the built-in edit tool with opencode's
3
3
  * schema and matching engine.
4
4
  *
5
+ * Aligned with opencode commit 999be62662 (v1.2.25-1672-g999be62662, 2026-08-12):
6
+ * https://github.com/anomalyco/opencode/blob/999be62662/packages/opencode/src/tool/edit.ts
7
+ * The matching engine (replacers + replace()) is byte-for-byte aligned with
8
+ * opencode (9 replacers, 0.65 similarity threshold, 0.25 line-delta, identical
9
+ * error messages), and empty-oldString file creation is implemented.
10
+ * Known gaps (intentionally not implemented): LSP diagnostics in the result,
11
+ * formatter run.
12
+ *
5
13
  * The matching engine (replacers + replace()) lives in opencode-edit-engine.ts
6
14
  * and is also used by workspace-guard for the diff preview.
7
15
  *
@@ -10,8 +18,8 @@
10
18
  */
11
19
 
12
20
  import { constants } from "node:fs";
13
- import { access, readFile, writeFile } from "node:fs/promises";
14
- import { isAbsolute, resolve } from "node:path";
21
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
22
+ import { dirname, isAbsolute, resolve } from "node:path";
15
23
 
16
24
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
25
  import {
@@ -78,6 +86,36 @@ export default function opencodeEdit(pi: ExtensionAPI) {
78
86
  return withFileMutationQueue(absolutePath, async () => {
79
87
  throwIfAborted();
80
88
 
89
+ // opencode: 前置校验,先于空 oldString 分支
90
+ if (oldString === newString) {
91
+ throw new Error("No changes to apply: oldString and newString are identical.");
92
+ }
93
+
94
+ // opencode: 空 oldString + 文件不存在 → 创建新文件;文件存在 → 报错
95
+ if (oldString === "") {
96
+ let exists = true;
97
+ try {
98
+ await access(absolutePath, constants.F_OK);
99
+ } catch {
100
+ exists = false;
101
+ }
102
+ throwIfAborted();
103
+ if (exists) {
104
+ throw new Error(
105
+ "oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
106
+ );
107
+ }
108
+ // opencode: writeWithDirs 自动创建父目录;newString 开头的 BOM 原样保留
109
+ await mkdir(dirname(absolutePath), { recursive: true });
110
+ throwIfAborted();
111
+ await writeFile(absolutePath, newString, "utf8");
112
+ throwIfAborted();
113
+ return {
114
+ content: [{ type: "text" as const, text: "Edit applied successfully." }],
115
+ details: { diff: "", patch: "", firstChangedLine: 0 },
116
+ };
117
+ }
118
+
81
119
  try {
82
120
  await access(absolutePath, constants.R_OK | constants.W_OK);
83
121
  } catch (error: unknown) {
@@ -113,7 +151,7 @@ export default function opencodeEdit(pi: ExtensionAPI) {
113
151
  content: [
114
152
  {
115
153
  type: "text" as const,
116
- text: `Successfully edited ${filePath}`,
154
+ text: "Edit applied successfully.",
117
155
  },
118
156
  ],
119
157
  details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
@@ -1,6 +1,17 @@
1
1
  /**
2
2
  * Enhanced Read Tool Extension
3
3
  *
4
+ * Aligned with opencode commit 999be62662 (v1.2.25-1672-g999be62662, 2026-08-12):
5
+ * https://github.com/anomalyco/opencode/blob/999be62662/packages/opencode/src/tool/read.ts
6
+ * Aligned behaviours: per-line `N: ` line-number prefix, single-line 2000-char
7
+ * truncation, 1-based offset (0 treated as 1), out-of-range offset error,
8
+ * cut/more/end truncation messages, `localeCompare` directory sorting.
9
+ * Known gaps (intentionally not implemented): PDF attachment support,
10
+ * instruction (AGENTS.md) loading and LSP warm-up.
11
+ * BMP sniffing is kept but `image/bmp` is NOT in SUPPORTED_IMAGE_MIMES, so a
12
+ * .bmp file falls through to binary detection — matching opencode, which only
13
+ * serves jpeg/png/gif/webp as attachments.
14
+ *
4
15
  * Overrides the built-in `read` tool with additional features inspired by
5
16
  * opencode's read implementation:
6
17
  *
@@ -31,15 +42,12 @@ import { Type } from "typebox";
31
42
 
32
43
  const DEFAULT_MAX_LINES = 2000;
33
44
  const DEFAULT_MAX_BYTES = 50 * 1024;
45
+ const MAX_LINE_LENGTH = 2000;
46
+ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`;
47
+ const MAX_BYTES_LABEL = `${DEFAULT_MAX_BYTES / 1024} KB`;
34
48
  const SAMPLE_BYTES = 4096;
35
49
 
36
- const SUPPORTED_IMAGE_MIMES = new Set([
37
- "image/jpeg",
38
- "image/png",
39
- "image/gif",
40
- "image/webp",
41
- "image/bmp",
42
- ]);
50
+ const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
43
51
 
44
52
  const BINARY_EXTENSIONS = new Set([
45
53
  ".zip",
@@ -159,13 +167,7 @@ function isBinaryFileBySample(sample: Uint8Array): boolean {
159
167
  return nonPrintableCount / sample.length > 0.3;
160
168
  }
161
169
 
162
- function formatSize(bytes: number): string {
163
- if (bytes < 1024) return `${bytes}B`;
164
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
165
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
166
- }
167
-
168
- interface TruncationResult {
170
+ export interface TruncationResult {
169
171
  content: string;
170
172
  truncated: boolean;
171
173
  truncatedBy: "lines" | "bytes" | null;
@@ -173,13 +175,17 @@ interface TruncationResult {
173
175
  totalBytes: number;
174
176
  outputLines: number;
175
177
  outputBytes: number;
176
- lastLinePartial: boolean;
177
- firstLineExceedsLimit: boolean;
178
178
  maxLines: number;
179
179
  maxBytes: number;
180
180
  }
181
181
 
182
- function truncateHead(
182
+ /**
183
+ * Truncate the head of `content` the way opencode's ReadTool.lines does:
184
+ * - per-line truncation to MAX_LINE_LENGTH chars (with MAX_LINE_SUFFIX)
185
+ * - line cap via maxLines (more)
186
+ * - byte cap via maxBytes, computed on the truncated lines (cut)
187
+ */
188
+ export function truncateHead(
183
189
  content: string,
184
190
  maxLines: number = DEFAULT_MAX_LINES,
185
191
  maxBytes: number = DEFAULT_MAX_BYTES,
@@ -189,68 +195,43 @@ function truncateHead(
189
195
  const totalLines = lines.length;
190
196
  const totalBytes = Buffer.byteLength(content, "utf8");
191
197
 
192
- if (totalLines <= maxLines && totalBytes <= maxBytes) {
193
- return {
194
- content,
195
- truncated: false,
196
- truncatedBy: null,
197
- totalLines,
198
- totalBytes,
199
- outputLines: totalLines,
200
- outputBytes: totalBytes,
201
- lastLinePartial: false,
202
- firstLineExceedsLimit: false,
203
- maxLines,
204
- maxBytes,
205
- };
206
- }
207
-
208
- const firstLineBytes = lines.length > 0 ? Buffer.byteLength(lines[0], "utf8") : 0;
209
- if (firstLineBytes > maxBytes) {
210
- return {
211
- content: "",
212
- truncated: true,
213
- truncatedBy: "bytes",
214
- totalLines,
215
- totalBytes,
216
- outputLines: 0,
217
- outputBytes: 0,
218
- lastLinePartial: false,
219
- firstLineExceedsLimit: true,
220
- maxLines,
221
- maxBytes,
222
- };
223
- }
224
-
225
198
  const outputLinesArr: string[] = [];
226
199
  let outputBytesCount = 0;
227
- let truncatedBy: "lines" | "bytes" = "lines";
228
-
229
- for (let i = 0; i < lines.length && i < maxLines; i++) {
230
- const lineBytes = Buffer.byteLength(lines[i], "utf8") + (i > 0 ? 1 : 0);
200
+ let truncated = false;
201
+ let truncatedBy: "lines" | "bytes" | null = null;
202
+
203
+ for (const rawLine of lines) {
204
+ // opencode: 行数到达 limit 即截断(more)
205
+ if (outputLinesArr.length >= maxLines) {
206
+ truncated = true;
207
+ truncatedBy = "lines";
208
+ break;
209
+ }
210
+ // opencode: 单行超过 MAX_LINE_LENGTH 截断并追加提示
211
+ const line =
212
+ rawLine.length > MAX_LINE_LENGTH
213
+ ? rawLine.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX
214
+ : rawLine;
215
+ const lineBytes = Buffer.byteLength(line, "utf8") + (outputLinesArr.length > 0 ? 1 : 0);
216
+ // opencode: 累计字节超 MAX_BYTES 即截断(cut,优先于 more)
231
217
  if (outputBytesCount + lineBytes > maxBytes) {
218
+ truncated = true;
232
219
  truncatedBy = "bytes";
233
220
  break;
234
221
  }
235
- outputLinesArr.push(lines[i]);
222
+ outputLinesArr.push(line);
236
223
  outputBytesCount += lineBytes;
237
224
  }
238
225
 
239
- if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
240
- truncatedBy = "lines";
241
- }
242
-
243
226
  const outputContent = outputLinesArr.join("\n");
244
227
  return {
245
228
  content: outputContent,
246
- truncated: true,
229
+ truncated,
247
230
  truncatedBy,
248
231
  totalLines,
249
232
  totalBytes,
250
233
  outputLines: outputLinesArr.length,
251
234
  outputBytes: Buffer.byteLength(outputContent, "utf8"),
252
- lastLinePartial: false,
253
- firstLineExceedsLimit: false,
254
235
  maxLines,
255
236
  maxBytes,
256
237
  };
@@ -297,7 +278,8 @@ async function formatDirectoryEntries(dirPath: string): Promise<string[]> {
297
278
  results.push(item + (isDir ? "/" : ""));
298
279
  }
299
280
 
300
- results.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
281
+ // opencode: items.sort((a, b) => a.localeCompare(b))
282
+ results.sort((a, b) => a.localeCompare(b));
301
283
  return results;
302
284
  }
303
285
 
@@ -311,10 +293,16 @@ export default function opencodeRead(pi: ExtensionAPI) {
311
293
  parameters: Type.Object({
312
294
  filePath: Type.String({ description: "The absolute path to the file or directory to read" }),
313
295
  offset: Type.Optional(
314
- Type.Number({ description: "The line number to start reading from (1-indexed)" }),
296
+ Type.Integer({
297
+ minimum: 0,
298
+ description: "The line number to start reading from (1-indexed)",
299
+ }),
315
300
  ),
316
301
  limit: Type.Optional(
317
- Type.Number({ description: "The maximum number of lines to read (defaults to 2000)" }),
302
+ Type.Integer({
303
+ minimum: 0,
304
+ description: "The maximum number of lines to read (defaults to 2000)",
305
+ }),
318
306
  ),
319
307
  }),
320
308
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
@@ -342,8 +330,9 @@ export default function opencodeRead(pi: ExtensionAPI) {
342
330
  if (fileStat.isDirectory()) {
343
331
  const entries = await formatDirectoryEntries(absolutePath);
344
332
  const limitVal = limit ?? DEFAULT_MAX_LINES;
345
- const offsetVal = offset ?? 1;
346
- const start = offsetVal <= 0 ? 0 : offsetVal - 1;
333
+ // opencode: params.offset || 1(0 视为 1)
334
+ const offsetVal = offset || 1;
335
+ const start = offsetVal - 1;
347
336
  const sliced = entries.slice(start, start + limitVal);
348
337
  const totalEntries = entries.length;
349
338
  const truncated = start + sliced.length < totalEntries;
@@ -353,8 +342,7 @@ export default function opencodeRead(pi: ExtensionAPI) {
353
342
  output += `<entries>\n`;
354
343
  output += sliced.join("\n");
355
344
  if (truncated) {
356
- const next = offsetVal + sliced.length;
357
- output += `\n(Showing ${sliced.length} of ${totalEntries} entries. Use offset=${next} to continue.)`;
345
+ output += `\n(Showing ${sliced.length} of ${totalEntries} entries. Use 'offset' parameter to read beyond entry ${offsetVal + sliced.length})`;
358
346
  } else {
359
347
  output += `\n(${totalEntries} entries)`;
360
348
  }
@@ -386,7 +374,8 @@ export default function opencodeRead(pi: ExtensionAPI) {
386
374
  const buffer = await readFile(absolutePath);
387
375
  const base64 = buffer.toString("base64");
388
376
  content = [
389
- { type: "text", text: `[Image: ${mimeType}, ${formatSize(buffer.length)}]` },
377
+ // opencode: output is "Image read successfully"
378
+ { type: "text", text: "Image read successfully" },
390
379
  { type: "image", data: base64, mimeType },
391
380
  ];
392
381
  return { content, details: undefined };
@@ -405,72 +394,52 @@ export default function opencodeRead(pi: ExtensionAPI) {
405
394
  }
406
395
 
407
396
  const textContent = buffer.toString("utf8");
397
+ // opencode 用 Stream.splitLines,不含末尾换行产生的空行
408
398
  const allLines = textContent.split("\n");
399
+ if (textContent.endsWith("\n")) allLines.pop();
409
400
  const totalFileLines = allLines.length;
410
401
 
411
- // Apply offset
412
- const startLine = offset ? Math.max(0, offset - 1) : 0;
402
+ // opencode: offset 1 起始,offset=0 视为 1(params.offset || 1)
403
+ const effectiveOffset = offset || 1;
404
+ const startLine = Math.max(0, effectiveOffset - 1);
413
405
 
414
- if (startLine >= allLines.length) {
415
- return {
416
- content: [
417
- {
418
- type: "text",
419
- text: `Offset ${offset} is beyond end of file (${allLines.length} lines total)`,
420
- },
421
- ],
422
- details: undefined,
423
- };
406
+ // opencode: 越界报错(空文件 + offset=1 除外)
407
+ if (totalFileLines < effectiveOffset && !(totalFileLines === 0 && effectiveOffset === 1)) {
408
+ throw new Error(
409
+ `Offset ${effectiveOffset} is out of range for this file (${totalFileLines} lines)`,
410
+ );
424
411
  }
425
412
 
426
413
  const startLineDisplay = startLine + 1;
427
414
 
428
- let details: { truncation?: TruncationResult } | undefined;
429
-
430
- // Apply user-specified limit or default truncation
431
- let selectedContent: string;
432
- let userLimitedLines: number | undefined;
433
-
434
- if (limit === undefined) {
435
- selectedContent = allLines.slice(startLine).join("\n");
436
- } else {
437
- const endLine = Math.min(startLine + limit, allLines.length);
438
- selectedContent = allLines.slice(startLine, endLine).join("\n");
439
- userLimitedLines = endLine - startLine;
440
- }
441
-
442
- // Apply byte/line truncation
443
- const truncation = truncateHead(selectedContent);
415
+ // opencode: limit 即行数上限(默认 2000)
416
+ const selectedContent = allLines.slice(startLine).join("\n");
417
+ const truncation = truncateHead(selectedContent, limit ?? DEFAULT_MAX_LINES);
444
418
  let outputText: string;
445
419
 
446
420
  const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
421
+ const header = `<path>${absolutePath}</path>\n<type>file</type>\n<content>\n`;
422
+ const footer = "\n</content>";
423
+ // opencode: 每行 `${i + offset}: ${line}` 行号前缀
424
+ const numbered =
425
+ truncation.content === ""
426
+ ? ""
427
+ : truncation.content
428
+ .split("\n")
429
+ .map((line, i) => `${startLineDisplay + i}: ${line}`)
430
+ .join("\n");
447
431
 
448
- if (truncation.firstLineExceedsLimit) {
449
- const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf8"));
450
- outputText = `<path>${absolutePath}</path>\n<type>file</type>\n`;
451
- outputText += `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash to read this line.]`;
452
- details = { truncation };
453
- } else {
454
- const header = `<path>${absolutePath}</path>\n<type>file</type>\n<content>\n`;
455
- const footer = "\n</content>";
456
- if (truncation.truncated) {
457
- const nextOffset = endLineDisplay + 1;
458
- if (truncation.truncatedBy === "lines") {
459
- outputText = `${header}${truncation.content}\n\n(Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.)${footer}`;
460
- } else {
461
- outputText = `${header}${truncation.content}\n\n(Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.)${footer}`;
462
- }
463
- details = { truncation };
464
- } else if (
465
- userLimitedLines !== undefined &&
466
- startLine + userLimitedLines < allLines.length
467
- ) {
468
- const remaining = allLines.length - (startLine + userLimitedLines);
469
- const nextOffset = startLine + userLimitedLines + 1;
470
- outputText = `${header}${truncation.content}\n\n(${remaining} more lines in file. Use offset=${nextOffset} to continue.)${footer}`;
432
+ let details: { truncation?: TruncationResult } | undefined;
433
+ if (truncation.truncated) {
434
+ const nextOffset = endLineDisplay + 1;
435
+ if (truncation.truncatedBy === "bytes") {
436
+ outputText = `${header}${numbered}\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${startLineDisplay}-${endLineDisplay}. Use offset=${nextOffset} to continue.)${footer}`;
471
437
  } else {
472
- outputText = `${header}${truncation.content}\n\n(End of file - total ${totalFileLines} lines)${footer}`;
438
+ outputText = `${header}${numbered}\n\n(Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.)${footer}`;
473
439
  }
440
+ details = { truncation };
441
+ } else {
442
+ outputText = `${header}${numbered}\n\n(End of file - total ${totalFileLines} lines)${footer}`;
474
443
  }
475
444
 
476
445
  content = [{ type: "text", text: outputText }];
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * todowrite —— opencode 风格的任务列表工具
3
3
  *
4
+ * Aligned with opencode commit 999be62662 (v1.2.25-1672-g999be62662, 2026-08-12):
5
+ * https://github.com/anomalyco/opencode/blob/999be62662/packages/opencode/src/tool/todo.ts
6
+ * 与 opencode 的差异:status/priority 这里用 StringEnum 做运行时校验
7
+ * (opencode schema 层不校验);opencode 输出 title "N todos",这里未设置;
8
+ * widget/pendant 渲染为本仓库增强(pi 特有)。
9
+ *
4
10
  * 完整列表替换语义(与 opencode 的 todowrite 工具一致):
5
11
  * 每次调用用给定的 todos 数组整体替换当前任务列表。
6
12
  * todos 数组,每项含 content / status / priority 三个字段:
@@ -1,6 +1,13 @@
1
1
  /**
2
2
  * Enhanced Write Tool Extension
3
3
  *
4
+ * Aligned with opencode commit 999be62662 (v1.2.25-1672-g999be62662, 2026-08-12):
5
+ * https://github.com/anomalyco/opencode/blob/999be62662/packages/opencode/src/tool/write.ts
6
+ * Aligned behaviours: BOM preservation (source.bom || next.bom).
7
+ * Known gaps (intentionally not implemented): LSP diagnostics in the result,
8
+ * formatter run. Unlike opencode (which has no write lock), this extension
9
+ * serialises writes via the mutation queue.
10
+ *
4
11
  * Overrides the built-in `write` tool with opencode-compatible parameter names.
5
12
  *
6
13
  * - Uses `filePath` (opencode) instead of `path` (pi built-in)
@@ -14,13 +21,37 @@
14
21
  * cp enhanced-write.ts .pi/extensions/
15
22
  */
16
23
 
17
- import { mkdir, writeFile } from "node:fs/promises";
24
+ import { mkdir, open, writeFile } from "node:fs/promises";
18
25
  import { dirname, resolve as resolvePath } from "node:path";
19
26
 
20
27
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
28
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
22
29
  import { Type } from "typebox";
23
30
 
31
+ import { stripBom } from "./opencode-edit-engine.js";
32
+
33
+ /**
34
+ * opencode: desiredBom = source.bom || next.bom —— 优先保留原文件 BOM,
35
+ * 否则用新内容自带的 BOM。
36
+ * @param existing - 旧文件前几个字节(undefined 表示文件不存在)
37
+ * @param content - 要写入的完整内容
38
+ */
39
+ export function resolveBom(
40
+ existing: Buffer | undefined,
41
+ content: string,
42
+ ): { bom: string; text: string } {
43
+ const sourceBom =
44
+ existing !== undefined &&
45
+ existing.length >= 3 &&
46
+ existing[0] === 0xef &&
47
+ existing[1] === 0xbb &&
48
+ existing[2] === 0xbf
49
+ ? "\uFEFF"
50
+ : "";
51
+ const { bom: nextBom, text } = stripBom(content);
52
+ return { bom: sourceBom || nextBom, text };
53
+ }
54
+
24
55
  export default function opencodeWrite(pi: ExtensionAPI) {
25
56
  pi.registerTool({
26
57
  name: "write",
@@ -46,13 +77,32 @@ export default function opencodeWrite(pi: ExtensionAPI) {
46
77
 
47
78
  return withFileMutationQueue(absolutePath, async () => {
48
79
  throwIfAborted();
80
+
81
+ // opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
82
+ // 否则用新内容自带的 BOM
83
+ let existing: Buffer | undefined;
84
+ try {
85
+ const fh = await open(absolutePath, "r");
86
+ try {
87
+ existing = Buffer.alloc(3);
88
+ const { bytesRead } = await fh.read(existing, 0, 3, 0);
89
+ if (bytesRead < 3) existing = undefined;
90
+ } finally {
91
+ await fh.close();
92
+ }
93
+ } catch {
94
+ // 文件不存在:无旧 BOM
95
+ }
96
+ throwIfAborted();
97
+ const { bom: desiredBom, text: nextText } = resolveBom(existing, content);
98
+
49
99
  await mkdir(dir, { recursive: true });
50
100
  throwIfAborted();
51
- await writeFile(absolutePath, content, "utf8");
101
+ await writeFile(absolutePath, desiredBom + nextText, "utf8");
52
102
  throwIfAborted();
53
103
 
54
104
  return {
55
- content: [{ type: "text", text: `Wrote file successfully: ${absolutePath}` }],
105
+ content: [{ type: "text", text: "Wrote file successfully." }],
56
106
  details: undefined,
57
107
  };
58
108
  });
package/src/question.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * question —— opencode 风格的提问工具
3
3
  *
4
+ * Aligned with opencode commit 999be62662 (v1.2.25-1672-g999be62662, 2026-08-12):
5
+ * https://github.com/anomalyco/opencode/blob/999be62662/packages/opencode/src/tool/question.ts
6
+ * 与 opencode 的差异:opencode 输出 title "Asked N question(s)",这里未设置;
7
+ * 多选交互是平台差异(opencode 用 checkbox,这里循环 ctx.ui.select 勾选)。
8
+ *
4
9
  * 参数与语义和 opencode 的 `question` 工具一致:
5
10
  * questions 数组,每项含 question / header / options / multiple:
6
11
  * - options 每项为 label / description