@trim21/personal-pi-extensions 0.1.683 → 0.1.685

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/lib/github.ts CHANGED
@@ -186,6 +186,11 @@ export function renderHits(hits: SearchHit[], options: { repo?: string; fields?:
186
186
  }
187
187
 
188
188
  function toDate(iso: string | null | undefined): string {
189
+ return dateOnly(iso);
190
+ }
191
+
192
+ /** ISO 时间戳取日期部分(`2026-10-03`);缺省或 null 给空串。 */
193
+ export function dateOnly(iso: string | null | undefined): string {
189
194
  return iso ? iso.slice(0, 10) : "";
190
195
  }
191
196
 
@@ -217,6 +222,76 @@ function normalize(raw: SearchItem): SearchHit {
217
222
  };
218
223
  }
219
224
 
225
+ /**
226
+ * `gh issue list --json …` / `gh pr list --json …` 的响应形状。gh 走 GraphQL,字段是
227
+ * camelCase,`state` 是大写的 `OPEN` / `CLOSED` / `MERGED`,`comments` 是整个评论数组
228
+ * (不是计数)——所以浏览分支要在归一化这一步把它压成计数,才能和搜索分支(REST,
229
+ * 下划线命名、state 小写、comments 是数字)共用同一套 SearchHit、渲染与载荷。
230
+ */
231
+ const ghListRecordSchema = Type.Object({
232
+ number: Type.Number(),
233
+ title: Type.String(),
234
+ state: Type.String(),
235
+ url: Type.String(),
236
+ labels: Type.Array(Type.Object({ name: Type.String() }, { additionalProperties: true })),
237
+ milestone: Type.Union([
238
+ Type.Object({ title: Type.String() }, { additionalProperties: true }),
239
+ Type.Null(),
240
+ ]),
241
+ assignees: Type.Array(Type.Object({ login: Type.String() }, { additionalProperties: true })),
242
+ author: Type.Union([
243
+ Type.Object({ login: Type.String() }, { additionalProperties: true }),
244
+ Type.Null(),
245
+ ]),
246
+ comments: Type.Array(Type.Unknown()),
247
+ createdAt: Type.String(),
248
+ updatedAt: Type.String(),
249
+ closedAt: Type.Union([Type.String(), Type.Null()]),
250
+ mergedAt: Type.Optional(Type.Union([Type.String(), Type.Null()])),
251
+ });
252
+
253
+ /** 浏览分支要 gh 给的字段,与 `SearchHit` 的列一一对应(`comments` 只用来数个数)。 */
254
+ export const GH_LIST_FIELDS = [
255
+ "number",
256
+ "title",
257
+ "state",
258
+ "url",
259
+ "labels",
260
+ "milestone",
261
+ "assignees",
262
+ "author",
263
+ "comments",
264
+ "createdAt",
265
+ "updatedAt",
266
+ "closedAt",
267
+ "mergedAt",
268
+ ].join(",");
269
+
270
+ /** 把 `gh issue list --json` / `gh pr list --json` 的输出归一到 `SearchHit`(repo 由调用方给)。 */
271
+ export function normalizeGhList(stdout: string, repo: string): SearchHit[] {
272
+ const items = parseWithSchema(Type.Array(ghListRecordSchema), JSON.parse(stdout));
273
+ return items.map((item) => {
274
+ const mergedAt = item.mergedAt ?? null;
275
+ return {
276
+ number: item.number,
277
+ // gh 给 MERGED 状态的 PR 与 OPEN/CLOSED 一样是 state 字段,merged 语义与搜索分支对齐
278
+ state: mergedAt ? "merged" : (item.state.toLowerCase() as SearchHit["state"]),
279
+ title: item.title,
280
+ url: item.url,
281
+ repo,
282
+ author: item.author?.login ?? "",
283
+ labels: item.labels.map((label) => label.name),
284
+ milestone: item.milestone?.title ?? "",
285
+ assignees: item.assignees.map((assignee) => assignee.login),
286
+ comments: item.comments.length,
287
+ createdAt: toDate(item.createdAt),
288
+ updatedAt: toDate(item.updatedAt),
289
+ closedAt: toDate(item.closedAt),
290
+ mergedAt: toDate(mergedAt),
291
+ };
292
+ });
293
+ }
294
+
220
295
  /**
221
296
  * Error thrown when the GitHub search API rejects the request. Carries the
222
297
  * original toolcall params so the model can see the exact input.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 三个只读 LSP 查询工具(`lsp-find-definition` / `lsp-find-reference` / `lsp-inspect`)的
3
+ * 结构化结果 schema。
4
+ *
5
+ * 载荷只有我们自己归一化过的数据(`service.inspect` 的产出),没有外部引擎字段,因此这里
6
+ * 不学 AFT 那样全线可选:字段都在,类型确定。位置的行列号是 **1-based**(与工具文本、
7
+ * 与这三个工具自己的 `line` / `character` 参数一致),脚本拿到就能直接回喂给另一次查询。
8
+ *
9
+ * `locations` 给的是查询到的**全部**位置:文本会为了模型上下文对引用列表做截断
10
+ * (每文件 10 条、最多 30 个文件),载荷不跟着截——它是给程序用的数据,截断是渲染的事。
11
+ */
12
+
13
+ import { Type } from "typebox";
14
+
15
+ /** 一个位置:绝对路径 + 1-based 行列号(与工具文本里的 `path:line:col` 同源)。 */
16
+ export const lspLocationSchema = Type.Object(
17
+ {
18
+ path: Type.String({ description: "文件的绝对路径" }),
19
+ line: Type.Integer({ minimum: 1, description: "1-based 行号" }),
20
+ character: Type.Integer({ minimum: 1, description: "1-based 列号" }),
21
+ },
22
+ { additionalProperties: false },
23
+ );
24
+
25
+ /** 回答这次查询的语言服务器 id,以及查询到的全部位置。 */
26
+ export const lspLocationsStructuredSchema = Type.Object(
27
+ {
28
+ text: Type.String({ description: "与工具输出一致的渲染文本(引用列表可能被截断)" }),
29
+ serverID: Type.String({ description: "回答这次查询的语言服务器 id" }),
30
+ locations: Type.Array(lspLocationSchema, {
31
+ description: "查询到的全部位置,1-based 行列号,顺序与文本一致",
32
+ }),
33
+ },
34
+ { additionalProperties: false },
35
+ );
36
+
37
+ /** hover 的内容是服务器给的异构结构,渲染成文本后原样给出。 */
38
+ export const lspHoverStructuredSchema = Type.Object(
39
+ {
40
+ text: Type.String({ description: "与工具输出一致的 hover 文本(服务器返回的内容)" }),
41
+ serverID: Type.String({ description: "回答这次查询的语言服务器 id" }),
42
+ },
43
+ { additionalProperties: false },
44
+ );
@@ -18,8 +18,9 @@ import { Type } from "typebox";
18
18
  import type { Hover } from "vscode-languageserver-types";
19
19
 
20
20
  import { resolvePathArg } from "../path.js";
21
- import type { ToolBus } from "../tool-bus.js";
21
+ import { defineStructuredTool, type ToolBus } from "../tool-bus.js";
22
22
  import type { InspectLocation } from "./client.js";
23
+ import { lspHoverStructuredSchema, lspLocationsStructuredSchema } from "./inspect-schemas.js";
23
24
  import type { LspService } from "./lsp.js";
24
25
  import { type LspPosition, symbolCandidates } from "./rename.js";
25
26
 
@@ -29,12 +30,31 @@ const MAX_ENTRIES_PER_FILE = 10;
29
30
  const MAX_FILES_LISTED = 30;
30
31
  const MAX_SNIPPET_LENGTH = 200;
31
32
 
32
- /** 一次探测的产出:text 是消歧分组键,subtitle 供 pendant 摘要。 */
33
- interface InspectOutput {
33
+ /** 渲染产出:text 也是消歧分组键。 */
34
+ interface RenderedOutput {
34
35
  text: string;
35
36
  subtitle: string;
36
37
  }
37
38
 
39
+ /**
40
+ * 一次探测的产出:渲染文本 + 结构化结果里除 `text` 之外的字段(选中哪一组,就取那一组的
41
+ * payload)。泛型让每个工具自己决定载荷。
42
+ */
43
+ interface InspectOutput<TPayload> extends RenderedOutput {
44
+ payload: TPayload;
45
+ }
46
+
47
+ /** 位置载荷:服务层归一化后的绝对路径 + 1-based 行列号。 */
48
+ function toLocationPayload(
49
+ locations: readonly InspectLocation[],
50
+ ): { path: string; line: number; character: number }[] {
51
+ return locations.map((location) => ({
52
+ path: location.path,
53
+ line: location.line + 1,
54
+ character: location.character + 1,
55
+ }));
56
+ }
57
+
38
58
  function loadPrompt(fileName: string): string {
39
59
  return readFileSync(fileURLToPath(new URL(fileName, import.meta.url)), "utf8").trim();
40
60
  }
@@ -113,7 +133,7 @@ function toCoordinates(location: InspectLocation): string {
113
133
  async function formatDefinitionLocations(
114
134
  locations: InspectLocation[],
115
135
  cache: Map<string, string[]>,
116
- ): Promise<InspectOutput> {
136
+ ): Promise<RenderedOutput> {
117
137
  if (locations.length === 0) {
118
138
  return { text: "No definition found for this symbol.", subtitle: "0 definition(s)" };
119
139
  }
@@ -131,7 +151,7 @@ async function formatDefinitionLocations(
131
151
  async function formatReferenceLocations(
132
152
  locations: InspectLocation[],
133
153
  cache: Map<string, string[]>,
134
- ): Promise<InspectOutput> {
154
+ ): Promise<RenderedOutput> {
135
155
  if (locations.length === 0) {
136
156
  return { text: "No references found for this symbol.", subtitle: "0 reference(s)" };
137
157
  }
@@ -198,15 +218,15 @@ function formatHoverContents(contents: Hover["contents"]): string {
198
218
  * 行内同名候选逐个探测:输出一致即同一符号的多次出现;不一致报歧义并列出
199
219
  * 各候选的 1-based 列号(与 lsp-rename 的消歧行为一致)。
200
220
  */
201
- async function probeSymbolCandidates(options: {
221
+ async function probeSymbolCandidates<TPayload>(options: {
202
222
  content: string;
203
223
  filePath: string;
204
224
  line: number;
205
225
  symbol: string;
206
226
  character?: number;
207
227
  signal?: AbortSignal;
208
- probe: (position: LspPosition) => Promise<InspectOutput>;
209
- }): Promise<InspectOutput> {
228
+ probe: (position: LspPosition) => Promise<InspectOutput<TPayload>>;
229
+ }): Promise<InspectOutput<TPayload>> {
210
230
  const candidates = symbolCandidates(
211
231
  options.content,
212
232
  options.line - 1,
@@ -218,12 +238,12 @@ async function probeSymbolCandidates(options: {
218
238
  `Symbol "${options.symbol}" not found on line ${options.line} of ${options.filePath}. Read the file again and locate the symbol.`,
219
239
  );
220
240
  }
221
- const outputs: InspectOutput[] = [];
241
+ const outputs: InspectOutput<TPayload>[] = [];
222
242
  for (const candidate of candidates) {
223
243
  options.signal?.throwIfAborted();
224
244
  outputs.push(await options.probe(candidate));
225
245
  }
226
- const groups = new Map<string, { output: InspectOutput; candidates: LspPosition[] }>();
246
+ const groups = new Map<string, { output: InspectOutput<TPayload>; candidates: LspPosition[] }>();
227
247
  for (const [index, candidate] of candidates.entries()) {
228
248
  const output = outputs.at(index);
229
249
  if (!output) {
@@ -256,137 +276,168 @@ async function probeSymbolCandidates(options: {
256
276
  }
257
277
 
258
278
  export function registerLspInspectTools(bus: ToolBus, service: LspService): void {
259
- bus.register({
260
- name: "lsp-find-definition",
261
- label: "Lsp Find Definition",
262
- description:
263
- "Find where a code symbol is defined via LSP. Returns 1-based path:line:col locations with source line snippets.",
264
- promptSnippet: "Find symbol definitions via LSP",
265
- promptGuidelines: [FIND_DEFINITION_PROMPT],
266
- parameters: POSITION_SCHEMA,
267
- async execute(_id, params, signal, _onUpdate, ctx) {
268
- signal?.throwIfAborted();
269
- const { path: filePath, content } = await readSymbolFile(ctx.cwd, params.file_path);
270
- const cache = new Map<string, string[]>();
271
- const output = await probeSymbolCandidates({
272
- content,
273
- filePath,
274
- line: params.line,
275
- symbol: params.symbol,
276
- character: params.character,
277
- signal,
278
- probe: async (position) => {
279
- const result = await service.inspect({
280
- file: filePath,
281
- cwd: ctx.cwd,
282
- line: position.line,
283
- character: position.character,
284
- query: "definition",
285
- options: { signal },
286
- });
287
- return formatDefinitionLocations(result.locations, cache);
288
- },
289
- });
290
- return {
291
- content: [{ type: "text" as const, text: output.text }],
292
- details: {
293
- pendant: {
294
- title: "lsp-find-definition",
295
- subtitle: `${params.symbol} · ${output.subtitle}`,
279
+ bus.register(
280
+ defineStructuredTool({
281
+ name: "lsp-find-definition",
282
+ label: "Lsp Find Definition",
283
+ description:
284
+ "Find where a code symbol is defined via LSP. Returns 1-based path:line:col locations with source line snippets.",
285
+ promptSnippet: "Find symbol definitions via LSP",
286
+ promptGuidelines: [FIND_DEFINITION_PROMPT],
287
+ parameters: POSITION_SCHEMA,
288
+ structuredSchema: lspLocationsStructuredSchema,
289
+ async execute(_id, params, signal, _onUpdate, ctx) {
290
+ signal?.throwIfAborted();
291
+ const { path: filePath, content } = await readSymbolFile(ctx.cwd, params.file_path);
292
+ const cache = new Map<string, string[]>();
293
+ const output = await probeSymbolCandidates({
294
+ content,
295
+ filePath,
296
+ line: params.line,
297
+ symbol: params.symbol,
298
+ character: params.character,
299
+ signal,
300
+ probe: async (position) => {
301
+ const result = await service.inspect({
302
+ file: filePath,
303
+ cwd: ctx.cwd,
304
+ line: position.line,
305
+ character: position.character,
306
+ query: "definition",
307
+ options: { signal },
308
+ });
309
+ const rendered = await formatDefinitionLocations(result.locations, cache);
310
+ return {
311
+ ...rendered,
312
+ payload: {
313
+ serverID: result.serverID,
314
+ locations: toLocationPayload(result.locations),
315
+ },
316
+ };
296
317
  },
297
- },
298
- };
299
- },
300
- });
318
+ });
319
+ return {
320
+ content: [{ type: "text" as const, text: output.text }],
321
+ details: {
322
+ pendant: {
323
+ title: "lsp-find-definition",
324
+ subtitle: `${params.symbol} · ${output.subtitle}`,
325
+ },
326
+ },
327
+ structuredResult: { ok: true as const, value: { text: output.text, ...output.payload } },
328
+ };
329
+ },
330
+ }),
331
+ );
301
332
 
302
- bus.register({
303
- name: "lsp-find-reference",
304
- label: "Lsp Find Reference",
305
- description:
306
- "Find all references to a code symbol across the workspace via LSP (includes the declaration). Grouped by file with 1-based line:col and source snippets.",
307
- promptSnippet: "Find symbol references via LSP",
308
- promptGuidelines: [FIND_REFERENCE_PROMPT],
309
- parameters: POSITION_SCHEMA,
310
- async execute(_id, params, signal, _onUpdate, ctx) {
311
- signal?.throwIfAborted();
312
- const { path: filePath, content } = await readSymbolFile(ctx.cwd, params.file_path);
313
- const cache = new Map<string, string[]>();
314
- const output = await probeSymbolCandidates({
315
- content,
316
- filePath,
317
- line: params.line,
318
- symbol: params.symbol,
319
- character: params.character,
320
- signal,
321
- probe: async (position) => {
322
- const result = await service.inspect({
323
- file: filePath,
324
- cwd: ctx.cwd,
325
- line: position.line,
326
- character: position.character,
327
- query: "references",
328
- options: { signal },
329
- });
330
- return formatReferenceLocations(result.locations, cache);
331
- },
332
- });
333
- return {
334
- content: [{ type: "text" as const, text: output.text }],
335
- details: {
336
- pendant: {
337
- title: "lsp-find-reference",
338
- subtitle: `${params.symbol} · ${output.subtitle}`,
333
+ bus.register(
334
+ defineStructuredTool({
335
+ name: "lsp-find-reference",
336
+ label: "Lsp Find Reference",
337
+ description:
338
+ "Find all references to a code symbol across the workspace via LSP (includes the declaration). Grouped by file with 1-based line:col and source snippets.",
339
+ promptSnippet: "Find symbol references via LSP",
340
+ promptGuidelines: [FIND_REFERENCE_PROMPT],
341
+ parameters: POSITION_SCHEMA,
342
+ structuredSchema: lspLocationsStructuredSchema,
343
+ async execute(_id, params, signal, _onUpdate, ctx) {
344
+ signal?.throwIfAborted();
345
+ const { path: filePath, content } = await readSymbolFile(ctx.cwd, params.file_path);
346
+ const cache = new Map<string, string[]>();
347
+ const output = await probeSymbolCandidates({
348
+ content,
349
+ filePath,
350
+ line: params.line,
351
+ symbol: params.symbol,
352
+ character: params.character,
353
+ signal,
354
+ probe: async (position) => {
355
+ const result = await service.inspect({
356
+ file: filePath,
357
+ cwd: ctx.cwd,
358
+ line: position.line,
359
+ character: position.character,
360
+ query: "references",
361
+ options: { signal },
362
+ });
363
+ const rendered = await formatReferenceLocations(result.locations, cache);
364
+ return {
365
+ ...rendered,
366
+ // 文本为上下文做了截断(每文件 10 条、最多 30 个文件),载荷给全部位置
367
+ payload: {
368
+ serverID: result.serverID,
369
+ locations: toLocationPayload(result.locations),
370
+ },
371
+ };
339
372
  },
340
- },
341
- };
342
- },
343
- });
373
+ });
374
+ return {
375
+ content: [{ type: "text" as const, text: output.text }],
376
+ details: {
377
+ pendant: {
378
+ title: "lsp-find-reference",
379
+ subtitle: `${params.symbol} · ${output.subtitle}`,
380
+ },
381
+ },
382
+ structuredResult: { ok: true as const, value: { text: output.text, ...output.payload } },
383
+ };
384
+ },
385
+ }),
386
+ );
344
387
 
345
- bus.register({
346
- name: "lsp-inspect",
347
- label: "Lsp Inspect",
348
- description:
349
- "Get hover information (type signature, documentation) for a code symbol via LSP. Content is passed through from the language server.",
350
- promptSnippet: "Get hover info for a symbol via LSP",
351
- promptGuidelines: [INSPECT_PROMPT],
352
- parameters: POSITION_SCHEMA,
353
- async execute(_id, params, signal, _onUpdate, ctx) {
354
- signal?.throwIfAborted();
355
- const { path: filePath, content } = await readSymbolFile(ctx.cwd, params.file_path);
356
- const output = await probeSymbolCandidates({
357
- content,
358
- filePath,
359
- line: params.line,
360
- symbol: params.symbol,
361
- character: params.character,
362
- signal,
363
- probe: async (position) => {
364
- const result = await service.inspect({
365
- file: filePath,
366
- cwd: ctx.cwd,
367
- line: position.line,
368
- character: position.character,
369
- query: "hover",
370
- options: { signal },
371
- });
372
- if (result.hover === null) {
388
+ bus.register(
389
+ defineStructuredTool({
390
+ name: "lsp-inspect",
391
+ label: "Lsp Inspect",
392
+ description:
393
+ "Get hover information (type signature, documentation) for a code symbol via LSP. Content is passed through from the language server.",
394
+ promptSnippet: "Get hover info for a symbol via LSP",
395
+ promptGuidelines: [INSPECT_PROMPT],
396
+ parameters: POSITION_SCHEMA,
397
+ structuredSchema: lspHoverStructuredSchema,
398
+ async execute(_id, params, signal, _onUpdate, ctx) {
399
+ signal?.throwIfAborted();
400
+ const { path: filePath, content } = await readSymbolFile(ctx.cwd, params.file_path);
401
+ const output = await probeSymbolCandidates({
402
+ content,
403
+ filePath,
404
+ line: params.line,
405
+ symbol: params.symbol,
406
+ character: params.character,
407
+ signal,
408
+ probe: async (position) => {
409
+ const result = await service.inspect({
410
+ file: filePath,
411
+ cwd: ctx.cwd,
412
+ line: position.line,
413
+ character: position.character,
414
+ query: "hover",
415
+ options: { signal },
416
+ });
417
+ // hover 的内容是服务器给的异构结构(string / MarkedString / MarkupContent),
418
+ // 渲染成文本给它;载荷里只另带是谁回答的
419
+ if (result.hover === null) {
420
+ return {
421
+ text: `No hover information for '${params.symbol}' at line ${params.line} of ${filePath}.`,
422
+ subtitle: "no hover info",
423
+ payload: { serverID: result.serverID },
424
+ };
425
+ }
373
426
  return {
374
- text: `No hover information for '${params.symbol}' at line ${params.line} of ${filePath}.`,
375
- subtitle: "no hover info",
427
+ text: formatHoverContents(result.hover.contents),
428
+ subtitle: "hover",
429
+ payload: { serverID: result.serverID },
376
430
  };
377
- }
378
- return {
379
- text: formatHoverContents(result.hover.contents),
380
- subtitle: "hover",
381
- };
382
- },
383
- });
384
- return {
385
- content: [{ type: "text" as const, text: output.text }],
386
- details: {
387
- pendant: { title: "lsp-inspect", subtitle: `${params.symbol} · ${output.subtitle}` },
388
- },
389
- };
390
- },
391
- });
431
+ },
432
+ });
433
+ return {
434
+ content: [{ type: "text" as const, text: output.text }],
435
+ details: {
436
+ pendant: { title: "lsp-inspect", subtitle: `${params.symbol} · ${output.subtitle}` },
437
+ },
438
+ structuredResult: { ok: true as const, value: { text: output.text, ...output.payload } },
439
+ };
440
+ },
441
+ }),
442
+ );
392
443
  }