@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 +64 -1
- package/package.json +4 -3
- package/src/opencode-edit-engine.ts +3 -2
- package/src/opencode-edit.ts +41 -3
- package/src/opencode-read.ts +91 -122
- package/src/{todowrite.ts → opencode-todo.ts} +6 -0
- package/src/opencode-write.ts +53 -3
- package/src/question.ts +5 -0
- package/src/talk/core.ts +630 -0
- package/src/talk/format.ts +54 -0
- package/src/talk/index.ts +351 -0
- package/src/talk/mailbox.ts +306 -0
- package/src/talk/policy.ts +84 -0
- package/src/talk/registry.ts +148 -0
- package/src/talk/storage.ts +142 -0
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/
|
|
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.
|
|
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/
|
|
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
|
-
*
|
|
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:
|
package/src/opencode-edit.ts
CHANGED
|
@@ -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:
|
|
154
|
+
text: "Edit applied successfully.",
|
|
117
155
|
},
|
|
118
156
|
],
|
|
119
157
|
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
|
package/src/opencode-read.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
346
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
412
|
-
const
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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}${
|
|
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 三个字段:
|
package/src/opencode-write.ts
CHANGED
|
@@ -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,
|
|
101
|
+
await writeFile(absolutePath, desiredBom + nextText, "utf8");
|
|
52
102
|
throwIfAborted();
|
|
53
103
|
|
|
54
104
|
return {
|
|
55
|
-
content: [{ type: "text", text:
|
|
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
|