@danypops/pi-lector 0.2.2 → 0.4.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.
@@ -1,21 +1,17 @@
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,
13
+ MutationHistoryEntry,
14
+ OperationOutputs,
19
15
  PackageSourceOperationResult,
20
16
  PopulateSymbolGraphResult,
21
17
  RepoFetchResult,
@@ -27,14 +23,23 @@ import type {
27
23
  WorkspaceMapResult,
28
24
  WorkspaceQueryOutcome,
29
25
  } from "@danypops/lector";
30
- import { createEditToolDefinition, createReadToolDefinition, createWriteToolDefinition, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+ import {
27
+ type AgentToolResult,
28
+ createEditToolDefinition,
29
+ createReadToolDefinition,
30
+ createWriteToolDefinition,
31
+ type ExtensionAPI,
32
+ } from "@earendil-works/pi-coding-agent";
31
33
  import { Text } from "@earendil-works/pi-tui";
32
34
  import { Type } from "typebox";
33
35
  import { createLectorApplyPatchOperations } from "./apply-patch-operations.ts";
34
36
  import { formatApplyPatchCall, formatApplyPatchResult } from "./apply-patch-rendering.ts";
35
37
  import { createLectorCodeIntelligenceOperations } from "./code-intelligence-operations.ts";
36
38
  import {
39
+ type CallHierarchyToolDetails,
37
40
  describePopulateSymbolGraphJob,
41
+ formatCallHierarchyCall,
42
+ formatCallHierarchyResult,
38
43
  formatDiagnosticsCall,
39
44
  formatDiagnosticsResult,
40
45
  formatDocumentSymbolsCall,
@@ -47,14 +52,8 @@ import {
47
52
  formatGoToImplementationResult,
48
53
  formatHoverCall,
49
54
  formatHoverResult,
50
- formatIncomingCallsCall,
51
- formatIncomingCallsResult,
52
- formatOutgoingCallsCall,
53
- formatOutgoingCallsResult,
54
55
  formatPopulateSymbolGraphCall,
55
56
  formatPopulateSymbolGraphResult,
56
- formatPrepareCallHierarchyCall,
57
- formatPrepareCallHierarchyResult,
58
57
  formatReachableFromCall,
59
58
  formatReachableFromResult,
60
59
  formatWorkspaceMapCall,
@@ -68,13 +67,18 @@ import { formatFindFilesCall, formatFindFilesResult } from "./find-files-renderi
68
67
  import { createLectorFindSymbolsOperations } from "./find-symbols-operations.ts";
69
68
  import { describeFindSymbolSources, formatFindSymbolsCall, formatFindSymbolsResult } from "./find-symbols-rendering.ts";
70
69
  import { createLectorGitOperations } from "./git-operations.ts";
71
- import { formatGitDiffCall, formatGitDiffResult, formatGitLogCall, formatGitLogResult, formatGitStatusCall, formatGitStatusResult } from "./git-rendering.ts";
70
+ import { formatGitCall, formatGitResult, type GitToolDetails } from "./git-rendering.ts";
71
+ import { setNewWorkspaceObserver } from "./lector-client.ts";
72
72
  import { createLectorLineEditOperations } from "./line-edit-operations.ts";
73
73
  import { formatLineEditCall, formatLineEditResult } from "./line-edit-rendering.ts";
74
+ import { createMutationHistoryOperations } from "./mutation-history-operations.ts";
74
75
  import { nearestGitRoot } from "./nearest-workspace-root.ts";
75
76
  import { createLectorPackageSourceOperations } from "./package-source-operations.ts";
76
77
  import { formatPackageSourceCall, formatPackageSourceResult } from "./package-source-rendering.ts";
77
78
  import { createLectorReadOperations } from "./read-operations.ts";
79
+ import { createReferenceBasedRenameOperations } from "./reference-based-rename-operations.ts";
80
+ import { createRenameOperations } from "./rename-operations.ts";
81
+ import { createRepoCacheListOperations } from "./repo-cache-list-operations.ts";
78
82
  import { createLectorRepoFetchOperations } from "./repo-fetch-operations.ts";
79
83
  import { formatRepoFetchCall, formatRepoFetchResult } from "./repo-fetch-rendering.ts";
80
84
  import { createLectorSearchOperations } from "./search-operations.ts";
@@ -118,63 +122,115 @@ function renderIntelligenceSource(body: string, provenance: IntelligenceProvenan
118
122
  */
119
123
  export default function (pi: ExtensionAPI) {
120
124
  const cacheOperations = createWorkspaceCacheOperations();
121
- let cacheRun = 0;
122
- let cacheState: CachePresentationState | undefined;
123
- let lastInjectedCacheState: string | undefined;
125
+ // One generation counter shared by every root's monitor loop, not per-root -- a new session
126
+ // (or shutdown) invalidates every previous session's in-flight monitor regardless of which
127
+ // root it tracked, and there is exactly one "current session" at a time.
128
+ let sessionGeneration = 0;
129
+ // Every workspace root actually touched so far this session, not just one fixed cwd root --
130
+ // populated by setNewWorkspaceObserver below, the real "first touch" trigger.
131
+ const cacheStatesByRoot = new Map<string, CachePresentationState>();
132
+ // Roots already monitored this session -- guards against starting the SAME root's monitor
133
+ // twice: session_start's own direct kick-off for the cwd root itself calls
134
+ // cacheOperations.status(), which registers that root via workspace.registerPath, which fires
135
+ // setNewWorkspaceObserver for it a moment later -- without this guard that would start a
136
+ // second, redundant concurrent monitor loop for the exact same root.
137
+ const monitoringRoots = new Set<string>();
138
+ let lastInjectedSummary: string | undefined;
139
+ let uiContext: Parameters<Parameters<ExtensionAPI["on"]>[1]>[1] | undefined;
140
+
141
+ function combinedSummary(): string {
142
+ const states = [...cacheStatesByRoot.values()];
143
+ if (states.length === 0) return "";
144
+ const [only] = states;
145
+ if (states.length === 1 && only) return describeCacheState(only);
146
+ const counts = new Map<string, number>();
147
+ for (const state of states) counts.set(state.status, (counts.get(state.status) ?? 0) + 1);
148
+ return [...counts.entries()].map(([status, count]) => `${count} ${status}`).join(", ");
149
+ }
150
+
151
+ function refreshStatusBar(): void {
152
+ if (!uiContext) return;
153
+ const summary = combinedSummary();
154
+ if (!summary) {
155
+ uiContext.ui.setStatus("lector-cache", undefined);
156
+ return;
157
+ }
158
+ const states = [...cacheStatesByRoot.values()];
159
+ const worst = states.some((state) => state.status === "not-cached" || state.status === "caching")
160
+ ? "warning"
161
+ : states.every((state) => state.status === "cached")
162
+ ? "success"
163
+ : "accent";
164
+ uiContext.ui.setStatus("lector-cache", uiContext.ui.theme.fg(worst, `Lector: ${summary}`));
165
+ }
166
+
167
+ /** Starts (or restarts, on a stale generation) monitoring one workspace root's cache lifecycle -- shared by session_start's own cwd root and every later root a tool call first touches. */
168
+ function startMonitoringRoot(root: string, ctx: Parameters<Parameters<ExtensionAPI["on"]>[1]>[1]): void {
169
+ if (monitoringRoots.has(root)) return;
170
+ monitoringRoots.add(root);
171
+ const thisGeneration = sessionGeneration;
172
+ void monitorWorkspaceCache(cacheOperations, {
173
+ directory: root,
174
+ maxFiles: 500,
175
+ maxSymbolsPerFile: 100,
176
+ pollIntervalMs: 1_000,
177
+ maxPolls: 300,
178
+ shouldContinue: () => sessionGeneration === thisGeneration,
179
+ onState: (state) => {
180
+ if (sessionGeneration !== thisGeneration) return;
181
+ cacheStatesByRoot.set(root, state);
182
+ if (state.status === "finished-caching") {
183
+ if (ctx.hasUI) ctx.ui.notify(`Lector finished caching ${root}`, "info");
184
+ return;
185
+ }
186
+ refreshStatusBar();
187
+ },
188
+ }).catch((error: unknown) => {
189
+ if (sessionGeneration !== thisGeneration) return;
190
+ const message = error instanceof Error ? error.message : String(error);
191
+ cacheStatesByRoot.delete(root);
192
+ refreshStatusBar();
193
+ if (ctx.hasUI) ctx.ui.notify(`Lector cache failed for ${root}: ${message}`, "error");
194
+ });
195
+ }
124
196
 
125
197
  pi.on("before_agent_start", () => {
126
- if (!cacheState) return;
127
- const description = describeCacheState(cacheState);
128
- if (description === lastInjectedCacheState) return;
129
- lastInjectedCacheState = description;
198
+ const summary = combinedSummary();
199
+ if (!summary || summary === lastInjectedSummary) return;
200
+ lastInjectedSummary = summary;
201
+ const messages = [...cacheStatesByRoot.entries()]
202
+ .filter(([, state]) => state.status !== "cached")
203
+ .map(([root, state]) => `${root}: ${cacheContextMessage(state)}`);
204
+ if (messages.length === 0) return;
130
205
  return {
131
206
  message: {
132
207
  customType: "lector-cache-status",
133
- content: cacheContextMessage(cacheState),
208
+ content: messages.join("\n"),
134
209
  display: false,
135
210
  },
136
211
  };
137
212
  });
138
213
 
139
214
  pi.on("session_shutdown", (_event, ctx) => {
140
- cacheRun++;
141
- cacheState = undefined;
142
- lastInjectedCacheState = undefined;
215
+ sessionGeneration++;
216
+ cacheStatesByRoot.clear();
217
+ monitoringRoots.clear();
218
+ lastInjectedSummary = undefined;
219
+ uiContext = undefined;
143
220
  ctx.ui.setStatus("lector-cache", undefined);
144
221
  });
145
222
 
146
223
  pi.on("session_start", (_event, ctx) => {
147
224
  const { cwd } = ctx;
225
+ sessionGeneration++;
226
+ cacheStatesByRoot.clear();
227
+ monitoringRoots.clear();
228
+ lastInjectedSummary = undefined;
229
+ uiContext = ctx;
230
+ setNewWorkspaceObserver((root) => startMonitoringRoot(root, ctx));
148
231
  const projectRoot = nearestGitRoot(cwd);
149
- const thisRun = ++cacheRun;
150
- cacheState = undefined;
151
- lastInjectedCacheState = undefined;
152
- if (projectRoot) {
153
- void monitorWorkspaceCache(cacheOperations, {
154
- directory: projectRoot,
155
- maxFiles: 500,
156
- maxSymbolsPerFile: 100,
157
- pollIntervalMs: 1_000,
158
- maxPolls: 300,
159
- shouldContinue: () => cacheRun === thisRun,
160
- onState: (state) => {
161
- cacheState = state;
162
- if (state.status === "finished-caching") {
163
- if (ctx.hasUI) ctx.ui.notify(`Lector finished caching ${projectRoot}`, "info");
164
- return;
165
- }
166
- const color = state.status === "cached" ? "success" : state.status === "caching" ? "accent" : "warning";
167
- ctx.ui.setStatus("lector-cache", ctx.ui.theme.fg(color, `Lector: ${describeCacheState(state)}`));
168
- },
169
- }).catch((error: unknown) => {
170
- if (cacheRun !== thisRun) return;
171
- const message = error instanceof Error ? error.message : String(error);
172
- ctx.ui.setStatus("lector-cache", ctx.ui.theme.fg("error", "Lector: cache error"));
173
- if (ctx.hasUI) ctx.ui.notify(`Lector cache failed: ${message}`, "error");
174
- });
175
- } else {
176
- ctx.ui.setStatus("lector-cache", undefined);
177
- }
232
+ if (projectRoot) startMonitoringRoot(projectRoot, ctx);
233
+ else ctx.ui.setStatus("lector-cache", undefined);
178
234
 
179
235
  pi.registerTool(createReadToolDefinition(cwd, { operations: createLectorReadOperations() }));
180
236
  pi.registerTool(createWriteToolDefinition(cwd, { operations: createLectorWriteOperations() }));
@@ -221,7 +277,7 @@ export default function (pi: ExtensionAPI) {
221
277
  },
222
278
  renderCall(args, theme, context) {
223
279
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
224
- text.setText(formatFindSymbolsCall(args as { query?: unknown; directory?: unknown }, theme));
280
+ text.setText(formatFindSymbolsCall(args, theme));
225
281
  return text;
226
282
  },
227
283
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -236,7 +292,7 @@ export default function (pi: ExtensionAPI) {
236
292
  return new Text(theme.fg("error", errorText || "find_symbols failed"), 0, 0);
237
293
  }
238
294
  const details = result.details as SymbolSearchResult | undefined;
239
- const query = typeof context.args?.query === "string" ? context.args.query : "";
295
+ const query = typeof context.args.query === "string" ? context.args.query : "";
240
296
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
241
297
  text.setText(formatFindSymbolsResult(details, query, expanded, theme));
242
298
  return text;
@@ -244,6 +300,8 @@ export default function (pi: ExtensionAPI) {
244
300
  });
245
301
 
246
302
  const codeIntelligenceOperations = createLectorCodeIntelligenceOperations();
303
+ const referenceBasedRenameOperations = createReferenceBasedRenameOperations();
304
+ const renameOperations = createRenameOperations();
247
305
  const positionParameters = {
248
306
  path: Type.String({ description: "Absolute or cwd-relative path to the file" }),
249
307
  line: Type.Number({ description: "1-indexed line number" }),
@@ -265,7 +323,7 @@ export default function (pi: ExtensionAPI) {
265
323
  },
266
324
  renderCall(args, theme, context) {
267
325
  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));
326
+ text.setText(formatGoToDefinitionCall(args, theme));
269
327
  return text;
270
328
  },
271
329
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -303,7 +361,7 @@ export default function (pi: ExtensionAPI) {
303
361
  },
304
362
  renderCall(args, theme, context) {
305
363
  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));
364
+ text.setText(formatGoToImplementationCall(args, theme));
307
365
  return text;
308
366
  },
309
367
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -348,7 +406,7 @@ export default function (pi: ExtensionAPI) {
348
406
  },
349
407
  renderCall(args, theme, context) {
350
408
  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));
409
+ text.setText(formatFindReferencesCall(args, theme));
352
410
  return text;
353
411
  },
354
412
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -388,7 +446,7 @@ export default function (pi: ExtensionAPI) {
388
446
  },
389
447
  renderCall(args, theme, context) {
390
448
  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));
449
+ text.setText(formatHoverCall(args, theme));
392
450
  return text;
393
451
  },
394
452
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -422,7 +480,7 @@ export default function (pi: ExtensionAPI) {
422
480
  },
423
481
  renderCall(args, theme, context) {
424
482
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
425
- text.setText(formatDocumentSymbolsCall(args as { path?: unknown }, theme));
483
+ text.setText(formatDocumentSymbolsCall(args, theme));
426
484
  return text;
427
485
  },
428
486
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -459,7 +517,7 @@ export default function (pi: ExtensionAPI) {
459
517
  },
460
518
  renderCall(args, theme, context) {
461
519
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
462
- text.setText(formatDiagnosticsCall(args as { path?: unknown }, theme));
520
+ text.setText(formatDiagnosticsCall(args, theme));
463
521
  return text;
464
522
  },
465
523
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -479,118 +537,67 @@ export default function (pi: ExtensionAPI) {
479
537
  });
480
538
 
481
539
  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",
540
+ name: "call_hierarchy",
541
+ label: "Call Hierarchy",
542
+ description:
543
+ "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).",
544
+ promptSnippet: "Resolve a position to a call-hierarchy root, or find its callers/callees",
486
545
  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.",
546
+ "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.",
547
+ "direction=incoming finds real callers, distinct from find_references which also finds non-call usages like type positions or re-exports.",
488
548
  ],
489
- parameters: Type.Object(positionParameters),
490
- async execute(_toolCallId, params) {
549
+ parameters: Type.Object({
550
+ direction: Type.String({ description: "prepare | incoming | outgoing" }),
551
+ ...positionParameters,
552
+ }),
553
+ async execute(_toolCallId, params): Promise<AgentToolResult<CallHierarchyToolDetails>> {
491
554
  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);
555
+ if (params.direction === "prepare") {
556
+ const { items, provenance } = await codeIntelligenceOperations.prepareCallHierarchy(path, params.line, params.character);
557
+ const text =
558
+ items.length === 0
559
+ ? "No call-hierarchy root at this position."
560
+ : items.map((i) => `${i.kind} ${i.name} -- ${i.location.path}:${i.location.line}:${i.location.character}`).join("\n");
561
+ const details: CallHierarchyToolDetails = { direction: "prepare", items, provenance };
562
+ return { content: [{ type: "text", text: `${describeIntelligenceSource(provenance)}\n${text}` }], details };
512
563
  }
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);
564
+ if (params.direction === "incoming") {
565
+ const { calls, provenance } = await codeIntelligenceOperations.incomingCalls(path, params.line, params.character);
566
+ const text =
567
+ calls.length === 0
568
+ ? "No incoming calls found."
569
+ : calls.map((c) => `${c.from.kind} ${c.from.name} -- ${c.from.location.path}:${c.from.location.line}:${c.from.location.character}`).join("\n");
570
+ const details: CallHierarchyToolDetails = { direction: "incoming", calls, provenance };
571
+ return { content: [{ type: "text", text: `${describeIntelligenceSource(provenance)}\n${text}` }], details };
553
572
  }
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 };
573
+ if (params.direction === "outgoing") {
574
+ const { calls, provenance } = await codeIntelligenceOperations.outgoingCalls(path, params.line, params.character);
575
+ const text =
576
+ calls.length === 0
577
+ ? "No outgoing calls found."
578
+ : calls.map((c) => `${c.to.kind} ${c.to.name} -- ${c.to.location.path}:${c.to.location.line}:${c.to.location.character}`).join("\n");
579
+ const details: CallHierarchyToolDetails = { direction: "outgoing", calls, provenance };
580
+ return { content: [{ type: "text", text: `${describeIntelligenceSource(provenance)}\n${text}` }], details };
581
+ }
582
+ throw new Error(`unknown call_hierarchy direction: ${String(params.direction)}`);
576
583
  },
577
584
  renderCall(args, theme, context) {
578
585
  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));
586
+ text.setText(formatCallHierarchyCall(args, theme));
580
587
  return text;
581
588
  },
582
589
  renderResult(result, { expanded, isPartial }, theme, context) {
583
- if (isPartial) return new Text(theme.fg("warning", "Searching for callees..."), 0, 0);
590
+ if (isPartial) return new Text(theme.fg("warning", "Resolving call hierarchy..."), 0, 0);
584
591
  if (context.isError) {
585
592
  const errorText = result.content
586
593
  .filter((block) => block.type === "text")
587
594
  .map((block) => block.text)
588
595
  .join("\n");
589
- return new Text(theme.fg("error", errorText || "outgoing_calls failed"), 0, 0);
596
+ return new Text(theme.fg("error", errorText || "call_hierarchy failed"), 0, 0);
590
597
  }
591
- const details = result.details as { calls?: readonly OutgoingCall[]; provenance?: IntelligenceProvenance } | undefined;
598
+ const details = result.details as CallHierarchyToolDetails | undefined;
592
599
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
593
- text.setText(renderIntelligenceSource(formatOutgoingCallsResult(details?.calls, expanded, theme), details?.provenance, theme));
600
+ text.setText(renderIntelligenceSource(formatCallHierarchyResult(details, expanded, theme), details?.provenance, theme));
594
601
  return text;
595
602
  },
596
603
  });
@@ -599,7 +606,7 @@ export default function (pi: ExtensionAPI) {
599
606
  name: "populate_symbol_graph",
600
607
  label: "Populate Symbol Graph",
601
608
  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.",
609
+ "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
610
  promptSnippet: "Populate a workspace's symbol graph for multi-hop queries",
604
611
  promptGuidelines: [
605
612
  "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 +629,7 @@ export default function (pi: ExtensionAPI) {
622
629
  },
623
630
  renderCall(args, theme, context) {
624
631
  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));
632
+ text.setText(formatPopulateSymbolGraphCall(args, theme));
626
633
  return text;
627
634
  },
628
635
  renderResult(result, { isPartial }, theme, context) {
@@ -676,6 +683,125 @@ export default function (pi: ExtensionAPI) {
676
683
  },
677
684
  });
678
685
 
686
+ pi.registerTool({
687
+ name: "reference_based_rename",
688
+ label: "Reference-Based Rename",
689
+ description:
690
+ "Move/rename a file and rewrite every static import/export specifier the workspace's own populated symbol graph knows references it -- atomically, rolled back entirely on any failure. Non-LSP: uses find_references + a real parse of import/export declarations, not a language server's own rename. Refuses outright (touches nothing) unless the workspace's symbol graph is fully populated and current for the given bounds -- a partial rename that silently misses a reference is worse than refusing (Sourcegraph's CodeScaleBench finding). Does not follow dynamic import(expr)/require(expr) or any plain string reference to the file -- always check the returned caveats.",
691
+ promptSnippet: "Move a file and update every import that references it",
692
+ promptGuidelines: [
693
+ "Run populate_symbol_graph for this workspace first if cache_status/has_warm_index doesn't already show a fully cached (never partial) graph -- reference_based_rename refuses outright otherwise.",
694
+ "Always read the returned caveats: this never rewrites a dynamic import(expr)/require(expr) or a plain string reference to the old path, even if one exists.",
695
+ ],
696
+ parameters: Type.Object({
697
+ fromPath: Type.String({ description: "Absolute or cwd-relative path to the file to move" }),
698
+ toPath: Type.String({ description: "Absolute or cwd-relative path for its new location" }),
699
+ maxFiles: Type.Number({ description: "Maximum number of source files the workspace's symbol graph must have scanned" }),
700
+ maxSymbolsPerFile: Type.Number({ description: "Maximum number of declarations per file the workspace's symbol graph must have processed" }),
701
+ }),
702
+ async execute(_toolCallId, params) {
703
+ const fromPath = resolve(cwd, params.fromPath);
704
+ const toPath = resolve(cwd, params.toPath);
705
+ const outcome = await referenceBasedRenameOperations.rename(fromPath, toPath, params.maxFiles, params.maxSymbolsPerFile);
706
+ const lines = [
707
+ `moved to ${outcome.movedTo}`,
708
+ outcome.filesUpdated.length === 0
709
+ ? "no other files referenced it"
710
+ : `updated imports in ${outcome.filesUpdated.length} file(s): ${outcome.filesUpdated.join(", ")}`,
711
+ ...outcome.caveats.map((caveat) => `caveat: ${caveat}`),
712
+ ];
713
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { outcome } };
714
+ },
715
+ renderCall(args, theme, context) {
716
+ const fromPath = typeof args.fromPath === "string" ? args.fromPath : "";
717
+ const toPath = typeof args.toPath === "string" ? args.toPath : "";
718
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
719
+ text.setText(
720
+ `${theme.fg("toolTitle", theme.bold("reference_based_rename"))} ${theme.fg("accent", fromPath)} ${theme.fg("dim", "->")} ${theme.fg("accent", toPath)}`,
721
+ );
722
+ return text;
723
+ },
724
+ renderResult(result, { isPartial }, theme, context) {
725
+ if (isPartial) return new Text(theme.fg("warning", "Renaming..."), 0, 0);
726
+ if (context.isError) {
727
+ const errorText = result.content
728
+ .filter((block) => block.type === "text")
729
+ .map((block) => block.text)
730
+ .join("\n");
731
+ return new Text(theme.fg("error", errorText || "reference_based_rename failed"), 0, 0);
732
+ }
733
+ const details = result.details as { outcome?: { movedTo: string; filesUpdated: readonly string[] } } | undefined;
734
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
735
+ text.setText(
736
+ details?.outcome
737
+ ? `${theme.fg("success", "moved")} ${theme.fg("accent", details.outcome.movedTo)} ${theme.fg("dim", `(${details.outcome.filesUpdated.length} import(s) updated)`)}`
738
+ : theme.fg("success", "rename complete"),
739
+ );
740
+ return text;
741
+ },
742
+ });
743
+
744
+ interface RenameToolDetails {
745
+ prepared?: OperationOutputs["workspace.prepareRename"];
746
+ applied?: OperationOutputs["workspace.rename"];
747
+ }
748
+
749
+ pi.registerTool({
750
+ name: "rename",
751
+ label: "Rename",
752
+ description:
753
+ "LSP-driven rename via the negotiated language server's own textDocument/prepareRename and textDocument/rename -- the semantic sibling of reference_based_rename, cross-file and identity-aware (resolves through re-exports/aliasing, not just static import specifiers), but only where the workspace's language server actually implements rename. prepare checks whether the symbol at a position can be renamed at all before committing; apply requests the rename and applies the server's own WorkspaceEdit atomically across every file it touches, rolled back entirely on any failure. Actions: prepare, apply.",
754
+ promptSnippet: "Rename a symbol everywhere it's used, via the language server",
755
+ promptGuidelines: [
756
+ "Call prepare first when unsure a position is renameable -- a null range means nothing to rename there, not an error.",
757
+ "apply fails outright if the negotiated server never advertised rename support -- reference_based_rename is the non-LSP fallback for that case.",
758
+ ],
759
+ parameters: Type.Object({
760
+ action: Type.String({ description: "prepare | apply" }),
761
+ ...positionParameters,
762
+ newName: Type.Optional(Type.String({ description: "Required for apply" })),
763
+ }),
764
+ async execute(_toolCallId, params): Promise<{ content: [{ type: "text"; text: string }]; details: RenameToolDetails }> {
765
+ const path = resolve(cwd, params.path);
766
+ if (params.action === "prepare") {
767
+ const prepared = await renameOperations.prepareRename(path, params.line, params.character);
768
+ const text = prepared.range
769
+ ? `renameable${prepared.range.placeholder ? `: "${prepared.range.placeholder}"` : ""}`
770
+ : "nothing renameable at this position";
771
+ return { content: [{ type: "text", text }], details: { prepared } };
772
+ }
773
+ if (params.action === "apply") {
774
+ if (!params.newName) throw new Error("rename apply requires newName");
775
+ const applied = await renameOperations.rename(path, params.line, params.character, params.newName);
776
+ const text = `renamed to "${params.newName}" -- updated ${applied.touchedPaths.length} file(s): ${applied.touchedPaths.join(", ")}`;
777
+ return { content: [{ type: "text", text }], details: { applied } };
778
+ }
779
+ throw new Error(`unknown rename action "${params.action}" -- expected prepare or apply`);
780
+ },
781
+ renderCall(args, theme, context) {
782
+ const action = typeof args.action === "string" ? args.action : "";
783
+ const path = typeof args.path === "string" ? args.path : "";
784
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
785
+ text.setText(`${theme.fg("toolTitle", theme.bold("rename"))} ${theme.fg("dim", action)} ${theme.fg("accent", path)}`);
786
+ return text;
787
+ },
788
+ renderResult(result, { isPartial }, theme, context) {
789
+ if (isPartial) return new Text(theme.fg("warning", "Renaming..."), 0, 0);
790
+ if (context.isError) {
791
+ const errorText = result.content
792
+ .filter((block) => block.type === "text")
793
+ .map((block) => block.text)
794
+ .join("\n");
795
+ return new Text(theme.fg("error", errorText || "rename failed"), 0, 0);
796
+ }
797
+ const text = result.content
798
+ .filter((block) => block.type === "text")
799
+ .map((block) => block.text)
800
+ .join("\n");
801
+ return new Text(theme.fg("success", text), 0, 0);
802
+ },
803
+ });
804
+
679
805
  const symbolAnnotationOperations = createLectorSymbolAnnotationOperations();
680
806
  function resolveAnchorInputs(anchors: readonly { path: string; line: number; character: number }[]): AnnotationAnchorInput[] {
681
807
  return anchors.map((anchor) => ({ path: resolve(cwd, anchor.path), line: anchor.line, character: anchor.character }));
@@ -685,20 +811,24 @@ export default function (pi: ExtensionAPI) {
685
811
  annotations?: readonly SymbolAnnotation[];
686
812
  scrubbed?: boolean;
687
813
  restored?: boolean;
814
+ contained?: boolean;
815
+ uncontained?: boolean;
688
816
  }
689
817
 
690
818
  pi.registerTool({
691
819
  name: "symbol_annotations",
692
820
  label: "Symbol Annotations",
693
821
  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.',
822
+ '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
823
  promptSnippet: "Attach, read, or invalidate narrative annotations on the symbol graph",
696
824
  promptGuidelines: [
697
825
  "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
826
  "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.",
827
+ "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.",
828
+ "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
829
  ],
700
830
  parameters: Type.Object({
701
- action: Type.String({ description: "create | get | list | refresh | scrub | restore" }),
831
+ action: Type.String({ description: "create | get | list | refresh | scrub | restore | contain | uncontain | tree" }),
702
832
  path: Type.String({ description: "Absolute or cwd-relative path used to resolve which workspace this annotation belongs to" }),
703
833
  id: Type.Optional(Type.String({ description: "Annotation id -- required for get/refresh/scrub/restore" })),
704
834
  subtype: Type.Optional(Type.String({ description: 'Free-form kind, e.g. "user-story-dataflow" or "comment" -- required for create/refresh' })),
@@ -716,7 +846,14 @@ export default function (pi: ExtensionAPI) {
716
846
  ),
717
847
  listStatus: Type.Optional(Type.String({ description: "fresh | stale | scrubbed -- for list; defaults to excluding scrubbed" })),
718
848
  listSubtype: Type.Optional(Type.String({ description: "For list: filter by subtype" })),
849
+ listQuery: Type.Optional(
850
+ Type.String({ description: "For list: case-insensitive substring match against title or body -- the near-term free-text search over annotations" }),
851
+ ),
719
852
  maxResults: Type.Optional(Type.Number({ description: "For list: bounds the number of results" })),
853
+ parentId: Type.Optional(Type.String({ description: "The containing annotation's id -- required for contain/uncontain" })),
854
+ childId: Type.Optional(Type.String({ description: "The contained annotation's id -- required for contain/uncontain" })),
855
+ rootId: Type.Optional(Type.String({ description: "The subtree's root annotation id -- required for tree" })),
856
+ maxDepth: Type.Optional(Type.Number({ description: "Maximum containment hops from rootId to include -- required for tree" })),
720
857
  }),
721
858
  async execute(_toolCallId, params) {
722
859
  const path = resolve(cwd, params.path);
@@ -736,7 +873,12 @@ export default function (pi: ExtensionAPI) {
736
873
  text = annotation ? formatAnnotationDetail(annotation) : `no annotation "${params.id}"`;
737
874
  } else if (params.action === "list") {
738
875
  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 });
876
+ const { annotations } = await symbolAnnotationOperations.list(path, {
877
+ subtype: params.listSubtype,
878
+ status,
879
+ maxResults: params.maxResults,
880
+ query: params.listQuery,
881
+ });
740
882
  details.annotations = annotations;
741
883
  text = annotations.length === 0 ? "no annotations" : annotations.map(formatAnnotationDetail).join("\n\n");
742
884
  } else if (params.action === "refresh") {
@@ -763,6 +905,21 @@ export default function (pi: ExtensionAPI) {
763
905
  const { restored } = await symbolAnnotationOperations.restore(path, params.id);
764
906
  details.restored = restored;
765
907
  text = restored ? `restored ${params.id}` : `"${params.id}" was not scrubbed or does not exist`;
908
+ } else if (params.action === "contain") {
909
+ if (!params.parentId || !params.childId) throw new Error("symbol_annotations contain requires parentId and childId");
910
+ const { contained } = await symbolAnnotationOperations.contain(path, params.parentId, params.childId);
911
+ details.contained = contained;
912
+ text = `"${params.parentId}" now contains "${params.childId}"`;
913
+ } else if (params.action === "uncontain") {
914
+ if (!params.parentId || !params.childId) throw new Error("symbol_annotations uncontain requires parentId and childId");
915
+ const { uncontained } = await symbolAnnotationOperations.uncontain(path, params.parentId, params.childId);
916
+ details.uncontained = uncontained;
917
+ text = uncontained ? `"${params.parentId}" no longer contains "${params.childId}"` : `"${params.parentId}" did not contain "${params.childId}"`;
918
+ } else if (params.action === "tree") {
919
+ if (!params.rootId || params.maxDepth === undefined) throw new Error("symbol_annotations tree requires rootId and maxDepth");
920
+ const { annotations } = await symbolAnnotationOperations.tree(path, params.rootId, params.maxDepth);
921
+ details.annotations = annotations;
922
+ text = annotations.length === 0 ? `no annotation "${params.rootId}"` : annotations.map(formatAnnotationDetail).join("\n\n");
766
923
  } else {
767
924
  throw new Error(`unknown symbol_annotations action: ${String(params.action)}`);
768
925
  }
@@ -770,7 +927,14 @@ export default function (pi: ExtensionAPI) {
770
927
  },
771
928
  renderCall(args, theme, context) {
772
929
  const action = typeof args.action === "string" ? args.action : "";
773
- const id = typeof args.id === "string" ? ` ${args.id}` : "";
930
+ const id =
931
+ typeof args.id === "string"
932
+ ? ` ${args.id}`
933
+ : typeof args.parentId === "string" && typeof args.childId === "string"
934
+ ? ` ${args.parentId} -> ${args.childId}`
935
+ : typeof args.rootId === "string"
936
+ ? ` ${args.rootId}`
937
+ : "";
774
938
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
775
939
  text.setText(`${theme.fg("toolTitle", theme.bold("symbol_annotations"))} ${theme.fg("accent", action)}${theme.fg("dim", id)}`);
776
940
  return text;
@@ -803,6 +967,14 @@ export default function (pi: ExtensionAPI) {
803
967
  text.setText(details.restored ? theme.fg("success", "restored") : theme.fg("muted", "not scrubbed or not found"));
804
968
  return text;
805
969
  }
970
+ if (details?.contained !== undefined) {
971
+ text.setText(theme.fg("success", "contained"));
972
+ return text;
973
+ }
974
+ if (details?.uncontained !== undefined) {
975
+ text.setText(details.uncontained ? theme.fg("success", "uncontained") : theme.fg("muted", "was not contained"));
976
+ return text;
977
+ }
806
978
  text.setText(theme.fg("muted", "done"));
807
979
  return text;
808
980
  },
@@ -812,10 +984,10 @@ export default function (pi: ExtensionAPI) {
812
984
  name: "reachable_from",
813
985
  label: "Reachable From",
814
986
  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.",
987
+ "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
988
  promptSnippet: "Find symbols reachable from a position, up to N hops, via the persisted graph",
817
989
  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.",
990
+ "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
991
  ],
820
992
  parameters: Type.Object({
821
993
  ...positionParameters,
@@ -837,7 +1009,7 @@ export default function (pi: ExtensionAPI) {
837
1009
  },
838
1010
  renderCall(args, theme, context) {
839
1011
  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));
1012
+ text.setText(formatReachableFromCall(args, theme));
841
1013
  return text;
842
1014
  },
843
1015
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -888,7 +1060,7 @@ export default function (pi: ExtensionAPI) {
888
1060
  },
889
1061
  renderCall(args, theme, context) {
890
1062
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
891
- text.setText(formatWorkspaceMapCall(args as { path?: unknown; maxEntries?: unknown }, theme));
1063
+ text.setText(formatWorkspaceMapCall(args, theme));
892
1064
  return text;
893
1065
  },
894
1066
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -909,111 +1081,61 @@ export default function (pi: ExtensionAPI) {
909
1081
 
910
1082
  const gitOperations = createLectorGitOperations();
911
1083
  pi.registerTool({
912
- name: "git_status",
913
- label: "Git Status",
1084
+ name: "git",
1085
+ label: "Git",
914
1086
  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",
1087
+ "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).",
1088
+ promptSnippet: "Show a repository's status, log, or diff",
1089
+ promptGuidelines: [
1090
+ "maxCount is required for action=log; maxBytes is required for action=diff -- every bounded query needs its bound stated explicitly, never defaulted silently.",
1091
+ ],
917
1092
  parameters: Type.Object({
1093
+ action: Type.String({ description: "status | log | diff" }),
918
1094
  directory: Type.String({ description: "Directory inside the repository to check, absolute or relative to the current working directory" }),
1095
+ maxCount: Type.Optional(Type.Number({ description: "Maximum number of commits to return, most recent first -- required for action=log" })),
1096
+ ref: Type.Optional(Type.String({ description: "Ref to diff against; defaults to HEAD -- only used for action=diff" })),
1097
+ maxBytes: Type.Optional(Type.Number({ description: "Maximum diff size in bytes before truncating -- required for action=diff" })),
919
1098
  }),
920
1099
  async execute(_toolCallId, params) {
921
1100
  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);
1101
+ if (params.action === "status") {
1102
+ const summary = await gitOperations.status(directory);
1103
+ const details: GitToolDetails = { action: "status", summary };
1104
+ return { content: [{ type: "text", text: JSON.stringify(summary) }], details };
938
1105
  }
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);
1106
+ if (params.action === "log") {
1107
+ if (params.maxCount === undefined) throw new Error("git action=log requires maxCount");
1108
+ const entries = await gitOperations.log(directory, params.maxCount);
1109
+ const text =
1110
+ entries.length === 0 ? "No commits found." : entries.map((e) => `${e.sha.slice(0, 8)} ${e.authoredAt} ${e.authorName} -- ${e.message}`).join("\n");
1111
+ const details: GitToolDetails = { action: "log", entries };
1112
+ return { content: [{ type: "text", text }], details };
976
1113
  }
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 } };
1114
+ if (params.action === "diff") {
1115
+ if (params.maxBytes === undefined) throw new Error("git action=diff requires maxBytes");
1116
+ const result = await gitOperations.diff(directory, params.ref, params.maxBytes);
1117
+ const details: GitToolDetails = { action: "diff", result };
1118
+ return { content: [{ type: "text", text: result.diff.length === 0 ? "No differences." : result.diff }], details };
1119
+ }
1120
+ throw new Error(`unknown git action: ${String(params.action)}`);
999
1121
  },
1000
1122
  renderCall(args, theme, context) {
1001
1123
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1002
- text.setText(formatGitDiffCall(args as { directory?: unknown; ref?: unknown }, theme));
1124
+ text.setText(formatGitCall(args, theme));
1003
1125
  return text;
1004
1126
  },
1005
1127
  renderResult(result, { expanded, isPartial }, theme, context) {
1006
- if (isPartial) return new Text(theme.fg("warning", "Computing diff..."), 0, 0);
1128
+ if (isPartial) return new Text(theme.fg("warning", "Running git..."), 0, 0);
1007
1129
  if (context.isError) {
1008
1130
  const errorText = result.content
1009
1131
  .filter((block) => block.type === "text")
1010
1132
  .map((block) => block.text)
1011
1133
  .join("\n");
1012
- return new Text(theme.fg("error", errorText || "git_diff failed"), 0, 0);
1134
+ return new Text(theme.fg("error", errorText || "git failed"), 0, 0);
1013
1135
  }
1014
- const details = result.details as { result?: GitDiffResult } | undefined;
1136
+ const details = result.details as GitToolDetails | undefined;
1015
1137
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1016
- text.setText(formatGitDiffResult(details?.result, expanded, theme));
1138
+ text.setText(formatGitResult(details, expanded, theme));
1017
1139
  return text;
1018
1140
  },
1019
1141
  });
@@ -1040,7 +1162,7 @@ export default function (pi: ExtensionAPI) {
1040
1162
  },
1041
1163
  renderCall(args, theme, context) {
1042
1164
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1043
- text.setText(formatSearchCall(args as { directory?: unknown; query?: unknown }, theme));
1165
+ text.setText(formatSearchCall(args, theme));
1044
1166
  return text;
1045
1167
  },
1046
1168
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -1086,7 +1208,7 @@ export default function (pi: ExtensionAPI) {
1086
1208
  },
1087
1209
  renderCall(args, theme, context) {
1088
1210
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1089
- text.setText(formatFindFilesCall(args as { directory?: unknown; patterns?: unknown }, theme));
1211
+ text.setText(formatFindFilesCall(args, theme));
1090
1212
  return text;
1091
1213
  },
1092
1214
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -1151,12 +1273,13 @@ export default function (pi: ExtensionAPI) {
1151
1273
  // The Pi tool schema can only express plain strings for hash fields (TypeBox has no
1152
1274
  // concept of Lector's branded LineHash) -- the daemon's own domain validation is the
1153
1275
  // real runtime check regardless of what TypeScript sees at this call site.
1276
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
1154
1277
  const result = await lineEditOperations.lineEdit(absolutePath, params.edits as unknown as LineEdit[]);
1155
1278
  return { content: [{ type: "text", text: `${result.path}: ${result.previousHash} -> ${result.newHash}` }], details: { result } };
1156
1279
  },
1157
1280
  renderCall(args, theme, context) {
1158
1281
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1159
- text.setText(formatLineEditCall(args as { path?: unknown; edits?: unknown }, theme));
1282
+ text.setText(formatLineEditCall(args, theme));
1160
1283
  return text;
1161
1284
  },
1162
1285
  renderResult(result, { isPartial }, theme, context) {
@@ -1197,12 +1320,13 @@ export default function (pi: ExtensionAPI) {
1197
1320
  const absolutePath = resolve(cwd, params.path);
1198
1321
  // TypeBox has no concept of Lector's branded ContentHash -- the daemon's own domain
1199
1322
  // validation is the real runtime check regardless of what TypeScript sees here.
1323
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
1200
1324
  const result = await applyPatchOperations.applyPatch(absolutePath, params.expectedHash as ContentHash, params.patchText);
1201
1325
  return { content: [{ type: "text", text: `${result.path}: ${result.previousHash ?? "(new)"} -> ${result.newHash}` }], details: { result } };
1202
1326
  },
1203
1327
  renderCall(args, theme, context) {
1204
1328
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1205
- text.setText(formatApplyPatchCall(args as { path?: unknown }, theme));
1329
+ text.setText(formatApplyPatchCall(args, theme));
1206
1330
  return text;
1207
1331
  },
1208
1332
  renderResult(result, { isPartial }, theme, context) {
@@ -1221,6 +1345,67 @@ export default function (pi: ExtensionAPI) {
1221
1345
  },
1222
1346
  });
1223
1347
 
1348
+ type MutationHistoryToolDetails =
1349
+ | { readonly action: "list"; readonly entries: readonly MutationHistoryEntry[] }
1350
+ | { readonly action: "revert"; readonly reverted: { readonly path: string; readonly newHash: string | null } };
1351
+
1352
+ const mutationHistoryOperations = createMutationHistoryOperations();
1353
+ pi.registerTool({
1354
+ name: "mutation_history",
1355
+ label: "Mutation History",
1356
+ description:
1357
+ "List or revert a file's recorded edit history. Every successful edit/line_edit/apply_patch is recorded (newest first, bounded, not durable across a daemon restart). Reverting restores the file to its exact content immediately before that entry's own mutation, guarded the same way every Lector write is -- refuses if the file changed since, rather than silently clobbering a newer change. A revert is itself a real, further-revertible mutation -- reverting a revert works.",
1358
+ promptSnippet: "List or revert a file's recorded edit history",
1359
+ promptGuidelines: [
1360
+ "list first to find the entry id you want, then revert -- an id from a different file's history, or one already evicted by the bounded history, fails closed rather than guessing.",
1361
+ ],
1362
+ parameters: Type.Object({
1363
+ action: Type.Union([Type.Literal("list"), Type.Literal("revert")]),
1364
+ path: Type.String({ description: "Absolute or workspace-relative path to the file" }),
1365
+ maxResults: Type.Optional(Type.Number({ description: "Required for action=list -- maximum entries to return, newest first" })),
1366
+ entryId: Type.Optional(Type.String({ description: "Required for action=revert -- an id returned by a prior action=list" })),
1367
+ }),
1368
+ async execute(_toolCallId, params): Promise<AgentToolResult<MutationHistoryToolDetails>> {
1369
+ const absolutePath = resolve(cwd, params.path);
1370
+ if (params.action === "list") {
1371
+ if (params.maxResults === undefined) throw new Error("mutation_history action=list requires maxResults");
1372
+ const entries = await mutationHistoryOperations.list(absolutePath, params.maxResults);
1373
+ const text =
1374
+ entries.length === 0
1375
+ ? "no recorded mutation history for this path"
1376
+ : entries.map((entry) => `${entry.id} ${new Date(entry.timestamp).toISOString()} ${entry.operation}`).join("\n");
1377
+ return { content: [{ type: "text", text }], details: { action: "list", entries } };
1378
+ }
1379
+ if (params.entryId === undefined) throw new Error("mutation_history action=revert requires entryId");
1380
+ const reverted = await mutationHistoryOperations.revert(absolutePath, params.entryId);
1381
+ return {
1382
+ content: [{ type: "text", text: `${reverted.path} reverted -> ${reverted.newHash ?? "(deleted)"}` }],
1383
+ details: { action: "revert", reverted },
1384
+ };
1385
+ },
1386
+ renderCall(args, theme, context) {
1387
+ const action = typeof args.action === "string" ? args.action : "";
1388
+ const path = typeof args.path === "string" ? args.path : "";
1389
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1390
+ text.setText(`${theme.fg("toolTitle", theme.bold("mutation_history"))} ${theme.fg("accent", action)} ${theme.fg("dim", path)}`);
1391
+ return text;
1392
+ },
1393
+ renderResult(result, { isPartial }, theme, context) {
1394
+ if (isPartial) return new Text(theme.fg("warning", "Working on mutation history..."), 0, 0);
1395
+ if (context.isError) {
1396
+ const errorText = result.content
1397
+ .filter((block) => block.type === "text")
1398
+ .map((block) => block.text)
1399
+ .join("\n");
1400
+ return new Text(theme.fg("error", errorText || "mutation_history failed"), 0, 0);
1401
+ }
1402
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1403
+ const textBlock = result.content.find((block) => block.type === "text");
1404
+ text.setText(theme.fg("success", textBlock && "text" in textBlock ? textBlock.text : "done"));
1405
+ return text;
1406
+ },
1407
+ });
1408
+
1224
1409
  const packageSourceOperations = createLectorPackageSourceOperations();
1225
1410
  pi.registerTool({
1226
1411
  name: "package_source",
@@ -1241,7 +1426,7 @@ export default function (pi: ExtensionAPI) {
1241
1426
  },
1242
1427
  renderCall(args, theme, context) {
1243
1428
  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));
1429
+ text.setText(formatPackageSourceCall(args, theme));
1245
1430
  return text;
1246
1431
  },
1247
1432
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -1261,6 +1446,7 @@ export default function (pi: ExtensionAPI) {
1261
1446
  });
1262
1447
 
1263
1448
  const repoFetchOperations = createLectorRepoFetchOperations();
1449
+ const repoCacheListOperations = createRepoCacheListOperations();
1264
1450
  pi.registerTool({
1265
1451
  name: "repo_fetch",
1266
1452
  label: "Repo Fetch",
@@ -1279,7 +1465,7 @@ export default function (pi: ExtensionAPI) {
1279
1465
  },
1280
1466
  renderCall(args, theme, context) {
1281
1467
  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));
1468
+ text.setText(formatRepoFetchCall(args, theme));
1283
1469
  return text;
1284
1470
  },
1285
1471
  renderResult(result, { isPartial }, theme, context) {
@@ -1298,6 +1484,53 @@ export default function (pi: ExtensionAPI) {
1298
1484
  },
1299
1485
  });
1300
1486
 
1487
+ pi.registerTool({
1488
+ name: "repo_cache_list",
1489
+ label: "Repo Cache List",
1490
+ description:
1491
+ "Lists and queries repo_fetch's own on-disk cache -- no network call, no cache mutation. Filter by any combination of host/owner/repo/ref (exact match) and text (case-insensitive substring across host/owner/repo/refs). Each entry reports whether it's currently a registered workspace (usable directly by other tools) or just present on disk. Bounded and paginated via cursor.",
1492
+ promptSnippet: "List or search previously-fetched external repositories",
1493
+ parameters: Type.Object({
1494
+ text: Type.Optional(Type.String({ description: "Case-insensitive substring match across host/owner/repo/refs" })),
1495
+ host: Type.Optional(Type.String({ description: "Exact host match, e.g. github.com" })),
1496
+ owner: Type.Optional(Type.String()),
1497
+ repo: Type.Optional(Type.String()),
1498
+ ref: Type.Optional(Type.String({ description: "Matches either the requested or the resolved ref" })),
1499
+ maxResults: Type.Number({ description: "Maximum entries to return in this page" }),
1500
+ cursor: Type.Optional(Type.String({ description: "Opaque cursor from a prior call's nextCursor, to fetch the next page" })),
1501
+ }),
1502
+ async execute(_toolCallId, params) {
1503
+ const page = await repoCacheListOperations.list(
1504
+ { text: params.text, host: params.host, owner: params.owner, repo: params.repo, ref: params.ref },
1505
+ params.maxResults,
1506
+ params.cursor,
1507
+ );
1508
+ return { content: [{ type: "text", text: JSON.stringify(page) }], details: { page } };
1509
+ },
1510
+ renderCall(_args, theme, context) {
1511
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1512
+ text.setText(`${theme.fg("toolTitle", theme.bold("repo_cache_list"))}`);
1513
+ return text;
1514
+ },
1515
+ renderResult(result, { isPartial }, theme, context) {
1516
+ if (isPartial) return new Text(theme.fg("warning", "Listing cached repositories..."), 0, 0);
1517
+ if (context.isError) {
1518
+ const errorText = result.content
1519
+ .filter((block) => block.type === "text")
1520
+ .map((block) => block.text)
1521
+ .join("\n");
1522
+ return new Text(theme.fg("error", errorText || "repo_cache_list failed"), 0, 0);
1523
+ }
1524
+ const details = result.details as
1525
+ | { page?: { entries: readonly { host: string; owner: string; repo: string }[]; nextCursor: string | null } }
1526
+ | undefined;
1527
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1528
+ const count = details?.page?.entries.length ?? 0;
1529
+ text.setText(count === 0 ? theme.fg("dim", "no cached repositories") : theme.fg("success", `${count} cached repositor${count === 1 ? "y" : "ies"}`));
1530
+ return text;
1531
+ },
1532
+ });
1533
+
1301
1534
  const crossWorkspaceSearchOperations = createLectorCrossWorkspaceSearchOperations();
1302
1535
  pi.registerTool({
1303
1536
  name: "find_symbols_across_projects",
@@ -1317,7 +1550,7 @@ export default function (pi: ExtensionAPI) {
1317
1550
  },
1318
1551
  renderCall(args, theme, context) {
1319
1552
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1320
- text.setText(formatCrossWorkspaceCall(args as { directories?: unknown; query?: unknown }, theme));
1553
+ text.setText(formatCrossWorkspaceCall(args, theme));
1321
1554
  return text;
1322
1555
  },
1323
1556
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -1356,7 +1589,7 @@ export default function (pi: ExtensionAPI) {
1356
1589
  },
1357
1590
  renderCall(args, theme, context) {
1358
1591
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1359
- text.setText(formatCrossWorkspaceCall(args as { directories?: unknown; query?: unknown }, theme));
1592
+ text.setText(formatCrossWorkspaceCall(args, theme));
1360
1593
  return text;
1361
1594
  },
1362
1595
  renderResult(result, { expanded, isPartial }, theme, context) {