@trim21/personal-pi-extensions 0.1.572 → 0.1.573
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 +3 -1
- 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 +24 -1
- package/src/opencode/files.ts +100 -13
package/README.md
CHANGED
|
@@ -134,7 +134,9 @@ bwrap 已集成进 bash 工具实现(opencode 风格 `bash` 位于 `src/openco
|
|
|
134
134
|
- 上下文感知匹配(ContextAwareReplacer)
|
|
135
135
|
- 多次出现替换(MultiOccurrenceReplacer)
|
|
136
136
|
|
|
137
|
-
所有匹配策略按顺序尝试,第一个匹配成功即返回。同时自动处理 BOM、CRLF/LF
|
|
137
|
+
所有匹配策略按顺序尝试,第一个匹配成功即返回。同时自动处理 BOM、CRLF/LF 行尾转换和文件写入队列。`filePath` 接受绝对路径或相对工作目录的路径。
|
|
138
|
+
|
|
139
|
+
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
140
|
|
|
139
141
|
### 使用
|
|
140
142
|
|
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
|
+
}
|
package/src/opencode/bash.md
CHANGED
|
@@ -4,4 +4,27 @@ Executes a given bash command synchronously and returns its output.
|
|
|
4
4
|
|
|
5
5
|
Commands run in a sandbox: no write access outside the workspace and no network access. Use the `dangerouslyDisableSandbox` parameter to request unsandboxed execution; the user must approve this request.
|
|
6
6
|
|
|
7
|
-
Long command output is automatically truncated and the full output is saved to a file. Avoid piping commands through `tail` to limit output unless you have a specific reason.
|
|
7
|
+
Long command output is automatically truncated and the full output is saved to a file. Avoid piping commands through `tail` or `2>&1 | tee test.log` to limit output unless you have a specific reason.
|
|
8
|
+
|
|
9
|
+
IMPORTANT: Avoid using this tool to run `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user:
|
|
10
|
+
|
|
11
|
+
- Read files: Use `read` (NOT cat/head/tail)
|
|
12
|
+
- Edit files: Use `edit` (NOT sed/awk)
|
|
13
|
+
- Write files: Use `write` (NOT echo >/cat <<EOF)
|
|
14
|
+
- Communication: Output text directly (NOT echo/printf)
|
|
15
|
+
While the Bash tool can do similar things, it's better to use the built-in tools as they provide a better user experience and make it easier to review tool calls and give permission.
|
|
16
|
+
|
|
17
|
+
- You may specify an optional timeout in milliseconds (up to 7200000ms / 2 hours). By default, your command will timeout after 120000ms (2 minutes).
|
|
18
|
+
- Prefer to use the workdir argument over `cd ...`
|
|
19
|
+
- When issuing multiple commands:
|
|
20
|
+
- If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. Example: if you need to run "git status" and "git diff", send a single message with two Bash tool calls in parallel.
|
|
21
|
+
- If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together.
|
|
22
|
+
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail.
|
|
23
|
+
- DO NOT use newlines to separate commands (newlines are ok in quoted strings).
|
|
24
|
+
- For git commands:
|
|
25
|
+
- Prefer to create a new commit rather than amending an existing commit.
|
|
26
|
+
- Before running destructive operations (e.g., git reset --hard, git push --force, git checkout --), consider whether there is a safer alternative that achieves the same goal. Only use destructive operations when they are truly the best approach.
|
|
27
|
+
- Never skip hooks (--no-verify) or bypass signing (--no-gpg-sign, -c commit.gpgsign=false) unless the user has explicitly asked for it. If a hook fails, investigate and fix the underlying issue.
|
|
28
|
+
- Avoid unnecessary `sleep` commands:
|
|
29
|
+
- Do not sleep between commands that can run immediately — just run them.
|
|
30
|
+
- Do not retry failing commands in a sleep loop — diagnose the root cause.
|
package/src/opencode/files.ts
CHANGED
|
@@ -10,7 +10,13 @@
|
|
|
10
10
|
* 字符截断、1 起始 offset、目录排序;读取后等待并报告该文档的诊断。
|
|
11
11
|
* 不接 PDF、不接 <system-reminder>;图片 magic 检测保留。
|
|
12
12
|
* - edit:匹配引擎 + 把 old/new 转到文件换行后再替换;写后等待文档诊断。
|
|
13
|
-
*
|
|
13
|
+
* edit 要求文件已被 read 读过且内容未变(read 记账见下)。
|
|
14
|
+
* - write:BOM 保留(source.bom || next.bom);写后同 edit 的诊断输出。write 本身
|
|
15
|
+
* 不要求先 read,但会刷新记账,保证紧接着的 edit 不必重新 read。
|
|
16
|
+
*
|
|
17
|
+
* read 记账:read / edit / write / lsp-rename 各自把受影响文件的内容指纹写进
|
|
18
|
+
* details.reads(随 session 持久化),session_start / session_tree 时从当前分支
|
|
19
|
+
* 重放。这是 edit 的 read-before-edit 守卫的基础。
|
|
14
20
|
*
|
|
15
21
|
* 匹配引擎在 edit-engine.ts,也被 lib/write-guard 复用。
|
|
16
22
|
*/
|
|
@@ -29,6 +35,16 @@ import {
|
|
|
29
35
|
} from "@earendil-works/pi-coding-agent";
|
|
30
36
|
import { Type } from "typebox";
|
|
31
37
|
|
|
38
|
+
import {
|
|
39
|
+
createReadsState,
|
|
40
|
+
fileDigest,
|
|
41
|
+
type FileSnapshot,
|
|
42
|
+
type ReadsState,
|
|
43
|
+
readStateKey,
|
|
44
|
+
requireCurrentRead,
|
|
45
|
+
restoreReads,
|
|
46
|
+
snapshotOf,
|
|
47
|
+
} from "../lib/file-reads.js";
|
|
32
48
|
import { appendLspDiagnosticText } from "../lib/lsp/diagnostic.js";
|
|
33
49
|
import { registerLspInspectTools } from "../lib/lsp/inspect-tool.js";
|
|
34
50
|
import { createLspManager, type LspService, type LspServiceOptions } from "../lib/lsp/lsp.js";
|
|
@@ -359,7 +375,12 @@ async function formatDirectoryEntries(dirPath: string): Promise<string[]> {
|
|
|
359
375
|
return results;
|
|
360
376
|
}
|
|
361
377
|
|
|
362
|
-
|
|
378
|
+
// ── read-before-edit 记账 ────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
/** 会更新 reads 记账(src/lib/file-reads.ts)并随 details 持久化快照的工具名。 */
|
|
381
|
+
const READS_TOOL_NAMES = new Set(["read", "edit", "write", "lsp-rename"]);
|
|
382
|
+
|
|
383
|
+
function registerReadTool(pi: ExtensionAPI, getService: () => LspService, state: ReadsState): void {
|
|
363
384
|
pi.registerTool({
|
|
364
385
|
name: "read",
|
|
365
386
|
label: "read",
|
|
@@ -369,7 +390,10 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
369
390
|
"Use read to examine files instead of cat, sed, or tail. A negative offset reads from the end of the file (offset=-2 reads the last 2 lines).",
|
|
370
391
|
],
|
|
371
392
|
parameters: Type.Object({
|
|
372
|
-
filePath: Type.String({
|
|
393
|
+
filePath: Type.String({
|
|
394
|
+
description:
|
|
395
|
+
"The path to the file or directory to read (absolute or relative to the working directory)",
|
|
396
|
+
}),
|
|
373
397
|
offset: Type.Optional(
|
|
374
398
|
Type.Integer({
|
|
375
399
|
description:
|
|
@@ -454,9 +478,15 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
454
478
|
{ type: "text", text: "Image read successfully" },
|
|
455
479
|
{ type: "image", data: base64, mimeType },
|
|
456
480
|
];
|
|
481
|
+
const key = await readStateKey(absolutePath);
|
|
482
|
+
const snapshot = snapshotOf(buffer, false);
|
|
483
|
+
state.reads.set(key, snapshot);
|
|
457
484
|
return {
|
|
458
485
|
content,
|
|
459
|
-
details: {
|
|
486
|
+
details: {
|
|
487
|
+
reads: { [key]: snapshot },
|
|
488
|
+
pendant: { subtitle: formatSubtitlePath(ctx.cwd, absolutePath) },
|
|
489
|
+
},
|
|
460
490
|
};
|
|
461
491
|
}
|
|
462
492
|
|
|
@@ -467,6 +497,11 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
467
497
|
};
|
|
468
498
|
}
|
|
469
499
|
|
|
500
|
+
// 记账指纹先于分页读取:文件若在读取期间被改动,指纹对应的是改动前的
|
|
501
|
+
// 内容,后续 edit 会要求重新 read(失败方向安全)。
|
|
502
|
+
const key = await readStateKey(absolutePath);
|
|
503
|
+
const snapshot: FileSnapshot = { digest: await fileDigest(absolutePath), textEditable: true };
|
|
504
|
+
|
|
470
505
|
const effectiveOffset = offset || 1;
|
|
471
506
|
const page = await readLines(absolutePath, {
|
|
472
507
|
offset: effectiveOffset,
|
|
@@ -498,6 +533,8 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
498
533
|
outputText = `${header}${numbered}\n\n(End of file - total ${page.count} lines)${footer}`;
|
|
499
534
|
}
|
|
500
535
|
|
|
536
|
+
state.reads.set(key, snapshot);
|
|
537
|
+
|
|
501
538
|
// opencode: 与 edit / write 同一条驻留路径:didOpen 后等待该文件的诊断并报告
|
|
502
539
|
const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
|
|
503
540
|
signal,
|
|
@@ -513,6 +550,7 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
513
550
|
content,
|
|
514
551
|
details: {
|
|
515
552
|
...details,
|
|
553
|
+
reads: { [key]: snapshot },
|
|
516
554
|
pendant: {
|
|
517
555
|
subtitle: formatSubtitlePath(
|
|
518
556
|
ctx.cwd,
|
|
@@ -544,6 +582,7 @@ function registerEditTool(
|
|
|
544
582
|
pi: ExtensionAPI,
|
|
545
583
|
getService: () => LspService,
|
|
546
584
|
policy: RequestPolicy,
|
|
585
|
+
state: ReadsState,
|
|
547
586
|
): void {
|
|
548
587
|
pi.registerTool({
|
|
549
588
|
name: "edit",
|
|
@@ -606,12 +645,16 @@ function registerEditTool(
|
|
|
606
645
|
await mkdir(dirname(absolutePath), { recursive: true });
|
|
607
646
|
signal?.throwIfAborted();
|
|
608
647
|
await writeFile(absolutePath, newString, "utf8");
|
|
648
|
+
// 新建文件无需先 read(无处可读),写后记账;realpath 要等文件落盘
|
|
649
|
+
const key = await readStateKey(absolutePath);
|
|
650
|
+
const snapshot = snapshotOf(newString);
|
|
651
|
+
state.reads.set(key, snapshot);
|
|
609
652
|
const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
|
|
610
653
|
signal,
|
|
611
654
|
});
|
|
612
655
|
return [
|
|
613
656
|
"Edit applied successfully.",
|
|
614
|
-
{ diff: "", patch: "", firstChangedLine: 0 },
|
|
657
|
+
{ diff: "", patch: "", firstChangedLine: 0, reads: { [key]: snapshot } },
|
|
615
658
|
diagnostics,
|
|
616
659
|
] as const;
|
|
617
660
|
}
|
|
@@ -635,10 +678,16 @@ function registerEditTool(
|
|
|
635
678
|
throw new Error(`Could not edit file: ${filePath}. ${msg}.`, { cause: error });
|
|
636
679
|
}
|
|
637
680
|
|
|
681
|
+
// 指纹校验必须发生在写入之前:未 read 或外部已改动都拒绝编辑
|
|
638
682
|
const rawContent = await readFile(absolutePath, "utf8");
|
|
683
|
+
const key = await readStateKey(absolutePath);
|
|
684
|
+
requireCurrentRead(state, key, absolutePath, rawContent);
|
|
685
|
+
|
|
639
686
|
const applied = applyEdit(rawContent, oldString, newString, replaceAll);
|
|
640
687
|
signal?.throwIfAborted();
|
|
641
688
|
await writeFile(absolutePath, applied.finalContent, "utf8");
|
|
689
|
+
const snapshot = snapshotOf(applied.finalContent);
|
|
690
|
+
state.reads.set(key, snapshot);
|
|
642
691
|
|
|
643
692
|
const diffOld = normalizeToLF(applied.contentOld);
|
|
644
693
|
const diffNew = normalizeToLF(applied.contentNew);
|
|
@@ -649,7 +698,12 @@ function registerEditTool(
|
|
|
649
698
|
});
|
|
650
699
|
return [
|
|
651
700
|
"Edit applied successfully.",
|
|
652
|
-
{
|
|
701
|
+
{
|
|
702
|
+
diff: diffResult.diff,
|
|
703
|
+
patch,
|
|
704
|
+
firstChangedLine: diffResult.firstChangedLine,
|
|
705
|
+
reads: { [key]: snapshot },
|
|
706
|
+
},
|
|
653
707
|
diagnostics,
|
|
654
708
|
] as const;
|
|
655
709
|
},
|
|
@@ -702,6 +756,7 @@ function registerWriteTool(
|
|
|
702
756
|
pi: ExtensionAPI,
|
|
703
757
|
getService: () => LspService,
|
|
704
758
|
policy: RequestPolicy,
|
|
759
|
+
state: ReadsState,
|
|
705
760
|
): void {
|
|
706
761
|
pi.registerTool({
|
|
707
762
|
name: "write",
|
|
@@ -712,7 +767,8 @@ function registerWriteTool(
|
|
|
712
767
|
promptGuidelines: ["Use write only for new files or complete rewrites."],
|
|
713
768
|
parameters: Type.Object({
|
|
714
769
|
filePath: Type.String({
|
|
715
|
-
description:
|
|
770
|
+
description:
|
|
771
|
+
"The path to the file to write (absolute or relative to the working directory)",
|
|
716
772
|
}),
|
|
717
773
|
content: Type.String({ description: "The content to write to the file" }),
|
|
718
774
|
}),
|
|
@@ -753,11 +809,15 @@ function registerWriteTool(
|
|
|
753
809
|
await mkdir(dir, { recursive: true });
|
|
754
810
|
signal?.throwIfAborted();
|
|
755
811
|
await writeFile(absolutePath, desiredBom + nextText, "utf8");
|
|
812
|
+
// write 不要求先 read,但写后记账:紧接着的 edit 不该再要求重新 read
|
|
813
|
+
const key = await readStateKey(absolutePath);
|
|
814
|
+
const snapshot = snapshotOf(desiredBom + nextText);
|
|
815
|
+
state.reads.set(key, snapshot);
|
|
756
816
|
const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
|
|
757
817
|
signal,
|
|
758
818
|
});
|
|
759
819
|
|
|
760
|
-
return ["Wrote file successfully.", {}, diagnostics] as const;
|
|
820
|
+
return ["Wrote file successfully.", { reads: { [key]: snapshot } }, diagnostics] as const;
|
|
761
821
|
},
|
|
762
822
|
);
|
|
763
823
|
|
|
@@ -786,10 +846,11 @@ export function registerFileTools(
|
|
|
786
846
|
pi: ExtensionAPI,
|
|
787
847
|
getService: () => LspService,
|
|
788
848
|
policy: RequestPolicy,
|
|
849
|
+
state: ReadsState,
|
|
789
850
|
): void {
|
|
790
|
-
registerReadTool(pi, getService);
|
|
791
|
-
registerEditTool(pi, getService, policy);
|
|
792
|
-
registerWriteTool(pi, getService, policy);
|
|
851
|
+
registerReadTool(pi, getService, state);
|
|
852
|
+
registerEditTool(pi, getService, policy, state);
|
|
853
|
+
registerWriteTool(pi, getService, policy, state);
|
|
793
854
|
// lsp-rename / inspect 工具由 manager 的 onEnabled 回调注册,不在这里注册。
|
|
794
855
|
}
|
|
795
856
|
|
|
@@ -806,16 +867,42 @@ export default function opencodeFileTools(
|
|
|
806
867
|
// 聚合入口(index.ts)注入与 bash runtime 共享的那一份;独立入口自建并靠
|
|
807
868
|
// pi.events 跟随同一开关。
|
|
808
869
|
const policy = options?.policy ?? createRequestPolicy(pi.events);
|
|
870
|
+
const state = createReadsState();
|
|
809
871
|
const manager = createLspManager(
|
|
810
872
|
pi,
|
|
811
873
|
{
|
|
812
874
|
onEnabled: (pi, service) => {
|
|
813
|
-
registerLspRenameTool(pi, service, {
|
|
875
|
+
registerLspRenameTool(pi, service, {
|
|
876
|
+
policy,
|
|
877
|
+
recordReads: async (applied) => {
|
|
878
|
+
const reads: Record<string, FileSnapshot> = {};
|
|
879
|
+
for (const fileEdit of applied) {
|
|
880
|
+
const key = await readStateKey(fileEdit.path);
|
|
881
|
+
const snapshot = snapshotOf(fileEdit.newText);
|
|
882
|
+
state.reads.set(key, snapshot);
|
|
883
|
+
reads[key] = snapshot;
|
|
884
|
+
}
|
|
885
|
+
return reads;
|
|
886
|
+
},
|
|
887
|
+
});
|
|
814
888
|
registerLspInspectTools(pi, service);
|
|
815
889
|
},
|
|
816
890
|
},
|
|
817
891
|
options,
|
|
818
892
|
);
|
|
893
|
+
|
|
894
|
+
// 扩展实例在进程启动 / /reload / /new / /resume / /fork 时重建,内存里的已读
|
|
895
|
+
// 记账随之丢失,这里从当前分支的历史工具结果恢复;digest 仍是当时的值,文件
|
|
896
|
+
// 若在此期间被外部修改,edit 时的指纹对比照样要求重新 read。
|
|
897
|
+
pi.on("session_start", (_event, ctx) => {
|
|
898
|
+
restoreReads(state, ctx.sessionManager, READS_TOOL_NAMES);
|
|
899
|
+
});
|
|
900
|
+
// rewind / 树内跳转走 branch(),只发 session_tree:重放当前分支,丢弃被抛弃
|
|
901
|
+
// 分支的记账,避免 state 与当前分支脱节。
|
|
902
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
903
|
+
restoreReads(state, ctx.sessionManager, READS_TOOL_NAMES);
|
|
904
|
+
});
|
|
905
|
+
|
|
819
906
|
// 文件工具无条件注册;service 惰性获取,disabled 时为 no-op。
|
|
820
|
-
registerFileTools(pi, () => manager.mustLazyGetService(), policy);
|
|
907
|
+
registerFileTools(pi, () => manager.mustLazyGetService(), policy, state);
|
|
821
908
|
}
|