@danypops/pi-lector 0.1.11 → 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.
@@ -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(path: string, line: number, character: number, includeDeclaration: boolean): Promise<OperationOutputs["workspace.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
  },
@@ -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",
@@ -1,4 +1,5 @@
1
1
  import { dirname, parse } from "node:path";
2
+ import { createRetryingClient, type RetryingClient } from "@danypops/daemon-kit/pi-client";
2
3
  import {
3
4
  connectLectorClient,
4
5
  type LectorClient,
@@ -20,60 +21,33 @@ import { nearestGitRoot } from "./nearest-workspace-root.ts";
20
21
  *
21
22
  * The daemon binds a new random port on every restart. A client resolved
22
23
  * once and cached for the rest of the session would otherwise point at a
23
- * dead port after any later restart -- lectorClient()'s returned .call()
24
+ * dead port after any later restart -- daemon-kit's createRetryingClient
24
25
  * detects that on the failing call itself (not just the first connection
25
- * attempt) and retries once against a freshly re-resolved client, matching
26
- * the pattern already proven in this house's papyrusClient()/callService().
26
+ * attempt) and retries once against a freshly re-resolved client, the same
27
+ * policy this file used to hand-roll and now shares with web-spider's
28
+ * callWebSpider(), papyrus's callService(), and pi-packed's createNatives().
27
29
  */
28
30
 
29
31
  type ClientConnector = () => Promise<LectorClient>;
30
32
 
31
33
  let connector: ClientConnector = () => connectLectorClient();
32
- let cachedClient: Promise<LectorClient> | undefined;
34
+ // Wraps `() => connector()` rather than `connector` itself, so a test's
35
+ // setLectorClientConnectorForTests still takes effect after this retrying
36
+ // client is constructed once at module load.
37
+ const retryingClient: RetryingClient<LectorClient> = createRetryingClient(() => connector(), { label: "Lector" });
33
38
  const workspaceIdByRoot = new Map<string, WorkspaceId>();
34
39
 
35
- async function resolveClient(): Promise<LectorClient> {
36
- if (!cachedClient) {
37
- cachedClient = connector().catch((error: unknown) => {
38
- cachedClient = undefined;
39
- throw error;
40
- });
41
- }
42
- return cachedClient;
43
- }
44
-
45
- /**
46
- * True when `error` means the connection itself is bad (the daemon
47
- * restarted on a new port since this client was cached, or died outright)
48
- * -- worth invalidating the cache and retrying once. False for a genuine
49
- * domain-level rejection (e.g. UnknownWorkspace), which a retry cannot fix
50
- * and would only mask.
51
- */
52
- function isStaleConnectionError(error: unknown): boolean {
53
- if (error instanceof TypeError) return true; // fetch()'s own connection-refused/DNS-failure shape
54
- if (!(error instanceof Error)) return false;
55
- if (error.name === "AbortError" || error.name === "TimeoutError") return true;
56
- return /fetch failed|unable to connect|network|socket|ECONNRESET|ECONNREFUSED|connection refused/i.test(error.message);
57
- }
58
-
59
40
  export interface RetryingLectorClient {
60
41
  call<Name extends OperationName>(operation: Name, input: OperationInputs[Name]): Promise<OperationOutputs[Name]>;
61
42
  }
62
43
 
44
+ // Kept async even though its own body has no await: every call site across this package does
45
+ // `await lectorClient()`, and dropping async here (just to satisfy require-await) would turn an
46
+ // internal implementation detail into a signature change rippling through every one of them.
47
+ // eslint-disable-next-line @typescript-eslint/require-await
63
48
  export async function lectorClient(): Promise<RetryingLectorClient> {
64
49
  return {
65
- async call(operation, input) {
66
- for (let attempt = 0; attempt < 2; attempt++) {
67
- const client = await resolveClient();
68
- try {
69
- return await client.call(operation, input);
70
- } catch (error) {
71
- cachedClient = undefined;
72
- if (attempt === 1 || !isStaleConnectionError(error)) throw error;
73
- }
74
- }
75
- throw new Error("Lector daemon client retry exhausted");
76
- },
50
+ call: (operation, input) => retryingClient.call((client) => client.call(operation, input)),
77
51
  };
78
52
  }
79
53
 
@@ -163,13 +137,13 @@ export async function withWorkspace<T>(resolve: () => Promise<ResolvedWorkspace>
163
137
  }
164
138
 
165
139
  export function setLectorClientConnectorForTests(value: ClientConnector): void {
166
- cachedClient = undefined;
140
+ retryingClient.reset();
167
141
  workspaceIdByRoot.clear();
168
142
  connector = value;
169
143
  }
170
144
 
171
145
  export function resetLectorClientForTests(): void {
172
- cachedClient = undefined;
146
+ retryingClient.reset();
173
147
  workspaceIdByRoot.clear();
174
148
  connector = () => connectLectorClient();
175
149
  }
@@ -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.1.11",
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",
@@ -18,7 +18,8 @@
18
18
  "typebox": "*"
19
19
  },
20
20
  "dependencies": {
21
- "@danypops/lector": "^0.1.10"
21
+ "@danypops/daemon-kit": "^0.4.0",
22
+ "@danypops/lector": "^0.1.15"
22
23
  },
23
24
  "devDependencies": {
24
25
  "@earendil-works/pi-ai": "^0.81.1",