@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 +32 -2
- package/package.json +1 -1
- package/src/claude-code/common.ts +8 -51
- package/src/claude-code/edit-utils.ts +5 -113
- package/src/claude-code/files.ts +38 -94
- package/src/claude-code/read.md +1 -1
- package/src/lib/file-reads.ts +125 -0
- package/src/opencode/bash.md +26 -1
- package/src/opencode/files.ts +102 -14
- package/src/opencode/glob.md +6 -0
- package/src/opencode/glob.ts +120 -0
- package/src/opencode/grep.md +9 -0
- package/src/opencode/grep.ts +206 -0
- package/src/opencode/index.ts +10 -3
- package/src/opencode/ripgrep.ts +159 -0
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,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 {
|
|
16
|
-
import { Value } from "typebox/value";
|
|
5
|
+
import { resolvePathArg } from "../lib/path.js";
|
|
17
6
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
3
|
-
* FileEditTool/utils.ts)。
|
|
2
|
+
* Claude Code 风格 Edit 的展示辅助。
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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,不影响写盘内容。
|
package/src/claude-code/files.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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:
|
|
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
|
|
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({
|
|
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 =
|
|
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({
|
|
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 =
|
|
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
|
|
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 =
|
|
478
|
+
let searchString = oldString;
|
|
523
479
|
if (
|
|
524
|
-
|
|
525
|
-
!
|
|
526
|
-
normalized.includes(
|
|
480
|
+
newString === "" &&
|
|
481
|
+
!oldString.endsWith("\n") &&
|
|
482
|
+
normalized.includes(oldString + "\n")
|
|
527
483
|
) {
|
|
528
|
-
searchString =
|
|
484
|
+
searchString = oldString + "\n";
|
|
529
485
|
}
|
|
530
486
|
// split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
|
|
531
487
|
const updated = replaceAll
|
|
532
|
-
? normalized.split(searchString).join(
|
|
533
|
-
: normalized.replace(searchString, () =>
|
|
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:
|
|
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 =
|
|
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:
|
|
645
|
+
state: ReadsState,
|
|
693
646
|
sessionManager: ExtensionContext["sessionManager"],
|
|
694
647
|
): void {
|
|
695
|
-
state
|
|
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 =
|
|
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);
|
package/src/claude-code/read.md
CHANGED
|
@@ -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
|
|
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
|
+
}
|