@danypops/pi-lector 0.1.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.
- package/LICENSE +21 -0
- package/README.md +14 -0
- package/extension/src/code-intelligence-hints.ts +38 -0
- package/extension/src/code-intelligence-operations.ts +178 -0
- package/extension/src/code-intelligence-rendering.ts +237 -0
- package/extension/src/cross-workspace-search-operations.ts +47 -0
- package/extension/src/cross-workspace-search-rendering.ts +66 -0
- package/extension/src/edit-operations.ts +77 -0
- package/extension/src/find-symbols-operations.ts +34 -0
- package/extension/src/find-symbols-rendering.ts +54 -0
- package/extension/src/git-operations.ts +46 -0
- package/extension/src/git-rendering.ts +64 -0
- package/extension/src/index.ts +943 -0
- package/extension/src/lector-client.ts +175 -0
- package/extension/src/lector-tui-theme.ts +35 -0
- package/extension/src/nearest-workspace-root.ts +34 -0
- package/extension/src/read-operations.ts +73 -0
- package/extension/src/repo-fetch-operations.ts +20 -0
- package/extension/src/repo-fetch-rendering.ts +21 -0
- package/extension/src/search-operations.ts +24 -0
- package/extension/src/search-rendering.ts +21 -0
- package/extension/src/workspace-cache-operations.ts +97 -0
- package/extension/src/workspace-relative-path.ts +17 -0
- package/extension/src/write-operations.ts +61 -0
- package/package.json +40 -0
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import type {
|
|
3
|
+
CallHierarchyEntry,
|
|
4
|
+
Diagnostic,
|
|
5
|
+
DocumentSymbolEntry,
|
|
6
|
+
GitDiffResult,
|
|
7
|
+
GitLogEntry,
|
|
8
|
+
GitStatusSummary,
|
|
9
|
+
Hover,
|
|
10
|
+
IncomingCall,
|
|
11
|
+
JobSnapshot,
|
|
12
|
+
OutgoingCall,
|
|
13
|
+
PopulateSymbolGraphResult,
|
|
14
|
+
RepoFetchResult,
|
|
15
|
+
SymbolNode,
|
|
16
|
+
TextSearchResult,
|
|
17
|
+
WorkspaceLocation,
|
|
18
|
+
WorkspaceQueryOutcome,
|
|
19
|
+
WorkspaceSymbol,
|
|
20
|
+
} from "@danypops/lector";
|
|
21
|
+
import { createEditToolDefinition, createReadToolDefinition, createWriteToolDefinition, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
23
|
+
import { Type } from "typebox";
|
|
24
|
+
import { createLectorCodeIntelligenceOperations } from "./code-intelligence-operations.ts";
|
|
25
|
+
import {
|
|
26
|
+
describePopulateSymbolGraphJob,
|
|
27
|
+
formatDiagnosticsCall,
|
|
28
|
+
formatDiagnosticsResult,
|
|
29
|
+
formatDocumentSymbolsCall,
|
|
30
|
+
formatDocumentSymbolsResult,
|
|
31
|
+
formatFindReferencesCall,
|
|
32
|
+
formatFindReferencesResult,
|
|
33
|
+
formatGoToDefinitionCall,
|
|
34
|
+
formatGoToDefinitionResult,
|
|
35
|
+
formatGoToImplementationCall,
|
|
36
|
+
formatGoToImplementationResult,
|
|
37
|
+
formatHoverCall,
|
|
38
|
+
formatHoverResult,
|
|
39
|
+
formatIncomingCallsCall,
|
|
40
|
+
formatIncomingCallsResult,
|
|
41
|
+
formatOutgoingCallsCall,
|
|
42
|
+
formatOutgoingCallsResult,
|
|
43
|
+
formatPopulateSymbolGraphCall,
|
|
44
|
+
formatPopulateSymbolGraphResult,
|
|
45
|
+
formatPrepareCallHierarchyCall,
|
|
46
|
+
formatPrepareCallHierarchyResult,
|
|
47
|
+
formatReachableFromCall,
|
|
48
|
+
formatReachableFromResult,
|
|
49
|
+
} from "./code-intelligence-rendering.ts";
|
|
50
|
+
import { createLectorCrossWorkspaceSearchOperations } from "./cross-workspace-search-operations.ts";
|
|
51
|
+
import { formatCrossWorkspaceCall, formatFindSymbolsAcrossProjectsResult, formatSearchTextAcrossProjectsResult } from "./cross-workspace-search-rendering.ts";
|
|
52
|
+
import { createLectorEditOperations } from "./edit-operations.ts";
|
|
53
|
+
import { createLectorFindSymbolsOperations } from "./find-symbols-operations.ts";
|
|
54
|
+
import { formatFindSymbolsCall, formatFindSymbolsResult } from "./find-symbols-rendering.ts";
|
|
55
|
+
import { createLectorGitOperations } from "./git-operations.ts";
|
|
56
|
+
import { formatGitDiffCall, formatGitDiffResult, formatGitLogCall, formatGitLogResult, formatGitStatusCall, formatGitStatusResult } from "./git-rendering.ts";
|
|
57
|
+
import { nearestGitRoot } from "./nearest-workspace-root.ts";
|
|
58
|
+
import { createLectorReadOperations } from "./read-operations.ts";
|
|
59
|
+
import { createLectorRepoFetchOperations } from "./repo-fetch-operations.ts";
|
|
60
|
+
import { formatRepoFetchCall, formatRepoFetchResult } from "./repo-fetch-rendering.ts";
|
|
61
|
+
import { createLectorSearchOperations } from "./search-operations.ts";
|
|
62
|
+
import { formatSearchCall, formatSearchResult } from "./search-rendering.ts";
|
|
63
|
+
import { type CachePresentationState, createWorkspaceCacheOperations, monitorWorkspaceCache } from "./workspace-cache-operations.ts";
|
|
64
|
+
import { createLectorWriteOperations } from "./write-operations.ts";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* pi-lector -- the thin Pi host adapter for Lector. Overrides the built-in
|
|
68
|
+
* read/write/edit tools by name with Lector-backed Operations, so built-in
|
|
69
|
+
* rendering (syntax highlighting, diffs, truncation banners) is kept for
|
|
70
|
+
* free while every actual file operation routes through a running Lector
|
|
71
|
+
* daemon. Adds find_symbols and the code-intelligence tools, which have no
|
|
72
|
+
* built-in pi-coding-agent equivalent.
|
|
73
|
+
*
|
|
74
|
+
* read/write/edit's Lector-backed Operations resolve their own workspace
|
|
75
|
+
* per absolute path touched (workspaceForPath), never from a `cwd`
|
|
76
|
+
* captured once at session start -- `cwd` is still passed to
|
|
77
|
+
* createReadToolDefinition/etc. themselves, but only for their own
|
|
78
|
+
* relative-path display, not for workspace resolution.
|
|
79
|
+
*
|
|
80
|
+
* grep/find/ls are not overridden -- no Lector operation backs them yet.
|
|
81
|
+
* No daemon auto-spawn: a Lector-backed tool call fails with a clear
|
|
82
|
+
* "start it with `lector serve`" error if none is reachable.
|
|
83
|
+
*/
|
|
84
|
+
export default function (pi: ExtensionAPI) {
|
|
85
|
+
const cacheOperations = createWorkspaceCacheOperations();
|
|
86
|
+
let cacheRun = 0;
|
|
87
|
+
let cacheState: CachePresentationState | undefined;
|
|
88
|
+
let lastInjectedCacheState: string | undefined;
|
|
89
|
+
|
|
90
|
+
function describeCacheState(state: CachePresentationState): string {
|
|
91
|
+
if (state.status === "not-cached") return `not cached (${state.reason})`;
|
|
92
|
+
if (state.status === "caching") return `caching (job ${state.jobId})`;
|
|
93
|
+
if (state.status === "finished-caching") return `finished caching (job ${state.job.id})`;
|
|
94
|
+
return "cached";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
pi.on("before_agent_start", () => {
|
|
98
|
+
if (!cacheState) return;
|
|
99
|
+
const description = describeCacheState(cacheState);
|
|
100
|
+
if (description === lastInjectedCacheState) return;
|
|
101
|
+
lastInjectedCacheState = description;
|
|
102
|
+
return {
|
|
103
|
+
message: {
|
|
104
|
+
customType: "lector-cache-status",
|
|
105
|
+
content: `Lector workspace cache: ${description}. A caching graph is still loading; use live code-intelligence operations until it becomes cached.`,
|
|
106
|
+
display: false,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
112
|
+
cacheRun++;
|
|
113
|
+
cacheState = undefined;
|
|
114
|
+
lastInjectedCacheState = undefined;
|
|
115
|
+
ctx.ui.setStatus("lector-cache", undefined);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
pi.on("session_start", (_event, ctx) => {
|
|
119
|
+
const { cwd } = ctx;
|
|
120
|
+
const projectRoot = nearestGitRoot(cwd);
|
|
121
|
+
const thisRun = ++cacheRun;
|
|
122
|
+
cacheState = undefined;
|
|
123
|
+
lastInjectedCacheState = undefined;
|
|
124
|
+
if (projectRoot) {
|
|
125
|
+
void monitorWorkspaceCache(cacheOperations, {
|
|
126
|
+
directory: projectRoot,
|
|
127
|
+
maxFiles: 500,
|
|
128
|
+
maxSymbolsPerFile: 100,
|
|
129
|
+
pollIntervalMs: 1_000,
|
|
130
|
+
maxPolls: 300,
|
|
131
|
+
shouldContinue: () => cacheRun === thisRun,
|
|
132
|
+
onState: (state) => {
|
|
133
|
+
cacheState = state;
|
|
134
|
+
if (state.status === "finished-caching") {
|
|
135
|
+
if (ctx.hasUI) ctx.ui.notify(`Lector finished caching ${projectRoot}`, "info");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const color = state.status === "cached" ? "success" : state.status === "caching" ? "accent" : "warning";
|
|
139
|
+
ctx.ui.setStatus("lector-cache", ctx.ui.theme.fg(color, `Lector: ${describeCacheState(state)}`));
|
|
140
|
+
},
|
|
141
|
+
}).catch((error: unknown) => {
|
|
142
|
+
if (cacheRun !== thisRun) return;
|
|
143
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
144
|
+
ctx.ui.setStatus("lector-cache", ctx.ui.theme.fg("error", "Lector: cache error"));
|
|
145
|
+
if (ctx.hasUI) ctx.ui.notify(`Lector cache failed: ${message}`, "error");
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
ctx.ui.setStatus("lector-cache", undefined);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
pi.registerTool(createReadToolDefinition(cwd, { operations: createLectorReadOperations() }));
|
|
152
|
+
pi.registerTool(createWriteToolDefinition(cwd, { operations: createLectorWriteOperations() }));
|
|
153
|
+
pi.registerTool(createEditToolDefinition(cwd, { operations: createLectorEditOperations() }));
|
|
154
|
+
|
|
155
|
+
const findSymbolsOperations = createLectorFindSymbolsOperations();
|
|
156
|
+
pi.registerTool({
|
|
157
|
+
name: "find_symbols",
|
|
158
|
+
label: "Find Symbols",
|
|
159
|
+
description:
|
|
160
|
+
"Search a workspace for functions, classes, interfaces, types, enums, and methods by name " +
|
|
161
|
+
"(case-insensitive substring match). Returns each match's kind and file location. `directory` " +
|
|
162
|
+
"selects which project to search -- pass the current working directory to search it, or any " +
|
|
163
|
+
"other project's directory to get code intelligence there without needing to be in it.",
|
|
164
|
+
promptSnippet: "Search a workspace for a symbol (function, class, etc.) by name",
|
|
165
|
+
promptGuidelines: [
|
|
166
|
+
"Use find_symbols to locate where a function, class, interface, type, enum, or method is declared by name, instead of grepping for it.",
|
|
167
|
+
"find_symbols' directory argument selects which project to search; it is never inferred, so pass the current working directory explicitly to search the current project, or another project's directory to search that one instead.",
|
|
168
|
+
],
|
|
169
|
+
parameters: Type.Object({
|
|
170
|
+
query: Type.String({ description: "Name or substring to search for, case-insensitive" }),
|
|
171
|
+
directory: Type.String({ description: "Directory of the project to search, absolute or relative to the current working directory" }),
|
|
172
|
+
}),
|
|
173
|
+
async execute(_toolCallId, params) {
|
|
174
|
+
const directory = resolve(cwd, params.directory);
|
|
175
|
+
const symbols = await findSymbolsOperations.findSymbols(params.query, directory);
|
|
176
|
+
const text =
|
|
177
|
+
symbols.length === 0
|
|
178
|
+
? `No symbols found matching "${params.query}".`
|
|
179
|
+
: symbols
|
|
180
|
+
.map((symbol) => `${symbol.kind} ${symbol.name} -- ${symbol.location.path}:${symbol.location.line}:${symbol.location.character}`)
|
|
181
|
+
.join("\n");
|
|
182
|
+
return { content: [{ type: "text", text }], details: { symbols } };
|
|
183
|
+
},
|
|
184
|
+
renderCall(args, theme, context) {
|
|
185
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
186
|
+
text.setText(formatFindSymbolsCall(args as { query?: unknown; directory?: unknown }, theme));
|
|
187
|
+
return text;
|
|
188
|
+
},
|
|
189
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
190
|
+
if (isPartial) {
|
|
191
|
+
return new Text(theme.fg("warning", "Searching..."), 0, 0);
|
|
192
|
+
}
|
|
193
|
+
if (context.isError) {
|
|
194
|
+
const errorText = result.content
|
|
195
|
+
.filter((block) => block.type === "text")
|
|
196
|
+
.map((block) => block.text)
|
|
197
|
+
.join("\n");
|
|
198
|
+
return new Text(theme.fg("error", errorText || "find_symbols failed"), 0, 0);
|
|
199
|
+
}
|
|
200
|
+
const details = result.details as { symbols?: readonly WorkspaceSymbol[] } | undefined;
|
|
201
|
+
const query = typeof context.args?.query === "string" ? context.args.query : "";
|
|
202
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
203
|
+
text.setText(formatFindSymbolsResult(details?.symbols, query, expanded, theme));
|
|
204
|
+
return text;
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const codeIntelligenceOperations = createLectorCodeIntelligenceOperations();
|
|
209
|
+
const positionParameters = {
|
|
210
|
+
path: Type.String({ description: "Absolute or cwd-relative path to the file" }),
|
|
211
|
+
line: Type.Number({ description: "1-indexed line number" }),
|
|
212
|
+
character: Type.Number({ description: "1-indexed character offset within the line" }),
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
pi.registerTool({
|
|
216
|
+
name: "go_to_definition",
|
|
217
|
+
label: "Go to Definition",
|
|
218
|
+
description: "Find where the symbol at an exact file position is actually declared, across files, through re-exports and aliasing.",
|
|
219
|
+
promptSnippet: "Jump to a symbol's real declaration from an exact position",
|
|
220
|
+
promptGuidelines: ["Use go_to_definition with a position from a prior read or find_symbols result, not a symbol name -- position-based, not name-based."],
|
|
221
|
+
parameters: Type.Object(positionParameters),
|
|
222
|
+
async execute(_toolCallId, params) {
|
|
223
|
+
const path = resolve(cwd, params.path);
|
|
224
|
+
const locations = await codeIntelligenceOperations.goToDefinition(path, params.line, params.character);
|
|
225
|
+
const text = locations.length === 0 ? "No definition found." : locations.map((l) => `${l.path}:${l.line}:${l.character}`).join("\n");
|
|
226
|
+
return { content: [{ type: "text", text }], details: { locations } };
|
|
227
|
+
},
|
|
228
|
+
renderCall(args, theme, context) {
|
|
229
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
230
|
+
text.setText(formatGoToDefinitionCall(args as { path?: unknown; line?: unknown; character?: unknown }, theme));
|
|
231
|
+
return text;
|
|
232
|
+
},
|
|
233
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
234
|
+
if (isPartial) return new Text(theme.fg("warning", "Looking up definition..."), 0, 0);
|
|
235
|
+
if (context.isError) {
|
|
236
|
+
const errorText = result.content
|
|
237
|
+
.filter((block) => block.type === "text")
|
|
238
|
+
.map((block) => block.text)
|
|
239
|
+
.join("\n");
|
|
240
|
+
return new Text(theme.fg("error", errorText || "go_to_definition failed"), 0, 0);
|
|
241
|
+
}
|
|
242
|
+
const details = result.details as { locations?: readonly WorkspaceLocation[] } | undefined;
|
|
243
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
244
|
+
text.setText(formatGoToDefinitionResult(details?.locations, expanded, theme));
|
|
245
|
+
return text;
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
pi.registerTool({
|
|
250
|
+
name: "go_to_implementation",
|
|
251
|
+
label: "Go to Implementation",
|
|
252
|
+
description:
|
|
253
|
+
"Find every concrete implementation of the interface/abstract member at an exact file position -- crosses a port/interface boundary that go_to_definition cannot, since that resolves statically to the interface declaration itself.",
|
|
254
|
+
promptSnippet: "Jump from an interface/port member to its concrete implementation(s)",
|
|
255
|
+
promptGuidelines: [
|
|
256
|
+
"Use go_to_implementation, not go_to_definition, when the position is an interface or abstract member and you need the concrete adapter's real code, not the interface declaration.",
|
|
257
|
+
],
|
|
258
|
+
parameters: Type.Object(positionParameters),
|
|
259
|
+
async execute(_toolCallId, params) {
|
|
260
|
+
const path = resolve(cwd, params.path);
|
|
261
|
+
const locations = await codeIntelligenceOperations.goToImplementation(path, params.line, params.character);
|
|
262
|
+
const text = locations.length === 0 ? "No implementation found." : locations.map((l) => `${l.path}:${l.line}:${l.character}`).join("\n");
|
|
263
|
+
return { content: [{ type: "text", text }], details: { locations } };
|
|
264
|
+
},
|
|
265
|
+
renderCall(args, theme, context) {
|
|
266
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
267
|
+
text.setText(formatGoToImplementationCall(args as { path?: unknown; line?: unknown; character?: unknown }, theme));
|
|
268
|
+
return text;
|
|
269
|
+
},
|
|
270
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
271
|
+
if (isPartial) return new Text(theme.fg("warning", "Looking up implementations..."), 0, 0);
|
|
272
|
+
if (context.isError) {
|
|
273
|
+
const errorText = result.content
|
|
274
|
+
.filter((block) => block.type === "text")
|
|
275
|
+
.map((block) => block.text)
|
|
276
|
+
.join("\n");
|
|
277
|
+
return new Text(theme.fg("error", errorText || "go_to_implementation failed"), 0, 0);
|
|
278
|
+
}
|
|
279
|
+
const details = result.details as { locations?: readonly WorkspaceLocation[] } | undefined;
|
|
280
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
281
|
+
text.setText(formatGoToImplementationResult(details?.locations, expanded, theme));
|
|
282
|
+
return text;
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
pi.registerTool({
|
|
287
|
+
name: "find_references",
|
|
288
|
+
label: "Find References",
|
|
289
|
+
description: "Find every project-wide usage of the symbol at an exact file position.",
|
|
290
|
+
promptSnippet: "Find every usage of a symbol from an exact position",
|
|
291
|
+
promptGuidelines: [
|
|
292
|
+
"Use find_references with a position from a prior read or find_symbols result, not a symbol name -- position-based, not name-based.",
|
|
293
|
+
"A file you have already read or queried is guaranteed to have its own usages included; a file never touched this session may be missing until queried once (e.g. via document_symbols).",
|
|
294
|
+
],
|
|
295
|
+
parameters: Type.Object({
|
|
296
|
+
...positionParameters,
|
|
297
|
+
includeDeclaration: Type.Boolean({ description: "Include the declaration site itself among the results" }),
|
|
298
|
+
}),
|
|
299
|
+
async execute(_toolCallId, params) {
|
|
300
|
+
const path = resolve(cwd, params.path);
|
|
301
|
+
const locations = await codeIntelligenceOperations.findReferences(path, params.line, params.character, params.includeDeclaration);
|
|
302
|
+
const text = locations.length === 0 ? "No references found." : locations.map((l) => `${l.path}:${l.line}:${l.character}`).join("\n");
|
|
303
|
+
return { content: [{ type: "text", text }], details: { locations } };
|
|
304
|
+
},
|
|
305
|
+
renderCall(args, theme, context) {
|
|
306
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
307
|
+
text.setText(formatFindReferencesCall(args as { path?: unknown; line?: unknown; character?: unknown }, theme));
|
|
308
|
+
return text;
|
|
309
|
+
},
|
|
310
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
311
|
+
if (isPartial) return new Text(theme.fg("warning", "Searching for references..."), 0, 0);
|
|
312
|
+
if (context.isError) {
|
|
313
|
+
const errorText = result.content
|
|
314
|
+
.filter((block) => block.type === "text")
|
|
315
|
+
.map((block) => block.text)
|
|
316
|
+
.join("\n");
|
|
317
|
+
return new Text(theme.fg("error", errorText || "find_references failed"), 0, 0);
|
|
318
|
+
}
|
|
319
|
+
const details = result.details as { locations?: readonly WorkspaceLocation[] } | undefined;
|
|
320
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
321
|
+
text.setText(formatFindReferencesResult(details?.locations, expanded, theme));
|
|
322
|
+
return text;
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
pi.registerTool({
|
|
327
|
+
name: "hover",
|
|
328
|
+
label: "Hover",
|
|
329
|
+
description: "Get type and documentation information for the symbol at an exact file position.",
|
|
330
|
+
promptSnippet: "Get type/doc info for a symbol from an exact position",
|
|
331
|
+
promptGuidelines: [
|
|
332
|
+
"Use hover with a position from a prior read or find_symbols result to see a symbol's inferred type and JSDoc without opening its declaring file.",
|
|
333
|
+
],
|
|
334
|
+
parameters: Type.Object(positionParameters),
|
|
335
|
+
async execute(_toolCallId, params) {
|
|
336
|
+
const path = resolve(cwd, params.path);
|
|
337
|
+
const hover = await codeIntelligenceOperations.hover(path, params.line, params.character);
|
|
338
|
+
return { content: [{ type: "text", text: hover?.contents ?? "No hover information available." }], details: { hover } };
|
|
339
|
+
},
|
|
340
|
+
renderCall(args, theme, context) {
|
|
341
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
342
|
+
text.setText(formatHoverCall(args as { path?: unknown; line?: unknown; character?: unknown }, theme));
|
|
343
|
+
return text;
|
|
344
|
+
},
|
|
345
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
346
|
+
if (isPartial) return new Text(theme.fg("warning", "Loading hover information..."), 0, 0);
|
|
347
|
+
if (context.isError) {
|
|
348
|
+
const errorText = result.content
|
|
349
|
+
.filter((block) => block.type === "text")
|
|
350
|
+
.map((block) => block.text)
|
|
351
|
+
.join("\n");
|
|
352
|
+
return new Text(theme.fg("error", errorText || "hover failed"), 0, 0);
|
|
353
|
+
}
|
|
354
|
+
const details = result.details as { hover?: Hover } | undefined;
|
|
355
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
356
|
+
text.setText(formatHoverResult(details?.hover, expanded, theme));
|
|
357
|
+
return text;
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
pi.registerTool({
|
|
362
|
+
name: "document_symbols",
|
|
363
|
+
label: "Document Symbols",
|
|
364
|
+
description: "List every symbol declared in one file, hierarchically -- an outline of its classes, functions, and their members.",
|
|
365
|
+
promptSnippet: "Get a hierarchical outline of one file's declarations",
|
|
366
|
+
promptGuidelines: ["Use document_symbols to get a file's outline directly, instead of reading the whole file to find what it declares."],
|
|
367
|
+
parameters: Type.Object({ path: Type.String({ description: "Absolute or cwd-relative path to the file" }) }),
|
|
368
|
+
async execute(_toolCallId, params) {
|
|
369
|
+
const path = resolve(cwd, params.path);
|
|
370
|
+
const symbols = await codeIntelligenceOperations.documentSymbols(path);
|
|
371
|
+
const text = symbols.length === 0 ? "No symbols found." : symbols.map((s) => `${s.kind} ${s.name}`).join("\n");
|
|
372
|
+
return { content: [{ type: "text", text }], details: { symbols } };
|
|
373
|
+
},
|
|
374
|
+
renderCall(args, theme, context) {
|
|
375
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
376
|
+
text.setText(formatDocumentSymbolsCall(args as { path?: unknown }, theme));
|
|
377
|
+
return text;
|
|
378
|
+
},
|
|
379
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
380
|
+
if (isPartial) return new Text(theme.fg("warning", "Loading symbols..."), 0, 0);
|
|
381
|
+
if (context.isError) {
|
|
382
|
+
const errorText = result.content
|
|
383
|
+
.filter((block) => block.type === "text")
|
|
384
|
+
.map((block) => block.text)
|
|
385
|
+
.join("\n");
|
|
386
|
+
return new Text(theme.fg("error", errorText || "document_symbols failed"), 0, 0);
|
|
387
|
+
}
|
|
388
|
+
const details = result.details as { symbols?: readonly DocumentSymbolEntry[] } | undefined;
|
|
389
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
390
|
+
text.setText(formatDocumentSymbolsResult(details?.symbols, expanded, theme));
|
|
391
|
+
return text;
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
pi.registerTool({
|
|
396
|
+
name: "diagnostics",
|
|
397
|
+
label: "Diagnostics",
|
|
398
|
+
description: "List every error and warning a language server currently knows about for one file, as of its last analysis.",
|
|
399
|
+
promptSnippet: "List current errors/warnings for one file",
|
|
400
|
+
promptGuidelines: ["Use diagnostics after an edit to check for new type errors in one specific file, instead of running a full project build."],
|
|
401
|
+
parameters: Type.Object({ path: Type.String({ description: "Absolute or cwd-relative path to the file" }) }),
|
|
402
|
+
async execute(_toolCallId, params) {
|
|
403
|
+
const path = resolve(cwd, params.path);
|
|
404
|
+
const diagnostics = await codeIntelligenceOperations.diagnostics(path);
|
|
405
|
+
const text =
|
|
406
|
+
diagnostics.length === 0
|
|
407
|
+
? "No diagnostics."
|
|
408
|
+
: diagnostics.map((d) => `${d.severity} ${d.range.path}:${d.range.start.line}:${d.range.start.character} -- ${d.message}`).join("\n");
|
|
409
|
+
return { content: [{ type: "text", text }], details: { diagnostics } };
|
|
410
|
+
},
|
|
411
|
+
renderCall(args, theme, context) {
|
|
412
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
413
|
+
text.setText(formatDiagnosticsCall(args as { path?: unknown }, theme));
|
|
414
|
+
return text;
|
|
415
|
+
},
|
|
416
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
417
|
+
if (isPartial) return new Text(theme.fg("warning", "Checking diagnostics..."), 0, 0);
|
|
418
|
+
if (context.isError) {
|
|
419
|
+
const errorText = result.content
|
|
420
|
+
.filter((block) => block.type === "text")
|
|
421
|
+
.map((block) => block.text)
|
|
422
|
+
.join("\n");
|
|
423
|
+
return new Text(theme.fg("error", errorText || "diagnostics failed"), 0, 0);
|
|
424
|
+
}
|
|
425
|
+
const details = result.details as { diagnostics?: readonly Diagnostic[] } | undefined;
|
|
426
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
427
|
+
text.setText(formatDiagnosticsResult(details?.diagnostics, expanded, theme));
|
|
428
|
+
return text;
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
pi.registerTool({
|
|
433
|
+
name: "prepare_call_hierarchy",
|
|
434
|
+
label: "Prepare Call Hierarchy",
|
|
435
|
+
description: "Resolve the symbol at an exact file position to its call-hierarchy root -- the first step before incoming_calls or outgoing_calls.",
|
|
436
|
+
promptSnippet: "Resolve a position to a call-hierarchy root",
|
|
437
|
+
promptGuidelines: [
|
|
438
|
+
"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.",
|
|
439
|
+
],
|
|
440
|
+
parameters: Type.Object(positionParameters),
|
|
441
|
+
async execute(_toolCallId, params) {
|
|
442
|
+
const path = resolve(cwd, params.path);
|
|
443
|
+
const items = await codeIntelligenceOperations.prepareCallHierarchy(path, params.line, params.character);
|
|
444
|
+
const text =
|
|
445
|
+
items.length === 0
|
|
446
|
+
? "No call-hierarchy root at this position."
|
|
447
|
+
: items.map((i) => `${i.kind} ${i.name} -- ${i.location.path}:${i.location.line}:${i.location.character}`).join("\n");
|
|
448
|
+
return { content: [{ type: "text", text }], details: { items } };
|
|
449
|
+
},
|
|
450
|
+
renderCall(args, theme, context) {
|
|
451
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
452
|
+
text.setText(formatPrepareCallHierarchyCall(args as { path?: unknown; line?: unknown; character?: unknown }, theme));
|
|
453
|
+
return text;
|
|
454
|
+
},
|
|
455
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
456
|
+
if (isPartial) return new Text(theme.fg("warning", "Resolving position..."), 0, 0);
|
|
457
|
+
if (context.isError) {
|
|
458
|
+
const errorText = result.content
|
|
459
|
+
.filter((block) => block.type === "text")
|
|
460
|
+
.map((block) => block.text)
|
|
461
|
+
.join("\n");
|
|
462
|
+
return new Text(theme.fg("error", errorText || "prepare_call_hierarchy failed"), 0, 0);
|
|
463
|
+
}
|
|
464
|
+
const details = result.details as { items?: readonly CallHierarchyEntry[] } | undefined;
|
|
465
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
466
|
+
text.setText(formatPrepareCallHierarchyResult(details?.items, theme));
|
|
467
|
+
return text;
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
pi.registerTool({
|
|
472
|
+
name: "incoming_calls",
|
|
473
|
+
label: "Incoming Calls",
|
|
474
|
+
description: "Find every real caller of the function/method at an exact file position, project-wide.",
|
|
475
|
+
promptSnippet: "Find every caller of a function from an exact position",
|
|
476
|
+
promptGuidelines: [
|
|
477
|
+
"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.",
|
|
478
|
+
],
|
|
479
|
+
parameters: Type.Object(positionParameters),
|
|
480
|
+
async execute(_toolCallId, params) {
|
|
481
|
+
const path = resolve(cwd, params.path);
|
|
482
|
+
const calls = await codeIntelligenceOperations.incomingCalls(path, params.line, params.character);
|
|
483
|
+
const text =
|
|
484
|
+
calls.length === 0
|
|
485
|
+
? "No incoming calls found."
|
|
486
|
+
: calls.map((c) => `${c.from.kind} ${c.from.name} -- ${c.from.location.path}:${c.from.location.line}:${c.from.location.character}`).join("\n");
|
|
487
|
+
return { content: [{ type: "text", text }], details: { calls } };
|
|
488
|
+
},
|
|
489
|
+
renderCall(args, theme, context) {
|
|
490
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
491
|
+
text.setText(formatIncomingCallsCall(args as { path?: unknown; line?: unknown; character?: unknown }, theme));
|
|
492
|
+
return text;
|
|
493
|
+
},
|
|
494
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
495
|
+
if (isPartial) return new Text(theme.fg("warning", "Searching for callers..."), 0, 0);
|
|
496
|
+
if (context.isError) {
|
|
497
|
+
const errorText = result.content
|
|
498
|
+
.filter((block) => block.type === "text")
|
|
499
|
+
.map((block) => block.text)
|
|
500
|
+
.join("\n");
|
|
501
|
+
return new Text(theme.fg("error", errorText || "incoming_calls failed"), 0, 0);
|
|
502
|
+
}
|
|
503
|
+
const details = result.details as { calls?: readonly IncomingCall[] } | undefined;
|
|
504
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
505
|
+
text.setText(formatIncomingCallsResult(details?.calls, expanded, theme));
|
|
506
|
+
return text;
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
pi.registerTool({
|
|
511
|
+
name: "outgoing_calls",
|
|
512
|
+
label: "Outgoing Calls",
|
|
513
|
+
description: "Find every function/method the function at an exact file position itself calls.",
|
|
514
|
+
promptSnippet: "Find every function a function itself calls",
|
|
515
|
+
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."],
|
|
516
|
+
parameters: Type.Object(positionParameters),
|
|
517
|
+
async execute(_toolCallId, params) {
|
|
518
|
+
const path = resolve(cwd, params.path);
|
|
519
|
+
const calls = await codeIntelligenceOperations.outgoingCalls(path, params.line, params.character);
|
|
520
|
+
const text =
|
|
521
|
+
calls.length === 0
|
|
522
|
+
? "No outgoing calls found."
|
|
523
|
+
: calls.map((c) => `${c.to.kind} ${c.to.name} -- ${c.to.location.path}:${c.to.location.line}:${c.to.location.character}`).join("\n");
|
|
524
|
+
return { content: [{ type: "text", text }], details: { calls } };
|
|
525
|
+
},
|
|
526
|
+
renderCall(args, theme, context) {
|
|
527
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
528
|
+
text.setText(formatOutgoingCallsCall(args as { path?: unknown; line?: unknown; character?: unknown }, theme));
|
|
529
|
+
return text;
|
|
530
|
+
},
|
|
531
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
532
|
+
if (isPartial) return new Text(theme.fg("warning", "Searching for callees..."), 0, 0);
|
|
533
|
+
if (context.isError) {
|
|
534
|
+
const errorText = result.content
|
|
535
|
+
.filter((block) => block.type === "text")
|
|
536
|
+
.map((block) => block.text)
|
|
537
|
+
.join("\n");
|
|
538
|
+
return new Text(theme.fg("error", errorText || "outgoing_calls failed"), 0, 0);
|
|
539
|
+
}
|
|
540
|
+
const details = result.details as { calls?: readonly OutgoingCall[] } | undefined;
|
|
541
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
542
|
+
text.setText(formatOutgoingCallsResult(details?.calls, expanded, theme));
|
|
543
|
+
return text;
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
pi.registerTool({
|
|
548
|
+
name: "populate_symbol_graph",
|
|
549
|
+
label: "Populate Symbol Graph",
|
|
550
|
+
description:
|
|
551
|
+
"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.",
|
|
552
|
+
promptSnippet: "Populate a workspace's symbol graph for multi-hop queries",
|
|
553
|
+
promptGuidelines: [
|
|
554
|
+
"Run populate_symbol_graph once for a workspace before using reachable_from against it; an unpopulated workspace's graph is empty, not an error.",
|
|
555
|
+
"populate_symbol_graph waits briefly, then returns a job id with an explicit still-loading state instead of blocking the turn. Use job_status later; do not spin in a blind polling loop.",
|
|
556
|
+
"maxFiles and maxSymbolsPerFile are both required and bound the scan explicitly -- a symbol-dense file (many interfaces/properties) can easily exceed a small maxSymbolsPerFile before reaching the functions/methods that actually matter.",
|
|
557
|
+
],
|
|
558
|
+
parameters: Type.Object({
|
|
559
|
+
path: Type.String({ description: "Any absolute or cwd-relative path inside the workspace to populate" }),
|
|
560
|
+
maxFiles: Type.Number({ description: "Maximum number of source files to scan" }),
|
|
561
|
+
maxSymbolsPerFile: Type.Number({ description: "Maximum number of declarations to process per file" }),
|
|
562
|
+
initialWaitMs: Type.Optional(Type.Number({ description: "Bounded initial wait before returning a still-loading job; defaults to 500, maximum 30000" })),
|
|
563
|
+
}),
|
|
564
|
+
async execute(_toolCallId, params) {
|
|
565
|
+
const path = resolve(cwd, params.path);
|
|
566
|
+
const job = await codeIntelligenceOperations.populateSymbolGraph(path, params.maxFiles, params.maxSymbolsPerFile, params.initialWaitMs);
|
|
567
|
+
return {
|
|
568
|
+
content: [{ type: "text", text: describePopulateSymbolGraphJob(job) }],
|
|
569
|
+
details: { job },
|
|
570
|
+
};
|
|
571
|
+
},
|
|
572
|
+
renderCall(args, theme, context) {
|
|
573
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
574
|
+
text.setText(formatPopulateSymbolGraphCall(args as { path?: unknown; maxFiles?: unknown; maxSymbolsPerFile?: unknown }, theme));
|
|
575
|
+
return text;
|
|
576
|
+
},
|
|
577
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
578
|
+
if (isPartial) return new Text(theme.fg("warning", "Populating symbol graph..."), 0, 0);
|
|
579
|
+
if (context.isError) {
|
|
580
|
+
const errorText = result.content
|
|
581
|
+
.filter((block) => block.type === "text")
|
|
582
|
+
.map((block) => block.text)
|
|
583
|
+
.join("\n");
|
|
584
|
+
return new Text(theme.fg("error", errorText || "populate_symbol_graph failed"), 0, 0);
|
|
585
|
+
}
|
|
586
|
+
const details = result.details as { job?: JobSnapshot<PopulateSymbolGraphResult> } | undefined;
|
|
587
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
588
|
+
text.setText(formatPopulateSymbolGraphResult(details?.job, theme));
|
|
589
|
+
return text;
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
pi.registerTool({
|
|
594
|
+
name: "job_status",
|
|
595
|
+
label: "Job Status",
|
|
596
|
+
description:
|
|
597
|
+
"Poll one process-lifetime Lector background job. Returns queued/running with an actionable still-loading state, succeeded with the bounded result, or failed with a stable error code and message. Jobs are bounded and do not survive daemon restart; an unknown id explains expiry/restart rather than returning empty data.",
|
|
598
|
+
promptSnippet: "Poll a Lector background job by id",
|
|
599
|
+
parameters: Type.Object({
|
|
600
|
+
jobId: Type.String({ description: "Job id returned by populate_symbol_graph" }),
|
|
601
|
+
}),
|
|
602
|
+
async execute(_toolCallId, params) {
|
|
603
|
+
const job = await codeIntelligenceOperations.jobStatus(params.jobId);
|
|
604
|
+
return { content: [{ type: "text", text: describePopulateSymbolGraphJob(job) }], details: { job } };
|
|
605
|
+
},
|
|
606
|
+
renderCall(args, theme, context) {
|
|
607
|
+
const jobId = typeof args.jobId === "string" ? args.jobId : "";
|
|
608
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
609
|
+
text.setText(`${theme.fg("toolTitle", theme.bold("job_status"))} ${theme.fg("accent", jobId)}`);
|
|
610
|
+
return text;
|
|
611
|
+
},
|
|
612
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
613
|
+
if (isPartial) return new Text(theme.fg("warning", "Checking background job..."), 0, 0);
|
|
614
|
+
if (context.isError) {
|
|
615
|
+
const errorText = result.content
|
|
616
|
+
.filter((block) => block.type === "text")
|
|
617
|
+
.map((block) => block.text)
|
|
618
|
+
.join("\n");
|
|
619
|
+
return new Text(theme.fg("error", errorText || "job_status failed"), 0, 0);
|
|
620
|
+
}
|
|
621
|
+
const details = result.details as { job?: JobSnapshot<PopulateSymbolGraphResult> } | undefined;
|
|
622
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
623
|
+
text.setText(formatPopulateSymbolGraphResult(details?.job, theme));
|
|
624
|
+
return text;
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
pi.registerTool({
|
|
629
|
+
name: "reachable_from",
|
|
630
|
+
label: "Reachable From",
|
|
631
|
+
description:
|
|
632
|
+
"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.",
|
|
633
|
+
promptSnippet: "Find symbols reachable from a position, up to N hops, via the persisted graph",
|
|
634
|
+
promptGuidelines: [
|
|
635
|
+
"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.",
|
|
636
|
+
],
|
|
637
|
+
parameters: Type.Object({
|
|
638
|
+
...positionParameters,
|
|
639
|
+
maxDepth: Type.Number({ description: "Maximum number of hops to traverse" }),
|
|
640
|
+
kind: Type.Optional(
|
|
641
|
+
Type.Union([Type.Literal("calls"), Type.Literal("references"), Type.Literal("contains")], {
|
|
642
|
+
description: "Restrict to one edge kind; omit for any kind",
|
|
643
|
+
}),
|
|
644
|
+
),
|
|
645
|
+
}),
|
|
646
|
+
async execute(_toolCallId, params) {
|
|
647
|
+
const path = resolve(cwd, params.path);
|
|
648
|
+
const symbols = await codeIntelligenceOperations.reachableFrom(path, params.line, params.character, params.maxDepth, params.kind);
|
|
649
|
+
const text =
|
|
650
|
+
symbols.length === 0
|
|
651
|
+
? "Nothing reachable at this position."
|
|
652
|
+
: symbols.map((s) => `${s.kind} ${s.name} -- ${s.location.path}:${s.location.line}:${s.location.character}`).join("\n");
|
|
653
|
+
return { content: [{ type: "text", text }], details: { symbols } };
|
|
654
|
+
},
|
|
655
|
+
renderCall(args, theme, context) {
|
|
656
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
657
|
+
text.setText(formatReachableFromCall(args as { path?: unknown; line?: unknown; character?: unknown; maxDepth?: unknown }, theme));
|
|
658
|
+
return text;
|
|
659
|
+
},
|
|
660
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
661
|
+
if (isPartial) return new Text(theme.fg("warning", "Traversing symbol graph..."), 0, 0);
|
|
662
|
+
if (context.isError) {
|
|
663
|
+
const errorText = result.content
|
|
664
|
+
.filter((block) => block.type === "text")
|
|
665
|
+
.map((block) => block.text)
|
|
666
|
+
.join("\n");
|
|
667
|
+
return new Text(theme.fg("error", errorText || "reachable_from failed"), 0, 0);
|
|
668
|
+
}
|
|
669
|
+
const details = result.details as { symbols?: readonly SymbolNode[] } | undefined;
|
|
670
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
671
|
+
text.setText(formatReachableFromResult(details?.symbols, expanded, theme));
|
|
672
|
+
return text;
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const gitOperations = createLectorGitOperations();
|
|
677
|
+
pi.registerTool({
|
|
678
|
+
name: "git_status",
|
|
679
|
+
label: "Git Status",
|
|
680
|
+
description:
|
|
681
|
+
"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.",
|
|
682
|
+
promptSnippet: "Show a repository's working tree status",
|
|
683
|
+
parameters: Type.Object({
|
|
684
|
+
directory: Type.String({ description: "Directory inside the repository to check, absolute or relative to the current working directory" }),
|
|
685
|
+
}),
|
|
686
|
+
async execute(_toolCallId, params) {
|
|
687
|
+
const directory = resolve(cwd, params.directory);
|
|
688
|
+
const summary = await gitOperations.status(directory);
|
|
689
|
+
return { content: [{ type: "text", text: JSON.stringify(summary) }], details: { summary } };
|
|
690
|
+
},
|
|
691
|
+
renderCall(args, theme, context) {
|
|
692
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
693
|
+
text.setText(formatGitStatusCall(args as { directory?: unknown }, theme));
|
|
694
|
+
return text;
|
|
695
|
+
},
|
|
696
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
697
|
+
if (isPartial) return new Text(theme.fg("warning", "Checking status..."), 0, 0);
|
|
698
|
+
if (context.isError) {
|
|
699
|
+
const errorText = result.content
|
|
700
|
+
.filter((block) => block.type === "text")
|
|
701
|
+
.map((block) => block.text)
|
|
702
|
+
.join("\n");
|
|
703
|
+
return new Text(theme.fg("error", errorText || "git_status failed"), 0, 0);
|
|
704
|
+
}
|
|
705
|
+
const details = result.details as { summary?: GitStatusSummary } | undefined;
|
|
706
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
707
|
+
text.setText(formatGitStatusResult(details?.summary, expanded, theme));
|
|
708
|
+
return text;
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
pi.registerTool({
|
|
713
|
+
name: "git_log",
|
|
714
|
+
label: "Git Log",
|
|
715
|
+
description:
|
|
716
|
+
"Recent commits for a real git repository, most recent first, bounded to maxCount. Fails clearly if `directory` is not inside a git repository.",
|
|
717
|
+
promptSnippet: "List a repository's recent commits",
|
|
718
|
+
parameters: Type.Object({
|
|
719
|
+
directory: Type.String({ description: "Directory inside the repository to check, absolute or relative to the current working directory" }),
|
|
720
|
+
maxCount: Type.Number({ description: "Maximum number of commits to return, most recent first" }),
|
|
721
|
+
}),
|
|
722
|
+
async execute(_toolCallId, params) {
|
|
723
|
+
const directory = resolve(cwd, params.directory);
|
|
724
|
+
const entries = await gitOperations.log(directory, params.maxCount);
|
|
725
|
+
const text =
|
|
726
|
+
entries.length === 0 ? "No commits found." : entries.map((e) => `${e.sha.slice(0, 8)} ${e.authoredAt} ${e.authorName} -- ${e.message}`).join("\n");
|
|
727
|
+
return { content: [{ type: "text", text }], details: { entries } };
|
|
728
|
+
},
|
|
729
|
+
renderCall(args, theme, context) {
|
|
730
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
731
|
+
text.setText(formatGitLogCall(args as { directory?: unknown; maxCount?: unknown }, theme));
|
|
732
|
+
return text;
|
|
733
|
+
},
|
|
734
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
735
|
+
if (isPartial) return new Text(theme.fg("warning", "Reading log..."), 0, 0);
|
|
736
|
+
if (context.isError) {
|
|
737
|
+
const errorText = result.content
|
|
738
|
+
.filter((block) => block.type === "text")
|
|
739
|
+
.map((block) => block.text)
|
|
740
|
+
.join("\n");
|
|
741
|
+
return new Text(theme.fg("error", errorText || "git_log failed"), 0, 0);
|
|
742
|
+
}
|
|
743
|
+
const details = result.details as { entries?: readonly GitLogEntry[] } | undefined;
|
|
744
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
745
|
+
text.setText(formatGitLogResult(details?.entries, expanded, theme));
|
|
746
|
+
return text;
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
pi.registerTool({
|
|
751
|
+
name: "git_diff",
|
|
752
|
+
label: "Git Diff",
|
|
753
|
+
description:
|
|
754
|
+
"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.",
|
|
755
|
+
promptSnippet: "Show a repository's working tree diff",
|
|
756
|
+
parameters: Type.Object({
|
|
757
|
+
directory: Type.String({ description: "Directory inside the repository to check, absolute or relative to the current working directory" }),
|
|
758
|
+
ref: Type.Optional(Type.String({ description: "Ref to diff against; defaults to HEAD" })),
|
|
759
|
+
maxBytes: Type.Number({ description: "Maximum diff size in bytes before truncating" }),
|
|
760
|
+
}),
|
|
761
|
+
async execute(_toolCallId, params) {
|
|
762
|
+
const directory = resolve(cwd, params.directory);
|
|
763
|
+
const result = await gitOperations.diff(directory, params.ref, params.maxBytes);
|
|
764
|
+
return { content: [{ type: "text", text: result.diff.length === 0 ? "No differences." : result.diff }], details: { result } };
|
|
765
|
+
},
|
|
766
|
+
renderCall(args, theme, context) {
|
|
767
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
768
|
+
text.setText(formatGitDiffCall(args as { directory?: unknown; ref?: unknown }, theme));
|
|
769
|
+
return text;
|
|
770
|
+
},
|
|
771
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
772
|
+
if (isPartial) return new Text(theme.fg("warning", "Computing diff..."), 0, 0);
|
|
773
|
+
if (context.isError) {
|
|
774
|
+
const errorText = result.content
|
|
775
|
+
.filter((block) => block.type === "text")
|
|
776
|
+
.map((block) => block.text)
|
|
777
|
+
.join("\n");
|
|
778
|
+
return new Text(theme.fg("error", errorText || "git_diff failed"), 0, 0);
|
|
779
|
+
}
|
|
780
|
+
const details = result.details as { result?: GitDiffResult } | undefined;
|
|
781
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
782
|
+
text.setText(formatGitDiffResult(details?.result, expanded, theme));
|
|
783
|
+
return text;
|
|
784
|
+
},
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
const searchOperations = createLectorSearchOperations();
|
|
788
|
+
pi.registerTool({
|
|
789
|
+
name: "search_code",
|
|
790
|
+
label: "Search Code",
|
|
791
|
+
description:
|
|
792
|
+
"Multi-file text/regex search scoped to a real project directory, backed by ripgrep -- respects .gitignore, skips node_modules/.git/build output. Bounded by maxMatches and maxBytes; results are cached.",
|
|
793
|
+
promptSnippet: "Search a project's files for a pattern",
|
|
794
|
+
parameters: Type.Object({
|
|
795
|
+
directory: Type.String({ description: "Directory inside the project to search, absolute or relative to the current working directory" }),
|
|
796
|
+
query: Type.String({ description: "Text or regex pattern to search for" }),
|
|
797
|
+
maxMatches: Type.Number({ description: "Maximum number of matches to return before truncating" }),
|
|
798
|
+
maxBytes: Type.Number({ description: "Maximum total bytes of matched line text before truncating" }),
|
|
799
|
+
}),
|
|
800
|
+
async execute(_toolCallId, params) {
|
|
801
|
+
const directory = resolve(cwd, params.directory);
|
|
802
|
+
const result = await searchOperations.search(params.query, directory, params.maxMatches, params.maxBytes);
|
|
803
|
+
const text =
|
|
804
|
+
result.matches.length === 0 ? "No matches found." : result.matches.map((m) => `${m.path}:${m.lineNumber}: ${m.line.replace(/\n$/, "")}`).join("\n");
|
|
805
|
+
return { content: [{ type: "text", text }], details: { result } };
|
|
806
|
+
},
|
|
807
|
+
renderCall(args, theme, context) {
|
|
808
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
809
|
+
text.setText(formatSearchCall(args as { directory?: unknown; query?: unknown }, theme));
|
|
810
|
+
return text;
|
|
811
|
+
},
|
|
812
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
813
|
+
if (isPartial) return new Text(theme.fg("warning", "Searching..."), 0, 0);
|
|
814
|
+
if (context.isError) {
|
|
815
|
+
const errorText = result.content
|
|
816
|
+
.filter((block) => block.type === "text")
|
|
817
|
+
.map((block) => block.text)
|
|
818
|
+
.join("\n");
|
|
819
|
+
return new Text(theme.fg("error", errorText || "search_code failed"), 0, 0);
|
|
820
|
+
}
|
|
821
|
+
const details = result.details as { result?: TextSearchResult } | undefined;
|
|
822
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
823
|
+
text.setText(formatSearchResult(details?.result, expanded, theme));
|
|
824
|
+
return text;
|
|
825
|
+
},
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
const repoFetchOperations = createLectorRepoFetchOperations();
|
|
829
|
+
pi.registerTool({
|
|
830
|
+
name: "repo_fetch",
|
|
831
|
+
label: "Repo Fetch",
|
|
832
|
+
description:
|
|
833
|
+
"Shallow-clones an external repository into a disk-bounded cache and registers it as a read-only project -- every other tool (search_code, find_symbols, go_to_definition, ...) then works on it unchanged. Explicit owner/repo[@ref] only, no discovery/search -- use web_fetch to find candidates first.",
|
|
834
|
+
promptSnippet: "Fetch an external open-source repo to search or analyze",
|
|
835
|
+
parameters: Type.Object({
|
|
836
|
+
owner: Type.String({ description: "Repository owner or organization" }),
|
|
837
|
+
repo: Type.String({ description: "Repository name" }),
|
|
838
|
+
ref: Type.Optional(Type.String({ description: "Branch, tag, or commit to fetch; defaults to the repository's default branch" })),
|
|
839
|
+
host: Type.Optional(Type.String({ description: "Git host; defaults to github.com" })),
|
|
840
|
+
}),
|
|
841
|
+
async execute(_toolCallId, params) {
|
|
842
|
+
const result = await repoFetchOperations.fetch(params.host ?? "github.com", params.owner, params.repo, params.ref ?? null);
|
|
843
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: { result } };
|
|
844
|
+
},
|
|
845
|
+
renderCall(args, theme, context) {
|
|
846
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
847
|
+
text.setText(formatRepoFetchCall(args as { owner?: unknown; repo?: unknown; ref?: unknown; host?: unknown }, theme));
|
|
848
|
+
return text;
|
|
849
|
+
},
|
|
850
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
851
|
+
if (isPartial) return new Text(theme.fg("warning", "Fetching repository..."), 0, 0);
|
|
852
|
+
if (context.isError) {
|
|
853
|
+
const errorText = result.content
|
|
854
|
+
.filter((block) => block.type === "text")
|
|
855
|
+
.map((block) => block.text)
|
|
856
|
+
.join("\n");
|
|
857
|
+
return new Text(theme.fg("error", errorText || "repo_fetch failed"), 0, 0);
|
|
858
|
+
}
|
|
859
|
+
const details = result.details as { result?: RepoFetchResult & { workspaceId: string } } | undefined;
|
|
860
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
861
|
+
text.setText(formatRepoFetchResult(details?.result, theme));
|
|
862
|
+
return text;
|
|
863
|
+
},
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
const crossWorkspaceSearchOperations = createLectorCrossWorkspaceSearchOperations();
|
|
867
|
+
pi.registerTool({
|
|
868
|
+
name: "find_symbols_across_projects",
|
|
869
|
+
label: "Find Symbols Across Projects",
|
|
870
|
+
description:
|
|
871
|
+
"Fans out a symbol-name search across several explicitly-named project directories at once (e.g. several fetched repos, or a handful of related local projects) and reports one outcome per project -- ready with real results, loading (a project's language server is still cold-starting; retry shortly), or error. Directories are required and explicit -- never every project this daemon happens to have registered, which can include unrelated projects from other concurrent sessions.",
|
|
872
|
+
promptSnippet: "Search for a symbol name across several projects at once",
|
|
873
|
+
parameters: Type.Object({
|
|
874
|
+
directories: Type.Array(Type.String(), { description: "Project directories to search, each absolute or relative to the current working directory" }),
|
|
875
|
+
query: Type.String({ description: "Symbol name (or substring) to search for" }),
|
|
876
|
+
timeoutMs: Type.Optional(Type.Number({ description: "How long to wait per project before reporting it as still-loading; defaults to 3000" })),
|
|
877
|
+
}),
|
|
878
|
+
async execute(_toolCallId, params) {
|
|
879
|
+
const directories = params.directories.map((directory) => resolve(cwd, directory));
|
|
880
|
+
const results = await crossWorkspaceSearchOperations.findSymbols(params.query, directories, params.timeoutMs);
|
|
881
|
+
return { content: [{ type: "text", text: JSON.stringify(results) }], details: { results } };
|
|
882
|
+
},
|
|
883
|
+
renderCall(args, theme, context) {
|
|
884
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
885
|
+
text.setText(formatCrossWorkspaceCall(args as { directories?: unknown; query?: unknown }, theme));
|
|
886
|
+
return text;
|
|
887
|
+
},
|
|
888
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
889
|
+
if (isPartial) return new Text(theme.fg("warning", "Searching across projects..."), 0, 0);
|
|
890
|
+
if (context.isError) {
|
|
891
|
+
const errorText = result.content
|
|
892
|
+
.filter((block) => block.type === "text")
|
|
893
|
+
.map((block) => block.text)
|
|
894
|
+
.join("\n");
|
|
895
|
+
return new Text(theme.fg("error", errorText || "find_symbols_across_projects failed"), 0, 0);
|
|
896
|
+
}
|
|
897
|
+
const details = result.details as { results?: readonly WorkspaceQueryOutcome<{ symbols: readonly WorkspaceSymbol[] }>[] } | undefined;
|
|
898
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
899
|
+
text.setText(formatFindSymbolsAcrossProjectsResult(details?.results, expanded, theme));
|
|
900
|
+
return text;
|
|
901
|
+
},
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
pi.registerTool({
|
|
905
|
+
name: "search_code_across_projects",
|
|
906
|
+
label: "Search Code Across Projects",
|
|
907
|
+
description:
|
|
908
|
+
"Fans out a ripgrep-backed text/regex search across several explicitly-named project directories at once and reports one outcome per project. Directories are required and explicit -- never every project this daemon happens to have registered, which can include unrelated projects from other concurrent sessions.",
|
|
909
|
+
promptSnippet: "Search for a pattern across several projects at once",
|
|
910
|
+
parameters: Type.Object({
|
|
911
|
+
directories: Type.Array(Type.String(), { description: "Project directories to search, each absolute or relative to the current working directory" }),
|
|
912
|
+
query: Type.String({ description: "Text or regex pattern to search for" }),
|
|
913
|
+
maxMatches: Type.Number({ description: "Maximum number of matches to return per project before truncating" }),
|
|
914
|
+
maxBytes: Type.Number({ description: "Maximum total bytes of matched line text to return per project before truncating" }),
|
|
915
|
+
timeoutMs: Type.Optional(Type.Number({ description: "How long to wait per project before reporting it as still-loading; defaults to 3000" })),
|
|
916
|
+
}),
|
|
917
|
+
async execute(_toolCallId, params) {
|
|
918
|
+
const directories = params.directories.map((directory) => resolve(cwd, directory));
|
|
919
|
+
const results = await crossWorkspaceSearchOperations.searchText(params.query, directories, params.maxMatches, params.maxBytes, params.timeoutMs);
|
|
920
|
+
return { content: [{ type: "text", text: JSON.stringify(results) }], details: { results } };
|
|
921
|
+
},
|
|
922
|
+
renderCall(args, theme, context) {
|
|
923
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
924
|
+
text.setText(formatCrossWorkspaceCall(args as { directories?: unknown; query?: unknown }, theme));
|
|
925
|
+
return text;
|
|
926
|
+
},
|
|
927
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
928
|
+
if (isPartial) return new Text(theme.fg("warning", "Searching across projects..."), 0, 0);
|
|
929
|
+
if (context.isError) {
|
|
930
|
+
const errorText = result.content
|
|
931
|
+
.filter((block) => block.type === "text")
|
|
932
|
+
.map((block) => block.text)
|
|
933
|
+
.join("\n");
|
|
934
|
+
return new Text(theme.fg("error", errorText || "search_code_across_projects failed"), 0, 0);
|
|
935
|
+
}
|
|
936
|
+
const details = result.details as { results?: readonly WorkspaceQueryOutcome<TextSearchResult>[] } | undefined;
|
|
937
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
938
|
+
text.setText(formatSearchTextAcrossProjectsResult(details?.results, expanded, theme));
|
|
939
|
+
return text;
|
|
940
|
+
},
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
}
|