@trim21/personal-pi-extensions 0.1.572 → 0.1.574

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
@@ -11,6 +11,8 @@
11
11
  | [bwrap](#bwrap) | 基于 bubblewrap 的 OS 级沙箱,提供文件系统和网络隔离 |
12
12
  | [写保护(内置)](#写保护内置) | 写工具内置:限制文件写入在 workspace 内,外部写入需审批 |
13
13
  | [opencode-edit](#opencode-edit) | 替换内置 edit 工具,使用 opencode 的 schema 和匹配引擎 |
14
+ | [opencode-grep](#opencode-grep) | opencode 风格 grep,基于 ripgrep 的内容搜索 |
15
+ | [opencode-glob](#opencode-glob) | opencode 风格 glob,基于 ripgrep 的文件名匹配 |
14
16
  | [vision-agent](#vision-agent) | 视觉代理:主模型不支持视觉时,spawn 子 agent 识别图片 |
15
17
  | [session-name](#session-name) | 首个 user prompt 自动生成会话名,失败仅告警不命名 |
16
18
  | [todowrite](#todowrite) | opencode 风格的任务列表工具,完整列表替换语义 |
@@ -19,7 +21,7 @@
19
21
  | [openai-cost](#openai-cost) | OpenAI Chat Completions,费用取自响应 `usage.cost` |
20
22
 
21
23
  > **两套工具风格,按预期只启用其中一套**:本包同时提供 opencode 风格
22
- > (小写 `read`/`edit`/`write`/`bash`/`todowrite`/`question`)与 Claude Code
24
+ > (小写 `read`/`edit`/`write`/`grep`/`glob`/`bash`/`todowrite`/`question`)与 Claude Code
23
25
  > 风格(大写 `Read`/`Edit`/`Write`/`Bash`/`Grep`/`Glob`/`TodoWrite`/
24
26
  > `AskUserQuestion`)两套工具集,二者共享 bwrap 沙箱与写保护实现。两套同时
25
27
  > 启用会带来预期外的冗余:同名命令重复注册(如 `/bwrap` 出现 `/bwrap:1`
@@ -134,7 +136,9 @@ bwrap 已集成进 bash 工具实现(opencode 风格 `bash` 位于 `src/openco
134
136
  - 上下文感知匹配(ContextAwareReplacer)
135
137
  - 多次出现替换(MultiOccurrenceReplacer)
136
138
 
137
- 所有匹配策略按顺序尝试,第一个匹配成功即返回。同时自动处理 BOM、CRLF/LF 行尾转换和文件写入队列。
139
+ 所有匹配策略按顺序尝试,第一个匹配成功即返回。同时自动处理 BOM、CRLF/LF 行尾转换和文件写入队列。`filePath` 接受绝对路径或相对工作目录的路径。
140
+
141
+ edit 要求目标文件已被 `read` 读过且内容未变(内容指纹比对,与 Claude Code 风格 Edit 同一套语义):没读过报 `File has not been read yet. Read it first before editing it.`,读后文件被外部改动报 `File has been modified since read...`,两种情况都要重新 `read`。`read`/`edit`/`write`/`lsp-rename` 都会刷新记账并随工具结果持久化,session 恢复 / fork / rewind 后依然有效;`write` 本身不要求先读,但写后会刷新记账,紧随其后的 `edit` 不必重新读。
138
142
 
139
143
  ### 使用
140
144
 
@@ -144,6 +148,32 @@ pi -e ./src/opencode-edit.ts
144
148
 
145
149
  ---
146
150
 
151
+ ## opencode-grep
152
+
153
+ opencode 风格的 `grep` 工具,替换 pi 内置 `grep`。参数与输出格式对齐 opencode 的 [`grep`](https://github.com/anomalyco/opencode) 工具:`pattern` / `path` / `include`,输出以 `Found N matches` 开头,按文件分组(`<绝对路径>:` + ` Line N: <文本>`)。隐藏文件参与搜索、`.git` 排除、结果上限 100 条(触顶时提示 `(more matches available)` 并附截断说明)。
154
+
155
+ 执行层在 `src/opencode/ripgrep.ts`:`rg` 子进程流式读取 stdout,读满 100 条即终止进程,宽泛 pattern 不会把整个结果集读进内存;退出码语义与上游一致(1 = 无匹配,2 = 部分文件读失败仍返回已有结果,正则语法错误单独报错)。
156
+
157
+ 与上游的三处有意差异:行文本去掉 rg JSON 带出的行尾换行(否则每条匹配后面会多一个空行);`path` 指向文件时只搜该文件(上游按目录搜索);`path` 不存在时报错(含同目录相近名字提示),而不是静默返回 `No files found`。
158
+
159
+ ### 使用
160
+
161
+ 随 `src/opencode/index.ts` 一起加载。
162
+
163
+ ---
164
+
165
+ ## opencode-glob
166
+
167
+ opencode 风格的 `glob` 工具(与 pi 内置 `find` 并存)。参数 `pattern` / `path`,内部是 `rg --files` 语义:尊重 `.gitignore`、不列隐藏文件、不按修改时间排序、排除 `.git`,输出绝对路径,上限 100 条(截断时附 `(Results are truncated: ...)`)。与 Claude Code 风格 `Glob` 的差异(`--no-ignore` / `--hidden` / `--sort=modified`)是各自跟随上游的结果。
168
+
169
+ `path` 不存在或指向文件时报错(含同目录相近名字提示)。
170
+
171
+ ### 使用
172
+
173
+ 随 `src/opencode/index.ts` 一起加载。
174
+
175
+ ---
176
+
147
177
  ## vision-agent
148
178
 
149
179
  视觉代理扩展。主模型不支持视觉(如 DeepSeek)时自动启用 `describe_image` 工具;主模型支持视觉时自动隐藏,图片由 pi 原生透传。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.572",
3
+ "version": "0.1.574",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -1,40 +1,15 @@
1
1
  import { readdirSync } from "node:fs";
2
2
  import { realpath, stat } from "node:fs/promises";
3
- import {
4
- basename,
5
- dirname,
6
- extname,
7
- isAbsolute,
8
- join,
9
- normalize,
10
- relative,
11
- resolve,
12
- sep,
13
- } from "node:path";
3
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
14
4
 
15
- import { type Static, Type } from "typebox";
16
- import { Value } from "typebox/value";
5
+ import { resolvePathArg } from "../lib/path.js";
17
6
 
18
- const fileSnapshotSchema = Type.Object({
19
- digest: Type.String(),
20
- textEditable: Type.Boolean(),
21
- });
22
-
23
- export type FileSnapshot = Static<typeof fileSnapshotSchema>;
24
-
25
- export interface ClaudeCodeState {
26
- readonly reads: Map<string, FileSnapshot>;
27
- }
28
-
29
- export function createClaudeCodeState(): ClaudeCodeState {
30
- return { reads: new Map() };
31
- }
32
-
33
- export function requireAbsolutePath(filePath: string, parameter = "file_path"): string {
34
- if (!isAbsolute(filePath)) {
35
- throw new Error(`The ${parameter} parameter must be an absolute path, not a relative path.`);
36
- }
37
- return normalize(filePath);
7
+ /**
8
+ * 解析 `file_path`:绝对路径规整化后原样使用,相对路径(含 `~` 前缀)按调用
9
+ * cwd 展开。工具内部一律用解析后的绝对路径,写保护与 reads 记账都依赖它。
10
+ */
11
+ export function resolveToolFilePath(filePath: string, cwd: string): string {
12
+ return resolve(resolvePathArg(cwd, filePath));
38
13
  }
39
14
 
40
15
  /** Resolve a search root path against the working directory. */
@@ -49,10 +24,6 @@ export function toRelativePath(filePath: string, cwd: string): string {
49
24
  return relativePath.startsWith("..") ? filePath : relativePath;
50
25
  }
51
26
 
52
- export function snapshotsEqual(left: FileSnapshot, right: FileSnapshot): boolean {
53
- return left.digest === right.digest;
54
- }
55
-
56
27
  /**
57
28
  * 同目录下"同名不同扩展名"的文件(如请求 foo.ts 不存在,目录里有 foo.js),
58
29
  * 对齐 Claude Code 的 findSimilarFile。返回文件名(不含路径)。
@@ -114,17 +85,3 @@ export async function didYouMean(filePath: string, cwd: string): Promise<string
114
85
  if (cwdSuggestion) return cwdSuggestion;
115
86
  return findSimilarFile(filePath);
116
87
  }
117
-
118
- /**
119
- * 从工具结果 details 里恢复文件已读记账(跨进程 resume / reload / fork)。
120
- * 数据来自 session 文件,可能缺失或损坏:逐条 TypeBox 校验,非法条目丢弃。
121
- * 只接受 plain object,数组、null 等异常形态直接返回空 map。
122
- */
123
- export function deserializeReads(data: unknown): Map<string, FileSnapshot> {
124
- const reads = new Map<string, FileSnapshot>();
125
- if (typeof data !== "object" || data === null || Array.isArray(data)) return reads;
126
- for (const [filePath, snapshot] of Object.entries(data)) {
127
- if (Value.Check(fileSnapshotSchema, snapshot)) reads.set(filePath, snapshot);
128
- }
129
- return reads;
130
- }
@@ -1,120 +1,12 @@
1
1
  /**
2
- * Claude Code 风格 Edit 的字符串匹配工具(移植自真实 Claude Code 的
3
- * FileEditTool/utils.ts)。
2
+ * Claude Code 风格 Edit 的展示辅助。
4
3
  *
5
- * - findActualString:先精确匹配,失败则做引号规范化匹配(文件里的花引号
6
- * 与模型输出的直引号等价),返回文件中的真实文本;
7
- * - preserveQuoteStyle:当 old_string 通过引号规范化命中时,把 new_string
8
- * 里的直引号换成文件使用的花引号风格,保持文件的排版一致。
4
+ * 上游 Claude Code 还带一套引号规范化(花引号与直引号互相等价匹配,并把
5
+ * new_string 改写成文件的花引号风格),这里刻意没有移植:它无法用语言语法
6
+ * 判断两个引号是否语义等价(markdown 里 `'` 与 `’` 就是两个字符),而且会
7
+ * 替模型改写它没有写的字符。Edit 只做精确匹配。
9
8
  */
10
9
 
11
- export const LEFT_SINGLE_CURLY_QUOTE = "‘";
12
- export const RIGHT_SINGLE_CURLY_QUOTE = "’";
13
- export const LEFT_DOUBLE_CURLY_QUOTE = "“";
14
- export const RIGHT_DOUBLE_CURLY_QUOTE = "”";
15
-
16
- /** 把花引号规范化为直引号(匹配与替换两侧都用它做归一)。 */
17
- export function normalizeQuotes(str: string): string {
18
- return str
19
- .replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'")
20
- .replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'")
21
- .replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"')
22
- .replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"');
23
- }
24
-
25
- /**
26
- * 在文件内容中查找与搜索串匹配的实际文本。
27
- * 先精确匹配;失败则把两侧花引号归一为直引号后重试,返回文件中的原文。
28
- */
29
- export function findActualString(fileContent: string, searchString: string): string | null {
30
- if (fileContent.includes(searchString)) return searchString;
31
- const normalizedSearch = normalizeQuotes(searchString);
32
- const normalizedFile = normalizeQuotes(fileContent);
33
- const searchIndex = normalizedFile.indexOf(normalizedSearch);
34
- if (searchIndex !== -1) {
35
- return fileContent.slice(searchIndex, searchIndex + searchString.length);
36
- }
37
- return null;
38
- }
39
-
40
- /** 引号前是空白/行首/开括号/破折号时视为开引号,否则视为闭引号。 */
41
- function isOpeningContext(chars: string[], index: number): boolean {
42
- if (index === 0) return true;
43
- const prev = chars[index - 1];
44
- return (
45
- prev === " " ||
46
- prev === "\t" ||
47
- prev === "\n" ||
48
- prev === "\r" ||
49
- prev === "(" ||
50
- prev === "[" ||
51
- prev === "{" ||
52
- prev === "\u2014" || // em dash
53
- prev === "\u2013" // en dash
54
- );
55
- }
56
-
57
- function applyCurlyDoubleQuotes(str: string): string {
58
- const chars = [...str];
59
- const result: string[] = [];
60
- for (let i = 0; i < chars.length; i++) {
61
- if (chars[i] === '"') {
62
- result.push(isOpeningContext(chars, i) ? LEFT_DOUBLE_CURLY_QUOTE : RIGHT_DOUBLE_CURLY_QUOTE);
63
- } else {
64
- result.push(chars[i]);
65
- }
66
- }
67
- return result.join("");
68
- }
69
-
70
- function applyCurlySingleQuotes(str: string): string {
71
- const chars = [...str];
72
- const result: string[] = [];
73
- for (let i = 0; i < chars.length; i++) {
74
- if (chars[i] === "'") {
75
- // 缩写中的撇号(如 don't)用右花引号,不做开/闭判断
76
- const prev = i > 0 ? chars[i - 1] : "";
77
- const next = i + 1 < chars.length ? chars[i + 1] : "";
78
- const prevIsLetter = /\p{L}/u.test(prev);
79
- const nextIsLetter = /\p{L}/u.test(next);
80
- if (prevIsLetter && nextIsLetter) {
81
- result.push(RIGHT_SINGLE_CURLY_QUOTE);
82
- } else {
83
- result.push(
84
- isOpeningContext(chars, i) ? LEFT_SINGLE_CURLY_QUOTE : RIGHT_SINGLE_CURLY_QUOTE,
85
- );
86
- }
87
- } else {
88
- result.push(chars[i]);
89
- }
90
- }
91
- return result.join("");
92
- }
93
-
94
- /**
95
- * 当 old_string 通过引号规范化命中(oldString !== actualOldString)时,
96
- * 把 new_string 中的直引号换成文件实际使用的花引号风格。
97
- */
98
- export function preserveQuoteStyle(
99
- oldString: string,
100
- actualOldString: string,
101
- newString: string,
102
- ): string {
103
- if (oldString === actualOldString) return newString;
104
- const hasDoubleQuotes =
105
- actualOldString.includes(LEFT_DOUBLE_CURLY_QUOTE) ||
106
- actualOldString.includes(RIGHT_DOUBLE_CURLY_QUOTE);
107
- const hasSingleQuotes =
108
- actualOldString.includes(LEFT_SINGLE_CURLY_QUOTE) ||
109
- actualOldString.includes(RIGHT_SINGLE_CURLY_QUOTE);
110
- if (!hasDoubleQuotes && !hasSingleQuotes) return newString;
111
-
112
- let result = newString;
113
- if (hasDoubleQuotes) result = applyCurlyDoubleQuotes(result);
114
- if (hasSingleQuotes) result = applyCurlySingleQuotes(result);
115
- return result;
116
- }
117
-
118
10
  /**
119
11
  * patch 显示用:把行首 tab 转成 2 空格(对齐 Claude Code 的
120
12
  * convertLeadingTabsToSpaces)。仅用于 details 里展示的 diff,不影响写盘内容。
@@ -1,6 +1,5 @@
1
- import { createHash } from "node:crypto";
2
1
  import { constants, readFileSync, type Stats } from "node:fs";
3
- import { access, mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
2
+ import { access, mkdir, readFile, stat, writeFile } from "node:fs/promises";
4
3
  import { dirname, extname } from "node:path";
5
4
  import { fileURLToPath } from "node:url";
6
5
 
@@ -14,6 +13,15 @@ import {
14
13
  } from "@earendil-works/pi-coding-agent";
15
14
  import { Type } from "typebox";
16
15
 
16
+ import {
17
+ createReadsState,
18
+ type FileSnapshot,
19
+ type ReadsState,
20
+ readStateKey,
21
+ requireCurrentRead,
22
+ restoreReads,
23
+ snapshotOf,
24
+ } from "../lib/file-reads.js";
17
25
  import { appendLspDiagnosticText, type DiagnosticReport } from "../lib/lsp/diagnostic.js";
18
26
  import { registerLspInspectTools } from "../lib/lsp/inspect-tool.js";
19
27
  import { createLspManager, type LspService, type LspServiceOptions } from "../lib/lsp/lsp.js";
@@ -22,16 +30,8 @@ import { formatSubtitlePath } from "../lib/path.js";
22
30
  import type { ToolPendant } from "../lib/pendant.ts";
23
31
  import { createRequestPolicy, type RequestPolicy } from "../lib/request-policy.js";
24
32
  import { guardWriteAccess } from "../lib/write-guard.js";
25
- import {
26
- type ClaudeCodeState,
27
- createClaudeCodeState,
28
- deserializeReads,
29
- didYouMean,
30
- type FileSnapshot,
31
- requireAbsolutePath,
32
- snapshotsEqual,
33
- } from "./common.js";
34
- import { convertLeadingTabsToSpaces, findActualString, preserveQuoteStyle } from "./edit-utils.js";
33
+ import { didYouMean, resolveToolFilePath } from "./common.js";
34
+ import { convertLeadingTabsToSpaces } from "./edit-utils.js";
35
35
 
36
36
  const SAMPLE_BYTES = 4096;
37
37
 
@@ -76,10 +76,6 @@ export interface FileToolDetails {
76
76
  reads?: Record<string, FileSnapshot>;
77
77
  }
78
78
 
79
- function snapshotOf(content: Uint8Array | string, textEditable = true): FileSnapshot {
80
- return { digest: createHash("sha256").update(content).digest("hex"), textEditable };
81
- }
82
-
83
79
  async function assertReadableFile(filePath: string): Promise<Stats> {
84
80
  const value = await stat(filePath);
85
81
  if (value.isDirectory()) {
@@ -192,53 +188,9 @@ export function exactReplace(
192
188
  return content.slice(0, index) + newString + content.slice(index + oldString.length);
193
189
  }
194
190
 
195
- /**
196
- * reads 记账 key:解析 symlink 后的真实路径,与 withFileMutationQueue 的队列
197
- * key 对齐。文件尚不存在(Write 新建 / Edit 空 old_string 创建)时 realpath
198
- * 抛 ENOENT,回退到已规范化路径。
199
- */
200
- async function readStateKey(filePath: string): Promise<string> {
201
- try {
202
- return await realpath(filePath);
203
- } catch (error) {
204
- if (
205
- error instanceof Error &&
206
- "code" in error &&
207
- (error.code === "ENOENT" || error.code === "ENOTDIR")
208
- ) {
209
- return filePath;
210
- }
211
- throw error;
212
- }
213
- }
214
-
215
- /**
216
- * 校验「已读且未变」。key 与 currentContent 由调用方提供:调用方每次工具调用
217
- * 只 realpath / readFile 一次,避免重复 IO。
218
- */
219
- function requireCurrentRead(
220
- state: ClaudeCodeState,
221
- key: string,
222
- filePath: string,
223
- currentContent: Uint8Array,
224
- ): void {
225
- const readSnapshot = state.reads.get(key);
226
- if (!readSnapshot) {
227
- throw new Error("File has not been read yet. Read it first before writing to it.");
228
- }
229
- if (!readSnapshot.textEditable) {
230
- throw new Error(`Cannot edit or overwrite a binary file with a text tool: ${filePath}`);
231
- }
232
- if (!snapshotsEqual(readSnapshot, snapshotOf(currentContent))) {
233
- throw new Error(
234
- "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.",
235
- );
236
- }
237
- }
238
-
239
191
  export function registerFileTools(
240
192
  pi: ExtensionAPI,
241
- state: ClaudeCodeState,
193
+ state: ReadsState,
242
194
  getService: () => LspService,
243
195
  policy: RequestPolicy,
244
196
  ): void {
@@ -248,7 +200,7 @@ export function registerFileTools(
248
200
  description: [
249
201
  "Reads a file from the local filesystem. You can access any file directly using this tool.",
250
202
  "Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.",
251
- "The file_path parameter must be an absolute path. By default, it reads the entire file; files over 256 KB or 25K tokens require offset and limit.",
203
+ "The file_path parameter accepts an absolute path or a path relative to the working directory. By default, it reads the entire file; files over 256 KB or 25K tokens require offset and limit.",
252
204
  "Read parts of files with offset and limit instead of running head/tail via Bash: a positive offset is the starting line number, a negative offset counts from the end of the file (offset=-5 reads the last 5 lines, like tail -n 5).",
253
205
  "Results use cat -n style line numbers starting at 1. Images are returned visually.",
254
206
  "This tool reads files, not directories.",
@@ -257,7 +209,10 @@ export function registerFileTools(
257
209
  promptGuidelines: [READ_PROMPT],
258
210
  parameters: Type.Object(
259
211
  {
260
- file_path: Type.String({ description: "The absolute path to the file to read" }),
212
+ file_path: Type.String({
213
+ description:
214
+ "The path to the file to read (absolute or relative to the working directory)",
215
+ }),
261
216
  offset: Type.Optional(
262
217
  Type.Integer({
263
218
  description:
@@ -273,7 +228,7 @@ export function registerFileTools(
273
228
  ),
274
229
  async execute(_id, params, signal, _onUpdate, ctx) {
275
230
  signal?.throwIfAborted();
276
- const filePath = requireAbsolutePath(params.file_path);
231
+ const filePath = resolveToolFilePath(params.file_path, ctx.cwd);
277
232
  if (params.offset !== undefined && !Number.isSafeInteger(params.offset)) {
278
233
  throw new Error("offset must be an integer");
279
234
  }
@@ -389,7 +344,10 @@ export function registerFileTools(
389
344
  promptGuidelines: [EDIT_PROMPT],
390
345
  parameters: Type.Object(
391
346
  {
392
- file_path: Type.String({ description: "The absolute path to the file to modify" }),
347
+ file_path: Type.String({
348
+ description:
349
+ "The path to the file to modify (absolute or relative to the working directory)",
350
+ }),
393
351
  old_string: Type.String({ description: "The text to replace" }),
394
352
  new_string: Type.String({
395
353
  description: "The text to replace it with (must be different from old_string)",
@@ -402,7 +360,7 @@ export function registerFileTools(
402
360
  ),
403
361
  async execute(_id, params, signal, _onUpdate, ctx) {
404
362
  signal?.throwIfAborted();
405
- const filePath = requireAbsolutePath(params.file_path);
363
+ const filePath = resolveToolFilePath(params.file_path, ctx.cwd);
406
364
  await guardWriteAccess(ctx, {
407
365
  toolName: "Edit",
408
366
  absolutePath: filePath,
@@ -505,8 +463,7 @@ export function registerFileTools(
505
463
  const lfCount = (original.match(/(?<!\r)\n/g) ?? []).length;
506
464
  const lineEnding = crlfCount > lfCount ? "\r\n" : "\n";
507
465
  const normalized = original.replaceAll("\r\n", "\n");
508
- const actualOldString = findActualString(normalized, oldString) ?? oldString;
509
- const matches = normalized.split(actualOldString).length - 1;
466
+ const matches = normalized.split(oldString).length - 1;
510
467
  if (matches === 0) {
511
468
  throw new Error(`String to replace not found in file.\nString: ${oldString}`);
512
469
  }
@@ -515,22 +472,21 @@ export function registerFileTools(
515
472
  `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${oldString}`,
516
473
  );
517
474
  }
518
- const actualNewString = preserveQuoteStyle(oldString, actualOldString, newString);
519
475
  // 删除场景(new_string 为空):old_string 不以换行结尾且文件里是
520
476
  // "old_string\n" 时连换行一起删,避免留下空行(对齐 Claude Code
521
477
  // applyEditToFile 的 stripTrailingNewline 语义)
522
- let searchString = actualOldString;
478
+ let searchString = oldString;
523
479
  if (
524
- actualNewString === "" &&
525
- !actualOldString.endsWith("\n") &&
526
- normalized.includes(actualOldString + "\n")
480
+ newString === "" &&
481
+ !oldString.endsWith("\n") &&
482
+ normalized.includes(oldString + "\n")
527
483
  ) {
528
- searchString = actualOldString + "\n";
484
+ searchString = oldString + "\n";
529
485
  }
530
486
  // split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
531
487
  const updated = replaceAll
532
- ? normalized.split(searchString).join(actualNewString)
533
- : normalized.replace(searchString, () => actualNewString);
488
+ ? normalized.split(searchString).join(newString)
489
+ : normalized.replace(searchString, () => newString);
534
490
  const restored = lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
535
491
  await writeFile(filePath, restored, "utf8");
536
492
  const snapshot = snapshotOf(restored);
@@ -596,7 +552,8 @@ export function registerFileTools(
596
552
  parameters: Type.Object(
597
553
  {
598
554
  file_path: Type.String({
599
- description: "The absolute path to the file to write (must be absolute, not relative)",
555
+ description:
556
+ "The path to the file to write (absolute or relative to the working directory)",
600
557
  }),
601
558
  content: Type.String({ description: "The content to write to the file" }),
602
559
  },
@@ -604,7 +561,7 @@ export function registerFileTools(
604
561
  ),
605
562
  async execute(_id, params, signal, _onUpdate, ctx) {
606
563
  signal?.throwIfAborted();
607
- const filePath = requireAbsolutePath(params.file_path);
564
+ const filePath = resolveToolFilePath(params.file_path, ctx.cwd);
608
565
  await guardWriteAccess(ctx, {
609
566
  toolName: "Write",
610
567
  absolutePath: filePath,
@@ -684,24 +641,11 @@ export function registerFileTools(
684
641
  /** 会更新 reads state 并随 details 持久化快照的工具名。 */
685
642
  const FILE_TOOL_NAMES = new Set(["Read", "Edit", "Write", "lsp-rename"]);
686
643
 
687
- /**
688
- * 从当前分支的历史工具结果重建已读记账。先清空再重放,保证 state 只反映
689
- * 当前分支:rewind / fork / resume 后,被抛弃分支上的 Read 不再残留。
690
- */
691
644
  function restoreFileReads(
692
- state: ClaudeCodeState,
645
+ state: ReadsState,
693
646
  sessionManager: ExtensionContext["sessionManager"],
694
647
  ): void {
695
- state.reads.clear();
696
- for (const entry of sessionManager.getBranch()) {
697
- if (entry.type !== "message" || entry.message.role !== "toolResult") continue;
698
- if (!FILE_TOOL_NAMES.has(entry.message.toolName)) continue;
699
- const details = entry.message.details as { reads?: unknown } | undefined;
700
- if (!details?.reads) continue;
701
- for (const [filePath, snapshot] of deserializeReads(details.reads)) {
702
- state.reads.set(filePath, snapshot);
703
- }
704
- }
648
+ restoreReads(state, sessionManager, FILE_TOOL_NAMES);
705
649
  }
706
650
 
707
651
  export interface ClaudeCodeFileToolOptions extends LspServiceOptions {
@@ -719,7 +663,7 @@ export default function claudeCodeFileTools(
719
663
  pi: ExtensionAPI,
720
664
  options?: ClaudeCodeFileToolOptions,
721
665
  ): void {
722
- const state = createClaudeCodeState();
666
+ const state = createReadsState();
723
667
  // 聚合入口(index.ts)注入与 bash runtime 共享的那一份;独立入口(spawn-agent
724
668
  // 按工具名 `-e` 加载本文件)自建一份,靠 pi.events 跟随同一开关。
725
669
  const policy = options?.policy ?? createRequestPolicy(pi.events);
@@ -4,7 +4,7 @@ Reads a file from the local filesystem. You can access any file directly by usin
4
4
 
5
5
  Usage:
6
6
 
7
- - The file_path parameter must be an absolute path, not a relative path
7
+ - The file_path parameter accepts an absolute path or a path relative to the working directory
8
8
  - By default, it reads the entire file; files over 256 KB or 25K tokens require offset and limit to read specific portions
9
9
  - When you already know which part of the file you need, only read that part. This can be important for larger files.
10
10
  - Do not use head/tail (or sed -n) via the Bash tool to read parts of files — use offset and limit here instead. They are line-based: `offset=5, limit=10` reads 10 lines starting at line 5 (like `sed -n '5,14p'`), and a negative offset counts from the end of the file — `offset=-5` reads the last 5 lines (like `tail -n 5`). Line numbers in the output are always absolute.
@@ -0,0 +1,125 @@
1
+ /**
2
+ * read-before-edit 记账:已读文件的内容指纹快照 + 会话分支重放。
3
+ *
4
+ * claude-code 与 opencode 两套工具集共用这一份机制(守卫语义、错误文案、
5
+ * details.reads 格式一致),差异只有两点:
6
+ * - 工具名集合:claude-code 是大写 Read/Edit/Write,opencode 是小写;
7
+ * - 指纹来源:claude-code 手里已有内容,直接 snapshotOf(content);opencode 的
8
+ * read 是流式分页读取,整文件指纹用 fileDigest(path)。
9
+ */
10
+
11
+ import { createHash } from "node:crypto";
12
+ import { createReadStream } from "node:fs";
13
+ import { realpath } from "node:fs/promises";
14
+
15
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { type Static, Type } from "typebox";
17
+ import { Value } from "typebox/value";
18
+
19
+ const fileSnapshotSchema = Type.Object({
20
+ digest: Type.String(),
21
+ textEditable: Type.Boolean(),
22
+ });
23
+
24
+ export type FileSnapshot = Static<typeof fileSnapshotSchema>;
25
+
26
+ /** 已读文件记账:key 为解析 symlink 后的路径 → 读取时的内容指纹。 */
27
+ export interface ReadsState {
28
+ readonly reads: Map<string, FileSnapshot>;
29
+ }
30
+
31
+ export function createReadsState(): ReadsState {
32
+ return { reads: new Map() };
33
+ }
34
+
35
+ export function snapshotOf(content: Uint8Array | string, textEditable = true): FileSnapshot {
36
+ return { digest: createHash("sha256").update(content).digest("hex"), textEditable };
37
+ }
38
+
39
+ /** 整文件指纹:流式读取,避免为记账把大文件整个读进内存。 */
40
+ export async function fileDigest(filePath: string): Promise<string> {
41
+ const hash = createHash("sha256");
42
+ for await (const chunk of createReadStream(filePath)) {
43
+ hash.update(chunk as Buffer);
44
+ }
45
+ return hash.digest("hex");
46
+ }
47
+
48
+ /**
49
+ * 记账 key:解析 symlink 后的真实路径。文件尚不存在(Write 新建 / Edit 空
50
+ * old_string 创建)时 realpath 抛 ENOENT,回退到调用方给出的路径。
51
+ */
52
+ export async function readStateKey(filePath: string): Promise<string> {
53
+ try {
54
+ return await realpath(filePath);
55
+ } catch (error) {
56
+ if (
57
+ error instanceof Error &&
58
+ "code" in error &&
59
+ (error.code === "ENOENT" || error.code === "ENOTDIR")
60
+ ) {
61
+ return filePath;
62
+ }
63
+ throw error;
64
+ }
65
+ }
66
+
67
+ export function snapshotsEqual(left: FileSnapshot, right: FileSnapshot): boolean {
68
+ return left.digest === right.digest;
69
+ }
70
+
71
+ /** 校验「已读且未变」:未读过、非文本、指纹不符都拒绝写入。 */
72
+ export function requireCurrentRead(
73
+ state: ReadsState,
74
+ key: string,
75
+ filePath: string,
76
+ currentContent: Uint8Array | string,
77
+ ): void {
78
+ const readSnapshot = state.reads.get(key);
79
+ if (!readSnapshot) {
80
+ throw new Error("File has not been read yet. Read it first before writing to it.");
81
+ }
82
+ if (!readSnapshot.textEditable) {
83
+ throw new Error(`Cannot edit or overwrite a binary file with a text tool: ${filePath}`);
84
+ }
85
+ if (!snapshotsEqual(readSnapshot, snapshotOf(currentContent))) {
86
+ throw new Error(
87
+ "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.",
88
+ );
89
+ }
90
+ }
91
+
92
+ /**
93
+ * 从工具结果 details 恢复已读记账(跨进程 resume / reload / fork)。数据来自
94
+ * session 文件,可能缺失或损坏:逐条 TypeBox 校验,非法条目丢弃。只接受
95
+ * plain object,数组、null 等异常形态直接返回空 map。
96
+ */
97
+ export function deserializeReads(data: unknown): Map<string, FileSnapshot> {
98
+ const reads = new Map<string, FileSnapshot>();
99
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return reads;
100
+ for (const [filePath, snapshot] of Object.entries(data)) {
101
+ if (Value.Check(fileSnapshotSchema, snapshot)) reads.set(filePath, snapshot);
102
+ }
103
+ return reads;
104
+ }
105
+
106
+ /**
107
+ * 先清空再重放当前分支,保证 state 只反映当前分支:rewind / fork / resume 后,
108
+ * 被抛弃分支上的已读不再残留。工具名集合由调用方给出(两套工具集大小写不同)。
109
+ */
110
+ export function restoreReads(
111
+ state: ReadsState,
112
+ sessionManager: ExtensionContext["sessionManager"],
113
+ toolNames: ReadonlySet<string>,
114
+ ): void {
115
+ state.reads.clear();
116
+ for (const entry of sessionManager.getBranch()) {
117
+ if (entry.type !== "message" || entry.message.role !== "toolResult") continue;
118
+ if (!toolNames.has(entry.message.toolName)) continue;
119
+ const details = entry.message.details as { reads?: unknown } | undefined;
120
+ if (!details?.reads) continue;
121
+ for (const [filePath, snapshot] of deserializeReads(details.reads)) {
122
+ state.reads.set(filePath, snapshot);
123
+ }
124
+ }
125
+ }