@trim21/personal-pi-extensions 0.1.448 → 0.1.451
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 +13 -2
- package/package.json +1 -1
- package/src/aft/bridge.ts +53 -12
- package/src/aft/callgraph.md +3 -1
- package/src/aft/config.ts +99 -26
- package/src/aft/index.ts +16 -5
- package/src/aft/outline.md +1 -1
- package/src/aft/refactor.md +46 -2
- package/src/aft/refactor.ts +3 -4
- package/src/aft/search.md +1 -1
- package/src/aft/tools.ts +1 -3
- package/src/claude-code/files.ts +3 -3
- package/src/lib/lsp/client.ts +125 -22
- package/src/lib/lsp/lsp.ts +165 -2
- package/src/lib/lsp/server-config.ts +3 -5
- package/src/lib/lsp/watcher.ts +174 -0
- package/src/opencode/files.ts +3 -3
- package/src/aft/ast-edit.ts +0 -310
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工作区文件监听器:单个递归 fs.watch + 去抖批量回调。
|
|
3
|
+
*
|
|
4
|
+
* - 事件源用 `node:fs/promises` 的 `watch(dir, { recursive: true, signal })`,
|
|
5
|
+
* 不引入 chokidar(与仓库"FS 一律用 node:fs/promises"约定一致);
|
|
6
|
+
* - create / delete / rename 在底层都表现为 `rename`,内容改动为 `change`,
|
|
7
|
+
* 故 LSP 的 created / changed / deleted 类型由 `lstat` 判定;
|
|
8
|
+
* - 尾部去抖(缺省 300ms)合并短时洪峰,最长 flushMs(缺省 1s)强制清批;
|
|
9
|
+
* - 内置忽略 `node_modules` / `.git` / `dist` / `build` / `.venv` / `venv` /
|
|
10
|
+
* `target` / `coverage`,配置可追加;
|
|
11
|
+
* - 单批超过 maxBatch(缺省 500)截断并回调 onTruncated 提示一次;
|
|
12
|
+
* - 目录事件默认丢弃;目录被删除时上报(isDirectory: true),由上层对其中
|
|
13
|
+
* 的驻留文档补 deleted 事件。监听器启动或运行期失败时调用 onError 降级,
|
|
14
|
+
* 不抛错。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { lstat, watch } from "node:fs/promises";
|
|
18
|
+
import { join, normalize, relative, sep } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { minimatch } from "minimatch";
|
|
21
|
+
|
|
22
|
+
export type FileChangeType = "created" | "changed" | "deleted";
|
|
23
|
+
|
|
24
|
+
export interface FileChange {
|
|
25
|
+
/** 绝对路径(normalized)。 */
|
|
26
|
+
path: string;
|
|
27
|
+
type: FileChangeType;
|
|
28
|
+
/** 目录被删除时 true(created/changed 目录事件默认不上报)。 */
|
|
29
|
+
isDirectory: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_IGNORE = [
|
|
33
|
+
"**/node_modules/**",
|
|
34
|
+
"**/.git/**",
|
|
35
|
+
"**/dist/**",
|
|
36
|
+
"**/build/**",
|
|
37
|
+
"**/.venv/**",
|
|
38
|
+
"**/venv/**",
|
|
39
|
+
"**/target/**",
|
|
40
|
+
"**/coverage/**",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export interface WatchOptions {
|
|
44
|
+
/** 尾部去抖窗口(ms,缺省 300)。 */
|
|
45
|
+
debounceMs?: number;
|
|
46
|
+
/** 从首个事件起的最长 flush 间隔(ms,缺省 1_000)。 */
|
|
47
|
+
flushMs?: number;
|
|
48
|
+
/** 单批上限,超出截断(缺省 500)。 */
|
|
49
|
+
maxBatch?: number;
|
|
50
|
+
/** 追加忽略 glob(相对工作区根的 POSIX 路径)。 */
|
|
51
|
+
ignore?: string[];
|
|
52
|
+
/** 监听器不可用(无法启动 / 运行期失败)时的降级提示回调。 */
|
|
53
|
+
onError?: (message: string) => void;
|
|
54
|
+
/** 单批超限被截断时回调(提示一次,不逐条刷屏)。 */
|
|
55
|
+
onTruncated?: () => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface WorkspaceWatcher {
|
|
59
|
+
stop(): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isIgnored(path: string, dir: string, patterns: string[]): boolean {
|
|
63
|
+
const candidate = relative(dir, path).split(sep).join("/");
|
|
64
|
+
if (candidate.startsWith("..")) return false;
|
|
65
|
+
return patterns.some((pattern) => minimatch(candidate, pattern));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 启动对 dir 的递归监听。onBatch 收到去抖合并后的批次;stop 后不再回调。
|
|
70
|
+
* 返回的 promise 只在监听器无法建立(目录不存在等)时 reject——运行期
|
|
71
|
+
* 错误一律走 onError 降级。
|
|
72
|
+
*/
|
|
73
|
+
export function watchWorkspace(
|
|
74
|
+
dir: string,
|
|
75
|
+
onBatch: (changes: FileChange[]) => void,
|
|
76
|
+
options?: WatchOptions,
|
|
77
|
+
): Promise<WorkspaceWatcher> {
|
|
78
|
+
const debounceMs = options?.debounceMs ?? 300;
|
|
79
|
+
const flushMs = options?.flushMs ?? 1_000;
|
|
80
|
+
const maxBatch = options?.maxBatch ?? 500;
|
|
81
|
+
const ignorePatterns = [...DEFAULT_IGNORE, ...(options?.ignore ?? [])];
|
|
82
|
+
const abort = new AbortController();
|
|
83
|
+
|
|
84
|
+
// 从 stat 成功事件学习已知目录,目录被删除时据此标记 isDirectory
|
|
85
|
+
const seenDirectories = new Set<string>();
|
|
86
|
+
|
|
87
|
+
let pending: FileChange[] = [];
|
|
88
|
+
let flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
89
|
+
let maxTimer: ReturnType<typeof setTimeout> | undefined;
|
|
90
|
+
|
|
91
|
+
const clearTimers = (): void => {
|
|
92
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
93
|
+
if (maxTimer) clearTimeout(maxTimer);
|
|
94
|
+
flushTimer = undefined;
|
|
95
|
+
maxTimer = undefined;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const flush = (): void => {
|
|
99
|
+
clearTimers();
|
|
100
|
+
if (pending.length === 0) return;
|
|
101
|
+
let batch = pending;
|
|
102
|
+
pending = [];
|
|
103
|
+
if (batch.length > maxBatch) {
|
|
104
|
+
batch = batch.slice(0, maxBatch);
|
|
105
|
+
options?.onTruncated?.();
|
|
106
|
+
}
|
|
107
|
+
onBatch(batch);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const push = (change: FileChange): void => {
|
|
111
|
+
if (pending.length === 0) maxTimer = setTimeout(flush, flushMs);
|
|
112
|
+
pending.push(change);
|
|
113
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
114
|
+
flushTimer = setTimeout(flush, debounceMs);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const classify = async (
|
|
118
|
+
filename: string,
|
|
119
|
+
eventType: "rename" | "change",
|
|
120
|
+
): Promise<FileChange | undefined> => {
|
|
121
|
+
const path = normalize(join(dir, filename));
|
|
122
|
+
if (path === dir) return undefined;
|
|
123
|
+
let isDirectory = false;
|
|
124
|
+
let exists = true;
|
|
125
|
+
try {
|
|
126
|
+
const info = await lstat(path);
|
|
127
|
+
isDirectory = info.isDirectory();
|
|
128
|
+
if (isDirectory) seenDirectories.add(path);
|
|
129
|
+
} catch {
|
|
130
|
+
exists = false;
|
|
131
|
+
if (seenDirectories.delete(path)) isDirectory = true;
|
|
132
|
+
}
|
|
133
|
+
if (eventType === "change") {
|
|
134
|
+
if (!exists) return { path, type: "deleted", isDirectory };
|
|
135
|
+
if (isDirectory) return undefined;
|
|
136
|
+
return { path, type: "changed", isDirectory };
|
|
137
|
+
}
|
|
138
|
+
// rename:创建 / 删除 / 移入移出
|
|
139
|
+
if (!exists) return { path, type: "deleted", isDirectory };
|
|
140
|
+
if (isDirectory) return undefined;
|
|
141
|
+
return { path, type: "created", isDirectory };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const consumer = (async () => {
|
|
145
|
+
try {
|
|
146
|
+
const iterator = watch(dir, { recursive: true, signal: abort.signal });
|
|
147
|
+
for await (const event of iterator) {
|
|
148
|
+
if (!event.filename) continue;
|
|
149
|
+
const change = await classify(
|
|
150
|
+
event.filename,
|
|
151
|
+
event.eventType === "change" ? "change" : "rename",
|
|
152
|
+
);
|
|
153
|
+
if (!change) continue;
|
|
154
|
+
if (isIgnored(change.path, dir, ignorePatterns)) continue;
|
|
155
|
+
push(change);
|
|
156
|
+
}
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (abort.signal.aborted) return;
|
|
159
|
+
options?.onError?.(`workspace watcher failed for ${dir}: ${String(error)}`);
|
|
160
|
+
}
|
|
161
|
+
})();
|
|
162
|
+
|
|
163
|
+
// 目录不存在 / 无权限等启动期问题在这里暴露,让调用方可以降级
|
|
164
|
+
return lstat(dir).then(() => ({
|
|
165
|
+
async stop(): Promise<void> {
|
|
166
|
+
abort.abort();
|
|
167
|
+
try {
|
|
168
|
+
await consumer;
|
|
169
|
+
} catch {
|
|
170
|
+
// 监听器已因 abort 正常退出
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
}));
|
|
174
|
+
}
|
package/src/opencode/files.ts
CHANGED
|
@@ -431,9 +431,9 @@ function registerReadTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
431
431
|
|
|
432
432
|
content = [{ type: "text", text: outputText }];
|
|
433
433
|
|
|
434
|
-
// opencode: LSP
|
|
435
|
-
void service.
|
|
436
|
-
//
|
|
434
|
+
// opencode: LSP 文件事件通知是后台任务,失败不影响读取(read 不驻留文档)
|
|
435
|
+
void service.notifyFile(absolutePath, ctx.cwd).catch(() => {
|
|
436
|
+
// 后台通知失败不影响读取
|
|
437
437
|
});
|
|
438
438
|
|
|
439
439
|
return {
|
package/src/aft/ast-edit.ts
DELETED
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ast_edit —— 符号级/AST 感知编辑工具。
|
|
3
|
-
*
|
|
4
|
-
* 用 AFT 的 edit 命令做符号级替换 / 模糊匹配 / 批量编辑,流程:
|
|
5
|
-
* 1. preview(只计算不写盘)拿所有变动文件的 before/after
|
|
6
|
-
* 2. write-guard:工作区内自动放行,外部路径用真实 diff 审批
|
|
7
|
-
* 3. 再次调用 edit(不带 preview)→ AFT 自己原子写盘(备份 + 格式化 +
|
|
8
|
-
* undo),本工具不再直接写文件
|
|
9
|
-
*
|
|
10
|
-
* 不维护 reads 记账:写盘后模型重新 Read 目标文件即可(与本仓库 Edit 的
|
|
11
|
-
* 防呆不同,ast_edit 不要求先读)。
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
type ExtensionAPI,
|
|
16
|
-
generateDiffString,
|
|
17
|
-
generateUnifiedPatch,
|
|
18
|
-
} from "@earendil-works/pi-coding-agent";
|
|
19
|
-
import { Type } from "typebox";
|
|
20
|
-
import { Value } from "typebox/value";
|
|
21
|
-
|
|
22
|
-
import { requireAbsolutePath } from "../claude-code/common.js";
|
|
23
|
-
import { type ToolPendant } from "../lib/pendant.js";
|
|
24
|
-
import { guardWriteAccess } from "../lib/write-guard.js";
|
|
25
|
-
import { callAftTool } from "./bridge.js";
|
|
26
|
-
import { type AftToolContext, bridgeFor, buildPendantMarkdown } from "./tools.js";
|
|
27
|
-
|
|
28
|
-
// ── 纯函数(可测) ──────────────────────────────────────────────────────────
|
|
29
|
-
|
|
30
|
-
export interface PreviewFile {
|
|
31
|
-
/** 绝对路径;symbol 模式(顶层 diff)时为调用方传入的 file_path 兜底。 */
|
|
32
|
-
file: string;
|
|
33
|
-
before: string;
|
|
34
|
-
after: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface PreviewExtract {
|
|
38
|
-
files: PreviewFile[];
|
|
39
|
-
/** 任一文件超过 512KB 未返回全文(diff.truncated)。 */
|
|
40
|
-
truncated: boolean;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** AFT preview diff 条目:preview 模式下默认带 before/after,>512KB 只带 truncated。 */
|
|
44
|
-
const previewDiffSchema = Type.Object({
|
|
45
|
-
before: Type.Optional(Type.String()),
|
|
46
|
-
after: Type.Optional(Type.String()),
|
|
47
|
-
truncated: Type.Optional(Type.Boolean()),
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
type PreviewDiff = { truncated: true } | { truncated: false; before: string; after: string };
|
|
51
|
-
|
|
52
|
-
function findDiffObject(value: unknown): PreviewDiff | undefined {
|
|
53
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
54
|
-
if (!Value.Check(previewDiffSchema, value)) return undefined;
|
|
55
|
-
if (value.truncated === true) return { truncated: true };
|
|
56
|
-
if (value.before === undefined || value.after === undefined) return undefined;
|
|
57
|
-
return { before: value.before, after: value.after, truncated: false };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* 从 AFT edit preview 响应提取所有变动文件的 before/after,供写保护审批。
|
|
62
|
-
* 批量/单文件 edits/glob 模式在 `files[]`(每项带 `file` 与 `diff`);
|
|
63
|
-
* symbol 模式在顶层 `diff`(file 留空,由调用方兜底)。
|
|
64
|
-
*/
|
|
65
|
-
export function extractPreviewFiles(response: Record<string, unknown>): PreviewExtract {
|
|
66
|
-
const rawFiles = response.files;
|
|
67
|
-
if (Array.isArray(rawFiles) && rawFiles.length > 0) {
|
|
68
|
-
const files: PreviewFile[] = [];
|
|
69
|
-
let truncated = false;
|
|
70
|
-
for (const raw of rawFiles as unknown[]) {
|
|
71
|
-
if (typeof raw !== "object" || raw === null) continue;
|
|
72
|
-
const entry = raw as Record<string, unknown>;
|
|
73
|
-
const diff = findDiffObject(entry.diff);
|
|
74
|
-
if (!diff) continue;
|
|
75
|
-
if (diff.truncated) {
|
|
76
|
-
truncated = true;
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
files.push({
|
|
80
|
-
file: typeof entry.file === "string" ? entry.file : "",
|
|
81
|
-
before: diff.before,
|
|
82
|
-
after: diff.after,
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
if (truncated || files.length > 0) return { files, truncated };
|
|
86
|
-
}
|
|
87
|
-
const top = findDiffObject(response.diff);
|
|
88
|
-
if (top) {
|
|
89
|
-
if (top.truncated) return { files: [], truncated: true };
|
|
90
|
-
return {
|
|
91
|
-
files: [{ file: "", before: top.before, after: top.after }],
|
|
92
|
-
truncated: false,
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
return { files: [], truncated: false };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** 把本工具的参数名映射到 AFT edit wire 格式。 */
|
|
99
|
-
export function mapEditItems(
|
|
100
|
-
items: {
|
|
101
|
-
old_string?: string;
|
|
102
|
-
new_string?: string;
|
|
103
|
-
start_line?: number | string;
|
|
104
|
-
end_line?: number | string;
|
|
105
|
-
content?: string;
|
|
106
|
-
occurrence?: number | string;
|
|
107
|
-
}[],
|
|
108
|
-
): Record<string, unknown>[] {
|
|
109
|
-
return items.map((item) => {
|
|
110
|
-
const out: Record<string, unknown> = {};
|
|
111
|
-
if (item.old_string !== undefined) out.oldString = item.old_string;
|
|
112
|
-
if (item.new_string !== undefined) out.newString = item.new_string;
|
|
113
|
-
if (item.start_line !== undefined) out.startLine = item.start_line;
|
|
114
|
-
if (item.end_line !== undefined) out.endLine = item.end_line;
|
|
115
|
-
if (item.content !== undefined) out.content = item.content;
|
|
116
|
-
if (item.occurrence !== undefined) out.occurrence = item.occurrence;
|
|
117
|
-
return out;
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* 参照 Edit 工具的结果输出:把 preview 的 before/after 转成行号 diff、
|
|
123
|
-
* unified patch 与首个变更行。多文件(glob 批量)时逐文件拼接 diff/patch,
|
|
124
|
-
* firstChangedLine 取首个文件的。
|
|
125
|
-
*/
|
|
126
|
-
export function buildEditDiffDetails(
|
|
127
|
-
previewFiles: PreviewFile[],
|
|
128
|
-
fallbackFile: string,
|
|
129
|
-
): { diff: string; patch: string; firstChangedLine: number | undefined } {
|
|
130
|
-
const parts = previewFiles.map((f) => {
|
|
131
|
-
const file = f.file || fallbackFile;
|
|
132
|
-
const diff = generateDiffString(f.before, f.after);
|
|
133
|
-
return {
|
|
134
|
-
file,
|
|
135
|
-
diff: diff.diff,
|
|
136
|
-
patch: generateUnifiedPatch(file, f.before, f.after),
|
|
137
|
-
firstChangedLine: diff.firstChangedLine,
|
|
138
|
-
};
|
|
139
|
-
});
|
|
140
|
-
if (parts.length === 0) {
|
|
141
|
-
return { diff: "", patch: "", firstChangedLine: undefined };
|
|
142
|
-
}
|
|
143
|
-
return {
|
|
144
|
-
diff: parts.map((p) => `--- ${p.file}\n${p.diff}`).join("\n"),
|
|
145
|
-
patch: parts.map((p) => `--- ${p.file}\n${p.patch}`).join("\n"),
|
|
146
|
-
firstChangedLine: parts[0]?.firstChangedLine,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ── 工具 ────────────────────────────────────────────────────────────────────
|
|
151
|
-
|
|
152
|
-
const EditItemParams = Type.Object({
|
|
153
|
-
old_string: Type.Optional(Type.String({ description: "要替换的文本" })),
|
|
154
|
-
new_string: Type.Optional(Type.String({ description: "替换后的文本" })),
|
|
155
|
-
start_line: Type.Optional(
|
|
156
|
-
Type.Union([Type.Number(), Type.String()], {
|
|
157
|
-
description: "行范围编辑:起始行(1 起)",
|
|
158
|
-
}),
|
|
159
|
-
),
|
|
160
|
-
end_line: Type.Optional(
|
|
161
|
-
Type.Union([Type.Number(), Type.String()], {
|
|
162
|
-
description: "行范围编辑:结束行",
|
|
163
|
-
}),
|
|
164
|
-
),
|
|
165
|
-
content: Type.Optional(Type.String({ description: "替换内容;空字符串删除这些行" })),
|
|
166
|
-
occurrence: Type.Optional(
|
|
167
|
-
Type.Union([Type.Number(), Type.String()], {
|
|
168
|
-
description: "第几次匹配(0 起);多匹配时指定",
|
|
169
|
-
}),
|
|
170
|
-
),
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
const AstEditParams = Type.Object(
|
|
174
|
-
{
|
|
175
|
-
file_path: Type.String({
|
|
176
|
-
description:
|
|
177
|
-
"要修改的文件(绝对路径);配合 old_string+replace_all 可传绝对路径 glob(如 /path/src/**/*.ts)批量替换多个文件",
|
|
178
|
-
}),
|
|
179
|
-
old_string: Type.Optional(Type.String({ description: "精确/模糊匹配的旧文本" })),
|
|
180
|
-
new_string: Type.Optional(Type.String({ description: "替换后的新文本" })),
|
|
181
|
-
replace_all: Type.Optional(Type.Boolean({ description: "替换所有匹配(默认 false)" })),
|
|
182
|
-
occurrence: Type.Optional(
|
|
183
|
-
Type.Union([Type.Number(), Type.String()], {
|
|
184
|
-
description: "第几次匹配(0 起);多匹配时指定",
|
|
185
|
-
}),
|
|
186
|
-
),
|
|
187
|
-
symbol: Type.Optional(Type.String({ description: "要替换的符号名(函数/类等)" })),
|
|
188
|
-
content: Type.Optional(Type.String({ description: "符号的新实现内容" })),
|
|
189
|
-
edits: Type.Optional(
|
|
190
|
-
Type.Array(EditItemParams, {
|
|
191
|
-
description: "批量编辑(原子应用,全部成功或全部失败)",
|
|
192
|
-
}),
|
|
193
|
-
),
|
|
194
|
-
append_content: Type.Optional(Type.String({ description: "追加到文件末尾" })),
|
|
195
|
-
},
|
|
196
|
-
{ additionalProperties: false },
|
|
197
|
-
);
|
|
198
|
-
|
|
199
|
-
function buildWireArgs(
|
|
200
|
-
params: {
|
|
201
|
-
old_string?: string;
|
|
202
|
-
new_string?: string;
|
|
203
|
-
replace_all?: boolean;
|
|
204
|
-
occurrence?: number | string;
|
|
205
|
-
symbol?: string;
|
|
206
|
-
content?: string;
|
|
207
|
-
edits?: Record<string, unknown>[];
|
|
208
|
-
append_content?: string;
|
|
209
|
-
},
|
|
210
|
-
filePath: string,
|
|
211
|
-
): Record<string, unknown> {
|
|
212
|
-
const rawArgs: Record<string, unknown> = { path: filePath };
|
|
213
|
-
const modes = [
|
|
214
|
-
params.old_string !== undefined,
|
|
215
|
-
params.symbol !== undefined,
|
|
216
|
-
params.edits !== undefined,
|
|
217
|
-
params.append_content !== undefined,
|
|
218
|
-
].filter(Boolean).length;
|
|
219
|
-
if (modes !== 1) {
|
|
220
|
-
throw new Error(
|
|
221
|
-
"Provide exactly one mode: old_string+new_string, symbol+content, edits, or append_content",
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
if (params.old_string !== undefined) {
|
|
225
|
-
if (params.new_string === undefined) {
|
|
226
|
-
throw new Error("'new_string' is required with 'old_string'");
|
|
227
|
-
}
|
|
228
|
-
rawArgs.oldString = params.old_string;
|
|
229
|
-
rawArgs.newString = params.new_string;
|
|
230
|
-
if (params.replace_all) rawArgs.replaceAll = true;
|
|
231
|
-
if (params.occurrence !== undefined) rawArgs.occurrence = params.occurrence;
|
|
232
|
-
} else if (params.symbol !== undefined) {
|
|
233
|
-
if (params.content === undefined) {
|
|
234
|
-
throw new Error("'content' is required with 'symbol'");
|
|
235
|
-
}
|
|
236
|
-
rawArgs.symbol = params.symbol;
|
|
237
|
-
rawArgs.content = params.content;
|
|
238
|
-
} else if (params.edits !== undefined) {
|
|
239
|
-
rawArgs.edits = mapEditItems(params.edits);
|
|
240
|
-
} else if (params.append_content !== undefined) {
|
|
241
|
-
rawArgs.appendContent = params.append_content;
|
|
242
|
-
}
|
|
243
|
-
return rawArgs;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
export function registerAstEditTool(pi: ExtensionAPI, ctx: AftToolContext): void {
|
|
247
|
-
pi.registerTool({
|
|
248
|
-
name: "ast_edit",
|
|
249
|
-
label: "ast_edit",
|
|
250
|
-
description: [
|
|
251
|
-
"符号级/AST 感知编辑:按符号名替换函数/类实现,或做模糊匹配/批量/追加编辑。",
|
|
252
|
-
"与 Edit 的区别:Edit 是精确字符串替换;ast_edit 支持 symbol+content(符号级,包含装饰器/注释/属性)、",
|
|
253
|
-
"edits[] 批量(原子)、fuzzy 匹配(4 步容错)与 append_content 追加。",
|
|
254
|
-
"old_string + replace_all 且 file_path 为 glob 时批量替换多个文件(结果为所有变动文件)。",
|
|
255
|
-
"写盘由 AFT 原子完成(自动备份、格式化);调用前先以 preview 计算 diff 套用工作区写保护。",
|
|
256
|
-
"不要求先 Read(与本仓库 Edit 不同);写入后如需确认可重新 Read 文件。四种模式互斥:",
|
|
257
|
-
" • old_string + new_string(+ replace_all / occurrence;glob 批量需 replace_all)",
|
|
258
|
-
" • symbol + content",
|
|
259
|
-
" • edits[](每项 old_string+new_string 或 start_line+end_line+content)",
|
|
260
|
-
" • append_content",
|
|
261
|
-
].join("\n"),
|
|
262
|
-
promptSnippet: "Symbol-aware / fuzzy file edits via AFT",
|
|
263
|
-
parameters: AstEditParams,
|
|
264
|
-
async execute(_id, params, _signal, _onUpdate, extCtx) {
|
|
265
|
-
const filePath = requireAbsolutePath(params.file_path);
|
|
266
|
-
const rawArgs = buildWireArgs(params, filePath);
|
|
267
|
-
|
|
268
|
-
// 第一步:preview(不写盘)拿所有变动文件的 before/after,供写保护审批。
|
|
269
|
-
// preview 必须经 options 传入(bridge 会放到 tool_call 消息顶层),
|
|
270
|
-
// 放进 arguments 会被 AFT 忽略并真的写盘。
|
|
271
|
-
const { response } = await callAftTool(bridgeFor(ctx), "edit", rawArgs, extCtx, {
|
|
272
|
-
preview: true,
|
|
273
|
-
});
|
|
274
|
-
const { files: previewFiles, truncated } = extractPreviewFiles(response);
|
|
275
|
-
if (truncated) {
|
|
276
|
-
throw new Error("ast_edit: file too large for preview (over 512KB)");
|
|
277
|
-
}
|
|
278
|
-
if (previewFiles.length === 0) {
|
|
279
|
-
throw new Error("ast_edit: preview did not return before/after content");
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// 第二步:写保护——工作区内自动放行,外部路径用真实 diff 审批。
|
|
283
|
-
for (const preview of previewFiles) {
|
|
284
|
-
await guardWriteAccess(extCtx, {
|
|
285
|
-
toolName: "ast_edit",
|
|
286
|
-
absolutePath: preview.file || filePath,
|
|
287
|
-
change: { oldText: preview.before, newText: preview.after },
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// 第三步:AFT 原子写盘(备份 + 格式化 + undo),结果由 Rust 格式化返回。
|
|
292
|
-
const { text } = await callAftTool(bridgeFor(ctx), "edit", rawArgs, extCtx);
|
|
293
|
-
const files = previewFiles.map((f) => f.file || filePath);
|
|
294
|
-
return {
|
|
295
|
-
content: [{ type: "text", text }],
|
|
296
|
-
details: {
|
|
297
|
-
files,
|
|
298
|
-
...buildEditDiffDetails(previewFiles, filePath),
|
|
299
|
-
pendant: {
|
|
300
|
-
markdown: buildPendantMarkdown({
|
|
301
|
-
title: "ast_edit",
|
|
302
|
-
input: params,
|
|
303
|
-
output: text,
|
|
304
|
-
}),
|
|
305
|
-
} satisfies ToolPendant,
|
|
306
|
-
},
|
|
307
|
-
};
|
|
308
|
-
},
|
|
309
|
-
});
|
|
310
|
-
}
|