@danypops/pi-lector 0.13.5 → 0.13.7

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,9 +50,21 @@ 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
- export function createLectorCodeIntelligenceOperations(): CodeIntelligenceOperations {
67
+ export function createLectorCodeIntelligenceOperations(ownerId?: string): CodeIntelligenceOperations {
56
68
  return {
57
69
  async goToDefinition(path, line, character) {
58
70
  return withWorkspace(
@@ -144,6 +156,7 @@ export function createLectorCodeIntelligenceOperations(): CodeIntelligenceOperat
144
156
  operation: "workspace.populateSymbolGraph",
145
157
  input: { workspaceId, maxFiles, maxSymbolsPerFile },
146
158
  waitMs,
159
+ ...(ownerId ? { ownerId } : {}),
147
160
  });
148
161
  return job;
149
162
  },
@@ -183,5 +196,14 @@ export function createLectorCodeIntelligenceOperations(): CodeIntelligenceOperat
183
196
  },
184
197
  );
185
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
+ },
186
208
  };
187
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)")
@@ -38,4 +38,10 @@ export { ExplorerComponent, type ExplorerResult, joinExplorerPath } from "./expl
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,
@@ -38,10 +39,11 @@ import {
38
39
  createWriteToolDefinition,
39
40
  type ExtensionAPI,
40
41
  type ExtensionCommandContext,
42
+ type ToolDefinition,
41
43
  } from "@earendil-works/pi-coding-agent";
42
44
  import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
43
45
  import { renderBoundedTable, type TextMeasure } from "malevich-tui-components";
44
- import { Type } from "typebox";
46
+ import { type TSchema, Type } from "typebox";
45
47
 
46
48
  /** Real ANSI-aware measurement for Table -- Malevich's own default is ASCII-only, unsafe against theme-styled cell/header text. */
47
49
  const tableMeasure: TextMeasure = { visibleWidth, truncateToWidth };
@@ -95,7 +97,8 @@ import { formatGitCall, formatGitResult, type GitToolDetails } from "./git/rende
95
97
  import { nearestGitWorkspaceRoot, setNewWorkspaceObserver } from "./lector-client.ts";
96
98
  import { createLectorLineEditOperations } from "./line-edit/operations.ts";
97
99
  import { formatLineEditCall, formatLineEditResult } from "./line-edit/rendering.ts";
98
- 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";
99
102
  import { createLectorPackageSourceOperations, type PackageSourceListPage } from "./package-source/operations.ts";
100
103
  import {
101
104
  buildPackageSourceListTableRows,
@@ -128,6 +131,7 @@ import { createLectorSearchOperations } from "./search/operations.ts";
128
131
  import { formatSearchCall, formatSearchResult } from "./search/rendering.ts";
129
132
  import { type AnnotationAnchorInput, createLectorSymbolAnnotationOperations } from "./symbol-annotation/operations.ts";
130
133
  import { formatAnnotationDetail, formatAnnotationListSummary, formatAnnotationSummary } from "./symbol-annotation/rendering.ts";
134
+ import { boundLectorToolText } from "./tool-output-bounds.ts";
131
135
  import type { LectorVehicleCall } from "./vehicle-client.ts";
132
136
  import { CachingOverlay } from "./workspace-cache/caching-overlay.ts";
133
137
  import {
@@ -168,7 +172,23 @@ function renderIntelligenceSource(body: string, provenance: IntelligenceProvenan
168
172
  * "start it with `lector serve`" error if none is reachable.
169
173
  */
170
174
  export default function (pi: ExtensionAPI) {
171
- const cacheOperations = createWorkspaceCacheOperations();
175
+ let cacheOperations = createWorkspaceCacheOperations();
176
+ // Registration is the ownership boundary: every custom Lector tool enters this set
177
+ // automatically, so future tools cannot silently bypass the final model-content cap.
178
+ // Built-in-compatible read/write/edit stay on Pi's own definitions and truncation path.
179
+ const customToolNames = new Set<string>();
180
+ function registerLectorTool<TParams extends TSchema, TDetails = unknown, TState = unknown>(tool: ToolDefinition<TParams, TDetails, TState>): void {
181
+ customToolNames.add(tool.name);
182
+ pi.registerTool(tool);
183
+ }
184
+ pi.on("tool_result", (event) => {
185
+ if (!customToolNames.has(event.toolName)) return;
186
+ const textBlocks = event.content.filter((block): block is Extract<(typeof event.content)[number], { type: "text" }> => block.type === "text");
187
+ if (textBlocks.length === 0) return;
188
+ const bounded = boundLectorToolText(textBlocks.map((block) => block.text).join("\n"));
189
+ if (!bounded.truncation) return;
190
+ return { content: [{ type: "text", text: bounded.text }, ...event.content.filter((block) => block.type !== "text")] };
191
+ });
172
192
  // One generation counter shared by every root's monitor loop, not per-root -- a new session
173
193
  // (or shutdown) invalidates every previous session's in-flight monitor regardless of which
174
194
  // root it tracked, and there is exactly one "current session" at a time.
@@ -292,6 +312,8 @@ export default function (pi: ExtensionAPI) {
292
312
 
293
313
  pi.on("session_start", (_event, ctx) => {
294
314
  const { cwd } = ctx;
315
+ const ownerId = ctx.sessionManager.getSessionId();
316
+ cacheOperations = createWorkspaceCacheOperations(ownerId);
295
317
  sessionGeneration++;
296
318
  cacheStatesByRoot.clear();
297
319
  monitoringRoots.clear();
@@ -299,9 +321,9 @@ export default function (pi: ExtensionAPI) {
299
321
  uiContext = ctx;
300
322
  setNewWorkspaceObserver((root) => startMonitoringRoot(root, ctx));
301
323
  if (ctx.hasUI) {
302
- // The persistent widget counterpart to the single-line "lector-cache" status above --
303
- // enumerates EVERY workspace currently caching, not just this session's own cwd root.
304
- cachingOverlay ??= new CachingOverlay();
324
+ // The persistent widget counterpart to the single-line "lector-cache" status above.
325
+ // Ownership is the Pi session, not cwd: one session may legitimately touch several roots.
326
+ cachingOverlay = new CachingOverlay(undefined, ownerId);
305
327
  cachingOverlay.setUI(ctx.ui);
306
328
  void cachingOverlay.refresh();
307
329
  cachingOverlay.startPolling();
@@ -323,7 +345,7 @@ export default function (pi: ExtensionAPI) {
323
345
  pi.registerTool(createEditToolDefinition(cwd, { operations: createLectorEditOperations() }));
324
346
 
325
347
  const findSymbolsOperations = createLectorFindSymbolsOperations();
326
- pi.registerTool({
348
+ registerLectorTool({
327
349
  name: "find_symbols",
328
350
  label: "Find Symbols",
329
351
  description:
@@ -385,7 +407,75 @@ export default function (pi: ExtensionAPI) {
385
407
  },
386
408
  });
387
409
 
388
- const codeIntelligenceOperations = createLectorCodeIntelligenceOperations();
410
+ const codeIntelligenceOperations = createLectorCodeIntelligenceOperations(ownerId);
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
+ });
389
479
 
390
480
  const editorOverlayOptions = { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "center" } } as const;
391
481
 
@@ -406,6 +496,11 @@ export default function (pi: ExtensionAPI) {
406
496
  const result = await codeIntelligenceOperations.hover(absolutePath, line, character);
407
497
  return result.hover;
408
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
+ },
409
504
  };
410
505
  return new ModalEditorComponent(tui, theme, host, session.content, () => done(undefined));
411
506
  }, editorOverlayOptions);
@@ -451,7 +546,7 @@ export default function (pi: ExtensionAPI) {
451
546
  character: Type.Number({ description: "1-indexed character offset within the line" }),
452
547
  };
453
548
 
454
- pi.registerTool({
549
+ registerLectorTool({
455
550
  name: "go_to_definition",
456
551
  label: "Go to Definition",
457
552
  description: "Find where the symbol at an exact file position is actually declared, across files, through re-exports and aliasing.",
@@ -485,7 +580,7 @@ export default function (pi: ExtensionAPI) {
485
580
  },
486
581
  });
487
582
 
488
- pi.registerTool({
583
+ registerLectorTool({
489
584
  name: "go_to_implementation",
490
585
  label: "Go to Implementation",
491
586
  description:
@@ -523,7 +618,7 @@ export default function (pi: ExtensionAPI) {
523
618
  },
524
619
  });
525
620
 
526
- pi.registerTool({
621
+ registerLectorTool({
527
622
  name: "find_references",
528
623
  label: "Find References",
529
624
  description: "Find every project-wide usage of the symbol at an exact file position.",
@@ -568,7 +663,7 @@ export default function (pi: ExtensionAPI) {
568
663
  },
569
664
  });
570
665
 
571
- pi.registerTool({
666
+ registerLectorTool({
572
667
  name: "hover",
573
668
  label: "Hover",
574
669
  description: "Get type and documentation information for the symbol at an exact file position.",
@@ -608,7 +703,7 @@ export default function (pi: ExtensionAPI) {
608
703
  },
609
704
  });
610
705
 
611
- pi.registerTool({
706
+ registerLectorTool({
612
707
  name: "document_symbols",
613
708
  label: "Document Symbols",
614
709
  description: "List every symbol declared in one file, hierarchically -- an outline of its classes, functions, and their members.",
@@ -642,7 +737,7 @@ export default function (pi: ExtensionAPI) {
642
737
  },
643
738
  });
644
739
 
645
- pi.registerTool({
740
+ registerLectorTool({
646
741
  name: "diagnostics",
647
742
  label: "Diagnostics",
648
743
  description: "List every error and warning a language server currently knows about for one file, as of its last analysis.",
@@ -679,7 +774,7 @@ export default function (pi: ExtensionAPI) {
679
774
  },
680
775
  });
681
776
 
682
- pi.registerTool({
777
+ registerLectorTool({
683
778
  name: "call_hierarchy",
684
779
  label: "Call Hierarchy",
685
780
  description:
@@ -745,7 +840,7 @@ export default function (pi: ExtensionAPI) {
745
840
  },
746
841
  });
747
842
 
748
- pi.registerTool({
843
+ registerLectorTool({
749
844
  name: "reference_based_rename",
750
845
  label: "Reference-Based Rename",
751
846
  description:
@@ -808,7 +903,7 @@ export default function (pi: ExtensionAPI) {
808
903
  applied?: OperationOutputs["workspace.rename"];
809
904
  }
810
905
 
811
- pi.registerTool({
906
+ registerLectorTool({
812
907
  name: "rename",
813
908
  label: "Rename",
814
909
  description:
@@ -877,7 +972,7 @@ export default function (pi: ExtensionAPI) {
877
972
  uncontained?: boolean;
878
973
  }
879
974
 
880
- pi.registerTool({
975
+ registerLectorTool({
881
976
  name: "symbol_annotations",
882
977
  label: "Symbol Annotations",
883
978
  description:
@@ -1056,7 +1151,7 @@ export default function (pi: ExtensionAPI) {
1056
1151
  },
1057
1152
  });
1058
1153
 
1059
- pi.registerTool({
1154
+ registerLectorTool({
1060
1155
  name: "reachable_from",
1061
1156
  label: "Reachable From",
1062
1157
  description:
@@ -1105,7 +1200,7 @@ export default function (pi: ExtensionAPI) {
1105
1200
  },
1106
1201
  });
1107
1202
 
1108
- pi.registerTool({
1203
+ registerLectorTool({
1109
1204
  name: "workspace_map",
1110
1205
  label: "Workspace Map",
1111
1206
  description:
@@ -1125,14 +1220,16 @@ export default function (pi: ExtensionAPI) {
1125
1220
  async execute(_toolCallId, params) {
1126
1221
  const path = resolve(cwd, params.path);
1127
1222
  const result = await codeIntelligenceOperations.workspaceMap(path, params.maxNodes, params.maxEdges, params.maxEntries, params.maxBytes);
1128
- 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 = `${
1129
1225
  result.entries.length === 0
1130
1226
  ? "No ranked symbols (the workspace's symbol graph may still be populating in the background -- retry shortly)."
1131
1227
  : result.entries
1132
1228
  .map(
1133
1229
  (entry) => `${entry.kind} ${entry.name} -- ${entry.path}:${entry.line}:${entry.character}${entry.signature ? ` -- ${entry.signature}` : ""}`,
1134
1230
  )
1135
- .join("\n");
1231
+ .join("\n")
1232
+ }\n${coverage}`;
1136
1233
  return { content: [{ type: "text", text }], details: { result } };
1137
1234
  },
1138
1235
  renderCall(args, theme, context) {
@@ -1162,7 +1259,7 @@ export default function (pi: ExtensionAPI) {
1162
1259
  readonly job?: JobSnapshot<PopulateSymbolGraphResult>;
1163
1260
  }
1164
1261
 
1165
- pi.registerTool({
1262
+ registerLectorTool({
1166
1263
  name: "workspace_cache",
1167
1264
  label: "Workspace Cache",
1168
1265
  description:
@@ -1253,7 +1350,7 @@ export default function (pi: ExtensionAPI) {
1253
1350
  });
1254
1351
 
1255
1352
  const gitOperations = createLectorGitOperations();
1256
- pi.registerTool({
1353
+ registerLectorTool({
1257
1354
  name: "git",
1258
1355
  label: "Git",
1259
1356
  description:
@@ -1396,7 +1493,7 @@ export default function (pi: ExtensionAPI) {
1396
1493
  });
1397
1494
 
1398
1495
  const searchOperations = createLectorSearchOperations();
1399
- pi.registerTool({
1496
+ registerLectorTool({
1400
1497
  name: "search_code",
1401
1498
  label: "Search Code",
1402
1499
  description:
@@ -1437,7 +1534,7 @@ export default function (pi: ExtensionAPI) {
1437
1534
  });
1438
1535
 
1439
1536
  const findFilesOperations = createLectorFindFilesOperations();
1440
- pi.registerTool({
1537
+ registerLectorTool({
1441
1538
  name: "find_files",
1442
1539
  label: "Find Files",
1443
1540
  description:
@@ -1483,7 +1580,7 @@ export default function (pi: ExtensionAPI) {
1483
1580
  });
1484
1581
 
1485
1582
  const lineEditOperations = createLectorLineEditOperations();
1486
- pi.registerTool({
1583
+ registerLectorTool({
1487
1584
  name: "line_edit",
1488
1585
  label: "Line Edit",
1489
1586
  description:
@@ -1554,7 +1651,7 @@ export default function (pi: ExtensionAPI) {
1554
1651
  });
1555
1652
 
1556
1653
  const applyPatchOperations = createLectorApplyPatchOperations();
1557
- pi.registerTool({
1654
+ registerLectorTool({
1558
1655
  name: "apply_patch",
1559
1656
  label: "Apply Patch",
1560
1657
  description:
@@ -1602,23 +1699,25 @@ export default function (pi: ExtensionAPI) {
1602
1699
 
1603
1700
  type MutationHistoryToolDetails =
1604
1701
  | { readonly action: "list"; readonly entries: readonly MutationHistoryEntry[] }
1605
- | { 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 };
1606
1704
 
1607
1705
  const mutationHistoryOperations = createMutationHistoryOperations();
1608
- pi.registerTool({
1706
+ registerLectorTool({
1609
1707
  name: "mutation_history",
1610
1708
  label: "Mutation History",
1611
1709
  description:
1612
- "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.",
1613
- 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",
1614
1712
  promptGuidelines: [
1615
- "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.",
1616
1714
  ],
1617
1715
  parameters: Type.Object({
1618
- action: Type.Union([Type.Literal("list"), Type.Literal("revert")]),
1619
- 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" }),
1620
1718
  maxResults: Type.Optional(Type.Number({ description: "Required for action=list -- maximum entries to return, newest first" })),
1621
- 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" })),
1622
1721
  }),
1623
1722
  async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<MutationHistoryToolDetails>> {
1624
1723
  const absolutePath = resolve(cwd, params.path);
@@ -1631,17 +1730,21 @@ export default function (pi: ExtensionAPI) {
1631
1730
  if (params.action === "list") {
1632
1731
  if (params.maxResults === undefined) throw new Error("mutation_history action=list requires maxResults");
1633
1732
  const entries = await mutationHistoryOperations.list(absolutePath, params.maxResults, vehicleCall);
1634
- const text =
1635
- entries.length === 0
1636
- ? "no recorded mutation history for this path"
1637
- : entries.map((entry) => `${entry.id} ${new Date(entry.timestamp).toISOString()} ${entry.operation}`).join("\n");
1638
- return { content: [{ type: "text", text }], details: { action: "list", entries } };
1639
- }
1640
- if (params.entryId === undefined) throw new Error("mutation_history action=revert requires entryId");
1641
- 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);
1642
1745
  return {
1643
- content: [{ type: "text", text: `${reverted.path} reverted -> ${reverted.newHash ?? "(deleted)"}` }],
1644
- details: { action: "revert", reverted },
1746
+ content: [{ type: "text", text: formatMutationTransactionRevert(params.transactionId, reverted) }],
1747
+ details: { action: "revert-transaction", reverted },
1645
1748
  };
1646
1749
  },
1647
1750
  renderCall(args, theme, context) {
@@ -1690,21 +1793,26 @@ export default function (pi: ExtensionAPI) {
1690
1793
  }
1691
1794
 
1692
1795
  const packageSourceOperations = createLectorPackageSourceOperations();
1693
- pi.registerTool({
1796
+ registerLectorTool({
1694
1797
  name: "package_source",
1695
1798
  label: "Package Source",
1696
1799
  description:
1697
- "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.",
1698
1801
  promptSnippet: "Resolve, list, remove, or clean verified package source bookkeeping",
1699
1802
  parameters: Type.Object({
1700
1803
  action: Type.Optional(Type.Union([Type.Literal("resolve"), Type.Literal("list"), Type.Literal("remove"), Type.Literal("clean")])),
1701
- 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" })),
1702
1805
  name: Type.Optional(Type.String({ description: "Required for action=resolve/remove -- installed package name, including scope when present" })),
1703
1806
  version: Type.Optional(
1704
1807
  Type.String({ description: "action=resolve only -- exact installed version; required when the lockfile contains several versions" }),
1705
1808
  ),
1706
- registry: Type.Optional(Type.String({ description: "npm registry URL; defaults to the public npm registry" })),
1707
- 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
+ ),
1708
1816
  resolvedVersion: Type.Optional(Type.String({ description: "Required for action=remove -- the exact resolved version to remove" })),
1709
1817
  text: Type.Optional(Type.String({ description: "action=list only -- case-insensitive substring match across ecosystem/name/resolvedVersion" })),
1710
1818
  maxResults: Type.Optional(Type.Number({ description: "Required for action=list -- maximum entries to return in this page" })),
@@ -1737,7 +1845,13 @@ export default function (pi: ExtensionAPI) {
1737
1845
  }
1738
1846
  if (!params.directory || !params.name) throw new Error("package_source action=resolve requires directory and name");
1739
1847
  const directory = resolve(cwd, params.directory);
1740
- 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
+ );
1741
1855
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "resolve", result } };
1742
1856
  },
1743
1857
  renderCall(args, theme, context) {
@@ -1796,7 +1910,7 @@ export default function (pi: ExtensionAPI) {
1796
1910
  const repoFetchOperations = createLectorRepoFetchOperations();
1797
1911
  const repoCacheListOperations = createRepoCacheListOperations();
1798
1912
  const repoCacheEvictOperations = createRepoCacheEvictOperations();
1799
- pi.registerTool({
1913
+ registerLectorTool({
1800
1914
  name: "repo_cache",
1801
1915
  label: "Repo Cache",
1802
1916
  description:
@@ -1899,7 +2013,7 @@ export default function (pi: ExtensionAPI) {
1899
2013
  | { readonly action: "sourcegraph_code"; readonly result: { candidates: readonly SourcegraphCodeCandidate[] } };
1900
2014
 
1901
2015
  const externalSearchOperations = createExternalSearchOperations();
1902
- pi.registerTool({
2016
+ registerLectorTool({
1903
2017
  name: "external_search",
1904
2018
  label: "External Search",
1905
2019
  description:
@@ -1956,7 +2070,7 @@ export default function (pi: ExtensionAPI) {
1956
2070
  });
1957
2071
 
1958
2072
  const crossWorkspaceSearchOperations = createLectorCrossWorkspaceSearchOperations();
1959
- pi.registerTool({
2073
+ registerLectorTool({
1960
2074
  name: "find_symbols_across_projects",
1961
2075
  label: "Find Symbols Across Projects",
1962
2076
  description:
@@ -1996,7 +2110,7 @@ export default function (pi: ExtensionAPI) {
1996
2110
  },
1997
2111
  });
1998
2112
 
1999
- pi.registerTool({
2113
+ registerLectorTool({
2000
2114
  name: "search_code_across_projects",
2001
2115
  label: "Search Code Across Projects",
2002
2116
  description:
@@ -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
  });
@@ -0,0 +1,36 @@
1
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "@earendil-works/pi-coding-agent";
2
+
3
+ export interface BoundedLectorToolText {
4
+ readonly text: string;
5
+ readonly truncation: TruncationResult | undefined;
6
+ }
7
+
8
+ function noticeFor(truncation: TruncationResult): string {
9
+ return (
10
+ `[Lector tool output truncated: ${truncation.totalLines} lines total, ${formatSize(truncation.totalBytes)} total; ` +
11
+ `showing ${truncation.outputLines} lines and ${formatSize(truncation.outputBytes)}. Refine the query or lower its scope.]`
12
+ );
13
+ }
14
+
15
+ /**
16
+ * Applies Pi's process-level custom-tool output contract independently of Lector's domain bounds.
17
+ * Domain maxBytes values intentionally describe DTO payload fields, not the final serialized tool
18
+ * message; this final guard accounts for separators, provenance, JSON framing, and caller-selected
19
+ * bounds before content enters model context.
20
+ */
21
+ export function boundLectorToolText(text: string): BoundedLectorToolText {
22
+ const initial = truncateHead(text);
23
+ if (!initial.truncated) return { text, truncation: undefined };
24
+
25
+ let bounded = initial;
26
+ for (let attempt = 0; attempt < 3; attempt++) {
27
+ const notice = noticeFor(bounded);
28
+ const noticeBytes = Buffer.byteLength(`\n\n${notice}`, "utf8");
29
+ bounded = truncateHead(text, {
30
+ maxLines: Math.max(1, DEFAULT_MAX_LINES - 2),
31
+ maxBytes: Math.max(1, DEFAULT_MAX_BYTES - noticeBytes),
32
+ });
33
+ }
34
+ const notice = noticeFor(bounded);
35
+ return { text: bounded.content ? `${bounded.content}\n\n${notice}` : notice, truncation: bounded };
36
+ }
@@ -4,8 +4,8 @@
4
4
  * DoctorOverlay: factory-form ctx.ui.setWidget registration, requestRender on refresh, hides the
5
5
  * widget entirely (setWidget(key, undefined)) rather than an empty box once nothing is caching.
6
6
  *
7
- * workspace.activeCachingJobs enumerates every workspace with a currently active (queued/
8
- * running) population job -- see packages/lector/src/service/symbol-graph/cache-query-handlers.ts.
7
+ * workspace.activeCachingJobs is filtered by the owning Pi session, while its omitted-owner
8
+ * daemon contract remains available for global administration -- see cache-query-handlers.ts.
9
9
  */
10
10
  import type { ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
11
11
  import type { TUI } from "@earendil-works/pi-tui";
@@ -39,7 +39,10 @@ export class CachingOverlay {
39
39
  intervalMs: CACHING_WIDGET_ROTATION_INTERVAL_MS,
40
40
  });
41
41
 
42
- constructor(private readonly connect: () => Promise<RetryingLectorClient> = lectorClient) {}
42
+ constructor(
43
+ private readonly connect: () => Promise<RetryingLectorClient> = lectorClient,
44
+ private readonly ownerId?: string,
45
+ ) {}
43
46
 
44
47
  setUI(ctx: ExtensionUIContext): void {
45
48
  if (ctx !== this.uiCtx) {
@@ -55,7 +58,7 @@ export class CachingOverlay {
55
58
  async refresh(): Promise<void> {
56
59
  try {
57
60
  const client = await this.connect();
58
- const result = await client.call("workspace.activeCachingJobs", {});
61
+ const result = await client.call("workspace.activeCachingJobs", this.ownerId ? { ownerId: this.ownerId } : {});
59
62
  this.projection = buildCachingWidgetProjection(result.jobs);
60
63
  } catch {
61
64
  this.projection = EMPTY_PROJECTION;
@@ -39,7 +39,7 @@ export interface WorkspaceCacheOperations {
39
39
  watchJob?(jobId: string, onJob: (job: JobSnapshot<PopulateSymbolGraphResult>) => void): Promise<JobWatchOutcome>;
40
40
  }
41
41
 
42
- export function createWorkspaceCacheOperations(): WorkspaceCacheOperations {
42
+ export function createWorkspaceCacheOperations(ownerId?: string): WorkspaceCacheOperations {
43
43
  return {
44
44
  status(directory, maxFiles, maxSymbolsPerFile) {
45
45
  return withWorkspace(
@@ -59,6 +59,7 @@ export function createWorkspaceCacheOperations(): WorkspaceCacheOperations {
59
59
  operation: "workspace.populateSymbolGraph",
60
60
  input: { workspaceId, maxFiles, maxSymbolsPerFile, retryTimeBudgetMs },
61
61
  waitMs,
62
+ ...(ownerId ? { ownerId } : {}),
62
63
  });
63
64
  return job;
64
65
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.13.5",
3
+ "version": "0.13.7",
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",