@trim21/personal-pi-extensions 0.1.681 → 0.1.684

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/src/aft/tools.ts CHANGED
@@ -20,18 +20,24 @@ import {
20
20
  type StatusSnapshot,
21
21
  } from "@cortexkit/aft-bridge";
22
22
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
23
- import { type Static, Type } from "typebox";
23
+ import { type Static, type TSchema, Type } from "typebox";
24
24
  import { Value } from "typebox/value";
25
25
 
26
26
  import { formatDisplayPath, formatSubtitlePath, resolvePathArg } from "../lib/path.js";
27
27
  import { type ToolPendant } from "../lib/pendant.js";
28
- import type { ToolBus } from "../lib/tool-bus.js";
28
+ import { defineStructuredTool, type StructuredResult, type ToolBus } from "../lib/tool-bus.js";
29
29
  import {
30
30
  type AftState,
31
31
  callAftTool,
32
32
  resolveSessionId,
33
33
  SEMANTIC_INDEX_WAIT_TIMEOUT_MS,
34
34
  } from "./bridge.js";
35
+ import {
36
+ aftCallgraphStructuredSchema,
37
+ aftOutlineStructuredSchema,
38
+ aftSearchStructuredSchema,
39
+ aftZoomStructuredSchema,
40
+ } from "./schemas.js";
35
41
 
36
42
  /** 工具使用指南,以 markdown 形式维护,读起来像文档。 */
37
43
  const OUTLINE_PROMPT = readFileSync(
@@ -79,6 +85,24 @@ export function compactArgs(args: Record<string, unknown>): Record<string, unkno
79
85
  );
80
86
  }
81
87
 
88
+ /**
89
+ * 结构化载荷:引擎自己的响应字段(去掉 envelope 的 request id)原样给脚本,外加我们
90
+ * 渲染的文本。引擎的 `success` / `code` 保留在载荷里——软失败(symbol_not_found、
91
+ * callgraph_building、search_lanes_unavailable)是「工具跑成了、引擎给出否定答案」,
92
+ * 脚本据 `code` 分支,而不是被当成调用异常。
93
+ */
94
+ function aftPayload<TOutput extends TSchema>(
95
+ response: Record<string, unknown>,
96
+ text: string,
97
+ ): StructuredResult<Static<TOutput>> {
98
+ const value: Record<string, unknown> = { ...response, text };
99
+ // envelope 的 request id 是传输层的事,不进载荷
100
+ delete value.id;
101
+ // 载荷是引擎响应的原样透传(schema 只声明我们确证过的字段,其余允许通过),所以
102
+ // 编译期只能断言;类型正确性由脚本侧的声明与总线运行期的 Value.Parse 复核共同保证。
103
+ return { ok: true, value: value as Static<TOutput> };
104
+ }
105
+
82
106
  /** 人类视角的调用记录:input params + 与 LLM 相同的输出结果。 */
83
107
  function buildPendantMarkdown(params: {
84
108
  title: string;
@@ -127,60 +151,64 @@ const OutlineParams = Type.Object(
127
151
  );
128
152
 
129
153
  export function registerOutlineTool(bus: ToolBus, ctx: AftToolContext): void {
130
- bus.register({
131
- name: "aft_outline",
132
- label: "aft_outline",
133
- description: [
134
- "输出代码文件、目录的结构化大纲:函数/类/类型等符号及其行号范围;Markdown/HTML 返回标题层级。",
135
- "用它在读取具体内容之前先了解文件结构。",
136
- "深入了解某个符号用 aft_zoom;看跨文件调用关系用 aft_callgraph。",
137
- "target 支持:文件路径(带签名的符号大纲)、目录路径(递归最多 200 文件)。只接受单个 target。",
138
- "target 为目录时默认返回扁平文件树(语言、顶层符号数、字节大小);传 files: false 可改回符号大纲。",
139
- ].join("\n"),
140
- promptSnippet: "Output structural outline of a file/directory",
141
- promptGuidelines: [OUTLINE_PROMPT, AFT_GUIDELINES],
142
- parameters: OutlineParams,
143
- async execute(_id, params, signal, _onUpdate, extCtx) {
144
- const target = coerceTargetParam(params.target);
145
- if (typeof target !== "string" || target.length === 0) {
146
- throw new Error("'target' must be a single path (array targets are not supported)");
147
- }
148
- const resolved = resolvePathArg(extCtx.cwd, target);
149
- let filesMode = coerceBoolean(params.files);
150
- if (params.files === undefined) {
151
- const stats = await stat(resolved).catch(() => null);
152
- filesMode = stats?.isDirectory() ?? false;
153
- }
154
- const rawArgs = compactArgs({
155
- target: resolved,
156
- files: filesMode || undefined,
157
- includeTests: params.includeTests,
158
- });
159
-
160
- const subtitle = buildOutlineSubtitle(extCtx.cwd, target);
161
-
162
- const { text, response } = await callAftTool(
163
- bridgeFor(ctx),
164
- "outline",
165
- rawArgs,
166
- resolveSessionId(extCtx),
167
- undefined,
168
- undefined,
169
- signal,
170
- );
171
- const truncated = response.truncated === true;
172
- return {
173
- content: [{ type: "text", text }],
174
- details: {
175
- truncated,
176
- params,
177
- pendant: {
178
- subtitle,
179
- } satisfies ToolPendant,
180
- },
181
- };
182
- },
183
- });
154
+ bus.register(
155
+ defineStructuredTool({
156
+ name: "aft_outline",
157
+ label: "aft_outline",
158
+ description: [
159
+ "输出代码文件、目录的结构化大纲:函数/类/类型等符号及其行号范围;Markdown/HTML 返回标题层级。",
160
+ "用它在读取具体内容之前先了解文件结构。",
161
+ "深入了解某个符号用 aft_zoom;看跨文件调用关系用 aft_callgraph。",
162
+ "target 支持:文件路径(带签名的符号大纲)、目录路径(递归最多 200 文件)。只接受单个 target。",
163
+ "target 为目录时默认返回扁平文件树(语言、顶层符号数、字节大小);传 files: false 可改回符号大纲。",
164
+ ].join("\n"),
165
+ promptSnippet: "Output structural outline of a file/directory",
166
+ promptGuidelines: [OUTLINE_PROMPT, AFT_GUIDELINES],
167
+ parameters: OutlineParams,
168
+ structuredSchema: aftOutlineStructuredSchema,
169
+ async execute(_id, params, signal, _onUpdate, extCtx) {
170
+ const target = coerceTargetParam(params.target);
171
+ if (typeof target !== "string" || target.length === 0) {
172
+ throw new Error("'target' must be a single path (array targets are not supported)");
173
+ }
174
+ const resolved = resolvePathArg(extCtx.cwd, target);
175
+ let filesMode = coerceBoolean(params.files);
176
+ if (params.files === undefined) {
177
+ const stats = await stat(resolved).catch(() => null);
178
+ filesMode = stats?.isDirectory() ?? false;
179
+ }
180
+ const rawArgs = compactArgs({
181
+ target: resolved,
182
+ files: filesMode || undefined,
183
+ includeTests: params.includeTests,
184
+ });
185
+
186
+ const subtitle = buildOutlineSubtitle(extCtx.cwd, target);
187
+
188
+ const { text, response } = await callAftTool(
189
+ bridgeFor(ctx),
190
+ "outline",
191
+ rawArgs,
192
+ resolveSessionId(extCtx),
193
+ undefined,
194
+ undefined,
195
+ signal,
196
+ );
197
+ const truncated = response.truncated === true;
198
+ return {
199
+ content: [{ type: "text", text }],
200
+ details: {
201
+ truncated,
202
+ params,
203
+ pendant: {
204
+ subtitle,
205
+ } satisfies ToolPendant,
206
+ },
207
+ structuredResult: aftPayload<typeof aftOutlineStructuredSchema>(response, text),
208
+ };
209
+ },
210
+ }),
211
+ );
184
212
  }
185
213
 
186
214
  /** 构建 aft_outline pendant 的 subtitle:`target="…"`(路径过长时显示上一级目录加文件名)。 */
@@ -211,61 +239,65 @@ const ZoomParams = Type.Object(
211
239
  );
212
240
 
213
241
  export function registerZoomTool(bus: ToolBus, ctx: AftToolContext): void {
214
- bus.register({
215
- name: "aft_zoom",
216
- label: "aft_zoom",
217
- description: [
218
- "查看命名符号(函数/类/类型)的完整源码,或 Markdown/HTML 的标题段落内容。",
219
- "需要理解某个具体符号时用它(读整个文件用 read)。",
220
- "callgraph: true 时附带同文件内的调用关系标注。",
221
- "同文件多符号用 `symbols` 数组。",
222
- ].join("\n"),
223
- promptSnippet: "Inspect the full source of a named symbol",
224
- promptGuidelines: [ZOOM_PROMPT],
225
- parameters: ZoomParams,
226
- async execute(_id, params, signal, _onUpdate, extCtx) {
227
- const rawArgs = compactArgs({
228
- filePath: resolvePathArg(extCtx.cwd, params.path),
229
- symbols: params.symbols,
230
- contextLines: coerceOptionalInt(
231
- params.contextLines,
232
- "contextLines",
233
- 1,
234
- Number.MAX_SAFE_INTEGER,
235
- ),
236
- callgraph: coerceBoolean(params.callgraph) || undefined,
237
- });
238
-
239
- const subtitle = buildZoomSubtitle(extCtx.cwd, params);
240
-
241
- const { text, response } = await callAftTool(
242
- bridgeFor(ctx),
243
- "zoom",
244
- rawArgs,
245
- resolveSessionId(extCtx),
246
- undefined,
247
- undefined,
248
- signal,
249
- );
250
- const truncated = response.truncated === true;
251
- return {
252
- content: [{ type: "text", text }],
253
- details: {
254
- truncated,
255
- params,
256
- pendant: {
257
- subtitle,
258
- markdown: buildPendantMarkdown({
259
- title: "aft_zoom",
260
- input: params,
261
- output: text,
262
- truncated,
263
- }),
264
- } satisfies ToolPendant,
265
- },
266
- };
267
- },
268
- });
242
+ bus.register(
243
+ defineStructuredTool({
244
+ name: "aft_zoom",
245
+ label: "aft_zoom",
246
+ description: [
247
+ "查看命名符号(函数/类/类型)的完整源码,或 Markdown/HTML 的标题段落内容。",
248
+ "需要理解某个具体符号时用它(读整个文件用 read)。",
249
+ "callgraph: true 时附带同文件内的调用关系标注。",
250
+ "同文件多符号用 `symbols` 数组。",
251
+ ].join("\n"),
252
+ promptSnippet: "Inspect the full source of a named symbol",
253
+ promptGuidelines: [ZOOM_PROMPT],
254
+ parameters: ZoomParams,
255
+ structuredSchema: aftZoomStructuredSchema,
256
+ async execute(_id, params, signal, _onUpdate, extCtx) {
257
+ const rawArgs = compactArgs({
258
+ filePath: resolvePathArg(extCtx.cwd, params.path),
259
+ symbols: params.symbols,
260
+ contextLines: coerceOptionalInt(
261
+ params.contextLines,
262
+ "contextLines",
263
+ 1,
264
+ Number.MAX_SAFE_INTEGER,
265
+ ),
266
+ callgraph: coerceBoolean(params.callgraph) || undefined,
267
+ });
268
+
269
+ const subtitle = buildZoomSubtitle(extCtx.cwd, params);
270
+
271
+ const { text, response } = await callAftTool(
272
+ bridgeFor(ctx),
273
+ "zoom",
274
+ rawArgs,
275
+ resolveSessionId(extCtx),
276
+ undefined,
277
+ undefined,
278
+ signal,
279
+ );
280
+ const truncated = response.truncated === true;
281
+ return {
282
+ content: [{ type: "text", text }],
283
+ details: {
284
+ truncated,
285
+ params,
286
+ pendant: {
287
+ subtitle,
288
+ markdown: buildPendantMarkdown({
289
+ title: "aft_zoom",
290
+ input: params,
291
+ output: text,
292
+ truncated,
293
+ }),
294
+ } satisfies ToolPendant,
295
+ },
296
+ structuredResult: aftPayload<typeof aftZoomStructuredSchema>(response, text),
297
+ };
298
+ },
299
+ }),
300
+ );
269
301
  }
270
302
 
271
303
  /** 构建 aft_zoom pendant 的 subtitle:`path="…" symbol="…"`。 */
@@ -363,60 +395,64 @@ export async function callCallgraphWithBuildRetry(
363
395
  }
364
396
 
365
397
  export function registerCallgraphTool(bus: ToolBus, ctx: AftToolContext): void {
366
- bus.register({
367
- name: "aft_callgraph",
368
- label: "aft_callgraph",
369
- description: [
370
- "基于真实调用图回答代码关系问题(谁调用我、影响面、调用链),替代 grep + read 的链条式排查。",
371
- "op 语义:callers=调用点(改名/改签名前用);impact=影响面(改一个符号会波及谁);",
372
- "call_tree=该函数调用了什么;trace_to=从入口如何执行到某符号;",
373
- "trace_to_symbol=两符号间最短路径(需 toSymbol,歧义时需 toPath);trace_data=追踪值在参数/赋值间的流转(需 expression)。",
374
- "标记:~ = 仅按名字解析的边(可能指向同名符号);[unresolved] = 未解析到定义的调用点。",
375
- ].join("\n"),
376
- promptSnippet: "Call graph and data-flow navigation",
377
- promptGuidelines: [CALLGRAPH_PROMPT],
378
- parameters: CallgraphParams,
379
- async execute(_id, params, signal, _onUpdate, extCtx) {
380
- const rawArgs = compactArgs({
381
- op: params.op,
382
- filePath: resolvePathArg(extCtx.cwd, params.path),
383
- symbol: params.symbol,
384
- depth: coerceOptionalInt(params.depth, "depth", 1, Number.MAX_SAFE_INTEGER),
385
- expression: params.expression,
386
- toSymbol: params.toSymbol,
387
- toFile: params.toPath ? resolvePathArg(extCtx.cwd, params.toPath) : undefined,
388
- includeTests: params.includeTests,
389
- includeUnresolved: params.includeUnresolved,
390
- });
391
-
392
- const { text, response } = await callCallgraphWithBuildRetry(
393
- bridgeFor(ctx),
394
- rawArgs,
395
- extCtx,
396
- signal,
397
- );
398
- const out =
399
- text ||
400
- formatCallgraphSections(params.op, response, PLAIN_CALLGRAPH_THEME, {
401
- includeUnresolved: coerceBoolean(params.includeUnresolved),
402
- }).join("\n");
403
- const truncated = response.truncated === true;
404
- return {
405
- content: [{ type: "text", text: out }],
406
- details: {
407
- truncated,
408
- pendant: {
409
- markdown: buildPendantMarkdown({
410
- title: "aft_callgraph",
411
- input: params,
412
- output: out,
413
- truncated,
414
- }),
415
- } satisfies ToolPendant,
416
- },
417
- };
418
- },
419
- });
398
+ bus.register(
399
+ defineStructuredTool({
400
+ name: "aft_callgraph",
401
+ label: "aft_callgraph",
402
+ description: [
403
+ "基于真实调用图回答代码关系问题(谁调用我、影响面、调用链),替代 grep + read 的链条式排查。",
404
+ "op 语义:callers=调用点(改名/改签名前用);impact=影响面(改一个符号会波及谁);",
405
+ "call_tree=该函数调用了什么;trace_to=从入口如何执行到某符号;",
406
+ "trace_to_symbol=两符号间最短路径(需 toSymbol,歧义时需 toPath);trace_data=追踪值在参数/赋值间的流转(需 expression)。",
407
+ "标记:~ = 仅按名字解析的边(可能指向同名符号);[unresolved] = 未解析到定义的调用点。",
408
+ ].join("\n"),
409
+ promptSnippet: "Call graph and data-flow navigation",
410
+ promptGuidelines: [CALLGRAPH_PROMPT],
411
+ parameters: CallgraphParams,
412
+ structuredSchema: aftCallgraphStructuredSchema,
413
+ async execute(_id, params, signal, _onUpdate, extCtx) {
414
+ const rawArgs = compactArgs({
415
+ op: params.op,
416
+ filePath: resolvePathArg(extCtx.cwd, params.path),
417
+ symbol: params.symbol,
418
+ depth: coerceOptionalInt(params.depth, "depth", 1, Number.MAX_SAFE_INTEGER),
419
+ expression: params.expression,
420
+ toSymbol: params.toSymbol,
421
+ toFile: params.toPath ? resolvePathArg(extCtx.cwd, params.toPath) : undefined,
422
+ includeTests: params.includeTests,
423
+ includeUnresolved: params.includeUnresolved,
424
+ });
425
+
426
+ const { text, response } = await callCallgraphWithBuildRetry(
427
+ bridgeFor(ctx),
428
+ rawArgs,
429
+ extCtx,
430
+ signal,
431
+ );
432
+ const out =
433
+ text ||
434
+ formatCallgraphSections(params.op, response, PLAIN_CALLGRAPH_THEME, {
435
+ includeUnresolved: coerceBoolean(params.includeUnresolved),
436
+ }).join("\n");
437
+ const truncated = response.truncated === true;
438
+ return {
439
+ content: [{ type: "text", text: out }],
440
+ details: {
441
+ truncated,
442
+ pendant: {
443
+ markdown: buildPendantMarkdown({
444
+ title: "aft_callgraph",
445
+ input: params,
446
+ output: out,
447
+ truncated,
448
+ }),
449
+ } satisfies ToolPendant,
450
+ },
451
+ structuredResult: aftPayload<typeof aftCallgraphStructuredSchema>(response, out),
452
+ };
453
+ },
454
+ }),
455
+ );
420
456
  }
421
457
 
422
458
  /**
@@ -612,75 +648,79 @@ const SearchParams = Type.Object(
612
648
  );
613
649
 
614
650
  export function registerSearchTool(bus: ToolBus, ctx: AftToolContext): void {
615
- bus.register({
616
- name: "aft_search",
617
- label: "aft_search",
618
- description: [
619
- "一个工具完成代码搜索:概念、标识符、错误串、正则、字面量、文件名自动路由到合适的引擎并按相关度排序。",
620
- "概念类查询('ORM 如何构建并执行查询')用自然语言整句——语义通道理解意图并匹配 docstring 和注释;",
621
- "精确名字、字符串、正则保持简短('^export'、'Cargo.lock')。",
622
- "索引首次构建时本调用会阻塞到构建完成,避免返回部分结果。",
623
- ].join("\n"),
624
- promptSnippet: "Search code by meaning or exact text",
625
- promptGuidelines: [SEARCH_PROMPT],
626
- parameters: SearchParams,
627
- async execute(_id, params, signal, onUpdate, extCtx) {
628
- if (typeof params.query !== "string" || params.query.trim().length === 0) {
629
- throw new Error("'query' must be a non-empty string");
630
- }
631
- const rawArgs = compactArgs({
632
- query: params.query,
633
- topK: params.topK,
634
- includeTests: params.includeTests,
635
- });
636
-
637
- const bridge = bridgeFor(ctx);
638
- const formatProgress = createSemanticIndexProgressFormatter();
639
- const stopProgress =
640
- onUpdate === undefined
641
- ? undefined
642
- : subscribeBridgeStatus(bridge, (snapshot) => {
643
- const text = formatProgress(snapshot);
644
- if (text !== undefined) {
645
- onUpdate({ content: [{ type: "text", text }], details: undefined });
646
- }
647
- });
648
- let response: Record<string, unknown>;
649
- let text: string;
650
- try {
651
- ({ text, response } = await callAftTool(
652
- bridge,
653
- "search",
654
- rawArgs,
655
- resolveSessionId(extCtx),
656
- {
657
- // 默认 search 传输超时仅 60s,会早于索引等待(600s)触发;覆盖为等待
658
- // 上限 + 常规执行预算。超时只说明响应被挤掉而非 bridge 挂死,保留
659
- // 常驻的语义索引/LSP 状态。
660
- transportTimeoutMs: SEMANTIC_INDEX_WAIT_TIMEOUT_MS + 60_000,
661
- keepBridgeOnTimeout: true,
651
+ bus.register(
652
+ defineStructuredTool({
653
+ name: "aft_search",
654
+ label: "aft_search",
655
+ description: [
656
+ "一个工具完成代码搜索:概念、标识符、错误串、正则、字面量、文件名自动路由到合适的引擎并按相关度排序。",
657
+ "概念类查询('ORM 如何构建并执行查询')用自然语言整句——语义通道理解意图并匹配 docstring 和注释;",
658
+ "精确名字、字符串、正则保持简短('^export'、'Cargo.lock')。",
659
+ "索引首次构建时本调用会阻塞到构建完成,避免返回部分结果。",
660
+ ].join("\n"),
661
+ promptSnippet: "Search code by meaning or exact text",
662
+ promptGuidelines: [SEARCH_PROMPT],
663
+ parameters: SearchParams,
664
+ structuredSchema: aftSearchStructuredSchema,
665
+ async execute(_id, params, signal, onUpdate, extCtx) {
666
+ if (typeof params.query !== "string" || params.query.trim().length === 0) {
667
+ throw new Error("'query' must be a non-empty string");
668
+ }
669
+ const rawArgs = compactArgs({
670
+ query: params.query,
671
+ topK: params.topK,
672
+ includeTests: params.includeTests,
673
+ });
674
+
675
+ const bridge = bridgeFor(ctx);
676
+ const formatProgress = createSemanticIndexProgressFormatter();
677
+ const stopProgress =
678
+ onUpdate === undefined
679
+ ? undefined
680
+ : subscribeBridgeStatus(bridge, (snapshot) => {
681
+ const text = formatProgress(snapshot);
682
+ if (text !== undefined) {
683
+ onUpdate({ content: [{ type: "text", text }], details: undefined });
684
+ }
685
+ });
686
+ let response: Record<string, unknown>;
687
+ let text: string;
688
+ try {
689
+ ({ text, response } = await callAftTool(
690
+ bridge,
691
+ "search",
692
+ rawArgs,
693
+ resolveSessionId(extCtx),
694
+ {
695
+ // 默认 search 传输超时仅 60s,会早于索引等待(600s)触发;覆盖为等待
696
+ // 上限 + 常规执行预算。超时只说明响应被挤掉而非 bridge 挂死,保留
697
+ // 常驻的语义索引/LSP 状态。
698
+ transportTimeoutMs: SEMANTIC_INDEX_WAIT_TIMEOUT_MS + 60_000,
699
+ keepBridgeOnTimeout: true,
700
+ },
701
+ undefined,
702
+ signal,
703
+ ));
704
+ } finally {
705
+ stopProgress?.();
706
+ }
707
+ const truncated = response.truncated === true;
708
+ return {
709
+ content: [{ type: "text", text }],
710
+ details: {
711
+ truncated,
712
+ pendant: {
713
+ markdown: buildPendantMarkdown({
714
+ title: "aft_search",
715
+ input: params,
716
+ output: text,
717
+ truncated,
718
+ }),
719
+ } satisfies ToolPendant,
662
720
  },
663
- undefined,
664
- signal,
665
- ));
666
- } finally {
667
- stopProgress?.();
668
- }
669
- const truncated = response.truncated === true;
670
- return {
671
- content: [{ type: "text", text }],
672
- details: {
673
- truncated,
674
- pendant: {
675
- markdown: buildPendantMarkdown({
676
- title: "aft_search",
677
- input: params,
678
- output: text,
679
- truncated,
680
- }),
681
- } satisfies ToolPendant,
682
- },
683
- };
684
- },
685
- });
721
+ structuredResult: aftPayload<typeof aftSearchStructuredSchema>(response, text),
722
+ };
723
+ },
724
+ }),
725
+ );
686
726
  }