@danypops/pi-lector 0.2.1 → 0.3.0

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.
@@ -4,6 +4,7 @@ import type {
4
4
  DocumentSymbolEntry,
5
5
  Hover,
6
6
  IncomingCall,
7
+ IntelligenceProvenance,
7
8
  JobSnapshot,
8
9
  OutgoingCall,
9
10
  PopulateSymbolGraphResult,
@@ -159,20 +160,29 @@ function formatCallHierarchyEntry(entry: { kind: string; name: string; location:
159
160
  return `${kind} ${name} -- ${location}`;
160
161
  }
161
162
 
162
- export function formatPrepareCallHierarchyCall(args: { path?: unknown; line?: unknown; character?: unknown }, theme: LectorTheme): string {
163
- return formatPositionalCall("prepare_call_hierarchy", args, theme);
163
+ export type CallHierarchyDirection = "prepare" | "incoming" | "outgoing";
164
+
165
+ // A real discriminated union, not one shape with optional fields -- lets formatCallHierarchyResult
166
+ // narrow `items`/`calls` per branch without an unsafe assertion.
167
+ export type CallHierarchyToolDetails =
168
+ | { readonly direction: "prepare"; readonly items: readonly CallHierarchyEntry[]; readonly provenance?: IntelligenceProvenance }
169
+ | { readonly direction: "incoming"; readonly calls: readonly IncomingCall[]; readonly provenance?: IntelligenceProvenance }
170
+ | { readonly direction: "outgoing"; readonly calls: readonly OutgoingCall[]; readonly provenance?: IntelligenceProvenance };
171
+
172
+ export function formatCallHierarchyCall(args: { direction?: unknown; path?: unknown; line?: unknown; character?: unknown }, theme: LectorTheme): string {
173
+ const direction = typeof args.direction === "string" ? args.direction : "";
174
+ const path = typeof args.path === "string" ? args.path : "";
175
+ const line = typeof args.line === "number" ? args.line : "?";
176
+ const character = typeof args.character === "number" ? args.character : "?";
177
+ return `${theme.fg("toolTitle", theme.bold("call_hierarchy"))} ${theme.fg("muted", direction)} ${theme.fg("accent", `${path}:${line}:${character}`)}`;
164
178
  }
165
179
 
166
- export function formatPrepareCallHierarchyResult(items: readonly CallHierarchyEntry[] | undefined, theme: LectorTheme): string {
180
+ function formatPrepareCallHierarchyResult(items: readonly CallHierarchyEntry[] | undefined, theme: LectorTheme): string {
167
181
  if (!items || items.length === 0) return theme.fg("dim", "No call-hierarchy root at this position.");
168
182
  return items.map((item) => formatCallHierarchyEntry(item, theme)).join("\n");
169
183
  }
170
184
 
171
- export function formatIncomingCallsCall(args: { path?: unknown; line?: unknown; character?: unknown }, theme: LectorTheme): string {
172
- return formatPositionalCall("incoming_calls", args, theme);
173
- }
174
-
175
- export function formatIncomingCallsResult(calls: readonly IncomingCall[] | undefined, expanded: boolean, theme: LectorTheme): string {
185
+ function formatIncomingCallsResult(calls: readonly IncomingCall[] | undefined, expanded: boolean, theme: LectorTheme): string {
176
186
  if (!calls || calls.length === 0) return theme.fg("dim", "No incoming calls found.");
177
187
 
178
188
  const displayCount = expanded ? calls.length : Math.min(calls.length, DEFAULT_VISIBLE_CALLS);
@@ -184,11 +194,7 @@ export function formatIncomingCallsResult(calls: readonly IncomingCall[] | undef
184
194
  return lines.join("\n");
185
195
  }
186
196
 
187
- export function formatOutgoingCallsCall(args: { path?: unknown; line?: unknown; character?: unknown }, theme: LectorTheme): string {
188
- return formatPositionalCall("outgoing_calls", args, theme);
189
- }
190
-
191
- export function formatOutgoingCallsResult(calls: readonly OutgoingCall[] | undefined, expanded: boolean, theme: LectorTheme): string {
197
+ function formatOutgoingCallsResult(calls: readonly OutgoingCall[] | undefined, expanded: boolean, theme: LectorTheme): string {
192
198
  if (!calls || calls.length === 0) return theme.fg("dim", "No outgoing calls found.");
193
199
 
194
200
  const displayCount = expanded ? calls.length : Math.min(calls.length, DEFAULT_VISIBLE_CALLS);
@@ -200,6 +206,13 @@ export function formatOutgoingCallsResult(calls: readonly OutgoingCall[] | undef
200
206
  return lines.join("\n");
201
207
  }
202
208
 
209
+ export function formatCallHierarchyResult(details: CallHierarchyToolDetails | undefined, expanded: boolean, theme: LectorTheme): string {
210
+ if (!details) return theme.fg("dim", "No result.");
211
+ if (details.direction === "prepare") return formatPrepareCallHierarchyResult(details.items, theme);
212
+ if (details.direction === "incoming") return formatIncomingCallsResult(details.calls, expanded, theme);
213
+ return formatOutgoingCallsResult(details.calls, expanded, theme);
214
+ }
215
+
203
216
  export function formatPopulateSymbolGraphCall(args: { path?: unknown; maxFiles?: unknown; maxSymbolsPerFile?: unknown }, theme: LectorTheme): string {
204
217
  const path = typeof args.path === "string" ? args.path : "";
205
218
  return `${theme.fg("toolTitle", theme.bold("populate_symbol_graph"))} ${theme.fg("accent", path)}`;
@@ -6,12 +6,30 @@ const DEFAULT_VISIBLE_FILES = 20;
6
6
  const DEFAULT_VISIBLE_COMMITS = 10;
7
7
  const DEFAULT_VISIBLE_DIFF_LINES = 60;
8
8
 
9
- export function formatGitStatusCall(args: { directory?: unknown }, theme: LectorTheme): string {
9
+ export type GitAction = "status" | "log" | "diff";
10
+
11
+ export interface GitToolDetails {
12
+ readonly action: GitAction;
13
+ readonly summary?: GitStatusSummary;
14
+ readonly entries?: readonly GitLogEntry[];
15
+ readonly result?: GitDiffResult;
16
+ }
17
+
18
+ export function formatGitCall(args: { action?: unknown; directory?: unknown; ref?: unknown }, theme: LectorTheme): string {
19
+ const action = typeof args.action === "string" ? args.action : "";
10
20
  const directory = typeof args.directory === "string" ? args.directory : "";
11
- return `${theme.fg("toolTitle", theme.bold("git_status"))} ${theme.fg("accent", directory)}`;
21
+ const ref = typeof args.ref === "string" ? ` ${args.ref}` : "";
22
+ return `${theme.fg("toolTitle", theme.bold("git"))} ${theme.fg("muted", action)} ${theme.fg("accent", directory)}${ref}`;
12
23
  }
13
24
 
14
- export function formatGitStatusResult(summary: GitStatusSummary | undefined, expanded: boolean, theme: LectorTheme): string {
25
+ export function formatGitResult(details: GitToolDetails | undefined, expanded: boolean, theme: LectorTheme): string {
26
+ if (!details) return theme.fg("dim", "No result.");
27
+ if (details.action === "status") return formatGitStatusResult(details.summary, expanded, theme);
28
+ if (details.action === "log") return formatGitLogResult(details.entries, expanded, theme);
29
+ return formatGitDiffResult(details.result, expanded, theme);
30
+ }
31
+
32
+ function formatGitStatusResult(summary: GitStatusSummary | undefined, expanded: boolean, theme: LectorTheme): string {
15
33
  if (!summary) return theme.fg("dim", "No status available.");
16
34
  const branch = summary.current ?? "(detached)";
17
35
  const tracking = summary.tracking ? `, tracking ${summary.tracking} (+${summary.ahead}/-${summary.behind})` : "";
@@ -30,12 +48,7 @@ export function formatGitStatusResult(summary: GitStatusSummary | undefined, exp
30
48
  return lines.join("\n");
31
49
  }
32
50
 
33
- export function formatGitLogCall(args: { directory?: unknown; maxCount?: unknown }, theme: LectorTheme): string {
34
- const directory = typeof args.directory === "string" ? args.directory : "";
35
- return `${theme.fg("toolTitle", theme.bold("git_log"))} ${theme.fg("accent", directory)}`;
36
- }
37
-
38
- export function formatGitLogResult(entries: readonly GitLogEntry[] | undefined, expanded: boolean, theme: LectorTheme): string {
51
+ function formatGitLogResult(entries: readonly GitLogEntry[] | undefined, expanded: boolean, theme: LectorTheme): string {
39
52
  if (!entries || entries.length === 0) return theme.fg("dim", "No commits found.");
40
53
  const displayCount = expanded ? entries.length : Math.min(DEFAULT_VISIBLE_COMMITS, entries.length);
41
54
  const lines = entries
@@ -46,13 +59,7 @@ export function formatGitLogResult(entries: readonly GitLogEntry[] | undefined,
46
59
  return lines.join("\n");
47
60
  }
48
61
 
49
- export function formatGitDiffCall(args: { directory?: unknown; ref?: unknown }, theme: LectorTheme): string {
50
- const directory = typeof args.directory === "string" ? args.directory : "";
51
- const ref = typeof args.ref === "string" ? ` ${args.ref}` : "";
52
- return `${theme.fg("toolTitle", theme.bold("git_diff"))} ${theme.fg("accent", directory)}${ref}`;
53
- }
54
-
55
- export function formatGitDiffResult(result: GitDiffResult | undefined, expanded: boolean, theme: LectorTheme): string {
62
+ function formatGitDiffResult(result: GitDiffResult | undefined, expanded: boolean, theme: LectorTheme): string {
56
63
  if (!result || result.diff.length === 0) return theme.fg("dim", "No differences.");
57
64
  const lines = result.diff.split("\n");
58
65
  const displayCount = expanded ? lines.length : Math.min(DEFAULT_VISIBLE_DIFF_LINES, lines.length);
@@ -1,21 +1,15 @@
1
1
  import { resolve } from "node:path";
2
2
  import type {
3
- CallHierarchyEntry,
4
3
  ContentHash,
5
4
  Diagnostic,
6
5
  DocumentSymbolEntry,
7
6
  EditOutcome,
8
7
  FindFilesResult,
9
- GitDiffResult,
10
- GitLogEntry,
11
- GitStatusSummary,
12
8
  Hover,
13
- IncomingCall,
14
9
  IntelligenceProvenance,
15
10
  JobSnapshot,
16
11
  LineEdit,
17
12
  LineEditOutcome,
18
- OutgoingCall,
19
13
  PackageSourceOperationResult,
20
14
  PopulateSymbolGraphResult,
21
15
  RepoFetchResult,
@@ -27,14 +21,23 @@ import type {
27
21
  WorkspaceMapResult,
28
22
  WorkspaceQueryOutcome,
29
23
  } from "@danypops/lector";
30
- import { createEditToolDefinition, createReadToolDefinition, createWriteToolDefinition, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
24
+ import {
25
+ type AgentToolResult,
26
+ createEditToolDefinition,
27
+ createReadToolDefinition,
28
+ createWriteToolDefinition,
29
+ type ExtensionAPI,
30
+ } from "@earendil-works/pi-coding-agent";
31
31
  import { Text } from "@earendil-works/pi-tui";
32
32
  import { Type } from "typebox";
33
33
  import { createLectorApplyPatchOperations } from "./apply-patch-operations.ts";
34
34
  import { formatApplyPatchCall, formatApplyPatchResult } from "./apply-patch-rendering.ts";
35
35
  import { createLectorCodeIntelligenceOperations } from "./code-intelligence-operations.ts";
36
36
  import {
37
+ type CallHierarchyToolDetails,
37
38
  describePopulateSymbolGraphJob,
39
+ formatCallHierarchyCall,
40
+ formatCallHierarchyResult,
38
41
  formatDiagnosticsCall,
39
42
  formatDiagnosticsResult,
40
43
  formatDocumentSymbolsCall,
@@ -47,14 +50,8 @@ import {
47
50
  formatGoToImplementationResult,
48
51
  formatHoverCall,
49
52
  formatHoverResult,
50
- formatIncomingCallsCall,
51
- formatIncomingCallsResult,
52
- formatOutgoingCallsCall,
53
- formatOutgoingCallsResult,
54
53
  formatPopulateSymbolGraphCall,
55
54
  formatPopulateSymbolGraphResult,
56
- formatPrepareCallHierarchyCall,
57
- formatPrepareCallHierarchyResult,
58
55
  formatReachableFromCall,
59
56
  formatReachableFromResult,
60
57
  formatWorkspaceMapCall,
@@ -68,7 +65,7 @@ import { formatFindFilesCall, formatFindFilesResult } from "./find-files-renderi
68
65
  import { createLectorFindSymbolsOperations } from "./find-symbols-operations.ts";
69
66
  import { describeFindSymbolSources, formatFindSymbolsCall, formatFindSymbolsResult } from "./find-symbols-rendering.ts";
70
67
  import { createLectorGitOperations } from "./git-operations.ts";
71
- import { formatGitDiffCall, formatGitDiffResult, formatGitLogCall, formatGitLogResult, formatGitStatusCall, formatGitStatusResult } from "./git-rendering.ts";
68
+ import { formatGitCall, formatGitResult, type GitToolDetails } from "./git-rendering.ts";
72
69
  import { createLectorLineEditOperations } from "./line-edit-operations.ts";
73
70
  import { formatLineEditCall, formatLineEditResult } from "./line-edit-rendering.ts";
74
71
  import { nearestGitRoot } from "./nearest-workspace-root.ts";
@@ -221,7 +218,7 @@ export default function (pi: ExtensionAPI) {
221
218
  },
222
219
  renderCall(args, theme, context) {
223
220
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
224
- text.setText(formatFindSymbolsCall(args as { query?: unknown; directory?: unknown }, theme));
221
+ text.setText(formatFindSymbolsCall(args, theme));
225
222
  return text;
226
223
  },
227
224
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -236,7 +233,7 @@ export default function (pi: ExtensionAPI) {
236
233
  return new Text(theme.fg("error", errorText || "find_symbols failed"), 0, 0);
237
234
  }
238
235
  const details = result.details as SymbolSearchResult | undefined;
239
- const query = typeof context.args?.query === "string" ? context.args.query : "";
236
+ const query = typeof context.args.query === "string" ? context.args.query : "";
240
237
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
241
238
  text.setText(formatFindSymbolsResult(details, query, expanded, theme));
242
239
  return text;
@@ -265,7 +262,7 @@ export default function (pi: ExtensionAPI) {
265
262
  },
266
263
  renderCall(args, theme, context) {
267
264
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
268
- text.setText(formatGoToDefinitionCall(args as { path?: unknown; line?: unknown; character?: unknown }, theme));
265
+ text.setText(formatGoToDefinitionCall(args, theme));
269
266
  return text;
270
267
  },
271
268
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -303,7 +300,7 @@ export default function (pi: ExtensionAPI) {
303
300
  },
304
301
  renderCall(args, theme, context) {
305
302
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
306
- text.setText(formatGoToImplementationCall(args as { path?: unknown; line?: unknown; character?: unknown }, theme));
303
+ text.setText(formatGoToImplementationCall(args, theme));
307
304
  return text;
308
305
  },
309
306
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -348,7 +345,7 @@ export default function (pi: ExtensionAPI) {
348
345
  },
349
346
  renderCall(args, theme, context) {
350
347
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
351
- text.setText(formatFindReferencesCall(args as { path?: unknown; line?: unknown; character?: unknown }, theme));
348
+ text.setText(formatFindReferencesCall(args, theme));
352
349
  return text;
353
350
  },
354
351
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -388,7 +385,7 @@ export default function (pi: ExtensionAPI) {
388
385
  },
389
386
  renderCall(args, theme, context) {
390
387
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
391
- text.setText(formatHoverCall(args as { path?: unknown; line?: unknown; character?: unknown }, theme));
388
+ text.setText(formatHoverCall(args, theme));
392
389
  return text;
393
390
  },
394
391
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -422,7 +419,7 @@ export default function (pi: ExtensionAPI) {
422
419
  },
423
420
  renderCall(args, theme, context) {
424
421
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
425
- text.setText(formatDocumentSymbolsCall(args as { path?: unknown }, theme));
422
+ text.setText(formatDocumentSymbolsCall(args, theme));
426
423
  return text;
427
424
  },
428
425
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -459,7 +456,7 @@ export default function (pi: ExtensionAPI) {
459
456
  },
460
457
  renderCall(args, theme, context) {
461
458
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
462
- text.setText(formatDiagnosticsCall(args as { path?: unknown }, theme));
459
+ text.setText(formatDiagnosticsCall(args, theme));
463
460
  return text;
464
461
  },
465
462
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -479,118 +476,67 @@ export default function (pi: ExtensionAPI) {
479
476
  });
480
477
 
481
478
  pi.registerTool({
482
- name: "prepare_call_hierarchy",
483
- label: "Prepare Call Hierarchy",
484
- description: "Resolve the symbol at an exact file position to its call-hierarchy root -- the first step before incoming_calls or outgoing_calls.",
485
- promptSnippet: "Resolve a position to a call-hierarchy root",
479
+ name: "call_hierarchy",
480
+ label: "Call Hierarchy",
481
+ description:
482
+ "Resolve the symbol at an exact file position to its call-hierarchy root, or find its real callers/callees, project-wide, in one tool. ACTIONS: prepare (confirm what the position resolves to), incoming (who actually calls it, as distinct from find_references which also finds non-call usages), outgoing (what it itself calls).",
483
+ promptSnippet: "Resolve a position to a call-hierarchy root, or find its callers/callees",
486
484
  promptGuidelines: [
487
- "Use prepare_call_hierarchy to confirm what a position resolves to before asking for its callers or callees; incoming_calls and outgoing_calls also do this internally, so calling it first is optional, not required.",
485
+ "direction=prepare is optional -- incoming/outgoing already resolve the position internally, so calling prepare first is never required, only useful to confirm what a position resolves to.",
486
+ "direction=incoming finds real callers, distinct from find_references which also finds non-call usages like type positions or re-exports.",
488
487
  ],
489
- parameters: Type.Object(positionParameters),
490
- async execute(_toolCallId, params) {
488
+ parameters: Type.Object({
489
+ direction: Type.String({ description: "prepare | incoming | outgoing" }),
490
+ ...positionParameters,
491
+ }),
492
+ async execute(_toolCallId, params): Promise<AgentToolResult<CallHierarchyToolDetails>> {
491
493
  const path = resolve(cwd, params.path);
492
- const details = await codeIntelligenceOperations.prepareCallHierarchy(path, params.line, params.character);
493
- const text =
494
- details.items.length === 0
495
- ? "No call-hierarchy root at this position."
496
- : details.items.map((i) => `${i.kind} ${i.name} -- ${i.location.path}:${i.location.line}:${i.location.character}`).join("\n");
497
- return { content: [{ type: "text", text: `${describeIntelligenceSource(details.provenance)}\n${text}` }], details };
498
- },
499
- renderCall(args, theme, context) {
500
- const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
501
- text.setText(formatPrepareCallHierarchyCall(args as { path?: unknown; line?: unknown; character?: unknown }, theme));
502
- return text;
503
- },
504
- renderResult(result, { isPartial }, theme, context) {
505
- if (isPartial) return new Text(theme.fg("warning", "Resolving position..."), 0, 0);
506
- if (context.isError) {
507
- const errorText = result.content
508
- .filter((block) => block.type === "text")
509
- .map((block) => block.text)
510
- .join("\n");
511
- return new Text(theme.fg("error", errorText || "prepare_call_hierarchy failed"), 0, 0);
494
+ if (params.direction === "prepare") {
495
+ const { items, provenance } = await codeIntelligenceOperations.prepareCallHierarchy(path, params.line, params.character);
496
+ const text =
497
+ items.length === 0
498
+ ? "No call-hierarchy root at this position."
499
+ : items.map((i) => `${i.kind} ${i.name} -- ${i.location.path}:${i.location.line}:${i.location.character}`).join("\n");
500
+ const details: CallHierarchyToolDetails = { direction: "prepare", items, provenance };
501
+ return { content: [{ type: "text", text: `${describeIntelligenceSource(provenance)}\n${text}` }], details };
512
502
  }
513
- const details = result.details as { items?: readonly CallHierarchyEntry[]; provenance?: IntelligenceProvenance } | undefined;
514
- const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
515
- text.setText(renderIntelligenceSource(formatPrepareCallHierarchyResult(details?.items, theme), details?.provenance, theme));
516
- return text;
517
- },
518
- });
519
-
520
- pi.registerTool({
521
- name: "incoming_calls",
522
- label: "Incoming Calls",
523
- description: "Find every real caller of the function/method at an exact file position, project-wide.",
524
- promptSnippet: "Find every caller of a function from an exact position",
525
- promptGuidelines: [
526
- "Use incoming_calls to see who actually calls a function, as distinct from find_references, which also finds non-call usages like type positions or re-exports.",
527
- ],
528
- parameters: Type.Object(positionParameters),
529
- async execute(_toolCallId, params) {
530
- const path = resolve(cwd, params.path);
531
- const details = await codeIntelligenceOperations.incomingCalls(path, params.line, params.character);
532
- const text =
533
- details.calls.length === 0
534
- ? "No incoming calls found."
535
- : details.calls
536
- .map((c) => `${c.from.kind} ${c.from.name} -- ${c.from.location.path}:${c.from.location.line}:${c.from.location.character}`)
537
- .join("\n");
538
- return { content: [{ type: "text", text: `${describeIntelligenceSource(details.provenance)}\n${text}` }], details };
539
- },
540
- renderCall(args, theme, context) {
541
- const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
542
- text.setText(formatIncomingCallsCall(args as { path?: unknown; line?: unknown; character?: unknown }, theme));
543
- return text;
544
- },
545
- renderResult(result, { expanded, isPartial }, theme, context) {
546
- if (isPartial) return new Text(theme.fg("warning", "Searching for callers..."), 0, 0);
547
- if (context.isError) {
548
- const errorText = result.content
549
- .filter((block) => block.type === "text")
550
- .map((block) => block.text)
551
- .join("\n");
552
- return new Text(theme.fg("error", errorText || "incoming_calls failed"), 0, 0);
503
+ if (params.direction === "incoming") {
504
+ const { calls, provenance } = await codeIntelligenceOperations.incomingCalls(path, params.line, params.character);
505
+ const text =
506
+ calls.length === 0
507
+ ? "No incoming calls found."
508
+ : calls.map((c) => `${c.from.kind} ${c.from.name} -- ${c.from.location.path}:${c.from.location.line}:${c.from.location.character}`).join("\n");
509
+ const details: CallHierarchyToolDetails = { direction: "incoming", calls, provenance };
510
+ return { content: [{ type: "text", text: `${describeIntelligenceSource(provenance)}\n${text}` }], details };
553
511
  }
554
- const details = result.details as { calls?: readonly IncomingCall[]; provenance?: IntelligenceProvenance } | undefined;
555
- const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
556
- text.setText(renderIntelligenceSource(formatIncomingCallsResult(details?.calls, expanded, theme), details?.provenance, theme));
557
- return text;
558
- },
559
- });
560
-
561
- pi.registerTool({
562
- name: "outgoing_calls",
563
- label: "Outgoing Calls",
564
- description: "Find every function/method the function at an exact file position itself calls.",
565
- promptSnippet: "Find every function a function itself calls",
566
- promptGuidelines: ["Use outgoing_calls to see what a function calls internally, e.g. to trace a code path forward without opening every file by hand."],
567
- parameters: Type.Object(positionParameters),
568
- async execute(_toolCallId, params) {
569
- const path = resolve(cwd, params.path);
570
- const details = await codeIntelligenceOperations.outgoingCalls(path, params.line, params.character);
571
- const text =
572
- details.calls.length === 0
573
- ? "No outgoing calls found."
574
- : details.calls.map((c) => `${c.to.kind} ${c.to.name} -- ${c.to.location.path}:${c.to.location.line}:${c.to.location.character}`).join("\n");
575
- return { content: [{ type: "text", text: `${describeIntelligenceSource(details.provenance)}\n${text}` }], details };
512
+ if (params.direction === "outgoing") {
513
+ const { calls, provenance } = await codeIntelligenceOperations.outgoingCalls(path, params.line, params.character);
514
+ const text =
515
+ calls.length === 0
516
+ ? "No outgoing calls found."
517
+ : calls.map((c) => `${c.to.kind} ${c.to.name} -- ${c.to.location.path}:${c.to.location.line}:${c.to.location.character}`).join("\n");
518
+ const details: CallHierarchyToolDetails = { direction: "outgoing", calls, provenance };
519
+ return { content: [{ type: "text", text: `${describeIntelligenceSource(provenance)}\n${text}` }], details };
520
+ }
521
+ throw new Error(`unknown call_hierarchy direction: ${String(params.direction)}`);
576
522
  },
577
523
  renderCall(args, theme, context) {
578
524
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
579
- text.setText(formatOutgoingCallsCall(args as { path?: unknown; line?: unknown; character?: unknown }, theme));
525
+ text.setText(formatCallHierarchyCall(args, theme));
580
526
  return text;
581
527
  },
582
528
  renderResult(result, { expanded, isPartial }, theme, context) {
583
- if (isPartial) return new Text(theme.fg("warning", "Searching for callees..."), 0, 0);
529
+ if (isPartial) return new Text(theme.fg("warning", "Resolving call hierarchy..."), 0, 0);
584
530
  if (context.isError) {
585
531
  const errorText = result.content
586
532
  .filter((block) => block.type === "text")
587
533
  .map((block) => block.text)
588
534
  .join("\n");
589
- return new Text(theme.fg("error", errorText || "outgoing_calls failed"), 0, 0);
535
+ return new Text(theme.fg("error", errorText || "call_hierarchy failed"), 0, 0);
590
536
  }
591
- const details = result.details as { calls?: readonly OutgoingCall[]; provenance?: IntelligenceProvenance } | undefined;
537
+ const details = result.details as CallHierarchyToolDetails | undefined;
592
538
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
593
- text.setText(renderIntelligenceSource(formatOutgoingCallsResult(details?.calls, expanded, theme), details?.provenance, theme));
539
+ text.setText(renderIntelligenceSource(formatCallHierarchyResult(details, expanded, theme), details?.provenance, theme));
594
540
  return text;
595
541
  },
596
542
  });
@@ -599,7 +545,7 @@ export default function (pi: ExtensionAPI) {
599
545
  name: "populate_symbol_graph",
600
546
  label: "Populate Symbol Graph",
601
547
  description:
602
- "Walk a workspace's real call relationships into a persisted graph, so reachable_from can answer multi-hop questions (transitive callers, reachability) without chaining many find_references/outgoing_calls calls by hand. Run this once before reachable_from.",
548
+ "Walk a workspace's real call relationships into a persisted graph, so reachable_from can answer multi-hop questions (transitive callers, reachability) without chaining many find_references/call_hierarchy calls by hand. Run this once before reachable_from.",
603
549
  promptSnippet: "Populate a workspace's symbol graph for multi-hop queries",
604
550
  promptGuidelines: [
605
551
  "Run populate_symbol_graph once for a workspace before using reachable_from against it; an unpopulated workspace's graph is empty, not an error.",
@@ -622,7 +568,7 @@ export default function (pi: ExtensionAPI) {
622
568
  },
623
569
  renderCall(args, theme, context) {
624
570
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
625
- text.setText(formatPopulateSymbolGraphCall(args as { path?: unknown; maxFiles?: unknown; maxSymbolsPerFile?: unknown }, theme));
571
+ text.setText(formatPopulateSymbolGraphCall(args, theme));
626
572
  return text;
627
573
  },
628
574
  renderResult(result, { isPartial }, theme, context) {
@@ -685,20 +631,24 @@ export default function (pi: ExtensionAPI) {
685
631
  annotations?: readonly SymbolAnnotation[];
686
632
  scrubbed?: boolean;
687
633
  restored?: boolean;
634
+ contained?: boolean;
635
+ uncontained?: boolean;
688
636
  }
689
637
 
690
638
  pi.registerTool({
691
639
  name: "symbol_annotations",
692
640
  label: "Symbol Annotations",
693
641
  description:
694
- 'Agent-authored narrative content anchored to one or more symbols in the workspace\'s persisted graph -- e.g. a "user story dataflow" note spanning every symbol touched end-to-end. Every anchor must resolve to a real, currently-known symbol (run populate_symbol_graph first). get/list live-check staleness against the current graph/workspace on every call and persist a correction before returning, so a returned status never disagrees with reality -- a stale annotation must be refreshed (re-authored and re-anchored) or scrubbed (soft-deleted, restorable) by an explicit decision; Lector never rewrites the narrative itself. Actions: create, get, list, refresh, scrub, restore.',
642
+ 'Agent-authored narrative content anchored to one or more symbols in the workspace\'s persisted graph -- e.g. a "user story dataflow" note spanning every symbol touched end-to-end. Every anchor must resolve to a real, currently-known symbol (run populate_symbol_graph first). get/list/tree live-check staleness against the current graph/workspace on every call and persist a correction before returning, so a returned status never disagrees with reality -- a stale annotation must be refreshed (re-authored and re-anchored) or scrubbed (soft-deleted, restorable) by an explicit decision; Lector never rewrites the narrative itself. contain/uncontain build a reusable, nestable structure on top of plain annotations: a container (e.g. a "data flow") can contain other annotations -- including per-symbol notes shared by more than one container (DRY reuse) or another container one level deeper (nested data flows) -- without duplicating their content. tree reads a whole bounded subtree in one call. Actions: create, get, list, refresh, scrub, restore, contain, uncontain, tree.',
695
643
  promptSnippet: "Attach, read, or invalidate narrative annotations on the symbol graph",
696
644
  promptGuidelines: [
697
645
  "Resolve real anchor positions first (find_symbols/document_symbols/go_to_definition) -- an anchor position must match populate_symbol_graph's own recorded position for that symbol, not just any occurrence of its name.",
698
646
  "A stale annotation's body may no longer describe the code accurately -- read it, decide whether to refresh (re-author) or scrub (remove), never trust it as-is.",
647
+ "Prefer reusing an existing per-symbol annotation as a shared child of several containers over re-authoring the same explanation in each -- that reuse is the reason contain/uncontain exist.",
648
+ "contain/uncontain are idempotent (containing an already-contained child, or uncontaining an already-absent relationship, is a no-op, not an error) and reject a cycle up front rather than accepting one.",
699
649
  ],
700
650
  parameters: Type.Object({
701
- action: Type.String({ description: "create | get | list | refresh | scrub | restore" }),
651
+ action: Type.String({ description: "create | get | list | refresh | scrub | restore | contain | uncontain | tree" }),
702
652
  path: Type.String({ description: "Absolute or cwd-relative path used to resolve which workspace this annotation belongs to" }),
703
653
  id: Type.Optional(Type.String({ description: "Annotation id -- required for get/refresh/scrub/restore" })),
704
654
  subtype: Type.Optional(Type.String({ description: 'Free-form kind, e.g. "user-story-dataflow" or "comment" -- required for create/refresh' })),
@@ -716,7 +666,14 @@ export default function (pi: ExtensionAPI) {
716
666
  ),
717
667
  listStatus: Type.Optional(Type.String({ description: "fresh | stale | scrubbed -- for list; defaults to excluding scrubbed" })),
718
668
  listSubtype: Type.Optional(Type.String({ description: "For list: filter by subtype" })),
669
+ listQuery: Type.Optional(
670
+ Type.String({ description: "For list: case-insensitive substring match against title or body -- the near-term free-text search over annotations" }),
671
+ ),
719
672
  maxResults: Type.Optional(Type.Number({ description: "For list: bounds the number of results" })),
673
+ parentId: Type.Optional(Type.String({ description: "The containing annotation's id -- required for contain/uncontain" })),
674
+ childId: Type.Optional(Type.String({ description: "The contained annotation's id -- required for contain/uncontain" })),
675
+ rootId: Type.Optional(Type.String({ description: "The subtree's root annotation id -- required for tree" })),
676
+ maxDepth: Type.Optional(Type.Number({ description: "Maximum containment hops from rootId to include -- required for tree" })),
720
677
  }),
721
678
  async execute(_toolCallId, params) {
722
679
  const path = resolve(cwd, params.path);
@@ -736,7 +693,12 @@ export default function (pi: ExtensionAPI) {
736
693
  text = annotation ? formatAnnotationDetail(annotation) : `no annotation "${params.id}"`;
737
694
  } else if (params.action === "list") {
738
695
  const status = params.listStatus === "fresh" || params.listStatus === "stale" || params.listStatus === "scrubbed" ? params.listStatus : undefined;
739
- const { annotations } = await symbolAnnotationOperations.list(path, { subtype: params.listSubtype, status, maxResults: params.maxResults });
696
+ const { annotations } = await symbolAnnotationOperations.list(path, {
697
+ subtype: params.listSubtype,
698
+ status,
699
+ maxResults: params.maxResults,
700
+ query: params.listQuery,
701
+ });
740
702
  details.annotations = annotations;
741
703
  text = annotations.length === 0 ? "no annotations" : annotations.map(formatAnnotationDetail).join("\n\n");
742
704
  } else if (params.action === "refresh") {
@@ -763,6 +725,21 @@ export default function (pi: ExtensionAPI) {
763
725
  const { restored } = await symbolAnnotationOperations.restore(path, params.id);
764
726
  details.restored = restored;
765
727
  text = restored ? `restored ${params.id}` : `"${params.id}" was not scrubbed or does not exist`;
728
+ } else if (params.action === "contain") {
729
+ if (!params.parentId || !params.childId) throw new Error("symbol_annotations contain requires parentId and childId");
730
+ const { contained } = await symbolAnnotationOperations.contain(path, params.parentId, params.childId);
731
+ details.contained = contained;
732
+ text = `"${params.parentId}" now contains "${params.childId}"`;
733
+ } else if (params.action === "uncontain") {
734
+ if (!params.parentId || !params.childId) throw new Error("symbol_annotations uncontain requires parentId and childId");
735
+ const { uncontained } = await symbolAnnotationOperations.uncontain(path, params.parentId, params.childId);
736
+ details.uncontained = uncontained;
737
+ text = uncontained ? `"${params.parentId}" no longer contains "${params.childId}"` : `"${params.parentId}" did not contain "${params.childId}"`;
738
+ } else if (params.action === "tree") {
739
+ if (!params.rootId || params.maxDepth === undefined) throw new Error("symbol_annotations tree requires rootId and maxDepth");
740
+ const { annotations } = await symbolAnnotationOperations.tree(path, params.rootId, params.maxDepth);
741
+ details.annotations = annotations;
742
+ text = annotations.length === 0 ? `no annotation "${params.rootId}"` : annotations.map(formatAnnotationDetail).join("\n\n");
766
743
  } else {
767
744
  throw new Error(`unknown symbol_annotations action: ${String(params.action)}`);
768
745
  }
@@ -770,7 +747,14 @@ export default function (pi: ExtensionAPI) {
770
747
  },
771
748
  renderCall(args, theme, context) {
772
749
  const action = typeof args.action === "string" ? args.action : "";
773
- const id = typeof args.id === "string" ? ` ${args.id}` : "";
750
+ const id =
751
+ typeof args.id === "string"
752
+ ? ` ${args.id}`
753
+ : typeof args.parentId === "string" && typeof args.childId === "string"
754
+ ? ` ${args.parentId} -> ${args.childId}`
755
+ : typeof args.rootId === "string"
756
+ ? ` ${args.rootId}`
757
+ : "";
774
758
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
775
759
  text.setText(`${theme.fg("toolTitle", theme.bold("symbol_annotations"))} ${theme.fg("accent", action)}${theme.fg("dim", id)}`);
776
760
  return text;
@@ -803,6 +787,14 @@ export default function (pi: ExtensionAPI) {
803
787
  text.setText(details.restored ? theme.fg("success", "restored") : theme.fg("muted", "not scrubbed or not found"));
804
788
  return text;
805
789
  }
790
+ if (details?.contained !== undefined) {
791
+ text.setText(theme.fg("success", "contained"));
792
+ return text;
793
+ }
794
+ if (details?.uncontained !== undefined) {
795
+ text.setText(details.uncontained ? theme.fg("success", "uncontained") : theme.fg("muted", "was not contained"));
796
+ return text;
797
+ }
806
798
  text.setText(theme.fg("muted", "done"));
807
799
  return text;
808
800
  },
@@ -812,10 +804,10 @@ export default function (pi: ExtensionAPI) {
812
804
  name: "reachable_from",
813
805
  label: "Reachable From",
814
806
  description:
815
- "Every symbol reachable from an exact file position by following the workspace's persisted call graph up to maxDepth hops -- transitive callers/reachability that would otherwise require chaining many find_references/outgoing_calls calls by hand. Requires populate_symbol_graph to have been run for this workspace first.",
807
+ "Every symbol reachable from an exact file position by following the workspace's persisted call graph up to maxDepth hops -- transitive callers/reachability that would otherwise require chaining many find_references/call_hierarchy calls by hand. Requires populate_symbol_graph to have been run for this workspace first.",
816
808
  promptSnippet: "Find symbols reachable from a position, up to N hops, via the persisted graph",
817
809
  promptGuidelines: [
818
- "Use reachable_from for multi-hop questions (does A eventually call C through B); use outgoing_calls/incoming_calls for a single direct hop live against the language server.",
810
+ "Use reachable_from for multi-hop questions (does A eventually call C through B); use call_hierarchy (direction=incoming/outgoing) for a single direct hop live against the language server.",
819
811
  ],
820
812
  parameters: Type.Object({
821
813
  ...positionParameters,
@@ -837,7 +829,7 @@ export default function (pi: ExtensionAPI) {
837
829
  },
838
830
  renderCall(args, theme, context) {
839
831
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
840
- text.setText(formatReachableFromCall(args as { path?: unknown; line?: unknown; character?: unknown; maxDepth?: unknown }, theme));
832
+ text.setText(formatReachableFromCall(args, theme));
841
833
  return text;
842
834
  },
843
835
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -888,7 +880,7 @@ export default function (pi: ExtensionAPI) {
888
880
  },
889
881
  renderCall(args, theme, context) {
890
882
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
891
- text.setText(formatWorkspaceMapCall(args as { path?: unknown; maxEntries?: unknown }, theme));
883
+ text.setText(formatWorkspaceMapCall(args, theme));
892
884
  return text;
893
885
  },
894
886
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -909,111 +901,61 @@ export default function (pi: ExtensionAPI) {
909
901
 
910
902
  const gitOperations = createLectorGitOperations();
911
903
  pi.registerTool({
912
- name: "git_status",
913
- label: "Git Status",
904
+ name: "git",
905
+ label: "Git",
914
906
  description:
915
- "Working tree status for a real git repository -- modified/staged/untracked/renamed files, plus current branch and ahead/behind tracking. Fails clearly if `directory` is not inside a git repository.",
916
- promptSnippet: "Show a repository's working tree status",
907
+ "Working tree status, recent commit log, and unified diff for a real git repository, in one tool. Fails clearly if `directory` is not inside a git repository. ACTIONS: status (working tree state, ahead/behind tracking), log (recent commits, bounded by maxCount), diff (unified diff against `ref`, defaulting to HEAD, bounded by maxBytes).",
908
+ promptSnippet: "Show a repository's status, log, or diff",
909
+ promptGuidelines: [
910
+ "maxCount is required for action=log; maxBytes is required for action=diff -- every bounded query needs its bound stated explicitly, never defaulted silently.",
911
+ ],
917
912
  parameters: Type.Object({
913
+ action: Type.String({ description: "status | log | diff" }),
918
914
  directory: Type.String({ description: "Directory inside the repository to check, absolute or relative to the current working directory" }),
915
+ maxCount: Type.Optional(Type.Number({ description: "Maximum number of commits to return, most recent first -- required for action=log" })),
916
+ ref: Type.Optional(Type.String({ description: "Ref to diff against; defaults to HEAD -- only used for action=diff" })),
917
+ maxBytes: Type.Optional(Type.Number({ description: "Maximum diff size in bytes before truncating -- required for action=diff" })),
919
918
  }),
920
919
  async execute(_toolCallId, params) {
921
920
  const directory = resolve(cwd, params.directory);
922
- const summary = await gitOperations.status(directory);
923
- return { content: [{ type: "text", text: JSON.stringify(summary) }], details: { summary } };
924
- },
925
- renderCall(args, theme, context) {
926
- const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
927
- text.setText(formatGitStatusCall(args as { directory?: unknown }, theme));
928
- return text;
929
- },
930
- renderResult(result, { expanded, isPartial }, theme, context) {
931
- if (isPartial) return new Text(theme.fg("warning", "Checking status..."), 0, 0);
932
- if (context.isError) {
933
- const errorText = result.content
934
- .filter((block) => block.type === "text")
935
- .map((block) => block.text)
936
- .join("\n");
937
- return new Text(theme.fg("error", errorText || "git_status failed"), 0, 0);
921
+ if (params.action === "status") {
922
+ const summary = await gitOperations.status(directory);
923
+ const details: GitToolDetails = { action: "status", summary };
924
+ return { content: [{ type: "text", text: JSON.stringify(summary) }], details };
938
925
  }
939
- const details = result.details as { summary?: GitStatusSummary } | undefined;
940
- const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
941
- text.setText(formatGitStatusResult(details?.summary, expanded, theme));
942
- return text;
943
- },
944
- });
945
-
946
- pi.registerTool({
947
- name: "git_log",
948
- label: "Git Log",
949
- description:
950
- "Recent commits for a real git repository, most recent first, bounded to maxCount. Fails clearly if `directory` is not inside a git repository.",
951
- promptSnippet: "List a repository's recent commits",
952
- parameters: Type.Object({
953
- directory: Type.String({ description: "Directory inside the repository to check, absolute or relative to the current working directory" }),
954
- maxCount: Type.Number({ description: "Maximum number of commits to return, most recent first" }),
955
- }),
956
- async execute(_toolCallId, params) {
957
- const directory = resolve(cwd, params.directory);
958
- const entries = await gitOperations.log(directory, params.maxCount);
959
- const text =
960
- entries.length === 0 ? "No commits found." : entries.map((e) => `${e.sha.slice(0, 8)} ${e.authoredAt} ${e.authorName} -- ${e.message}`).join("\n");
961
- return { content: [{ type: "text", text }], details: { entries } };
962
- },
963
- renderCall(args, theme, context) {
964
- const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
965
- text.setText(formatGitLogCall(args as { directory?: unknown; maxCount?: unknown }, theme));
966
- return text;
967
- },
968
- renderResult(result, { expanded, isPartial }, theme, context) {
969
- if (isPartial) return new Text(theme.fg("warning", "Reading log..."), 0, 0);
970
- if (context.isError) {
971
- const errorText = result.content
972
- .filter((block) => block.type === "text")
973
- .map((block) => block.text)
974
- .join("\n");
975
- return new Text(theme.fg("error", errorText || "git_log failed"), 0, 0);
926
+ if (params.action === "log") {
927
+ if (params.maxCount === undefined) throw new Error("git action=log requires maxCount");
928
+ const entries = await gitOperations.log(directory, params.maxCount);
929
+ const text =
930
+ entries.length === 0 ? "No commits found." : entries.map((e) => `${e.sha.slice(0, 8)} ${e.authoredAt} ${e.authorName} -- ${e.message}`).join("\n");
931
+ const details: GitToolDetails = { action: "log", entries };
932
+ return { content: [{ type: "text", text }], details };
976
933
  }
977
- const details = result.details as { entries?: readonly GitLogEntry[] } | undefined;
978
- const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
979
- text.setText(formatGitLogResult(details?.entries, expanded, theme));
980
- return text;
981
- },
982
- });
983
-
984
- pi.registerTool({
985
- name: "git_diff",
986
- label: "Git Diff",
987
- description:
988
- "Unified diff of the working tree against `ref` (defaults to HEAD) for a real git repository, bounded to maxBytes. Fails clearly if `directory` is not inside a git repository.",
989
- promptSnippet: "Show a repository's working tree diff",
990
- parameters: Type.Object({
991
- directory: Type.String({ description: "Directory inside the repository to check, absolute or relative to the current working directory" }),
992
- ref: Type.Optional(Type.String({ description: "Ref to diff against; defaults to HEAD" })),
993
- maxBytes: Type.Number({ description: "Maximum diff size in bytes before truncating" }),
994
- }),
995
- async execute(_toolCallId, params) {
996
- const directory = resolve(cwd, params.directory);
997
- const result = await gitOperations.diff(directory, params.ref, params.maxBytes);
998
- return { content: [{ type: "text", text: result.diff.length === 0 ? "No differences." : result.diff }], details: { result } };
934
+ if (params.action === "diff") {
935
+ if (params.maxBytes === undefined) throw new Error("git action=diff requires maxBytes");
936
+ const result = await gitOperations.diff(directory, params.ref, params.maxBytes);
937
+ const details: GitToolDetails = { action: "diff", result };
938
+ return { content: [{ type: "text", text: result.diff.length === 0 ? "No differences." : result.diff }], details };
939
+ }
940
+ throw new Error(`unknown git action: ${String(params.action)}`);
999
941
  },
1000
942
  renderCall(args, theme, context) {
1001
943
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1002
- text.setText(formatGitDiffCall(args as { directory?: unknown; ref?: unknown }, theme));
944
+ text.setText(formatGitCall(args, theme));
1003
945
  return text;
1004
946
  },
1005
947
  renderResult(result, { expanded, isPartial }, theme, context) {
1006
- if (isPartial) return new Text(theme.fg("warning", "Computing diff..."), 0, 0);
948
+ if (isPartial) return new Text(theme.fg("warning", "Running git..."), 0, 0);
1007
949
  if (context.isError) {
1008
950
  const errorText = result.content
1009
951
  .filter((block) => block.type === "text")
1010
952
  .map((block) => block.text)
1011
953
  .join("\n");
1012
- return new Text(theme.fg("error", errorText || "git_diff failed"), 0, 0);
954
+ return new Text(theme.fg("error", errorText || "git failed"), 0, 0);
1013
955
  }
1014
- const details = result.details as { result?: GitDiffResult } | undefined;
956
+ const details = result.details as GitToolDetails | undefined;
1015
957
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1016
- text.setText(formatGitDiffResult(details?.result, expanded, theme));
958
+ text.setText(formatGitResult(details, expanded, theme));
1017
959
  return text;
1018
960
  },
1019
961
  });
@@ -1040,7 +982,7 @@ export default function (pi: ExtensionAPI) {
1040
982
  },
1041
983
  renderCall(args, theme, context) {
1042
984
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1043
- text.setText(formatSearchCall(args as { directory?: unknown; query?: unknown }, theme));
985
+ text.setText(formatSearchCall(args, theme));
1044
986
  return text;
1045
987
  },
1046
988
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -1086,7 +1028,7 @@ export default function (pi: ExtensionAPI) {
1086
1028
  },
1087
1029
  renderCall(args, theme, context) {
1088
1030
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1089
- text.setText(formatFindFilesCall(args as { directory?: unknown; patterns?: unknown }, theme));
1031
+ text.setText(formatFindFilesCall(args, theme));
1090
1032
  return text;
1091
1033
  },
1092
1034
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -1151,12 +1093,13 @@ export default function (pi: ExtensionAPI) {
1151
1093
  // The Pi tool schema can only express plain strings for hash fields (TypeBox has no
1152
1094
  // concept of Lector's branded LineHash) -- the daemon's own domain validation is the
1153
1095
  // real runtime check regardless of what TypeScript sees at this call site.
1096
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
1154
1097
  const result = await lineEditOperations.lineEdit(absolutePath, params.edits as unknown as LineEdit[]);
1155
1098
  return { content: [{ type: "text", text: `${result.path}: ${result.previousHash} -> ${result.newHash}` }], details: { result } };
1156
1099
  },
1157
1100
  renderCall(args, theme, context) {
1158
1101
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1159
- text.setText(formatLineEditCall(args as { path?: unknown; edits?: unknown }, theme));
1102
+ text.setText(formatLineEditCall(args, theme));
1160
1103
  return text;
1161
1104
  },
1162
1105
  renderResult(result, { isPartial }, theme, context) {
@@ -1197,12 +1140,13 @@ export default function (pi: ExtensionAPI) {
1197
1140
  const absolutePath = resolve(cwd, params.path);
1198
1141
  // TypeBox has no concept of Lector's branded ContentHash -- the daemon's own domain
1199
1142
  // validation is the real runtime check regardless of what TypeScript sees here.
1143
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
1200
1144
  const result = await applyPatchOperations.applyPatch(absolutePath, params.expectedHash as ContentHash, params.patchText);
1201
1145
  return { content: [{ type: "text", text: `${result.path}: ${result.previousHash ?? "(new)"} -> ${result.newHash}` }], details: { result } };
1202
1146
  },
1203
1147
  renderCall(args, theme, context) {
1204
1148
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1205
- text.setText(formatApplyPatchCall(args as { path?: unknown }, theme));
1149
+ text.setText(formatApplyPatchCall(args, theme));
1206
1150
  return text;
1207
1151
  },
1208
1152
  renderResult(result, { isPartial }, theme, context) {
@@ -1241,7 +1185,7 @@ export default function (pi: ExtensionAPI) {
1241
1185
  },
1242
1186
  renderCall(args, theme, context) {
1243
1187
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1244
- text.setText(formatPackageSourceCall(args as { directory?: unknown; name?: unknown; version?: unknown }, theme));
1188
+ text.setText(formatPackageSourceCall(args, theme));
1245
1189
  return text;
1246
1190
  },
1247
1191
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -1279,7 +1223,7 @@ export default function (pi: ExtensionAPI) {
1279
1223
  },
1280
1224
  renderCall(args, theme, context) {
1281
1225
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1282
- text.setText(formatRepoFetchCall(args as { owner?: unknown; repo?: unknown; ref?: unknown; host?: unknown }, theme));
1226
+ text.setText(formatRepoFetchCall(args, theme));
1283
1227
  return text;
1284
1228
  },
1285
1229
  renderResult(result, { isPartial }, theme, context) {
@@ -1317,7 +1261,7 @@ export default function (pi: ExtensionAPI) {
1317
1261
  },
1318
1262
  renderCall(args, theme, context) {
1319
1263
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1320
- text.setText(formatCrossWorkspaceCall(args as { directories?: unknown; query?: unknown }, theme));
1264
+ text.setText(formatCrossWorkspaceCall(args, theme));
1321
1265
  return text;
1322
1266
  },
1323
1267
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -1356,7 +1300,7 @@ export default function (pi: ExtensionAPI) {
1356
1300
  },
1357
1301
  renderCall(args, theme, context) {
1358
1302
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1359
- text.setText(formatCrossWorkspaceCall(args as { directories?: unknown; query?: unknown }, theme));
1303
+ text.setText(formatCrossWorkspaceCall(args, theme));
1360
1304
  return text;
1361
1305
  },
1362
1306
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -1,8 +1,9 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
- import { dirname, parse } from "node:path";
2
+ import { dirname, extname, parse } from "node:path";
3
3
  import { createRetryingClient, type RetryingClient } from "@danypops/daemon-kit/pi-client";
4
4
  import {
5
5
  connectLectorClient,
6
+ descriptorForExtension,
6
7
  type LectorClient,
7
8
  type OperationInputs,
8
9
  type OperationName,
@@ -10,7 +11,7 @@ import {
10
11
  remoteErrorIs,
11
12
  type WorkspaceId,
12
13
  } from "@danypops/lector";
13
- import { nearestGitRoot } from "./nearest-workspace-root.ts";
14
+ import { nearestGitRoot, nearestProjectRoot } from "./nearest-workspace-root.ts";
14
15
 
15
16
  /**
16
17
  * Lazily connects to a running Lector daemon and caches, per project root,
@@ -43,9 +44,8 @@ export interface RetryingLectorClient {
43
44
  }
44
45
 
45
46
  // Kept async even though its own body has no await: every call site across this package does
46
- // `await lectorClient()`, and dropping async here (just to satisfy require-await) would turn an
47
- // internal implementation detail into a signature change rippling through every one of them.
48
- // eslint-disable-next-line @typescript-eslint/require-await
47
+ // `await lectorClient()`, and dropping async here would turn an internal implementation detail
48
+ // into a signature change rippling through every one of them.
49
49
  export async function lectorClient(): Promise<RetryingLectorClient> {
50
50
  return {
51
51
  call: (operation, input) => retryingClient.call((client) => client.call(operation, input)),
@@ -107,9 +107,17 @@ export function workspaceForDirectory(directory: string): Promise<ResolvedWorksp
107
107
  * whose filesystem-root fallback would point a real server at scanning the
108
108
  * whole disk. Falls back to the file's own containing directory instead,
109
109
  * same bound as workspaceForDirectory.
110
+ *
111
+ * Unlike workspaceForDirectory, prefers the file's own language's root markers
112
+ * (tsconfig.json, go.mod, Cargo.toml, ...) over the nearest .git when both exist --
113
+ * a monorepo subproject's own root marker is nearer and wins, so its language server
114
+ * gets that subproject's rootUri instead of the whole repo's.
110
115
  */
111
116
  export function workspaceForCodeIntelligencePath(absolutePath: string): Promise<ResolvedWorkspace> {
112
- return workspaceForDirectory(dirname(absolutePath));
117
+ const directory = dirname(absolutePath);
118
+ const descriptor = descriptorForExtension(extname(absolutePath));
119
+ const root = descriptor ? (nearestProjectRoot(directory, descriptor.rootMarkers) ?? directory) : (nearestGitRoot(directory) ?? directory);
120
+ return workspaceForRoot(root);
113
121
  }
114
122
 
115
123
  /**
@@ -21,14 +21,30 @@ import { dirname, join, parse } from "node:path";
21
21
  * workspace root" error -- discovered live, in a separate session, working
22
22
  * against a completely different, unrelated repository.)
23
23
  */
24
- export function nearestGitRoot(startDirectory: string): string | undefined {
24
+ function walkUpForMarkers(startDirectory: string, markers: readonly string[]): string | undefined {
25
25
  let dir = startDirectory;
26
26
  const fsRoot = parse(dir).root;
27
27
  while (dir !== fsRoot) {
28
- if (existsSync(join(dir, ".git"))) return dir;
28
+ if (markers.some((marker) => existsSync(join(dir, marker)))) return dir;
29
29
  const parent = dirname(dir);
30
30
  if (parent === dir) break; // defensive: dirname must be strictly ascending
31
31
  dir = parent;
32
32
  }
33
- return existsSync(join(fsRoot, ".git")) ? fsRoot : undefined;
33
+ return markers.some((marker) => existsSync(join(fsRoot, marker))) ? fsRoot : undefined;
34
+ }
35
+
36
+ export function nearestGitRoot(startDirectory: string): string | undefined {
37
+ return walkUpForMarkers(startDirectory, [".git"]);
38
+ }
39
+
40
+ /**
41
+ * Same nearest-enclosing-root walk as nearestGitRoot, but also checks a language's own root
42
+ * markers (tsconfig.json, go.mod, Cargo.toml, ...) at each directory, nearest first -- so a
43
+ * monorepo subproject with its own root marker resolves to itself, not the outer repo's .git.
44
+ * Found via @arvoretech/pi-lsp comparison: without this, a file inside a monorepo subproject
45
+ * misattributes its whole project to the repo root, handing the language server the wrong
46
+ * rootUri (and, for TypeScript, the wrong tsconfig.json) even though a closer one exists.
47
+ */
48
+ export function nearestProjectRoot(startDirectory: string, rootMarkers: readonly string[]): string | undefined {
49
+ return walkUpForMarkers(startDirectory, [...rootMarkers, ".git"]);
34
50
  }
@@ -25,7 +25,7 @@ export interface SymbolAnnotationOperations {
25
25
  get(path: string, id: string): Promise<OperationOutputs["workspace.getAnnotation"]>;
26
26
  list(
27
27
  path: string,
28
- options?: { subtype?: string; status?: OperationInputs["workspace.listAnnotations"]["status"]; maxResults?: number },
28
+ options?: { subtype?: string; status?: OperationInputs["workspace.listAnnotations"]["status"]; maxResults?: number; query?: string },
29
29
  ): Promise<OperationOutputs["workspace.listAnnotations"]>;
30
30
  refresh(
31
31
  path: string,
@@ -37,6 +37,9 @@ export interface SymbolAnnotationOperations {
37
37
  ): Promise<OperationOutputs["workspace.refreshAnnotation"]>;
38
38
  scrub(path: string, id: string): Promise<OperationOutputs["workspace.scrubAnnotation"]>;
39
39
  restore(path: string, id: string): Promise<OperationOutputs["workspace.restoreAnnotation"]>;
40
+ contain(path: string, parentId: string, childId: string): Promise<OperationOutputs["workspace.containAnnotation"]>;
41
+ uncontain(path: string, parentId: string, childId: string): Promise<OperationOutputs["workspace.uncontainAnnotation"]>;
42
+ tree(path: string, rootId: string, maxDepth: number): Promise<OperationOutputs["workspace.annotationTree"]>;
40
43
  }
41
44
 
42
45
  export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperations {
@@ -64,7 +67,13 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
64
67
  () => workspaceForCodeIntelligencePath(path),
65
68
  async ({ workspaceId }) => {
66
69
  const client = await lectorClient();
67
- return client.call("workspace.listAnnotations", { workspaceId, subtype: options.subtype, status: options.status, maxResults: options.maxResults });
70
+ return client.call("workspace.listAnnotations", {
71
+ workspaceId,
72
+ subtype: options.subtype,
73
+ status: options.status,
74
+ maxResults: options.maxResults,
75
+ query: options.query,
76
+ });
68
77
  },
69
78
  );
70
79
  },
@@ -95,5 +104,32 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
95
104
  },
96
105
  );
97
106
  },
107
+ async contain(path, parentId, childId) {
108
+ return withWorkspace(
109
+ () => workspaceForCodeIntelligencePath(path),
110
+ async ({ workspaceId }) => {
111
+ const client = await lectorClient();
112
+ return client.call("workspace.containAnnotation", { workspaceId, parentId, childId });
113
+ },
114
+ );
115
+ },
116
+ async uncontain(path, parentId, childId) {
117
+ return withWorkspace(
118
+ () => workspaceForCodeIntelligencePath(path),
119
+ async ({ workspaceId }) => {
120
+ const client = await lectorClient();
121
+ return client.call("workspace.uncontainAnnotation", { workspaceId, parentId, childId });
122
+ },
123
+ );
124
+ },
125
+ async tree(path, rootId, maxDepth) {
126
+ return withWorkspace(
127
+ () => workspaceForCodeIntelligencePath(path),
128
+ async ({ workspaceId }) => {
129
+ const client = await lectorClient();
130
+ return client.call("workspace.annotationTree", { workspaceId, rootId, maxDepth });
131
+ },
132
+ );
133
+ },
98
134
  };
99
135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Pi host adapter for Lector: overrides read/write/edit with a daemon-backed, hash-guarded filesystem",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,8 +18,8 @@
18
18
  "typebox": "*"
19
19
  },
20
20
  "dependencies": {
21
- "@danypops/daemon-kit": "^0.4.0",
22
- "@danypops/lector": "^0.1.15"
21
+ "@danypops/daemon-kit": "^0.22.1",
22
+ "@danypops/lector": "^0.3.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@earendil-works/pi-ai": "^0.81.1",