@danypops/pi-lector 0.1.12 → 0.2.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/extension/src/apply-patch-operations.ts +29 -0
- package/extension/src/apply-patch-rendering.ts +12 -0
- package/extension/src/code-intelligence-operations.ts +20 -4
- package/extension/src/code-intelligence-rendering.ts +29 -0
- package/extension/src/find-files-operations.ts +25 -0
- package/extension/src/find-files-rendering.ts +21 -0
- package/extension/src/find-symbols-operations.ts +4 -4
- package/extension/src/index.ts +375 -2
- package/extension/src/line-edit-operations.ts +34 -0
- package/extension/src/line-edit-rendering.ts +13 -0
- package/extension/src/symbol-annotation-operations.ts +99 -0
- package/extension/src/symbol-annotation-rendering.ts +26 -0
- package/package.json +2 -2
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ContentHash, EditOutcome } from "@danypops/lector";
|
|
2
|
+
import { lectorClient, withWorkspace, workspaceForPath } from "./lector-client.ts";
|
|
3
|
+
import { toWorkspaceRelativePath } from "./workspace-relative-path.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Thin wrapper over workspace.applyPatch -- distinct from both the generic edit tool
|
|
7
|
+
* (whole-file replace) and line_edit (per-line hash guards): applies a real unified diff's
|
|
8
|
+
* hunks, guarded by one whole-file expectedHash (a patch inherently describes a
|
|
9
|
+
* whole-file transformation from a known pre-image). `path` is an absolute file path, the
|
|
10
|
+
* same convention edit-operations.ts and line-edit-operations.ts already use.
|
|
11
|
+
*/
|
|
12
|
+
export interface ApplyPatchOperations {
|
|
13
|
+
applyPatch(path: string, expectedHash: ContentHash, patchText: string): Promise<EditOutcome>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createLectorApplyPatchOperations(): ApplyPatchOperations {
|
|
17
|
+
return {
|
|
18
|
+
async applyPatch(path, expectedHash, patchText) {
|
|
19
|
+
return withWorkspace(
|
|
20
|
+
() => workspaceForPath(path),
|
|
21
|
+
async ({ workspaceId, root }) => {
|
|
22
|
+
const client = await lectorClient();
|
|
23
|
+
const relativePath = toWorkspaceRelativePath(root, path);
|
|
24
|
+
return client.call("workspace.applyPatch", { workspaceId, path: relativePath, expectedHash, patchText });
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { EditOutcome } from "@danypops/lector";
|
|
2
|
+
import type { LectorTheme } from "./lector-tui-theme.ts";
|
|
3
|
+
|
|
4
|
+
export function formatApplyPatchCall(args: { path?: unknown }, theme: LectorTheme): string {
|
|
5
|
+
const path = typeof args.path === "string" ? args.path : "";
|
|
6
|
+
return `${theme.fg("toolTitle", theme.bold("apply_patch"))} ${theme.fg("accent", path)}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function formatApplyPatchResult(result: EditOutcome | undefined, theme: LectorTheme): string {
|
|
10
|
+
if (!result) return theme.fg("dim", "No result.");
|
|
11
|
+
return theme.fg("accent", `${result.path}: ${result.previousHash ?? "(new)"} -> ${result.newHash}`);
|
|
12
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { JobSnapshot, OperationOutputs, PopulateSymbolGraphResult, SymbolEdgeKind, SymbolNode } from "@danypops/lector";
|
|
1
|
+
import type { JobSnapshot, OperationInputs, OperationOutputs, PopulateSymbolGraphResult, SymbolEdgeKind, SymbolNode } from "@danypops/lector";
|
|
2
2
|
import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath } from "./lector-client.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -17,7 +17,13 @@ import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath } from ".
|
|
|
17
17
|
export interface CodeIntelligenceOperations {
|
|
18
18
|
goToDefinition(path: string, line: number, character: number): Promise<OperationOutputs["workspace.goToDefinition"]>;
|
|
19
19
|
goToImplementation(path: string, line: number, character: number): Promise<OperationOutputs["workspace.goToImplementation"]>;
|
|
20
|
-
findReferences(
|
|
20
|
+
findReferences(
|
|
21
|
+
path: string,
|
|
22
|
+
line: number,
|
|
23
|
+
character: number,
|
|
24
|
+
includeDeclaration: boolean,
|
|
25
|
+
responseFormat?: OperationInputs["workspace.findReferences"]["responseFormat"],
|
|
26
|
+
): Promise<OperationOutputs["workspace.findReferences"]>;
|
|
21
27
|
hover(path: string, line: number, character: number): Promise<OperationOutputs["workspace.hover"]>;
|
|
22
28
|
documentSymbols(path: string): Promise<OperationOutputs["workspace.documentSymbols"]>;
|
|
23
29
|
diagnostics(path: string): Promise<OperationOutputs["workspace.diagnostics"]>;
|
|
@@ -29,6 +35,7 @@ export interface CodeIntelligenceOperations {
|
|
|
29
35
|
reachableFrom(path: string, line: number, character: number, maxDepth: number, kind?: SymbolEdgeKind): Promise<readonly SymbolNode[]>;
|
|
30
36
|
/** Never spawns a symbol index -- safe to call opportunistically (e.g. before deciding whether to enrich a result). */
|
|
31
37
|
hasWarmIndex(path: string): Promise<boolean>;
|
|
38
|
+
workspaceMap(path: string, maxNodes: number, maxEdges: number, maxEntries: number, maxBytes: number): Promise<OperationOutputs["workspace.map"]>;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
export function createLectorCodeIntelligenceOperations(): CodeIntelligenceOperations {
|
|
@@ -51,12 +58,12 @@ export function createLectorCodeIntelligenceOperations(): CodeIntelligenceOperat
|
|
|
51
58
|
},
|
|
52
59
|
);
|
|
53
60
|
},
|
|
54
|
-
async findReferences(path, line, character, includeDeclaration) {
|
|
61
|
+
async findReferences(path, line, character, includeDeclaration, responseFormat) {
|
|
55
62
|
return withWorkspace(
|
|
56
63
|
() => workspaceForCodeIntelligencePath(path),
|
|
57
64
|
async ({ workspaceId }) => {
|
|
58
65
|
const client = await lectorClient();
|
|
59
|
-
return client.call("workspace.findReferences", { workspaceId, path, line, character, includeDeclaration });
|
|
66
|
+
return client.call("workspace.findReferences", { workspaceId, path, line, character, includeDeclaration, responseFormat });
|
|
60
67
|
},
|
|
61
68
|
);
|
|
62
69
|
},
|
|
@@ -153,5 +160,14 @@ export function createLectorCodeIntelligenceOperations(): CodeIntelligenceOperat
|
|
|
153
160
|
},
|
|
154
161
|
);
|
|
155
162
|
},
|
|
163
|
+
async workspaceMap(path, maxNodes, maxEdges, maxEntries, maxBytes) {
|
|
164
|
+
return withWorkspace(
|
|
165
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
166
|
+
async ({ workspaceId }) => {
|
|
167
|
+
const client = await lectorClient();
|
|
168
|
+
return client.call("workspace.map", { workspaceId, maxNodes, maxEdges, maxEntries, maxBytes });
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
},
|
|
156
172
|
};
|
|
157
173
|
}
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
PopulateSymbolGraphResult,
|
|
10
10
|
SymbolNode,
|
|
11
11
|
WorkspaceLocation,
|
|
12
|
+
WorkspaceMapResult,
|
|
12
13
|
} from "@danypops/lector";
|
|
13
14
|
import { keyHint, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
14
15
|
import { colorForKind, formatLocation, type LectorTheme } from "./lector-tui-theme.ts";
|
|
@@ -239,3 +240,31 @@ export function formatReachableFromResult(symbols: readonly SymbolNode[] | undef
|
|
|
239
240
|
if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
|
|
240
241
|
return lines.join("\n");
|
|
241
242
|
}
|
|
243
|
+
|
|
244
|
+
export function formatWorkspaceMapCall(args: { path?: unknown; maxEntries?: unknown }, theme: LectorTheme): string {
|
|
245
|
+
const path = typeof args.path === "string" ? args.path : "";
|
|
246
|
+
const maxEntries = typeof args.maxEntries === "number" ? ` (top ${args.maxEntries})` : "";
|
|
247
|
+
return `${theme.fg("toolTitle", theme.bold("workspace_map"))} ${theme.fg("dim", path)}${theme.fg("muted", maxEntries)}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function formatWorkspaceMapResult(result: WorkspaceMapResult | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
251
|
+
if (!result || result.entries.length === 0) return theme.fg("dim", "No ranked symbols (has the graph been populated for this workspace?).");
|
|
252
|
+
|
|
253
|
+
const displayCount = expanded ? result.entries.length : Math.min(result.entries.length, DEFAULT_VISIBLE_SYMBOLS);
|
|
254
|
+
const lines = [
|
|
255
|
+
theme.fg(
|
|
256
|
+
"muted",
|
|
257
|
+
`${result.entries.length} of ${result.totalRanked} ranked symbol${result.totalRanked === 1 ? "" : "s"}, most structurally central first:`,
|
|
258
|
+
),
|
|
259
|
+
];
|
|
260
|
+
for (const entry of result.entries.slice(0, displayCount)) {
|
|
261
|
+
const signature = entry.signature ? ` -- ${entry.signature}` : "";
|
|
262
|
+
lines.push(
|
|
263
|
+
` ${theme.fg(colorForKind(entry.kind), entry.kind)} ${theme.bold(entry.name)} ${formatLocation(theme, entry.path, entry.line, entry.character)}${signature}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
const remaining = displayCount - result.entries.length;
|
|
267
|
+
if (remaining < 0) lines.push(theme.fg("dim", `... ${-remaining} more (${keyHint("app.tools.expand", "to expand")})`));
|
|
268
|
+
if (result.truncated) lines.push(theme.fg("warning", "budget-truncated -- raise --max-entries/--max-bytes for more"));
|
|
269
|
+
return lines.join("\n");
|
|
270
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { FindFilesResult } from "@danypops/lector";
|
|
2
|
+
import { lectorClient, withWorkspace, workspaceForDirectory } from "./lector-client.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Thin wrapper over workspace.findFiles -- the `find`-shaped half of the classic grep+find pair,
|
|
6
|
+
* distinct from search_code (content). `directory` is required, same convention as
|
|
7
|
+
* find_symbols/search_code -- no implicit "whatever the session's cwd is" fallback.
|
|
8
|
+
*/
|
|
9
|
+
export interface FindFilesOperations {
|
|
10
|
+
findFiles(patterns: readonly string[], directory: string, maxResults: number, maxBytes: number): Promise<FindFilesResult>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createLectorFindFilesOperations(): FindFilesOperations {
|
|
14
|
+
return {
|
|
15
|
+
async findFiles(patterns, directory, maxResults, maxBytes) {
|
|
16
|
+
return withWorkspace(
|
|
17
|
+
() => workspaceForDirectory(directory),
|
|
18
|
+
async ({ workspaceId }) => {
|
|
19
|
+
const client = await lectorClient();
|
|
20
|
+
return client.call("workspace.findFiles", { workspaceId, patterns, maxResults, maxBytes });
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FindFilesResult } from "@danypops/lector";
|
|
2
|
+
import { keyHint } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { LectorTheme } from "./lector-tui-theme.ts";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_VISIBLE_PATHS = 40;
|
|
6
|
+
|
|
7
|
+
export function formatFindFilesCall(args: { directory?: unknown; patterns?: unknown }, theme: LectorTheme): string {
|
|
8
|
+
const directory = typeof args.directory === "string" ? args.directory : "";
|
|
9
|
+
const patterns = Array.isArray(args.patterns) ? args.patterns.filter((p): p is string => typeof p === "string") : [];
|
|
10
|
+
return `${theme.fg("toolTitle", theme.bold("find_files"))} ${theme.fg("accent", patterns.map((p) => `"${p}"`).join(", "))} ${theme.fg("dim", directory)}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatFindFilesResult(result: FindFilesResult | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
14
|
+
if (!result || result.paths.length === 0) return theme.fg("dim", "No files found.");
|
|
15
|
+
const displayCount = expanded ? result.paths.length : Math.min(DEFAULT_VISIBLE_PATHS, result.paths.length);
|
|
16
|
+
const lines = result.paths.slice(0, displayCount).map((path) => theme.fg("accent", path));
|
|
17
|
+
const remaining = result.paths.length - displayCount;
|
|
18
|
+
if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
|
|
19
|
+
if (result.truncated) lines.push(theme.fg("warning", "(listing itself was truncated by maxResults/maxBytes -- results are incomplete)"));
|
|
20
|
+
return lines.join("\n");
|
|
21
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SymbolSearchResult } from "@danypops/lector";
|
|
1
|
+
import type { OperationInputs, SymbolSearchResult } from "@danypops/lector";
|
|
2
2
|
import { lectorClient, withWorkspace, workspaceForDirectory } from "./lector-client.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -15,17 +15,17 @@ import { lectorClient, withWorkspace, workspaceForDirectory } from "./lector-cli
|
|
|
15
15
|
* is no implicit fallback anywhere in this module.
|
|
16
16
|
*/
|
|
17
17
|
export interface FindSymbolsOperations {
|
|
18
|
-
findSymbols(query: string, directory: string): Promise<SymbolSearchResult>;
|
|
18
|
+
findSymbols(query: string, directory: string, responseFormat?: OperationInputs["workspace.findSymbols"]["responseFormat"]): Promise<SymbolSearchResult>;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function createLectorFindSymbolsOperations(): FindSymbolsOperations {
|
|
22
22
|
return {
|
|
23
|
-
async findSymbols(query, directory) {
|
|
23
|
+
async findSymbols(query, directory, responseFormat) {
|
|
24
24
|
return withWorkspace(
|
|
25
25
|
() => workspaceForDirectory(directory),
|
|
26
26
|
async ({ workspaceId }) => {
|
|
27
27
|
const client = await lectorClient();
|
|
28
|
-
return client.call("workspace.findSymbols", { workspaceId, query });
|
|
28
|
+
return client.call("workspace.findSymbols", { workspaceId, query, responseFormat });
|
|
29
29
|
},
|
|
30
30
|
);
|
|
31
31
|
},
|
package/extension/src/index.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import type {
|
|
3
3
|
CallHierarchyEntry,
|
|
4
|
+
ContentHash,
|
|
4
5
|
Diagnostic,
|
|
5
6
|
DocumentSymbolEntry,
|
|
7
|
+
EditOutcome,
|
|
8
|
+
FindFilesResult,
|
|
6
9
|
GitDiffResult,
|
|
7
10
|
GitLogEntry,
|
|
8
11
|
GitStatusSummary,
|
|
@@ -10,19 +13,25 @@ import type {
|
|
|
10
13
|
IncomingCall,
|
|
11
14
|
IntelligenceProvenance,
|
|
12
15
|
JobSnapshot,
|
|
16
|
+
LineEdit,
|
|
17
|
+
LineEditOutcome,
|
|
13
18
|
OutgoingCall,
|
|
14
19
|
PackageSourceOperationResult,
|
|
15
20
|
PopulateSymbolGraphResult,
|
|
16
21
|
RepoFetchResult,
|
|
22
|
+
SymbolAnnotation,
|
|
17
23
|
SymbolNode,
|
|
18
24
|
SymbolSearchResult,
|
|
19
25
|
TextSearchResult,
|
|
20
26
|
WorkspaceLocation,
|
|
27
|
+
WorkspaceMapResult,
|
|
21
28
|
WorkspaceQueryOutcome,
|
|
22
29
|
} from "@danypops/lector";
|
|
23
30
|
import { createEditToolDefinition, createReadToolDefinition, createWriteToolDefinition, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
24
31
|
import { Text } from "@earendil-works/pi-tui";
|
|
25
32
|
import { Type } from "typebox";
|
|
33
|
+
import { createLectorApplyPatchOperations } from "./apply-patch-operations.ts";
|
|
34
|
+
import { formatApplyPatchCall, formatApplyPatchResult } from "./apply-patch-rendering.ts";
|
|
26
35
|
import { createLectorCodeIntelligenceOperations } from "./code-intelligence-operations.ts";
|
|
27
36
|
import {
|
|
28
37
|
describePopulateSymbolGraphJob,
|
|
@@ -48,14 +57,20 @@ import {
|
|
|
48
57
|
formatPrepareCallHierarchyResult,
|
|
49
58
|
formatReachableFromCall,
|
|
50
59
|
formatReachableFromResult,
|
|
60
|
+
formatWorkspaceMapCall,
|
|
61
|
+
formatWorkspaceMapResult,
|
|
51
62
|
} from "./code-intelligence-rendering.ts";
|
|
52
63
|
import { createLectorCrossWorkspaceSearchOperations } from "./cross-workspace-search-operations.ts";
|
|
53
64
|
import { formatCrossWorkspaceCall, formatFindSymbolsAcrossProjectsResult, formatSearchTextAcrossProjectsResult } from "./cross-workspace-search-rendering.ts";
|
|
54
65
|
import { createLectorEditOperations } from "./edit-operations.ts";
|
|
66
|
+
import { createLectorFindFilesOperations } from "./find-files-operations.ts";
|
|
67
|
+
import { formatFindFilesCall, formatFindFilesResult } from "./find-files-rendering.ts";
|
|
55
68
|
import { createLectorFindSymbolsOperations } from "./find-symbols-operations.ts";
|
|
56
69
|
import { describeFindSymbolSources, formatFindSymbolsCall, formatFindSymbolsResult } from "./find-symbols-rendering.ts";
|
|
57
70
|
import { createLectorGitOperations } from "./git-operations.ts";
|
|
58
71
|
import { formatGitDiffCall, formatGitDiffResult, formatGitLogCall, formatGitLogResult, formatGitStatusCall, formatGitStatusResult } from "./git-rendering.ts";
|
|
72
|
+
import { createLectorLineEditOperations } from "./line-edit-operations.ts";
|
|
73
|
+
import { formatLineEditCall, formatLineEditResult } from "./line-edit-rendering.ts";
|
|
59
74
|
import { nearestGitRoot } from "./nearest-workspace-root.ts";
|
|
60
75
|
import { createLectorPackageSourceOperations } from "./package-source-operations.ts";
|
|
61
76
|
import { formatPackageSourceCall, formatPackageSourceResult } from "./package-source-rendering.ts";
|
|
@@ -64,6 +79,8 @@ import { createLectorRepoFetchOperations } from "./repo-fetch-operations.ts";
|
|
|
64
79
|
import { formatRepoFetchCall, formatRepoFetchResult } from "./repo-fetch-rendering.ts";
|
|
65
80
|
import { createLectorSearchOperations } from "./search-operations.ts";
|
|
66
81
|
import { formatSearchCall, formatSearchResult } from "./search-rendering.ts";
|
|
82
|
+
import { type AnnotationAnchorInput, createLectorSymbolAnnotationOperations } from "./symbol-annotation-operations.ts";
|
|
83
|
+
import { formatAnnotationDetail, formatAnnotationListSummary, formatAnnotationSummary } from "./symbol-annotation-rendering.ts";
|
|
67
84
|
import {
|
|
68
85
|
type CachePresentationState,
|
|
69
86
|
cacheContextMessage,
|
|
@@ -180,10 +197,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
180
197
|
parameters: Type.Object({
|
|
181
198
|
query: Type.String({ description: "Name or substring to search for, case-insensitive" }),
|
|
182
199
|
directory: Type.String({ description: "Directory of the project to search, absolute or relative to the current working directory" }),
|
|
200
|
+
responseFormat: Type.Optional(
|
|
201
|
+
Type.Union([Type.Literal("concise"), Type.Literal("detailed")], {
|
|
202
|
+
description:
|
|
203
|
+
'"concise" (default "detailed") drops containerName and per-symbol/top-level provenance detail to reduce payload size when you only need name/kind/location',
|
|
204
|
+
}),
|
|
205
|
+
),
|
|
183
206
|
}),
|
|
184
207
|
async execute(_toolCallId, params) {
|
|
185
208
|
const directory = resolve(cwd, params.directory);
|
|
186
|
-
const result = await findSymbolsOperations.findSymbols(params.query, directory);
|
|
209
|
+
const result = await findSymbolsOperations.findSymbols(params.query, directory, params.responseFormat);
|
|
187
210
|
const { symbols, provenance, truncated } = result;
|
|
188
211
|
const source = `${provenance.fidelity} via ${provenance.backend}${truncated ? " (truncated)" : ""}`;
|
|
189
212
|
const sourceDetails = describeFindSymbolSources(result);
|
|
@@ -311,10 +334,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
311
334
|
parameters: Type.Object({
|
|
312
335
|
...positionParameters,
|
|
313
336
|
includeDeclaration: Type.Boolean({ description: "Include the declaration site itself among the results" }),
|
|
337
|
+
responseFormat: Type.Optional(
|
|
338
|
+
Type.Union([Type.Literal("concise"), Type.Literal("detailed")], {
|
|
339
|
+
description: '"concise" (default "detailed") narrows the provenance detail to reduce payload size',
|
|
340
|
+
}),
|
|
341
|
+
),
|
|
314
342
|
}),
|
|
315
343
|
async execute(_toolCallId, params) {
|
|
316
344
|
const path = resolve(cwd, params.path);
|
|
317
|
-
const details = await codeIntelligenceOperations.findReferences(path, params.line, params.character, params.includeDeclaration);
|
|
345
|
+
const details = await codeIntelligenceOperations.findReferences(path, params.line, params.character, params.includeDeclaration, params.responseFormat);
|
|
318
346
|
const text = details.locations.length === 0 ? "No references found." : details.locations.map((l) => `${l.path}:${l.line}:${l.character}`).join("\n");
|
|
319
347
|
return { content: [{ type: "text", text: `${describeIntelligenceSource(details.provenance)}\n${text}` }], details };
|
|
320
348
|
},
|
|
@@ -648,6 +676,138 @@ export default function (pi: ExtensionAPI) {
|
|
|
648
676
|
},
|
|
649
677
|
});
|
|
650
678
|
|
|
679
|
+
const symbolAnnotationOperations = createLectorSymbolAnnotationOperations();
|
|
680
|
+
function resolveAnchorInputs(anchors: readonly { path: string; line: number; character: number }[]): AnnotationAnchorInput[] {
|
|
681
|
+
return anchors.map((anchor) => ({ path: resolve(cwd, anchor.path), line: anchor.line, character: anchor.character }));
|
|
682
|
+
}
|
|
683
|
+
interface SymbolAnnotationToolDetails {
|
|
684
|
+
annotation?: SymbolAnnotation;
|
|
685
|
+
annotations?: readonly SymbolAnnotation[];
|
|
686
|
+
scrubbed?: boolean;
|
|
687
|
+
restored?: boolean;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
pi.registerTool({
|
|
691
|
+
name: "symbol_annotations",
|
|
692
|
+
label: "Symbol Annotations",
|
|
693
|
+
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.',
|
|
695
|
+
promptSnippet: "Attach, read, or invalidate narrative annotations on the symbol graph",
|
|
696
|
+
promptGuidelines: [
|
|
697
|
+
"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
|
+
"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.",
|
|
699
|
+
],
|
|
700
|
+
parameters: Type.Object({
|
|
701
|
+
action: Type.String({ description: "create | get | list | refresh | scrub | restore" }),
|
|
702
|
+
path: Type.String({ description: "Absolute or cwd-relative path used to resolve which workspace this annotation belongs to" }),
|
|
703
|
+
id: Type.Optional(Type.String({ description: "Annotation id -- required for get/refresh/scrub/restore" })),
|
|
704
|
+
subtype: Type.Optional(Type.String({ description: 'Free-form kind, e.g. "user-story-dataflow" or "comment" -- required for create/refresh' })),
|
|
705
|
+
title: Type.Optional(Type.String({ description: "Required for create/refresh" })),
|
|
706
|
+
body: Type.Optional(Type.String({ description: "The narrative content -- required for create/refresh" })),
|
|
707
|
+
anchors: Type.Optional(
|
|
708
|
+
Type.Array(
|
|
709
|
+
Type.Object({
|
|
710
|
+
path: Type.String({ description: "Absolute or cwd-relative path to the anchored file" }),
|
|
711
|
+
line: Type.Number({ description: "1-indexed line number" }),
|
|
712
|
+
character: Type.Number({ description: "1-indexed character offset within the line" }),
|
|
713
|
+
}),
|
|
714
|
+
{ description: "At least one required for create/refresh -- each must resolve to a real, currently-known symbol" },
|
|
715
|
+
),
|
|
716
|
+
),
|
|
717
|
+
listStatus: Type.Optional(Type.String({ description: "fresh | stale | scrubbed -- for list; defaults to excluding scrubbed" })),
|
|
718
|
+
listSubtype: Type.Optional(Type.String({ description: "For list: filter by subtype" })),
|
|
719
|
+
maxResults: Type.Optional(Type.Number({ description: "For list: bounds the number of results" })),
|
|
720
|
+
}),
|
|
721
|
+
async execute(_toolCallId, params) {
|
|
722
|
+
const path = resolve(cwd, params.path);
|
|
723
|
+
const details: SymbolAnnotationToolDetails = {};
|
|
724
|
+
let text: string;
|
|
725
|
+
if (params.action === "create") {
|
|
726
|
+
if (!params.subtype || !params.title || params.body === undefined || !params.anchors || params.anchors.length === 0) {
|
|
727
|
+
throw new Error("symbol_annotations create requires subtype, title, body, and at least one anchor");
|
|
728
|
+
}
|
|
729
|
+
const { annotation } = await symbolAnnotationOperations.create(path, params.subtype, params.title, params.body, resolveAnchorInputs(params.anchors));
|
|
730
|
+
details.annotation = annotation;
|
|
731
|
+
text = formatAnnotationDetail(annotation);
|
|
732
|
+
} else if (params.action === "get") {
|
|
733
|
+
if (!params.id) throw new Error("symbol_annotations get requires id");
|
|
734
|
+
const { annotation } = await symbolAnnotationOperations.get(path, params.id);
|
|
735
|
+
details.annotation = annotation;
|
|
736
|
+
text = annotation ? formatAnnotationDetail(annotation) : `no annotation "${params.id}"`;
|
|
737
|
+
} else if (params.action === "list") {
|
|
738
|
+
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 });
|
|
740
|
+
details.annotations = annotations;
|
|
741
|
+
text = annotations.length === 0 ? "no annotations" : annotations.map(formatAnnotationDetail).join("\n\n");
|
|
742
|
+
} else if (params.action === "refresh") {
|
|
743
|
+
if (!params.id || !params.subtype || !params.title || params.body === undefined || !params.anchors || params.anchors.length === 0) {
|
|
744
|
+
throw new Error("symbol_annotations refresh requires id, subtype, title, body, and at least one anchor");
|
|
745
|
+
}
|
|
746
|
+
const { annotation } = await symbolAnnotationOperations.refresh(
|
|
747
|
+
path,
|
|
748
|
+
params.id,
|
|
749
|
+
params.subtype,
|
|
750
|
+
params.title,
|
|
751
|
+
params.body,
|
|
752
|
+
resolveAnchorInputs(params.anchors),
|
|
753
|
+
);
|
|
754
|
+
details.annotation = annotation;
|
|
755
|
+
text = annotation ? formatAnnotationDetail(annotation) : `no annotation "${params.id}"`;
|
|
756
|
+
} else if (params.action === "scrub") {
|
|
757
|
+
if (!params.id) throw new Error("symbol_annotations scrub requires id");
|
|
758
|
+
const { scrubbed } = await symbolAnnotationOperations.scrub(path, params.id);
|
|
759
|
+
details.scrubbed = scrubbed;
|
|
760
|
+
text = scrubbed ? `scrubbed ${params.id}` : `"${params.id}" was already scrubbed or does not exist`;
|
|
761
|
+
} else if (params.action === "restore") {
|
|
762
|
+
if (!params.id) throw new Error("symbol_annotations restore requires id");
|
|
763
|
+
const { restored } = await symbolAnnotationOperations.restore(path, params.id);
|
|
764
|
+
details.restored = restored;
|
|
765
|
+
text = restored ? `restored ${params.id}` : `"${params.id}" was not scrubbed or does not exist`;
|
|
766
|
+
} else {
|
|
767
|
+
throw new Error(`unknown symbol_annotations action: ${String(params.action)}`);
|
|
768
|
+
}
|
|
769
|
+
return { content: [{ type: "text", text }], details };
|
|
770
|
+
},
|
|
771
|
+
renderCall(args, theme, context) {
|
|
772
|
+
const action = typeof args.action === "string" ? args.action : "";
|
|
773
|
+
const id = typeof args.id === "string" ? ` ${args.id}` : "";
|
|
774
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
775
|
+
text.setText(`${theme.fg("toolTitle", theme.bold("symbol_annotations"))} ${theme.fg("accent", action)}${theme.fg("dim", id)}`);
|
|
776
|
+
return text;
|
|
777
|
+
},
|
|
778
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
779
|
+
if (isPartial) return new Text(theme.fg("warning", "Working on annotation..."), 0, 0);
|
|
780
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
781
|
+
if (context.isError) {
|
|
782
|
+
const errorText = result.content
|
|
783
|
+
.filter((block) => block.type === "text")
|
|
784
|
+
.map((block) => block.text)
|
|
785
|
+
.join("\n");
|
|
786
|
+
text.setText(theme.fg("error", errorText || "symbol_annotations failed"));
|
|
787
|
+
return text;
|
|
788
|
+
}
|
|
789
|
+
const details = result.details as SymbolAnnotationToolDetails | undefined;
|
|
790
|
+
if (details?.annotations) {
|
|
791
|
+
text.setText(formatAnnotationListSummary(details.annotations, theme));
|
|
792
|
+
return text;
|
|
793
|
+
}
|
|
794
|
+
if (details?.annotation) {
|
|
795
|
+
text.setText(formatAnnotationSummary(details.annotation, theme));
|
|
796
|
+
return text;
|
|
797
|
+
}
|
|
798
|
+
if (details?.scrubbed !== undefined) {
|
|
799
|
+
text.setText(details.scrubbed ? theme.fg("success", "scrubbed") : theme.fg("muted", "already scrubbed or not found"));
|
|
800
|
+
return text;
|
|
801
|
+
}
|
|
802
|
+
if (details?.restored !== undefined) {
|
|
803
|
+
text.setText(details.restored ? theme.fg("success", "restored") : theme.fg("muted", "not scrubbed or not found"));
|
|
804
|
+
return text;
|
|
805
|
+
}
|
|
806
|
+
text.setText(theme.fg("muted", "done"));
|
|
807
|
+
return text;
|
|
808
|
+
},
|
|
809
|
+
});
|
|
810
|
+
|
|
651
811
|
pi.registerTool({
|
|
652
812
|
name: "reachable_from",
|
|
653
813
|
label: "Reachable From",
|
|
@@ -696,6 +856,57 @@ export default function (pi: ExtensionAPI) {
|
|
|
696
856
|
},
|
|
697
857
|
});
|
|
698
858
|
|
|
859
|
+
pi.registerTool({
|
|
860
|
+
name: "workspace_map",
|
|
861
|
+
label: "Workspace Map",
|
|
862
|
+
description:
|
|
863
|
+
"A ranked, budget-bounded summary of the workspace's most structurally central symbols (aider-repomap-shaped) -- signature-only, highest-ranked first by PageRank over the populated call/reference graph, not full file dumps. Use when orienting in an unfamiliar or large codebase instead of reading many files one by one. Requires populate_symbol_graph to have been run for this workspace first; returns empty otherwise.",
|
|
864
|
+
promptSnippet: "Get a ranked, signature-only overview of the workspace's most central symbols",
|
|
865
|
+
promptGuidelines: [
|
|
866
|
+
"Prefer this over reading many files to get oriented in a large or unfamiliar codebase -- it surfaces the most-referenced symbols first, not an arbitrary file order.",
|
|
867
|
+
"A budget-truncated result means real symbols were left out, not that the workspace only has this many -- raise maxEntries/maxBytes for more.",
|
|
868
|
+
],
|
|
869
|
+
parameters: Type.Object({
|
|
870
|
+
path: Type.String({ description: "Absolute or cwd-relative path used to resolve which workspace to map" }),
|
|
871
|
+
maxNodes: Type.Number({ description: "Bounds the raw fetch from the graph before ranking" }),
|
|
872
|
+
maxEdges: Type.Number({ description: "Bounds the raw fetch from the graph before ranking" }),
|
|
873
|
+
maxEntries: Type.Number({ description: "Hard cap on the number of ranked entries returned" }),
|
|
874
|
+
maxBytes: Type.Number({ description: "Soft byte budget -- stops adding entries once exceeded, even under maxEntries" }),
|
|
875
|
+
}),
|
|
876
|
+
async execute(_toolCallId, params) {
|
|
877
|
+
const path = resolve(cwd, params.path);
|
|
878
|
+
const result = await codeIntelligenceOperations.workspaceMap(path, params.maxNodes, params.maxEdges, params.maxEntries, params.maxBytes);
|
|
879
|
+
const text =
|
|
880
|
+
result.entries.length === 0
|
|
881
|
+
? "No ranked symbols (has the graph been populated for this workspace?)."
|
|
882
|
+
: result.entries
|
|
883
|
+
.map(
|
|
884
|
+
(entry) => `${entry.kind} ${entry.name} -- ${entry.path}:${entry.line}:${entry.character}${entry.signature ? ` -- ${entry.signature}` : ""}`,
|
|
885
|
+
)
|
|
886
|
+
.join("\n");
|
|
887
|
+
return { content: [{ type: "text", text }], details: { result } };
|
|
888
|
+
},
|
|
889
|
+
renderCall(args, theme, context) {
|
|
890
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
891
|
+
text.setText(formatWorkspaceMapCall(args as { path?: unknown; maxEntries?: unknown }, theme));
|
|
892
|
+
return text;
|
|
893
|
+
},
|
|
894
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
895
|
+
if (isPartial) return new Text(theme.fg("warning", "Ranking workspace symbols..."), 0, 0);
|
|
896
|
+
if (context.isError) {
|
|
897
|
+
const errorText = result.content
|
|
898
|
+
.filter((block) => block.type === "text")
|
|
899
|
+
.map((block) => block.text)
|
|
900
|
+
.join("\n");
|
|
901
|
+
return new Text(theme.fg("error", errorText || "workspace_map failed"), 0, 0);
|
|
902
|
+
}
|
|
903
|
+
const details = result.details as { result?: WorkspaceMapResult } | undefined;
|
|
904
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
905
|
+
text.setText(formatWorkspaceMapResult(details?.result, expanded, theme));
|
|
906
|
+
return text;
|
|
907
|
+
},
|
|
908
|
+
});
|
|
909
|
+
|
|
699
910
|
const gitOperations = createLectorGitOperations();
|
|
700
911
|
pi.registerTool({
|
|
701
912
|
name: "git_status",
|
|
@@ -848,6 +1059,168 @@ export default function (pi: ExtensionAPI) {
|
|
|
848
1059
|
},
|
|
849
1060
|
});
|
|
850
1061
|
|
|
1062
|
+
const findFilesOperations = createLectorFindFilesOperations();
|
|
1063
|
+
pi.registerTool({
|
|
1064
|
+
name: "find_files",
|
|
1065
|
+
label: "Find Files",
|
|
1066
|
+
description:
|
|
1067
|
+
"Find files by glob/name pattern, distinct from content search -- the `find` half of the classic grep+find pair. Backed by ripgrep's own --files listing, respects .gitignore, skips node_modules/.git/build output. Bounded by maxResults and maxBytes.",
|
|
1068
|
+
promptSnippet: "Find files by path/name glob pattern, not content",
|
|
1069
|
+
promptGuidelines: [
|
|
1070
|
+
"Use find_files to locate files by path or name pattern (e.g. every *.test.ts under a directory) -- use search_code instead when you need to match file content, not just the path.",
|
|
1071
|
+
],
|
|
1072
|
+
parameters: Type.Object({
|
|
1073
|
+
directory: Type.String({ description: "Directory inside the project to search, absolute or relative to the current working directory" }),
|
|
1074
|
+
patterns: Type.Array(Type.String(), {
|
|
1075
|
+
description: "Glob pattern(s) to match file paths against, OR'd together -- a file matching any one pattern is included",
|
|
1076
|
+
minItems: 1,
|
|
1077
|
+
}),
|
|
1078
|
+
maxResults: Type.Number({ description: "Maximum number of file paths to return before truncating" }),
|
|
1079
|
+
maxBytes: Type.Number({ description: "Maximum total bytes of matched path text before truncating" }),
|
|
1080
|
+
}),
|
|
1081
|
+
async execute(_toolCallId, params) {
|
|
1082
|
+
const directory = resolve(cwd, params.directory);
|
|
1083
|
+
const result = await findFilesOperations.findFiles(params.patterns, directory, params.maxResults, params.maxBytes);
|
|
1084
|
+
const text = result.paths.length === 0 ? "No files found." : result.paths.join("\n");
|
|
1085
|
+
return { content: [{ type: "text", text }], details: { result } };
|
|
1086
|
+
},
|
|
1087
|
+
renderCall(args, theme, context) {
|
|
1088
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
1089
|
+
text.setText(formatFindFilesCall(args as { directory?: unknown; patterns?: unknown }, theme));
|
|
1090
|
+
return text;
|
|
1091
|
+
},
|
|
1092
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
1093
|
+
if (isPartial) return new Text(theme.fg("warning", "Finding files..."), 0, 0);
|
|
1094
|
+
if (context.isError) {
|
|
1095
|
+
const errorText = result.content
|
|
1096
|
+
.filter((block) => block.type === "text")
|
|
1097
|
+
.map((block) => block.text)
|
|
1098
|
+
.join("\n");
|
|
1099
|
+
return new Text(theme.fg("error", errorText || "find_files failed"), 0, 0);
|
|
1100
|
+
}
|
|
1101
|
+
const details = result.details as { result?: FindFilesResult } | undefined;
|
|
1102
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
1103
|
+
text.setText(formatFindFilesResult(details?.result, expanded, theme));
|
|
1104
|
+
return text;
|
|
1105
|
+
},
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
const lineEditOperations = createLectorLineEditOperations();
|
|
1109
|
+
pi.registerTool({
|
|
1110
|
+
name: "line_edit",
|
|
1111
|
+
label: "Line Edit",
|
|
1112
|
+
description:
|
|
1113
|
+
"Applies one or more per-line-hash-guarded edits to a file, atomically -- distinct from the generic edit tool's whole-file hash guard. A concurrent change to a line no edit here references never invalidates this call, unlike a whole-file guard where any edit anywhere in the file forces a re-read. Each edit's lines must currently hold the given hash(es) (from read tool content: hash each line's exact text yourself, or retry using the actualHash a hash-mismatch failure reports). All edits in one call land together, or none do.",
|
|
1114
|
+
promptSnippet: "Apply per-line hash-guarded edits to a file, atomically",
|
|
1115
|
+
promptGuidelines: [
|
|
1116
|
+
"Prefer line_edit over the generic edit tool when editing a large or heavily concurrent file where you only need to touch a few specific lines -- it survives a concurrent change elsewhere in the file that a whole-file hash guard would reject.",
|
|
1117
|
+
"A line's hash is not given to you ahead of time -- compute it yourself from content you already read (same algorithm as lineHashOf: sha256 of the exact line text, first 8 hex characters), or attempt the edit and use the actualHash a hash-mismatch failure reports to retry.",
|
|
1118
|
+
],
|
|
1119
|
+
parameters: Type.Object({
|
|
1120
|
+
path: Type.String({ description: "Absolute or workspace-relative path to the file to edit" }),
|
|
1121
|
+
edits: Type.Array(
|
|
1122
|
+
Type.Union([
|
|
1123
|
+
Type.Object({
|
|
1124
|
+
kind: Type.Literal("replace"),
|
|
1125
|
+
startLine: Type.Number({ description: "1-indexed first line of the inclusive range to replace" }),
|
|
1126
|
+
endLine: Type.Number({ description: "1-indexed last line of the inclusive range to replace (same as startLine for a single line)" }),
|
|
1127
|
+
expectedStartHash: Type.String({ description: "The hash startLine must currently hold" }),
|
|
1128
|
+
expectedEndHash: Type.String({
|
|
1129
|
+
description: "The hash endLine must currently hold (same value as expectedStartHash when startLine === endLine)",
|
|
1130
|
+
}),
|
|
1131
|
+
lines: Type.Array(Type.String(), { description: "Replacement lines -- an empty array deletes the range" }),
|
|
1132
|
+
}),
|
|
1133
|
+
Type.Object({
|
|
1134
|
+
kind: Type.Literal("insertBefore"),
|
|
1135
|
+
atLine: Type.Number({ description: "1-indexed anchor line to insert before" }),
|
|
1136
|
+
expectedHash: Type.String({ description: "The hash atLine must currently hold" }),
|
|
1137
|
+
lines: Type.Array(Type.String(), { description: "Lines to insert" }),
|
|
1138
|
+
}),
|
|
1139
|
+
Type.Object({
|
|
1140
|
+
kind: Type.Literal("insertAfter"),
|
|
1141
|
+
atLine: Type.Number({ description: "1-indexed anchor line to insert after" }),
|
|
1142
|
+
expectedHash: Type.String({ description: "The hash atLine must currently hold" }),
|
|
1143
|
+
lines: Type.Array(Type.String(), { description: "Lines to insert" }),
|
|
1144
|
+
}),
|
|
1145
|
+
]),
|
|
1146
|
+
{ description: "One or more edits, all applied atomically -- non-overlapping line ranges required", minItems: 1 },
|
|
1147
|
+
),
|
|
1148
|
+
}),
|
|
1149
|
+
async execute(_toolCallId, params) {
|
|
1150
|
+
const absolutePath = resolve(cwd, params.path);
|
|
1151
|
+
// The Pi tool schema can only express plain strings for hash fields (TypeBox has no
|
|
1152
|
+
// concept of Lector's branded LineHash) -- the daemon's own domain validation is the
|
|
1153
|
+
// real runtime check regardless of what TypeScript sees at this call site.
|
|
1154
|
+
const result = await lineEditOperations.lineEdit(absolutePath, params.edits as unknown as LineEdit[]);
|
|
1155
|
+
return { content: [{ type: "text", text: `${result.path}: ${result.previousHash} -> ${result.newHash}` }], details: { result } };
|
|
1156
|
+
},
|
|
1157
|
+
renderCall(args, theme, context) {
|
|
1158
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
1159
|
+
text.setText(formatLineEditCall(args as { path?: unknown; edits?: unknown }, theme));
|
|
1160
|
+
return text;
|
|
1161
|
+
},
|
|
1162
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
1163
|
+
if (isPartial) return new Text(theme.fg("warning", "Applying line edit..."), 0, 0);
|
|
1164
|
+
if (context.isError) {
|
|
1165
|
+
const errorText = result.content
|
|
1166
|
+
.filter((block) => block.type === "text")
|
|
1167
|
+
.map((block) => block.text)
|
|
1168
|
+
.join("\n");
|
|
1169
|
+
return new Text(theme.fg("error", errorText || "line_edit failed"), 0, 0);
|
|
1170
|
+
}
|
|
1171
|
+
const details = result.details as { result?: LineEditOutcome } | undefined;
|
|
1172
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
1173
|
+
text.setText(formatLineEditResult(details?.result, theme));
|
|
1174
|
+
return text;
|
|
1175
|
+
},
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
const applyPatchOperations = createLectorApplyPatchOperations();
|
|
1179
|
+
pi.registerTool({
|
|
1180
|
+
name: "apply_patch",
|
|
1181
|
+
label: "Apply Patch",
|
|
1182
|
+
description:
|
|
1183
|
+
"Applies a real unified diff (as `diff -u` / `git diff` produce) to a file, guarded by the whole-file hash you last observed there -- distinct from line_edit's per-line guards and the generic edit tool's plain replace. Hunk context is searched for near its own line-number hint rather than trusted as an exact offset, so the patch still applies correctly even if the file shifted slightly (e.g. unrelated lines added elsewhere) since the diff was generated.",
|
|
1184
|
+
promptSnippet: "Apply a real unified diff to a file, whole-file hash guarded",
|
|
1185
|
+
promptGuidelines: [
|
|
1186
|
+
"Use apply_patch when you already have a unified diff (e.g. from a prior read's content, or produced by a diffing step) rather than reconstructing the full post-patch content yourself for the generic edit tool.",
|
|
1187
|
+
"If a hunk's context can no longer be found, the file has drifted too far from what the patch assumed -- re-read the file and regenerate the patch, the same response as a stale hash on any other Lector edit tool.",
|
|
1188
|
+
],
|
|
1189
|
+
parameters: Type.Object({
|
|
1190
|
+
path: Type.String({ description: "Absolute or workspace-relative path to the file to patch" }),
|
|
1191
|
+
expectedHash: Type.String({ description: "The whole-file hash you last observed at path (from a prior read)" }),
|
|
1192
|
+
patchText: Type.String({
|
|
1193
|
+
description: "Real unified-diff text with one or more @@ hunks (--- / +++ file-header lines are optional and ignored if present)",
|
|
1194
|
+
}),
|
|
1195
|
+
}),
|
|
1196
|
+
async execute(_toolCallId, params) {
|
|
1197
|
+
const absolutePath = resolve(cwd, params.path);
|
|
1198
|
+
// TypeBox has no concept of Lector's branded ContentHash -- the daemon's own domain
|
|
1199
|
+
// validation is the real runtime check regardless of what TypeScript sees here.
|
|
1200
|
+
const result = await applyPatchOperations.applyPatch(absolutePath, params.expectedHash as ContentHash, params.patchText);
|
|
1201
|
+
return { content: [{ type: "text", text: `${result.path}: ${result.previousHash ?? "(new)"} -> ${result.newHash}` }], details: { result } };
|
|
1202
|
+
},
|
|
1203
|
+
renderCall(args, theme, context) {
|
|
1204
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
1205
|
+
text.setText(formatApplyPatchCall(args as { path?: unknown }, theme));
|
|
1206
|
+
return text;
|
|
1207
|
+
},
|
|
1208
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
1209
|
+
if (isPartial) return new Text(theme.fg("warning", "Applying patch..."), 0, 0);
|
|
1210
|
+
if (context.isError) {
|
|
1211
|
+
const errorText = result.content
|
|
1212
|
+
.filter((block) => block.type === "text")
|
|
1213
|
+
.map((block) => block.text)
|
|
1214
|
+
.join("\n");
|
|
1215
|
+
return new Text(theme.fg("error", errorText || "apply_patch failed"), 0, 0);
|
|
1216
|
+
}
|
|
1217
|
+
const details = result.details as { result?: EditOutcome } | undefined;
|
|
1218
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
1219
|
+
text.setText(formatApplyPatchResult(details?.result, theme));
|
|
1220
|
+
return text;
|
|
1221
|
+
},
|
|
1222
|
+
});
|
|
1223
|
+
|
|
851
1224
|
const packageSourceOperations = createLectorPackageSourceOperations();
|
|
852
1225
|
pi.registerTool({
|
|
853
1226
|
name: "package_source",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type LineEdit, type LineEditOutcome, type LineHash, lineHashOf } from "@danypops/lector";
|
|
2
|
+
import { lectorClient, withWorkspace, workspaceForPath } from "./lector-client.ts";
|
|
3
|
+
import { toWorkspaceRelativePath } from "./workspace-relative-path.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Thin wrapper over workspace.lineEdit -- distinct from the generic edit tool (backed by
|
|
7
|
+
* exactEdit's whole-file hash guard, see edit-operations.ts): every edit here is guarded by
|
|
8
|
+
* its own referenced line(s)' hash, so a concurrent change to a line no edit references never
|
|
9
|
+
* invalidates this one. `path` is an absolute file path, the same convention edit-operations.ts
|
|
10
|
+
* already uses -- the workspace is resolved from the file itself, not a separate directory arg.
|
|
11
|
+
*/
|
|
12
|
+
export interface LineEditOperations {
|
|
13
|
+
lineEdit(path: string, edits: readonly LineEdit[]): Promise<LineEditOutcome>;
|
|
14
|
+
/** Pure, no daemon round trip -- computes the hash a line must still hold from content the caller already has (e.g. from a prior read), rather than requiring a dedicated "give me line hashes" read operation. */
|
|
15
|
+
lineHash(line: string): LineHash;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createLectorLineEditOperations(): LineEditOperations {
|
|
19
|
+
return {
|
|
20
|
+
async lineEdit(path, edits) {
|
|
21
|
+
return withWorkspace(
|
|
22
|
+
() => workspaceForPath(path),
|
|
23
|
+
async ({ workspaceId, root }) => {
|
|
24
|
+
const client = await lectorClient();
|
|
25
|
+
const relativePath = toWorkspaceRelativePath(root, path);
|
|
26
|
+
return client.call("workspace.lineEdit", { workspaceId, path: relativePath, edits });
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
},
|
|
30
|
+
lineHash(line) {
|
|
31
|
+
return lineHashOf(line);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { LineEditOutcome } from "@danypops/lector";
|
|
2
|
+
import type { LectorTheme } from "./lector-tui-theme.ts";
|
|
3
|
+
|
|
4
|
+
export function formatLineEditCall(args: { path?: unknown; edits?: unknown }, theme: LectorTheme): string {
|
|
5
|
+
const path = typeof args.path === "string" ? args.path : "";
|
|
6
|
+
const count = Array.isArray(args.edits) ? args.edits.length : 0;
|
|
7
|
+
return `${theme.fg("toolTitle", theme.bold("line_edit"))} ${theme.fg("accent", path)} ${theme.fg("dim", `(${count} edit${count === 1 ? "" : "s"})`)}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function formatLineEditResult(result: LineEditOutcome | undefined, theme: LectorTheme): string {
|
|
11
|
+
if (!result) return theme.fg("dim", "No result.");
|
|
12
|
+
return theme.fg("accent", `${result.path}: ${result.previousHash} -> ${result.newHash}`);
|
|
13
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { OperationInputs, OperationOutputs } from "@danypops/lector";
|
|
2
|
+
import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath } from "./lector-client.ts";
|
|
3
|
+
|
|
4
|
+
/** A bare symbol position an anchor is given as -- symbolNodeId and the anchor's baseline file hash are derived server-side, never supplied by the caller. */
|
|
5
|
+
export interface AnnotationAnchorInput {
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly line: number;
|
|
8
|
+
readonly character: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Thin wrappers over Lector's annotation operations. Anchored to a workspace
|
|
13
|
+
* via the first anchor's own path (workspaceForCodeIntelligencePath) --
|
|
14
|
+
* every operation that needs a workspace already has at least one real
|
|
15
|
+
* anchor position or an id whose workspace the caller already knows.
|
|
16
|
+
*/
|
|
17
|
+
export interface SymbolAnnotationOperations {
|
|
18
|
+
create(
|
|
19
|
+
path: string,
|
|
20
|
+
subtype: string,
|
|
21
|
+
title: string,
|
|
22
|
+
body: string,
|
|
23
|
+
anchors: readonly AnnotationAnchorInput[],
|
|
24
|
+
): Promise<OperationOutputs["workspace.createAnnotation"]>;
|
|
25
|
+
get(path: string, id: string): Promise<OperationOutputs["workspace.getAnnotation"]>;
|
|
26
|
+
list(
|
|
27
|
+
path: string,
|
|
28
|
+
options?: { subtype?: string; status?: OperationInputs["workspace.listAnnotations"]["status"]; maxResults?: number },
|
|
29
|
+
): Promise<OperationOutputs["workspace.listAnnotations"]>;
|
|
30
|
+
refresh(
|
|
31
|
+
path: string,
|
|
32
|
+
id: string,
|
|
33
|
+
subtype: string,
|
|
34
|
+
title: string,
|
|
35
|
+
body: string,
|
|
36
|
+
anchors: readonly AnnotationAnchorInput[],
|
|
37
|
+
): Promise<OperationOutputs["workspace.refreshAnnotation"]>;
|
|
38
|
+
scrub(path: string, id: string): Promise<OperationOutputs["workspace.scrubAnnotation"]>;
|
|
39
|
+
restore(path: string, id: string): Promise<OperationOutputs["workspace.restoreAnnotation"]>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperations {
|
|
43
|
+
return {
|
|
44
|
+
async create(path, subtype, title, body, anchors) {
|
|
45
|
+
return withWorkspace(
|
|
46
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
47
|
+
async ({ workspaceId }) => {
|
|
48
|
+
const client = await lectorClient();
|
|
49
|
+
return client.call("workspace.createAnnotation", { workspaceId, subtype, title, body, anchors });
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
},
|
|
53
|
+
async get(path, id) {
|
|
54
|
+
return withWorkspace(
|
|
55
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
56
|
+
async ({ workspaceId }) => {
|
|
57
|
+
const client = await lectorClient();
|
|
58
|
+
return client.call("workspace.getAnnotation", { workspaceId, id });
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
},
|
|
62
|
+
async list(path, options = {}) {
|
|
63
|
+
return withWorkspace(
|
|
64
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
65
|
+
async ({ workspaceId }) => {
|
|
66
|
+
const client = await lectorClient();
|
|
67
|
+
return client.call("workspace.listAnnotations", { workspaceId, subtype: options.subtype, status: options.status, maxResults: options.maxResults });
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
},
|
|
71
|
+
async refresh(path, id, subtype, title, body, anchors) {
|
|
72
|
+
return withWorkspace(
|
|
73
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
74
|
+
async ({ workspaceId }) => {
|
|
75
|
+
const client = await lectorClient();
|
|
76
|
+
return client.call("workspace.refreshAnnotation", { workspaceId, id, subtype, title, body, anchors });
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
async scrub(path, id) {
|
|
81
|
+
return withWorkspace(
|
|
82
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
83
|
+
async ({ workspaceId }) => {
|
|
84
|
+
const client = await lectorClient();
|
|
85
|
+
return client.call("workspace.scrubAnnotation", { workspaceId, id });
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
},
|
|
89
|
+
async restore(path, id) {
|
|
90
|
+
return withWorkspace(
|
|
91
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
92
|
+
async ({ workspaceId }) => {
|
|
93
|
+
const client = await lectorClient();
|
|
94
|
+
return client.call("workspace.restoreAnnotation", { workspaceId, id });
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SymbolAnnotation } from "@danypops/lector";
|
|
2
|
+
import type { LectorTheme } from "./lector-tui-theme.ts";
|
|
3
|
+
|
|
4
|
+
const STATUS_COLOR: Record<SymbolAnnotation["status"], "success" | "warning" | "dim"> = {
|
|
5
|
+
fresh: "success",
|
|
6
|
+
stale: "warning",
|
|
7
|
+
scrubbed: "dim",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** One-line summary: status, title, subtype, id, anchor count -- the body is intentionally omitted (it can be long prose; the tool result's own text content carries it in full). */
|
|
11
|
+
export function formatAnnotationSummary(annotation: SymbolAnnotation, theme: LectorTheme): string {
|
|
12
|
+
const status = theme.fg(STATUS_COLOR[annotation.status], `[${annotation.status}]`);
|
|
13
|
+
const anchorCount = `${annotation.anchors.length} anchor${annotation.anchors.length === 1 ? "" : "s"}`;
|
|
14
|
+
return `${status} ${theme.bold(annotation.title)} ${theme.fg("muted", `(${annotation.subtype})`)} -- ${anchorCount} -- ${theme.fg("dim", annotation.id)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Full text for a tool result: summary line, body, and every anchor's exact symbol position. */
|
|
18
|
+
export function formatAnnotationDetail(annotation: SymbolAnnotation): string {
|
|
19
|
+
const anchorLines = annotation.anchors.map((anchor) => ` - ${anchor.symbolNodeId}`).join("\n");
|
|
20
|
+
return `[${annotation.status}] ${annotation.title} (${annotation.subtype})\nid: ${annotation.id}\n\n${annotation.body}\n\nAnchors:\n${anchorLines}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatAnnotationListSummary(annotations: readonly SymbolAnnotation[], theme: LectorTheme): string {
|
|
24
|
+
if (annotations.length === 0) return theme.fg("muted", "no annotations");
|
|
25
|
+
return annotations.map((annotation) => formatAnnotationSummary(annotation, theme)).join("\n");
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-lector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Pi host adapter for Lector: overrides read/write/edit with a daemon-backed, hash-guarded filesystem",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@danypops/daemon-kit": "^0.4.0",
|
|
22
|
-
"@danypops/lector": "^0.1.
|
|
22
|
+
"@danypops/lector": "^0.1.15"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@earendil-works/pi-ai": "^0.81.1",
|