@trim21/personal-pi-extensions 0.0.298 → 0.0.300

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.298",
3
+ "version": "0.0.300",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -88,6 +88,8 @@
88
88
  "@cortexkit/aft-bridge": "0.51.0",
89
89
  "@vscode/tree-sitter-wasm": "^0.3.1",
90
90
  "jsonc-parser": "^3.3.1",
91
+ "vscode-jsonrpc": "^9.0.1",
92
+ "vscode-languageserver-types": "^3.18.0",
91
93
  "web-tree-sitter": "^0.26.12"
92
94
  },
93
95
  "optionalDependencies": {
@@ -0,0 +1,266 @@
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 type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
+ import { Type } from "typebox";
16
+ import { Value } from "typebox/value";
17
+
18
+ import { requireAbsolutePath } from "../claude-code/common.js";
19
+ import { guardWriteAccess } from "../lib/write-guard.js";
20
+ import { callAftTool } from "./bridge.js";
21
+ import { type AftToolContext, bridgeFor } from "./tools.js";
22
+
23
+ // ── 纯函数(可测) ──────────────────────────────────────────────────────────
24
+
25
+ export interface PreviewFile {
26
+ /** 绝对路径;symbol 模式(顶层 diff)时为调用方传入的 file_path 兜底。 */
27
+ file: string;
28
+ before: string;
29
+ after: string;
30
+ }
31
+
32
+ export interface PreviewExtract {
33
+ files: PreviewFile[];
34
+ /** 任一文件超过 512KB 未返回全文(diff.truncated)。 */
35
+ truncated: boolean;
36
+ }
37
+
38
+ /** AFT preview diff 条目:`include_diff_content` 时带 before/after,>512KB 只带 truncated。 */
39
+ const previewDiffSchema = Type.Object({
40
+ before: Type.Optional(Type.String()),
41
+ after: Type.Optional(Type.String()),
42
+ truncated: Type.Optional(Type.Boolean()),
43
+ });
44
+
45
+ type PreviewDiff = { truncated: true } | { truncated: false; before: string; after: string };
46
+
47
+ function findDiffObject(value: unknown): PreviewDiff | undefined {
48
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
49
+ if (!Value.Check(previewDiffSchema, value)) return undefined;
50
+ if (value.truncated === true) return { truncated: true };
51
+ if (value.before === undefined || value.after === undefined) return undefined;
52
+ return { before: value.before, after: value.after, truncated: false };
53
+ }
54
+
55
+ /**
56
+ * 从 AFT edit preview 响应提取所有变动文件的 before/after,供写保护审批。
57
+ * 批量/单文件 edits/glob 模式在 `files[]`(每项带 `file` 与 `diff`);
58
+ * symbol 模式在顶层 `diff`(file 留空,由调用方兜底)。
59
+ */
60
+ export function extractPreviewFiles(response: Record<string, unknown>): PreviewExtract {
61
+ const rawFiles = response.files;
62
+ if (Array.isArray(rawFiles) && rawFiles.length > 0) {
63
+ const files: PreviewFile[] = [];
64
+ let truncated = false;
65
+ for (const raw of rawFiles as unknown[]) {
66
+ if (typeof raw !== "object" || raw === null) continue;
67
+ const entry = raw as Record<string, unknown>;
68
+ const diff = findDiffObject(entry.diff);
69
+ if (!diff) continue;
70
+ if (diff.truncated) {
71
+ truncated = true;
72
+ continue;
73
+ }
74
+ files.push({
75
+ file: typeof entry.file === "string" ? entry.file : "",
76
+ before: diff.before,
77
+ after: diff.after,
78
+ });
79
+ }
80
+ if (truncated || files.length > 0) return { files, truncated };
81
+ }
82
+ const top = findDiffObject(response.diff);
83
+ if (top) {
84
+ if (top.truncated) return { files: [], truncated: true };
85
+ return {
86
+ files: [{ file: "", before: top.before, after: top.after }],
87
+ truncated: false,
88
+ };
89
+ }
90
+ return { files: [], truncated: false };
91
+ }
92
+
93
+ /** 把本工具的参数名映射到 AFT edit wire 格式。 */
94
+ export function mapEditItems(
95
+ items: {
96
+ old_string?: string;
97
+ new_string?: string;
98
+ start_line?: number | string;
99
+ end_line?: number | string;
100
+ content?: string;
101
+ occurrence?: number | string;
102
+ }[],
103
+ ): Record<string, unknown>[] {
104
+ return items.map((item) => {
105
+ const out: Record<string, unknown> = {};
106
+ if (item.old_string !== undefined) out.oldString = item.old_string;
107
+ if (item.new_string !== undefined) out.newString = item.new_string;
108
+ if (item.start_line !== undefined) out.startLine = item.start_line;
109
+ if (item.end_line !== undefined) out.endLine = item.end_line;
110
+ if (item.content !== undefined) out.content = item.content;
111
+ if (item.occurrence !== undefined) out.occurrence = item.occurrence;
112
+ return out;
113
+ });
114
+ }
115
+
116
+ // ── 工具 ────────────────────────────────────────────────────────────────────
117
+
118
+ const EditItemParams = Type.Object({
119
+ old_string: Type.Optional(Type.String({ description: "要替换的文本" })),
120
+ new_string: Type.Optional(Type.String({ description: "替换后的文本" })),
121
+ start_line: Type.Optional(
122
+ Type.Union([Type.Number(), Type.String()], {
123
+ description: "行范围编辑:起始行(1 起)",
124
+ }),
125
+ ),
126
+ end_line: Type.Optional(
127
+ Type.Union([Type.Number(), Type.String()], {
128
+ description: "行范围编辑:结束行",
129
+ }),
130
+ ),
131
+ content: Type.Optional(Type.String({ description: "替换内容;空字符串删除这些行" })),
132
+ occurrence: Type.Optional(
133
+ Type.Union([Type.Number(), Type.String()], {
134
+ description: "第几次匹配(0 起);多匹配时指定",
135
+ }),
136
+ ),
137
+ });
138
+
139
+ const AstEditParams = Type.Object(
140
+ {
141
+ file_path: Type.String({
142
+ description:
143
+ "要修改的文件(绝对路径);配合 old_string+replace_all 可传绝对路径 glob(如 /path/src/**/*.ts)批量替换多个文件",
144
+ }),
145
+ old_string: Type.Optional(Type.String({ description: "精确/模糊匹配的旧文本" })),
146
+ new_string: Type.Optional(Type.String({ description: "替换后的新文本" })),
147
+ replace_all: Type.Optional(Type.Boolean({ description: "替换所有匹配(默认 false)" })),
148
+ occurrence: Type.Optional(
149
+ Type.Union([Type.Number(), Type.String()], {
150
+ description: "第几次匹配(0 起);多匹配时指定",
151
+ }),
152
+ ),
153
+ symbol: Type.Optional(Type.String({ description: "要替换的符号名(函数/类等)" })),
154
+ content: Type.Optional(Type.String({ description: "符号的新实现内容" })),
155
+ edits: Type.Optional(
156
+ Type.Array(EditItemParams, {
157
+ description: "批量编辑(原子应用,全部成功或全部失败)",
158
+ }),
159
+ ),
160
+ append_content: Type.Optional(Type.String({ description: "追加到文件末尾" })),
161
+ },
162
+ { additionalProperties: false },
163
+ );
164
+
165
+ function buildWireArgs(
166
+ params: {
167
+ old_string?: string;
168
+ new_string?: string;
169
+ replace_all?: boolean;
170
+ occurrence?: number | string;
171
+ symbol?: string;
172
+ content?: string;
173
+ edits?: Record<string, unknown>[];
174
+ append_content?: string;
175
+ },
176
+ filePath: string,
177
+ ): Record<string, unknown> {
178
+ const rawArgs: Record<string, unknown> = { path: filePath };
179
+ const modes = [
180
+ params.old_string !== undefined,
181
+ params.symbol !== undefined,
182
+ params.edits !== undefined,
183
+ params.append_content !== undefined,
184
+ ].filter(Boolean).length;
185
+ if (modes !== 1) {
186
+ throw new Error(
187
+ "Provide exactly one mode: old_string+new_string, symbol+content, edits, or append_content",
188
+ );
189
+ }
190
+ if (params.old_string !== undefined) {
191
+ if (params.new_string === undefined) {
192
+ throw new Error("'new_string' is required with 'old_string'");
193
+ }
194
+ rawArgs.oldString = params.old_string;
195
+ rawArgs.newString = params.new_string;
196
+ if (params.replace_all) rawArgs.replaceAll = true;
197
+ if (params.occurrence !== undefined) rawArgs.occurrence = params.occurrence;
198
+ } else if (params.symbol !== undefined) {
199
+ if (params.content === undefined) {
200
+ throw new Error("'content' is required with 'symbol'");
201
+ }
202
+ rawArgs.symbol = params.symbol;
203
+ rawArgs.content = params.content;
204
+ } else if (params.edits !== undefined) {
205
+ rawArgs.edits = mapEditItems(params.edits);
206
+ } else if (params.append_content !== undefined) {
207
+ rawArgs.appendContent = params.append_content;
208
+ }
209
+ return rawArgs;
210
+ }
211
+
212
+ export function registerAstEditTool(pi: ExtensionAPI, ctx: AftToolContext): void {
213
+ pi.registerTool({
214
+ name: "ast_edit",
215
+ label: "ast_edit",
216
+ description: [
217
+ "符号级/AST 感知编辑:按符号名替换函数/类实现,或做模糊匹配/批量/追加编辑。",
218
+ "与 Edit 的区别:Edit 是精确字符串替换;ast_edit 支持 symbol+content(符号级,包含装饰器/注释/属性)、",
219
+ "edits[] 批量(原子)、fuzzy 匹配(4 步容错)与 append_content 追加。",
220
+ "old_string + replace_all 且 file_path 为 glob 时批量替换多个文件(结果为所有变动文件)。",
221
+ "写盘由 AFT 原子完成(自动备份、格式化);调用前先以 preview 计算 diff 套用工作区写保护。",
222
+ "不要求先 Read(与本仓库 Edit 不同);写入后如需确认可重新 Read 文件。四种模式互斥:",
223
+ " • old_string + new_string(+ replace_all / occurrence;glob 批量需 replace_all)",
224
+ " • symbol + content",
225
+ " • edits[](每项 old_string+new_string 或 start_line+end_line+content)",
226
+ " • append_content",
227
+ ].join("\n"),
228
+ promptSnippet: "Symbol-aware / fuzzy file edits via AFT",
229
+ parameters: AstEditParams,
230
+ async execute(_id, params, _signal, _onUpdate, extCtx) {
231
+ const filePath = requireAbsolutePath(params.file_path);
232
+ const rawArgs = buildWireArgs(params, filePath);
233
+
234
+ // 第一步:preview(不写盘)拿所有变动文件的 before/after,供写保护审批。
235
+ const { response } = await callAftTool(
236
+ bridgeFor(ctx),
237
+ "edit",
238
+ { ...rawArgs, preview: true, include_diff_content: true },
239
+ extCtx,
240
+ );
241
+ const { files: previewFiles, truncated } = extractPreviewFiles(response);
242
+ if (truncated) {
243
+ throw new Error("ast_edit: file too large for preview (over 512KB)");
244
+ }
245
+ if (previewFiles.length === 0) {
246
+ throw new Error("ast_edit: preview did not return before/after content");
247
+ }
248
+
249
+ // 第二步:写保护——工作区内自动放行,外部路径用真实 diff 审批。
250
+ for (const preview of previewFiles) {
251
+ await guardWriteAccess(extCtx, {
252
+ toolName: "ast_edit",
253
+ absolutePath: preview.file || filePath,
254
+ change: { oldText: preview.before, newText: preview.after },
255
+ });
256
+ }
257
+
258
+ // 第三步:AFT 原子写盘(备份 + 格式化 + undo),结果由 Rust 格式化返回。
259
+ const { text } = await callAftTool(bridgeFor(ctx), "edit", rawArgs, extCtx);
260
+ return {
261
+ content: [{ type: "text", text }],
262
+ details: { input: params, files: previewFiles.map((f) => f.file || filePath) },
263
+ };
264
+ },
265
+ });
266
+ }
package/src/aft/index.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
- * AFT 感知工具扩展入口:aft_outline / aft_zoom / aft_callgraph / aft_search
2
+ * AFT 扩展入口:感知工具(aft_outline / aft_zoom / aft_callgraph / aft_search
3
+ * + ast_edit(符号级编辑,套用本仓库写保护机制)。
3
4
  *
4
- * 只读接入 AFT 的结构感知能力(tree-sitter 符号表、trigram 索引、调用图、
5
- * 语义搜索),不触碰本仓库自己的 read/write/edit/bash 工具及其安全机制
5
+ * 感知工具只读,不触碰本仓库自己的 read/write/edit/bash 工具及其安全机制
6
6
  * (bwrap 沙箱、write-guard、reads 记账)。aft_search 仅当用户级
7
7
  * aft.jsonc 开启 semantic_search 时注册(本地语义索引需 ONNX 运行时,
8
- * 内网默认关闭)。
8
+ * 内网默认关闭)。ast_edit 是写工具:用 AFT 的 preview 计算 diff,落盘走
9
+ * 本仓库的 write-guard + reads 记账 + 写管线。
9
10
  *
10
11
  * 二进制缺失或 pool 创建失败时降级:不注册任何工具并在 session 开始时报
11
12
  * 一次错,而不是让每个工具调用失败。
@@ -17,6 +18,7 @@
17
18
  import { resolveCortexKitConfigPaths } from "@cortexkit/aft-bridge";
18
19
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
20
 
21
+ import { registerAstEditTool } from "./ast-edit.js";
20
22
  import { type AftPool, createAftPool, shutdownAftPool } from "./bridge.js";
21
23
  import { loadAftConfig } from "./config.js";
22
24
  import {
@@ -49,6 +51,7 @@ export default async function aftReadTools(pi: ExtensionAPI): Promise<void> {
49
51
  registerOutlineTool(pi, toolCtx);
50
52
  registerZoomTool(pi, toolCtx);
51
53
  registerCallgraphTool(pi, toolCtx);
54
+ registerAstEditTool(pi, toolCtx);
52
55
  if (cfg.semanticSearch) {
53
56
  registerSearchTool(pi, toolCtx);
54
57
  }
package/src/aft/tools.ts CHANGED
@@ -31,12 +31,12 @@ export function resolvePathArg(cwd: string, input: string): string {
31
31
  return isAbsolute(input) ? input : resolve(cwd, input);
32
32
  }
33
33
 
34
- interface AftToolContext {
34
+ export interface AftToolContext {
35
35
  cwd: string;
36
36
  pool: AftTransportPool;
37
37
  }
38
38
 
39
- function bridgeFor(ctx: AftToolContext): AftProjectTransport {
39
+ export function bridgeFor(ctx: AftToolContext): AftProjectTransport {
40
40
  return ctx.pool.getBridge(ctx.cwd);
41
41
  }
42
42