@danypops/pi-lector 0.13.6 → 0.13.9

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.
@@ -50,6 +50,18 @@ export interface CodeIntelligenceOperations {
50
50
  /** Never spawns a symbol index -- safe to call opportunistically (e.g. before deciding whether to enrich a result). */
51
51
  hasWarmIndex(path: string): Promise<boolean>;
52
52
  workspaceMap(path: string, maxNodes: number, maxEdges: number, maxEntries: number, maxBytes: number): Promise<OperationOutputs["workspace.map"]>;
53
+ localizeContext(
54
+ path: string,
55
+ query: string,
56
+ options?: {
57
+ seedSymbols?: readonly string[];
58
+ seedLocations?: readonly { path: string; line: number; character?: number }[];
59
+ maxSymbols?: number;
60
+ maxBytes?: number;
61
+ maxDepth?: number;
62
+ deadlineMs?: number;
63
+ },
64
+ ): Promise<OperationOutputs["workspace.localizeContext"]>;
53
65
  }
54
66
 
55
67
  export function createLectorCodeIntelligenceOperations(ownerId?: string): CodeIntelligenceOperations {
@@ -184,5 +196,14 @@ export function createLectorCodeIntelligenceOperations(ownerId?: string): CodeIn
184
196
  },
185
197
  );
186
198
  },
199
+ async localizeContext(path, query, options = {}) {
200
+ return withWorkspace(
201
+ () => workspaceForPathOrDirectory(path),
202
+ async ({ workspaceId }) => {
203
+ const client = await lectorClient();
204
+ return client.call("workspace.localizeContext", { workspaceId, query, ...options });
205
+ },
206
+ );
207
+ },
187
208
  };
188
209
  }
@@ -92,7 +92,8 @@ export function formatSearchTextAcrossProjectsResult(
92
92
  items: outcome.result.matches,
93
93
  expanded,
94
94
  visibleCount: DEFAULT_VISIBLE_PER_WORKSPACE,
95
- formatItem: (match) => ` ${match.path}:${match.lineNumber}: ${match.line.replace(/\n$/, "")}`,
95
+ formatItem: (match) =>
96
+ ` ${match.path}:${match.lineNumber}: ${match.line.replace(/\n$/, "")}${match.lineTruncated ? theme.fg("warning", " (line truncated)") : ""}`,
96
97
  moreLine: (hidden) => theme.fg("dim", ` ... ${hidden} more (${keyHint("app.tools.expand", "to expand")})`),
97
98
  truncationWarning: outcome.result.truncated
98
99
  ? theme.fg("warning", " (this workspace's search was itself truncated by maxMatches/maxBytes)")
@@ -9,7 +9,14 @@ import type { EditorTheme } from "./editor-theme.ts";
9
9
  import type { ExplorerDiff, ExplorerEntry } from "./explorer-diff.ts";
10
10
  import { diffExplorerLines, formatExplorerLine, parseExplorerLine } from "./explorer-diff.ts";
11
11
 
12
- export type ExplorerResult = { kind: "quit" } | { kind: "open-file"; absolutePath: string };
12
+ /** Restores one Workspace's directory and active entry when its Explorer becomes visible again. */
13
+ export interface ExplorerViewState {
14
+ readonly relativePath: string;
15
+ readonly selectedEntryName?: string;
16
+ }
17
+
18
+ /** Reports why the Explorer closed plus its latest restorable location when the host supports view-state persistence. */
19
+ export type ExplorerResult = { kind: "quit"; viewState?: ExplorerViewState } | { kind: "open-file"; absolutePath: string; viewState?: ExplorerViewState };
13
20
 
14
21
  /** Joins a directory-relative name onto `directory` ("" means the resolved root itself). */
15
22
  export function joinExplorerPath(directory: string, name: string): string {
@@ -51,12 +58,19 @@ export class ExplorerComponent implements Component {
51
58
  private statusMessage = "";
52
59
  private confirming: PendingConfirmation | undefined;
53
60
 
54
- constructor(tui: TUI, theme: EditorTheme, session: DirectoryExplorerSession, initialRelativePath: string, done: (result: ExplorerResult) => void) {
61
+ constructor(
62
+ tui: TUI,
63
+ theme: EditorTheme,
64
+ session: DirectoryExplorerSession,
65
+ initialRelativePath: string,
66
+ done: (result: ExplorerResult) => void,
67
+ initialSelectedEntryName?: string,
68
+ ) {
55
69
  this.tui = tui;
56
70
  this.theme = theme;
57
71
  this.session = session;
58
72
  this.done = done;
59
- this.pending = this.loadDirectory(initialRelativePath).catch((error: unknown) => this.reportError(error));
73
+ this.pending = this.loadDirectory(initialRelativePath, initialSelectedEntryName).catch((error: unknown) => this.reportError(error));
60
74
  }
61
75
 
62
76
  private pending: Promise<void> = Promise.resolve();
@@ -111,13 +125,20 @@ export class ExplorerComponent implements Component {
111
125
  this.tui.requestRender();
112
126
  }
113
127
 
114
- private async loadDirectory(relativePath: string): Promise<void> {
128
+ private async loadDirectory(relativePath: string, selectedEntryName?: string): Promise<void> {
115
129
  const listing = await this.session.listDirectory(relativePath);
116
130
  this.currentPath = relativePath;
117
131
  this.nextId = 1;
118
132
  this.entries = listing.entries.map((entry) => ({ id: this.nextId++, name: entry.name, kind: entry.kind }));
119
133
  const text = this.entries.length > 0 ? this.entries.map((entry) => formatExplorerLine(entry)).join("\n") : "";
120
134
  this.state = new EditorState(text);
135
+ const selectedIndex = selectedEntryName === undefined ? -1 : this.entries.findIndex((entry) => entry.name === selectedEntryName);
136
+ const selectedEntry = selectedIndex >= 0 ? this.entries[selectedIndex] : undefined;
137
+ if (selectedEntry) {
138
+ this.state.cursorLine = selectedIndex + 1;
139
+ this.state.cursorCharacter = `${selectedEntry.id} `.length + 1;
140
+ this.scrollToKeepCursorVisible();
141
+ }
121
142
  this.confirming = undefined;
122
143
  this.statusMessage = "";
123
144
  this.tui.requestRender();
@@ -133,12 +154,17 @@ export class ExplorerComponent implements Component {
133
154
  await this.loadDirectory(joinExplorerPath(this.currentPath, entry.name));
134
155
  return;
135
156
  }
136
- this.done({ kind: "open-file", absolutePath: join(this.session.root, joinExplorerPath(this.currentPath, entry.name)) });
157
+ this.done({
158
+ kind: "open-file",
159
+ absolutePath: join(this.session.root, joinExplorerPath(this.currentPath, entry.name)),
160
+ viewState: { relativePath: this.currentPath, selectedEntryName: entry.name },
161
+ });
137
162
  }
138
163
 
139
164
  private async navigateToParent(): Promise<void> {
140
165
  if (this.currentPath === "") return; // already at the resolved root -- v1 never widens scope above it
141
- await this.loadDirectory(parentExplorerPath(this.currentPath));
166
+ const childName = posix.basename(this.currentPath);
167
+ await this.loadDirectory(parentExplorerPath(this.currentPath), childName);
142
168
  }
143
169
 
144
170
  private async performAction(action: EditorAction): Promise<void> {
@@ -149,14 +175,14 @@ export class ExplorerComponent implements Component {
149
175
  this.state.dirty = false;
150
176
  if (diffs.length === 0) {
151
177
  this.statusMessage = "no changes";
152
- if (action.kind === "save-and-quit") this.done({ kind: "quit" });
178
+ if (action.kind === "save-and-quit") this.done({ kind: "quit", viewState: this.viewState() });
153
179
  return;
154
180
  }
155
181
  this.confirming = { diffs, andQuit: action.kind === "save-and-quit" };
156
182
  return;
157
183
  }
158
184
  case "quit":
159
- this.done({ kind: "quit" });
185
+ this.done({ kind: "quit", viewState: this.viewState() });
160
186
  return;
161
187
  case "hover":
162
188
  this.statusMessage = "hover is not applicable in the file explorer";
@@ -186,13 +212,19 @@ export class ExplorerComponent implements Component {
186
212
  return;
187
213
  }
188
214
  if (andQuit) {
189
- this.done({ kind: "quit" });
215
+ this.done({ kind: "quit", viewState: this.viewState() });
190
216
  return;
191
217
  }
192
218
  await this.loadDirectory(this.currentPath);
193
219
  this.statusMessage = "applied";
194
220
  }
195
221
 
222
+ private viewState(): ExplorerViewState {
223
+ const parsed = parseExplorerLine(this.state.currentLineText);
224
+ const entry = !parsed || parsed.id === null ? undefined : this.entries.find((candidate) => candidate.id === parsed.id);
225
+ return entry ? { relativePath: this.currentPath, selectedEntryName: entry.name } : { relativePath: this.currentPath };
226
+ }
227
+
196
228
  private scrollToKeepCursorVisible(): void {
197
229
  const viewportHeight = Math.max(1, this.tui.terminal.rows - 2);
198
230
  if (this.state.cursorLine < this.scrollTop) this.scrollTop = this.state.cursorLine;
@@ -1,10 +1,10 @@
1
- import { dirname, relative } from "node:path";
1
+ import { basename, dirname, relative } from "node:path";
2
2
  import type { DirectoryExplorerSession } from "./directory-explorer-operations.ts";
3
- import type { ExplorerResult } from "./explorer-component.ts";
3
+ import type { ExplorerResult, ExplorerViewState } from "./explorer-component.ts";
4
4
 
5
5
  export interface ExplorerFlowHost {
6
- /** Shows the explorer at `relativePath` (root-relative, "" for the resolved root) and resolves once the user quits or opens a file. */
7
- showExplorer(session: DirectoryExplorerSession, relativePath: string): Promise<ExplorerResult>;
6
+ /** Shows the explorer at `relativePath` (root-relative, "" for the resolved root), optionally revealing one active entry, and resolves once the user quits or opens a file. */
7
+ showExplorer(session: DirectoryExplorerSession, relativePath: string, selectedEntryName?: string): Promise<ExplorerResult>;
8
8
  /** Shows the real file editor for `absolutePath` and resolves once the user quits it. */
9
9
  showEditor(absolutePath: string): Promise<void>;
10
10
  }
@@ -14,14 +14,21 @@ export interface ExplorerFlowHost {
14
14
  * the explorer -- at the directory the opened file lives in, not the resolved root -- once that
15
15
  * editor quits, rather than closing the whole session after the first file opened.
16
16
  */
17
- export async function runExplorerFlow(session: DirectoryExplorerSession, host: ExplorerFlowHost): Promise<void> {
18
- let relativePath = "";
17
+ export async function runExplorerFlow(
18
+ session: DirectoryExplorerSession,
19
+ host: ExplorerFlowHost,
20
+ initialViewState: ExplorerViewState = { relativePath: "" },
21
+ ): Promise<ExplorerViewState> {
22
+ let viewState = initialViewState;
19
23
  for (;;) {
20
- const result = await host.showExplorer(session, relativePath);
21
- if (result.kind === "quit") return;
24
+ const result = await host.showExplorer(session, viewState.relativePath, viewState.selectedEntryName);
25
+ if (result.kind === "quit") return result.viewState ?? viewState;
22
26
 
23
27
  await host.showEditor(result.absolutePath);
24
28
  // path.relative(root, root) is "" directly, matching ExplorerComponent's own root-relative convention -- no "." case to normalize.
25
- relativePath = relative(session.root, dirname(result.absolutePath));
29
+ viewState = result.viewState ?? {
30
+ relativePath: relative(session.root, dirname(result.absolutePath)),
31
+ selectedEntryName: basename(result.absolutePath),
32
+ };
26
33
  }
27
34
  }
@@ -31,11 +31,17 @@
31
31
  export type { DirectoryExplorerSession } from "./directory-explorer-operations.ts";
32
32
  export { type EditorAction, type EditorMode, EditorState } from "./editor-state.ts";
33
33
  export type { EditorTheme } from "./editor-theme.ts";
34
- export { ExplorerComponent, type ExplorerResult, joinExplorerPath } from "./explorer-component.ts";
34
+ export { ExplorerComponent, type ExplorerResult, type ExplorerViewState, joinExplorerPath } from "./explorer-component.ts";
35
35
  // runExplorerFlow is pure orchestration over the two interfaces above (browse, open a file into
36
36
  // the real editor, return to the explorer at that file's own directory once it quits) -- exported
37
37
  // for the same reason ExplorerComponent/DirectoryExplorerSession are: any real host can drive this
38
38
  // exact, already-tested loop without re-deriving it, instead of only this package's own Pi
39
39
  // extension entry point being able to.
40
40
  export { type ExplorerFlowHost, runExplorerFlow } from "./explorer-flow.ts";
41
- export { ModalEditorComponent, type ModalEditorHost } from "./modal-editor-component.ts";
41
+ export {
42
+ type EditorBufferSnapshot,
43
+ type EditorHoverOutcome,
44
+ type EditorHoverRequest,
45
+ ModalEditorComponent,
46
+ type ModalEditorHost,
47
+ } from "./modal-editor-component.ts";
@@ -1,6 +1,6 @@
1
1
  import { extname } from "node:path";
2
- import type { HighlightSpan } from "@danypops/lector";
3
- import { highlightSpans } from "@danypops/lector";
2
+ import type { ContentHash, HighlightSpan } from "@danypops/lector";
3
+ import { contentHashOf, highlightSpans } from "@danypops/lector";
4
4
  import type { ThemeColor } from "@earendil-works/pi-coding-agent";
5
5
  import type { Component, TUI } from "@earendil-works/pi-tui";
6
6
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
@@ -10,12 +10,30 @@ import type { EditorTheme } from "./editor-theme.ts";
10
10
 
11
11
  export type { EditorTheme } from "./editor-theme.ts";
12
12
 
13
+ export interface EditorBufferSnapshot {
14
+ readonly text: string;
15
+ readonly hash: ContentHash;
16
+ readonly dirty: boolean;
17
+ }
18
+
19
+ export interface EditorHoverRequest {
20
+ readonly line: number;
21
+ readonly character: number;
22
+ readonly buffer: EditorBufferSnapshot;
23
+ }
24
+
25
+ export type EditorHoverOutcome =
26
+ | { readonly kind: "ready"; readonly hover?: { readonly contents: string } }
27
+ | { readonly kind: "stale-active-buffer"; readonly bufferHash: ContentHash };
28
+
13
29
  export interface ModalEditorHost {
14
30
  filePath: string;
15
31
  /** Saves the buffer's current text through Lector's hash-guarded write. Throws (surfaced as a status message, not a crash) on a genuinely concurrent external change. */
16
32
  save(text: string): Promise<void>;
17
- /** Real hover info from Lector's existing code-intelligence operation, or undefined when there is none at this position. */
33
+ /** Resolves hover against the saved file for hosts that do not expose active-buffer semantics. */
18
34
  hover(line: number, character: number): Promise<{ contents: string } | undefined>;
35
+ /** Resolves hover against the supplied active-buffer snapshot or reports that semantic evidence is stale. */
36
+ hoverSnapshot?(request: EditorHoverRequest): Promise<EditorHoverOutcome>;
19
37
  }
20
38
 
21
39
  const CAPTURE_COLOR: Record<string, ThemeColor> = {
@@ -111,9 +129,29 @@ export class ModalEditorComponent implements Component {
111
129
  this.done();
112
130
  return;
113
131
  case "hover": {
114
- const hover = await this.host.hover(this.state.cursorLine, this.state.cursorCharacter);
115
- const firstLine = hover?.contents.split("\n")[0];
116
- this.statusMessage = firstLine ?? "no hover info at this position";
132
+ const text = this.state.buffer.text;
133
+ const request = {
134
+ line: this.state.cursorLine,
135
+ character: this.state.cursorCharacter,
136
+ buffer: { text, hash: contentHashOf(text), dirty: this.state.dirty },
137
+ };
138
+ const outcome = this.host.hoverSnapshot
139
+ ? await this.host.hoverSnapshot(request)
140
+ : { kind: "ready" as const, hover: await this.host.hover(request.line, request.character) };
141
+ switch (outcome.kind) {
142
+ case "ready": {
143
+ const firstLine = outcome.hover?.contents.split("\n")[0];
144
+ this.statusMessage = firstLine ?? "no hover info at this position";
145
+ break;
146
+ }
147
+ case "stale-active-buffer":
148
+ this.statusMessage = "stale active buffer: save or discard changes before semantic queries";
149
+ break;
150
+ default: {
151
+ const exhaustive: never = outcome;
152
+ throw new Error(`Unhandled hover outcome: ${JSON.stringify(exhaustive)}`);
153
+ }
154
+ }
117
155
  break;
118
156
  }
119
157
  default: {
@@ -4,6 +4,7 @@ import { resolve } from "node:path";
4
4
  import type {
5
5
  CachedRepositoryPage,
6
6
  ContentHash,
7
+ ContextBundleResult,
7
8
  Diagnostic,
8
9
  DocumentSymbolEntry,
9
10
  EditOutcome,
@@ -96,7 +97,8 @@ import { formatGitCall, formatGitResult, type GitToolDetails } from "./git/rende
96
97
  import { nearestGitWorkspaceRoot, setNewWorkspaceObserver } from "./lector-client.ts";
97
98
  import { createLectorLineEditOperations } from "./line-edit/operations.ts";
98
99
  import { formatLineEditCall, formatLineEditResult } from "./line-edit/rendering.ts";
99
- import { createMutationHistoryOperations } from "./mutation-history/operations.ts";
100
+ import { createMutationHistoryOperations, type MutationTransactionRevertOutcome } from "./mutation-history/operations.ts";
101
+ import { formatMutationHistoryList, formatMutationTransactionRevert } from "./mutation-history/rendering.ts";
100
102
  import { createLectorPackageSourceOperations, type PackageSourceListPage } from "./package-source/operations.ts";
101
103
  import {
102
104
  buildPackageSourceListTableRows,
@@ -407,6 +409,74 @@ export default function (pi: ExtensionAPI) {
407
409
 
408
410
  const codeIntelligenceOperations = createLectorCodeIntelligenceOperations(ownerId);
409
411
 
412
+ registerLectorTool({
413
+ name: "localize_context",
414
+ label: "Localize Context",
415
+ description:
416
+ "Localize a natural-language coding task to a bounded, ranked set of workspace symbols. Combines lexical source matches with the persisted call/reference/containment graph, returns compact signatures and explicit score reasons, and reports incomplete or unavailable graph coverage. The daemon does not invoke an LLM. `directory` selects the project explicitly.",
417
+ promptSnippet: "Localize a coding task to ranked symbols and compact graph-backed context",
418
+ promptGuidelines: [
419
+ "Use localize_context near the start of an unfamiliar implementation or debugging task to get a bounded candidate set before reading files one by one.",
420
+ "Treat every reason as provenance for a retrieval signal, not proof of semantic dataflow; inspect completeness before relying on graph omissions.",
421
+ ],
422
+ parameters: Type.Object({
423
+ query: Type.String({ description: "Natural-language coding task or issue text" }),
424
+ directory: Type.String({ description: "Directory of the project to localize, absolute or relative to the current working directory" }),
425
+ seedSymbols: Type.Optional(Type.Array(Type.String(), { description: "Optional exact symbol names that anchor graph expansion", maxItems: 100 })),
426
+ seedLocations: Type.Optional(
427
+ Type.Array(
428
+ Type.Object({
429
+ path: Type.String(),
430
+ line: Type.Number({ description: "1-indexed line" }),
431
+ character: Type.Optional(Type.Number({ description: "Optional 1-indexed declaration character" })),
432
+ }),
433
+ { description: "Optional declaration locations that anchor graph expansion", maxItems: 100 },
434
+ ),
435
+ ),
436
+ maxSymbols: Type.Optional(Type.Number({ description: "Maximum candidates returned (default 20, maximum 500)" })),
437
+ maxBytes: Type.Optional(Type.Number({ description: "Maximum serialized candidate bytes (default 30000, maximum 2 MiB)" })),
438
+ maxDepth: Type.Optional(Type.Number({ description: "Maximum call/reference/containment expansion depth (default 2, maximum 5)" })),
439
+ deadlineMs: Type.Optional(Type.Number({ description: "Wall-clock budget in milliseconds (default 5000, maximum 30000)" })),
440
+ }),
441
+ async execute(_toolCallId, params) {
442
+ const directory = resolve(cwd, params.directory);
443
+ const result = await codeIntelligenceOperations.localizeContext(directory, params.query, {
444
+ ...(params.seedSymbols ? { seedSymbols: params.seedSymbols } : {}),
445
+ ...(params.seedLocations ? { seedLocations: params.seedLocations.map((seed) => ({ ...seed, path: resolve(cwd, seed.path) })) } : {}),
446
+ ...(params.maxSymbols !== undefined ? { maxSymbols: params.maxSymbols } : {}),
447
+ ...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}),
448
+ ...(params.maxDepth !== undefined ? { maxDepth: params.maxDepth } : {}),
449
+ ...(params.deadlineMs !== undefined ? { deadlineMs: params.deadlineMs } : {}),
450
+ });
451
+ const completeness = `lexical=${result.completeness.lexical}, graph=${result.completeness.graph}${result.completeness.deadlineReached ? ", deadline reached" : ""}${result.truncated ? ", truncated" : ""}`;
452
+ const text =
453
+ result.candidates.length === 0
454
+ ? `No localization candidates.\nCompleteness: ${completeness}`
455
+ : `Primary candidates\n\n${result.candidates
456
+ .map(
457
+ (candidate, index) =>
458
+ `${index + 1}. ${candidate.name}\n ${candidate.path}:${candidate.line}:${candidate.character}\n ${candidate.signature ?? candidate.kind}\n Reasons:\n${candidate.reasons.map((reason) => ` - ${reason.detail} (+${reason.score})`).join("\n")}`,
459
+ )
460
+ .join("\n\n")}\n\nCompleteness: ${completeness}`;
461
+ return { content: [{ type: "text", text }], details: result };
462
+ },
463
+ renderCall(args, theme) {
464
+ return new Text(theme.fg("toolTitle", `localize ${typeof args.query === "string" ? args.query : "context"}`), 0, 0);
465
+ },
466
+ renderResult(result, { isPartial }, theme, context) {
467
+ if (isPartial) return new Text(theme.fg("warning", "Localizing..."), 0, 0);
468
+ if (context.isError) {
469
+ const errorText = result.content
470
+ .filter((block) => block.type === "text")
471
+ .map((block) => block.text)
472
+ .join("\n");
473
+ return new Text(theme.fg("error", errorText || "localize_context failed"), 0, 0);
474
+ }
475
+ const details = result.details as ContextBundleResult | undefined;
476
+ return new Text(details ? `${details.candidates.length} candidates · graph ${details.completeness.graph}` : "Localization complete", 0, 0);
477
+ },
478
+ });
479
+
410
480
  const editorOverlayOptions = { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "center" } } as const;
411
481
 
412
482
  async function openFileInEditor(commandCtx: ExtensionCommandContext, absolutePath: string): Promise<void> {
@@ -426,6 +496,11 @@ export default function (pi: ExtensionAPI) {
426
496
  const result = await codeIntelligenceOperations.hover(absolutePath, line, character);
427
497
  return result.hover;
428
498
  },
499
+ hoverSnapshot: async (request) => {
500
+ if (request.buffer.dirty) return { kind: "stale-active-buffer", bufferHash: request.buffer.hash };
501
+ const result = await codeIntelligenceOperations.hover(absolutePath, request.line, request.character);
502
+ return { kind: "ready", hover: result.hover };
503
+ },
429
504
  };
430
505
  return new ModalEditorComponent(tui, theme, host, session.content, () => done(undefined));
431
506
  }, editorOverlayOptions);
@@ -1145,14 +1220,16 @@ export default function (pi: ExtensionAPI) {
1145
1220
  async execute(_toolCallId, params) {
1146
1221
  const path = resolve(cwd, params.path);
1147
1222
  const result = await codeIntelligenceOperations.workspaceMap(path, params.maxNodes, params.maxEdges, params.maxEntries, params.maxBytes);
1148
- const text =
1223
+ const coverage = `Candidate coverage: languages=${result.candidateSelection.representedLanguages.join(",") || "none"}; scopes=${result.candidateSelection.representedScopes.join(",") || "none"}${result.candidateSelection.omittedScopes.length > 0 ? `; omitted=${result.candidateSelection.omittedScopes.join(",")}` : ""}; strategy=${result.candidateSelection.strategy}.`;
1224
+ const text = `${
1149
1225
  result.entries.length === 0
1150
1226
  ? "No ranked symbols (the workspace's symbol graph may still be populating in the background -- retry shortly)."
1151
1227
  : result.entries
1152
1228
  .map(
1153
1229
  (entry) => `${entry.kind} ${entry.name} -- ${entry.path}:${entry.line}:${entry.character}${entry.signature ? ` -- ${entry.signature}` : ""}`,
1154
1230
  )
1155
- .join("\n");
1231
+ .join("\n")
1232
+ }\n${coverage}`;
1156
1233
  return { content: [{ type: "text", text }], details: { result } };
1157
1234
  },
1158
1235
  renderCall(args, theme, context) {
@@ -1622,23 +1699,25 @@ export default function (pi: ExtensionAPI) {
1622
1699
 
1623
1700
  type MutationHistoryToolDetails =
1624
1701
  | { readonly action: "list"; readonly entries: readonly MutationHistoryEntry[] }
1625
- | { readonly action: "revert"; readonly reverted: { readonly path: string; readonly newHash: string | null } };
1702
+ | { readonly action: "revert"; readonly reverted: { readonly path: string; readonly newHash: string | null } }
1703
+ | { readonly action: "revert-transaction"; readonly reverted: MutationTransactionRevertOutcome };
1626
1704
 
1627
1705
  const mutationHistoryOperations = createMutationHistoryOperations();
1628
1706
  registerLectorTool({
1629
1707
  name: "mutation_history",
1630
1708
  label: "Mutation History",
1631
1709
  description:
1632
- "List or revert a file's recorded edit history. Every successful edit/line_edit/apply_patch is recorded (newest first, bounded, not durable across a daemon restart). Reverting restores the file to its exact content immediately before that entry's own mutation, guarded the same way every Lector write is -- refuses if the file changed since, rather than silently clobbering a newer change. A revert is itself a real, further-revertible mutation -- reverting a revert works.",
1633
- promptSnippet: "List or revert a file's recorded edit history",
1710
+ "List a file's recorded edit history, revert a standalone entry, or atomically revert every file in a rename/multi-file transaction. Every successful edit/line_edit/apply_patch is recorded (newest first, bounded, not durable across a daemon restart). Reverts are hash-guarded and further-revertible. A transaction member is never reverted alone: use action=revert-transaction with its transactionId.",
1711
+ promptSnippet: "List or safely revert standalone or transaction-grouped edit history",
1634
1712
  promptGuidelines: [
1635
- "list first to find the entry id you want, then revert -- an id from a different file's history, or one already evicted by the bounded history, fails closed rather than guessing.",
1713
+ "list first. Use action=revert only for a standalone entry; if list reports a transactionId, use action=revert-transaction so every member is restored atomically.",
1636
1714
  ],
1637
1715
  parameters: Type.Object({
1638
- action: Type.Union([Type.Literal("list"), Type.Literal("revert")]),
1639
- path: Type.String({ description: "Absolute or workspace-relative path to the file" }),
1716
+ action: Type.Union([Type.Literal("list"), Type.Literal("revert"), Type.Literal("revert-transaction")]),
1717
+ path: Type.String({ description: "Absolute or workspace-relative path used to resolve the owning workspace" }),
1640
1718
  maxResults: Type.Optional(Type.Number({ description: "Required for action=list -- maximum entries to return, newest first" })),
1641
- entryId: Type.Optional(Type.String({ description: "Required for action=revert -- an id returned by a prior action=list" })),
1719
+ entryId: Type.Optional(Type.String({ description: "Required for action=revert -- a standalone entry id returned by list" })),
1720
+ transactionId: Type.Optional(Type.String({ description: "Required for action=revert-transaction -- a transaction id returned by list" })),
1642
1721
  }),
1643
1722
  async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<MutationHistoryToolDetails>> {
1644
1723
  const absolutePath = resolve(cwd, params.path);
@@ -1651,17 +1730,21 @@ export default function (pi: ExtensionAPI) {
1651
1730
  if (params.action === "list") {
1652
1731
  if (params.maxResults === undefined) throw new Error("mutation_history action=list requires maxResults");
1653
1732
  const entries = await mutationHistoryOperations.list(absolutePath, params.maxResults, vehicleCall);
1654
- const text =
1655
- entries.length === 0
1656
- ? "no recorded mutation history for this path"
1657
- : entries.map((entry) => `${entry.id} ${new Date(entry.timestamp).toISOString()} ${entry.operation}`).join("\n");
1658
- return { content: [{ type: "text", text }], details: { action: "list", entries } };
1659
- }
1660
- if (params.entryId === undefined) throw new Error("mutation_history action=revert requires entryId");
1661
- const reverted = await mutationHistoryOperations.revert(absolutePath, params.entryId, vehicleCall);
1733
+ return { content: [{ type: "text", text: formatMutationHistoryList(entries) }], details: { action: "list", entries } };
1734
+ }
1735
+ if (params.action === "revert") {
1736
+ if (params.entryId === undefined) throw new Error("mutation_history action=revert requires entryId");
1737
+ const reverted = await mutationHistoryOperations.revert(absolutePath, params.entryId, vehicleCall);
1738
+ return {
1739
+ content: [{ type: "text", text: `${reverted.path} reverted -> ${reverted.newHash ?? "(deleted)"}` }],
1740
+ details: { action: "revert", reverted },
1741
+ };
1742
+ }
1743
+ if (params.transactionId === undefined) throw new Error("mutation_history action=revert-transaction requires transactionId");
1744
+ const reverted = await mutationHistoryOperations.revertTransaction(absolutePath, params.transactionId, vehicleCall);
1662
1745
  return {
1663
- content: [{ type: "text", text: `${reverted.path} reverted -> ${reverted.newHash ?? "(deleted)"}` }],
1664
- details: { action: "revert", reverted },
1746
+ content: [{ type: "text", text: formatMutationTransactionRevert(params.transactionId, reverted) }],
1747
+ details: { action: "revert-transaction", reverted },
1665
1748
  };
1666
1749
  },
1667
1750
  renderCall(args, theme, context) {
@@ -1714,17 +1797,22 @@ export default function (pi: ExtensionAPI) {
1714
1797
  name: "package_source",
1715
1798
  label: "Package Source",
1716
1799
  description:
1717
- "Resolve, list, remove, or clean bookkeeping for installed npm packages resolved to verified exact repository source. action=resolve uses the project's lockfile, bounded registry metadata, and an exact Git ref/commit; registers verified source as a read-only workspace for the other Lector tools. action=list reports every package coordinate already resolved this way -- no re-resolution, no network. action=remove drops one bookkeeping entry by its exact ecosystem/name/resolvedVersion; refuses if it is still a currently-registered workspace. action=clean removes every non-in-use entry, optionally scoped to one ecosystem. Neither remove nor clean deletes the underlying repo_cache disk entry -- use repo_cache(action=evict) for that, since a monorepo can share one checkout across several package coordinates.",
1800
+ "Resolve, list, remove, or clean bookkeeping for installed packages (npm, pypi, ...) resolved to verified exact repository source. action=resolve uses the project's own lockfile-family, bounded registry metadata, and an exact Git ref/commit (or, for an editable/direct-VCS/local install, the already-known source directly, no registry lookup needed); registers verified source as a read-only workspace for the other Lector tools. action=list reports every package coordinate already resolved this way -- no re-resolution, no network. action=remove drops one bookkeeping entry by its exact ecosystem/name/resolvedVersion; refuses if it is still a currently-registered workspace. action=clean removes every non-in-use entry, optionally scoped to one ecosystem. Neither remove nor clean deletes the underlying repo_cache disk entry -- use repo_cache(action=evict) for that, since a monorepo can share one checkout across several package coordinates.",
1718
1801
  promptSnippet: "Resolve, list, remove, or clean verified package source bookkeeping",
1719
1802
  parameters: Type.Object({
1720
1803
  action: Type.Optional(Type.Union([Type.Literal("resolve"), Type.Literal("list"), Type.Literal("remove"), Type.Literal("clean")])),
1721
- directory: Type.Optional(Type.String({ description: "Required for action=resolve -- project directory containing the npm-family lockfile" })),
1804
+ directory: Type.Optional(Type.String({ description: "Required for action=resolve -- project directory containing the package's own lockfile-family" })),
1722
1805
  name: Type.Optional(Type.String({ description: "Required for action=resolve/remove -- installed package name, including scope when present" })),
1723
1806
  version: Type.Optional(
1724
1807
  Type.String({ description: "action=resolve only -- exact installed version; required when the lockfile contains several versions" }),
1725
1808
  ),
1726
- registry: Type.Optional(Type.String({ description: "npm registry URL; defaults to the public npm registry" })),
1727
- ecosystem: Type.Optional(Type.String({ description: "Required for action=remove; optional filter for action=list/clean" })),
1809
+ registry: Type.Optional(Type.String({ description: "Registry URL; defaults to the ecosystem's own public registry (npm registry / pypi.org)" })),
1810
+ ecosystem: Type.Optional(
1811
+ Type.String({
1812
+ description:
1813
+ "npm/pypi/cargo/go/maven/conan/vcpkg/nuget/swiftpm -- action=resolve defaults to npm; required for action=remove; optional filter for action=list/clean",
1814
+ }),
1815
+ ),
1728
1816
  resolvedVersion: Type.Optional(Type.String({ description: "Required for action=remove -- the exact resolved version to remove" })),
1729
1817
  text: Type.Optional(Type.String({ description: "action=list only -- case-insensitive substring match across ecosystem/name/resolvedVersion" })),
1730
1818
  maxResults: Type.Optional(Type.Number({ description: "Required for action=list -- maximum entries to return in this page" })),
@@ -1757,7 +1845,13 @@ export default function (pi: ExtensionAPI) {
1757
1845
  }
1758
1846
  if (!params.directory || !params.name) throw new Error("package_source action=resolve requires directory and name");
1759
1847
  const directory = resolve(cwd, params.directory);
1760
- const result = await packageSourceOperations.resolve(directory, params.name, params.version ?? null, params.registry ?? null);
1848
+ const result = await packageSourceOperations.resolve(
1849
+ directory,
1850
+ params.name,
1851
+ params.version ?? null,
1852
+ params.registry ?? null,
1853
+ optionalPackageEcosystem(params.ecosystem),
1854
+ );
1761
1855
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "resolve", result } };
1762
1856
  },
1763
1857
  renderCall(args, theme, context) {
@@ -3,6 +3,13 @@ import { withWorkspace, workspaceForPath } from "../lector-client.ts";
3
3
  import { invokeLectorVehicleOperation, type LectorVehicleCall } from "../vehicle-client.ts";
4
4
  import { toWorkspaceRelativePath } from "../workspace-relative-path.ts";
5
5
 
6
+ const MAX_INTERNAL_HISTORY_LOOKUP_RESULTS = 2_000;
7
+
8
+ export interface MutationTransactionRevertOutcome {
9
+ readonly transactionId: string;
10
+ readonly reverted: readonly { readonly path: string; readonly newHash: string | null }[];
11
+ }
12
+
6
13
  /** Match MUTATION_HISTORY_READ_PERMISSIONS/MUTATION_HISTORY_WRITE_PERMISSIONS' own declared values server-side (mutation-history/operation-registration.ts). */
7
14
  const MUTATION_HISTORY_READ_PERMISSIONS = ["workspace:read"];
8
15
  const MUTATION_HISTORY_WRITE_PERMISSIONS = ["workspace:write"];
@@ -15,32 +22,72 @@ const MUTATION_HISTORY_WRITE_PERMISSIONS = ["workspace:write"];
15
22
  export interface MutationHistoryOperations {
16
23
  list(absolutePath: string, maxResults: number, call: LectorVehicleCall): Promise<readonly MutationHistoryEntry[]>;
17
24
  revert(absolutePath: string, entryId: string, call: LectorVehicleCall): Promise<{ path: string; newHash: string | null }>;
25
+ revertTransaction(absolutePath: string, transactionId: string, call: LectorVehicleCall): Promise<MutationTransactionRevertOutcome>;
26
+ }
27
+
28
+ async function listResolvedHistory(
29
+ workspaceId: string,
30
+ root: string,
31
+ absolutePath: string,
32
+ maxResults: number,
33
+ call: LectorVehicleCall,
34
+ ): Promise<readonly MutationHistoryEntry[]> {
35
+ const relativePath = toWorkspaceRelativePath(root, absolutePath);
36
+ // Single-file edits historically record the caller's workspace-relative path, while LSP
37
+ // WorkspaceEdits record canonical absolute paths. Query both identities until the daemon's
38
+ // stored-history migration can normalize old entries, then deduplicate by immutable entry id.
39
+ const paths = relativePath === absolutePath ? [absolutePath] : [relativePath, absolutePath];
40
+ const pages = await Promise.all(
41
+ paths.map((path) =>
42
+ invokeLectorVehicleOperation<{ entries: readonly MutationHistoryEntry[] }>(
43
+ "workspace.mutationHistory",
44
+ { workspaceId, path, maxResults },
45
+ MUTATION_HISTORY_READ_PERMISSIONS,
46
+ call,
47
+ ),
48
+ ),
49
+ );
50
+ const byId = new Map<string, MutationHistoryEntry>();
51
+ for (const page of pages) for (const entry of page.entries) byId.set(entry.id, entry);
52
+ return [...byId.values()].sort((a, b) => b.timestamp - a.timestamp).slice(0, maxResults);
18
53
  }
19
54
 
20
55
  export function createMutationHistoryOperations(): MutationHistoryOperations {
21
56
  return {
22
57
  list(absolutePath, maxResults, call) {
58
+ return withWorkspace(
59
+ () => workspaceForPath(absolutePath),
60
+ ({ workspaceId, root }) => listResolvedHistory(workspaceId, root, absolutePath, maxResults, call),
61
+ );
62
+ },
63
+ revert(absolutePath, entryId, call) {
23
64
  return withWorkspace(
24
65
  () => workspaceForPath(absolutePath),
25
66
  async ({ workspaceId, root }) => {
26
- const path = toWorkspaceRelativePath(root, absolutePath);
27
- const { entries } = await invokeLectorVehicleOperation<{ entries: readonly MutationHistoryEntry[] }>(
28
- "workspace.mutationHistory",
29
- { workspaceId, path, maxResults },
30
- MUTATION_HISTORY_READ_PERMISSIONS,
67
+ const entries = await listResolvedHistory(workspaceId, root, absolutePath, MAX_INTERNAL_HISTORY_LOOKUP_RESULTS, call);
68
+ const target = entries.find((entry) => entry.id === entryId);
69
+ if (!target) throw new Error(`mutation history entry "${entryId}" is not recorded for "${absolutePath}" -- list that path again before reverting`);
70
+ if (target.transactionId !== null) {
71
+ throw new Error(
72
+ `mutation history entry "${entryId}" belongs to multi-file transaction "${target.transactionId}" -- refusing a partial revert; use action=revert-transaction with transactionId`,
73
+ );
74
+ }
75
+ return invokeLectorVehicleOperation<{ path: string; newHash: string | null }>(
76
+ "workspace.revertMutation",
77
+ { workspaceId, entryId },
78
+ MUTATION_HISTORY_WRITE_PERMISSIONS,
31
79
  call,
32
80
  );
33
- return entries;
34
81
  },
35
82
  );
36
83
  },
37
- revert(absolutePath, entryId, call) {
84
+ revertTransaction(absolutePath, transactionId, call) {
38
85
  return withWorkspace(
39
86
  () => workspaceForPath(absolutePath),
40
87
  ({ workspaceId }) =>
41
- invokeLectorVehicleOperation<{ path: string; newHash: string | null }>(
42
- "workspace.revertMutation",
43
- { workspaceId, entryId },
88
+ invokeLectorVehicleOperation<MutationTransactionRevertOutcome>(
89
+ "workspace.revertMutationTransaction",
90
+ { workspaceId, transactionId },
44
91
  MUTATION_HISTORY_WRITE_PERMISSIONS,
45
92
  call,
46
93
  ),
@@ -0,0 +1,20 @@
1
+ import type { MutationHistoryEntry } from "@danypops/lector";
2
+ import type { MutationTransactionRevertOutcome } from "./operations.ts";
3
+
4
+ export function formatMutationHistoryList(entries: readonly MutationHistoryEntry[]): string {
5
+ if (entries.length === 0) return "no recorded mutation history for this path";
6
+ return entries
7
+ .map((entry) => {
8
+ const grouping = entry.transactionId === null ? "standalone mutation" : `transaction ${entry.transactionId}`;
9
+ return `${entry.id} ${new Date(entry.timestamp).toISOString()} ${entry.operation} ${grouping}`;
10
+ })
11
+ .join("\n");
12
+ }
13
+
14
+ export function formatMutationTransactionRevert(originalTransactionId: string, outcome: MutationTransactionRevertOutcome): string {
15
+ const lines = [
16
+ `${originalTransactionId} reverted atomically; revert recorded as transaction ${outcome.transactionId}`,
17
+ ...outcome.reverted.map((entry) => `${entry.path} -> ${entry.newHash ?? "(deleted)"}`),
18
+ ];
19
+ return lines.join("\n");
20
+ }
@@ -7,7 +7,13 @@ export interface PackageSourceListPage {
7
7
  }
8
8
 
9
9
  export interface PackageSourceOperations {
10
- resolve(directory: string, name: string, requestedVersion: string | null, registry: string | null): Promise<PackageSourceOperationResult>;
10
+ resolve(
11
+ directory: string,
12
+ name: string,
13
+ requestedVersion: string | null,
14
+ registry: string | null,
15
+ ecosystem?: PackageEcosystem,
16
+ ): Promise<PackageSourceOperationResult>;
11
17
  list(options: { ecosystem?: PackageEcosystem; text?: string; maxResults: number; cursor?: string }): Promise<PackageSourceListPage>;
12
18
  remove(ecosystem: PackageEcosystem, registry: string | null, name: string, resolvedVersion: string): Promise<{ removed: boolean }>;
13
19
  clean(ecosystem: PackageEcosystem | undefined): Promise<{ removed: number; skipped: number }>;
@@ -15,12 +21,12 @@ export interface PackageSourceOperations {
15
21
 
16
22
  export function createLectorPackageSourceOperations(): PackageSourceOperations {
17
23
  return {
18
- async resolve(directory, name, requestedVersion, registry) {
24
+ async resolve(directory, name, requestedVersion, registry, ecosystem = "npm") {
19
25
  const client = await lectorClient();
20
26
  return client.callOnce("package.resolveSource", {
21
27
  request: {
22
28
  projectRoot: directory,
23
- coordinate: { ecosystem: "npm", registry, name, requestedVersion },
29
+ coordinate: { ecosystem, registry, name, requestedVersion },
24
30
  },
25
31
  bounds: DEFAULT_PACKAGE_SOURCE_BOUNDS,
26
32
  });
@@ -17,7 +17,8 @@ export function formatSearchResult(result: TextSearchResult | undefined, expande
17
17
  items: result.matches,
18
18
  expanded,
19
19
  visibleCount: DEFAULT_VISIBLE_MATCHES,
20
- formatItem: (match) => `${theme.fg("accent", match.path)}:${match.lineNumber}: ${match.line.replace(/\n$/, "")}`,
20
+ formatItem: (match) =>
21
+ `${theme.fg("accent", match.path)}:${match.lineNumber}: ${match.line.replace(/\n$/, "")}${match.lineTruncated ? theme.fg("warning", " (line truncated)") : ""}`,
21
22
  moreLine: (hidden) => theme.fg("dim", `... ${hidden} more (${keyHint("app.tools.expand", "to expand")})`),
22
23
  truncationWarning: result.truncated ? theme.fg("warning", "(search itself was truncated by maxMatches/maxBytes -- results are incomplete)") : undefined,
23
24
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.13.6",
3
+ "version": "0.13.9",
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",
@@ -22,17 +22,19 @@
22
22
  "@danypops/vehicle-client-pi": "^0.45.0"
23
23
  },
24
24
  "dependencies": {
25
- "@danypops/lector": "^0.19.9",
25
+ "@danypops/lector": "^0.20.0",
26
26
  "@danypops/vehicle-client": "^0.10.3",
27
27
  "@danypops/vehicle-core": "^0.17.1",
28
28
  "malevich-tui-components": "^0.32.1",
29
29
  "picomatch": "^4.0.5"
30
30
  },
31
31
  "devDependencies": {
32
- "@danypops/vehicle-client-pi": "^0.45.0",
33
- "@danypops/vehicle-conformance": "^0.4.0",
32
+ "@danypops/pi-eval-harness": "^0.1.0",
34
33
  "@danypops/pi-extension-harness": "^0.2.0",
34
+ "@danypops/pi-process-harness": "^0.1.3",
35
35
  "@danypops/pi-tui-harness": "^0.0.1",
36
+ "@danypops/vehicle-client-pi": "^0.45.0",
37
+ "@danypops/vehicle-conformance": "^0.4.0",
36
38
  "@earendil-works/pi-ai": "^0.81.1",
37
39
  "@earendil-works/pi-coding-agent": "^0.81.1",
38
40
  "@earendil-works/pi-tui": "^0.81.1",