@danypops/pi-lector 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ import type { SymbolSearchResult, TextSearchResult } from "@danypops/lector";
2
+ import { boundModelContentText, DEFAULT_MODEL_CONTENT_BYTES } from "../presentation/model-content.ts";
3
+ import type { CrossWorkspaceOutcome } from "./operations.ts";
4
+
5
+ const MAX_RESULTS_PER_PROJECT = 10;
6
+
7
+ function appendOutcomeHeader<T>(lines: string[], entry: CrossWorkspaceOutcome<T>): boolean {
8
+ const { outcome } = entry;
9
+ switch (outcome.status) {
10
+ case "ready":
11
+ lines.push(`${entry.directory} -- ready`);
12
+ break;
13
+ case "loading":
14
+ lines.push(`${entry.directory} -- loading: ${outcome.message}`);
15
+ return false;
16
+ case "error":
17
+ lines.push(`${entry.directory} -- error: ${outcome.message}`);
18
+ return false;
19
+ default: {
20
+ const exhaustive: never = outcome;
21
+ throw new Error(`unhandled workspace query outcome: ${JSON.stringify(exhaustive)}`);
22
+ }
23
+ }
24
+ if (entry.collapsedWith.length > 0) lines.push(` same workspace as: ${entry.collapsedWith.join(", ")}`);
25
+ return true;
26
+ }
27
+
28
+ /** Formats cross-project symbol outcomes with concrete, independently bounded results for model consumption. */
29
+ export function formatFindSymbolsAcrossProjectsModelContent(
30
+ results: readonly CrossWorkspaceOutcome<SymbolSearchResult>[],
31
+ maxBytes = DEFAULT_MODEL_CONTENT_BYTES,
32
+ ): string {
33
+ const lines = ["Find Symbols Across Projects", `projects: ${results.length}`];
34
+ for (const entry of results) {
35
+ if (!appendOutcomeHeader(lines, entry)) continue;
36
+ if (entry.outcome.status !== "ready") continue;
37
+ const { result } = entry.outcome;
38
+ lines.push(` provenance: ${result.provenance.fidelity} via ${result.provenance.backend}`);
39
+ lines.push(` upstream truncated: ${result.truncated}`);
40
+ for (const item of result.symbols.slice(0, MAX_RESULTS_PER_PROJECT)) {
41
+ lines.push(` ${item.kind} ${item.name} -- ${item.location.path}:${item.location.line}:${item.location.character}`);
42
+ }
43
+ if (result.symbols.length > MAX_RESULTS_PER_PROJECT) lines.push(` ${result.symbols.length - MAX_RESULTS_PER_PROJECT} more symbols omitted`);
44
+ if (result.symbols.length === 0) lines.push(" no symbols matched");
45
+ }
46
+ return boundModelContentText(lines.join("\n"), maxBytes);
47
+ }
48
+
49
+ /** Formats cross-project text outcomes with concrete, independently bounded matches for model consumption. */
50
+ export function formatSearchTextAcrossProjectsModelContent(
51
+ results: readonly CrossWorkspaceOutcome<TextSearchResult>[],
52
+ maxBytes = DEFAULT_MODEL_CONTENT_BYTES,
53
+ ): string {
54
+ const lines = ["Search Code Across Projects", `projects: ${results.length}`];
55
+ for (const entry of results) {
56
+ if (!appendOutcomeHeader(lines, entry)) continue;
57
+ if (entry.outcome.status !== "ready") continue;
58
+ const { result } = entry.outcome;
59
+ if (result.provenance) lines.push(` provenance: lexical via ${result.provenance.backend} (${result.provenance.indexState})`);
60
+ lines.push(` upstream truncated: ${result.truncated}`);
61
+ for (const item of result.matches.slice(0, MAX_RESULTS_PER_PROJECT)) {
62
+ const line = item.line.replace(/\n$/, "");
63
+ lines.push(` ${item.path}:${item.lineNumber}: ${line}${item.lineTruncated ? " (line truncated)" : ""}`);
64
+ }
65
+ if (result.matches.length > MAX_RESULTS_PER_PROJECT) lines.push(` ${result.matches.length - MAX_RESULTS_PER_PROJECT} more matches omitted`);
66
+ if (result.matches.length === 0) lines.push(" no matches");
67
+ }
68
+ return boundModelContentText(lines.join("\n"), maxBytes);
69
+ }
@@ -1,4 +1,4 @@
1
- import type { GithubRepoSearchResult, NpmPackageCandidate, SourcegraphCodeCandidate } from "@danypops/lector";
1
+ import type { GithubRepoSearchResult, NpmPackageCandidate, SourcegraphCodeSearchResult } from "@danypops/lector";
2
2
  import { invokeLectorVehicleOperation, type LectorVehicleCall } from "../vehicle-client.ts";
3
3
 
4
4
  /** Matches EXTERNAL_SEARCH_PERMISSIONS' own declared value server-side (external-search/operation-registration.ts). */
@@ -12,7 +12,7 @@ const EXTERNAL_SEARCH_PERMISSIONS = ["external-search:read"];
12
12
  export interface ExternalSearchOperations {
13
13
  githubRepos(query: string, maxResults: number, call: LectorVehicleCall): Promise<GithubRepoSearchResult>;
14
14
  npmPackages(query: string, maxResults: number, call: LectorVehicleCall): Promise<{ candidates: readonly NpmPackageCandidate[] }>;
15
- sourcegraphCode(query: string, maxResults: number, call: LectorVehicleCall): Promise<{ candidates: readonly SourcegraphCodeCandidate[] }>;
15
+ sourcegraphCode(query: string, maxResults: number, call: LectorVehicleCall): Promise<SourcegraphCodeSearchResult>;
16
16
  }
17
17
 
18
18
  export function createExternalSearchOperations(): ExternalSearchOperations {
@@ -29,12 +29,7 @@ export function createExternalSearchOperations(): ExternalSearchOperations {
29
29
  );
30
30
  },
31
31
  sourcegraphCode(query, maxResults, call) {
32
- return invokeLectorVehicleOperation<{ candidates: readonly SourcegraphCodeCandidate[] }>(
33
- "search.sourcegraphCode",
34
- { query, maxResults },
35
- EXTERNAL_SEARCH_PERMISSIONS,
36
- call,
37
- );
32
+ return invokeLectorVehicleOperation<SourcegraphCodeSearchResult>("search.sourcegraphCode", { query, maxResults }, EXTERNAL_SEARCH_PERMISSIONS, call);
38
33
  },
39
34
  };
40
35
  }
@@ -1,4 +1,4 @@
1
- import type { GithubRepoSearchResult, NpmPackageCandidate, SourcegraphCodeCandidate } from "@danypops/lector";
1
+ import type { GithubRepoSearchResult, NpmPackageCandidate, SourcegraphCodeSearchResult } from "@danypops/lector";
2
2
  import type { LectorTheme } from "../lector-tui-theme.ts";
3
3
  import { presentationTitle } from "../presentation/tool-presentation.ts";
4
4
 
@@ -45,17 +45,15 @@ export function formatNpmPackageSearchResult(
45
45
  return lines.join("\n");
46
46
  }
47
47
 
48
- export function formatSourcegraphCodeSearchResult(
49
- result: { candidates: readonly SourcegraphCodeCandidate[] } | undefined,
50
- expanded: boolean,
51
- theme: LectorTheme,
52
- ): string {
53
- if (!result || result.candidates.length === 0) return theme.fg("dim", "no code matches");
48
+ export function formatSourcegraphCodeSearchResult(result: SourcegraphCodeSearchResult | undefined, expanded: boolean, theme: LectorTheme): string {
49
+ if (!result) return theme.fg("dim", "no code matches");
54
50
  const { visible, more } = boundedCandidates(result.candidates, expanded);
55
51
  const lines = visible.map((candidate) => {
56
52
  const matches = candidate.lineMatches.slice(0, expanded ? candidate.lineMatches.length : 3);
57
53
  return `${theme.fg("accent", `${candidate.repository}/${candidate.path}`)}\n${matches.map((match) => ` ${match.line}: ${match.preview}`).join("\n")}`;
58
54
  });
59
55
  if (more > 0) lines.push(theme.fg("muted", `… ${more} more (expand to show)`));
56
+ if (result.truncated) lines.push(theme.fg("warning", `partial results · ${result.stopReason ?? "bounded"}`));
57
+ if (lines.length === 0) return theme.fg("dim", "no code matches");
60
58
  return lines.join("\n");
61
59
  }
@@ -21,7 +21,7 @@ import type {
21
21
  PackageSourceOperationResult,
22
22
  PopulateSymbolGraphResult,
23
23
  RepoFetchResult,
24
- SourcegraphCodeCandidate,
24
+ SourcegraphCodeSearchResult,
25
25
  SymbolAnnotation,
26
26
  SymbolNode,
27
27
  SymbolSearchResult,
@@ -82,6 +82,7 @@ import {
82
82
  formatWorkspaceMapCall,
83
83
  formatWorkspaceMapResult,
84
84
  } from "./code-intelligence/rendering.ts";
85
+ import { formatFindSymbolsAcrossProjectsModelContent, formatSearchTextAcrossProjectsModelContent } from "./cross-workspace-search/model-content.ts";
85
86
  import { type CrossWorkspaceOutcome, createLectorCrossWorkspaceSearchOperations } from "./cross-workspace-search/operations.ts";
86
87
  import { formatCrossWorkspaceCall, formatFindSymbolsAcrossProjectsResult, formatSearchTextAcrossProjectsResult } from "./cross-workspace-search/rendering.ts";
87
88
  import { createLectorEditOperations } from "./edit/operations.ts";
@@ -125,6 +126,11 @@ import { withLectorPresentation } from "./presentation/presentation-contract.ts"
125
126
  import { presentationTitle } from "./presentation/tool-presentation.ts";
126
127
  import { createLectorReadOperations } from "./read/operations.ts";
127
128
  import { createReferenceBasedRenameOperations } from "./reference-based-rename/operations.ts";
129
+ import {
130
+ formatReferenceBasedRenameModelContent,
131
+ formatReferenceBasedRenameResult,
132
+ type ReferenceBasedRenameOutcome,
133
+ } from "./reference-based-rename/rendering.ts";
128
134
  import { createRenameOperations } from "./rename/operations.ts";
129
135
  import { createRepoCacheEvictOperations } from "./repo-cache/evict-operations.ts";
130
136
  import { createRepoCacheListOperations } from "./repo-cache/list-operations.ts";
@@ -154,7 +160,13 @@ import {
154
160
  monitorWorkspaceCache,
155
161
  waitForJobCompletion,
156
162
  } from "./workspace-cache/operations.ts";
157
- import { formatJobSnapshotResult, formatWorkspaceCacheCall, formatWorkspaceCacheStatusResult } from "./workspace-cache/rendering.ts";
163
+ import {
164
+ formatJobSnapshotResult,
165
+ formatWorkspaceCacheCall,
166
+ formatWorkspaceCacheStatusResult,
167
+ formatWorkspaceReleaseModelContent,
168
+ formatWorkspaceReleaseResult,
169
+ } from "./workspace-cache/rendering.ts";
158
170
  import { createLectorWriteOperations } from "./write/operations.ts";
159
171
 
160
172
  function describeIntelligenceSource(provenance: IntelligenceProvenance): string {
@@ -1190,14 +1202,7 @@ export default function (pi: ExtensionAPI) {
1190
1202
  const fromPath = resolve(cwd, params.fromPath);
1191
1203
  const toPath = resolve(cwd, params.toPath);
1192
1204
  const outcome = await referenceBasedRenameOperations.rename(fromPath, toPath, params.maxFiles, params.maxSymbolsPerFile);
1193
- const lines = [
1194
- `moved to ${outcome.movedTo}`,
1195
- outcome.filesUpdated.length === 0
1196
- ? "no other files referenced it"
1197
- : `updated imports in ${outcome.filesUpdated.length} file(s): ${outcome.filesUpdated.join(", ")}`,
1198
- ...outcome.caveats.map((caveat) => `caveat: ${caveat}`),
1199
- ];
1200
- return { content: [{ type: "text", text: lines.join("\n") }], details: { outcome } };
1205
+ return { content: [{ type: "text", text: formatReferenceBasedRenameModelContent(outcome) }], details: { outcome } };
1201
1206
  },
1202
1207
  renderCall(args, theme, context) {
1203
1208
  const fromPath = typeof args.fromPath === "string" ? args.fromPath : "";
@@ -1217,13 +1222,9 @@ export default function (pi: ExtensionAPI) {
1217
1222
  .join("\n");
1218
1223
  return new Text(theme.fg("error", errorText || "reference_based_rename failed"), 0, 0);
1219
1224
  }
1220
- const details = result.details as { outcome?: { movedTo: string; filesUpdated: readonly string[] } } | undefined;
1225
+ const details = result.details as { outcome?: ReferenceBasedRenameOutcome } | undefined;
1221
1226
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1222
- text.setText(
1223
- details?.outcome
1224
- ? `${theme.fg("success", "moved")} ${theme.fg("accent", details.outcome.movedTo)} ${theme.fg("dim", `(${details.outcome.filesUpdated.length} import(s) updated)`)}`
1225
- : theme.fg("success", "rename complete"),
1226
- );
1227
+ text.setText(details?.outcome ? formatReferenceBasedRenameResult(details.outcome, theme) : theme.fg("success", "rename complete"));
1227
1228
  return text;
1228
1229
  },
1229
1230
  });
@@ -1604,25 +1605,27 @@ export default function (pi: ExtensionAPI) {
1604
1605
  });
1605
1606
 
1606
1607
  interface WorkspaceCacheToolDetails {
1607
- readonly action: "status" | "populate" | "wait" | "job_status";
1608
+ readonly action: "status" | "populate" | "wait" | "job_status" | "release";
1608
1609
  readonly status?: WorkspaceCacheStatus;
1609
1610
  readonly job?: JobSnapshot<PopulateSymbolGraphResult>;
1611
+ readonly release?: OperationOutputs["workspace.release"];
1610
1612
  }
1611
1613
 
1612
1614
  registerLectorTool({
1613
1615
  name: "workspace_cache",
1614
1616
  label: "Workspace Cache",
1615
1617
  description:
1616
- "Checks or drives population of the workspace's persisted symbol graph -- the store reachable_from, symbol_annotations (anchor resolution), reference_based_rename, and workspace_map all read from, separate from the live language-server index find_symbols/hover/go_to_definition use. action=status reports not-cached/caching/partial/cached for the given bounds, without starting work. action=populate requests a scan and briefly waits for fast completion; a source file changing mid-scan (e.g. a concurrent edit or rename) is retried automatically in the background for up to a minute before surfacing as a real failure, no manual re-run needed. action=wait subscribes to daemon job completion, with bounded status polling only when push delivery is unavailable. action=job_status is a point-in-time diagnostic read.",
1618
+ "Checks or drives population of the workspace's persisted symbol graph -- the store reachable_from, symbol_annotations (anchor resolution), reference_based_rename, and workspace_map all read from, separate from the live language-server index find_symbols/hover/go_to_definition use. action=status reports not-cached/caching/partial/cached for the given bounds, without starting work. action=populate requests a scan and briefly waits for fast completion; a source file changing mid-scan (e.g. a concurrent edit or rename) is retried automatically in the background for up to a minute before surfacing as a real failure, no manual re-run needed. action=wait subscribes to daemon job completion, with bounded status polling only when push delivery is unavailable. action=job_status is a point-in-time diagnostic read. action=release safely closes idle workspace resources and unregisters it so fetched/package source cleanup can proceed.",
1617
1619
  promptSnippet: "Check or force-populate the workspace's persisted symbol graph",
1618
1620
  promptGuidelines: [
1619
1621
  "Use action=populate with a larger maxFiles/maxSymbolsPerFile before relying on reachable_from/symbol_annotations/reference_based_rename against a workspace bigger than the default 500-file auto-scan -- their own errors (empty results, UnknownAnnotationAnchor, ReferenceBasedRenameRequiresFreshGraph) usually mean the graph never reached the files you need, not that population is simply still catching up.",
1620
1622
  "When action=populate returns a queued/running job, call action=wait once with that jobId; do not run shell sleep or manually poll job_status.",
1623
+ "Use action=release after finishing with a fetched or package-source workspace so package_source remove/clean and repo_cache evict can reclaim it; active leases, jobs, or watches fail closed.",
1621
1624
  ],
1622
1625
  parameters: Type.Object({
1623
- action: Type.Union([Type.Literal("status"), Type.Literal("populate"), Type.Literal("wait"), Type.Literal("job_status")]),
1626
+ action: Type.Union([Type.Literal("status"), Type.Literal("populate"), Type.Literal("wait"), Type.Literal("job_status"), Type.Literal("release")]),
1624
1627
  directory: Type.Optional(
1625
- Type.String({ description: "Required for action=status/populate -- absolute or cwd-relative path used to resolve the workspace" }),
1628
+ Type.String({ description: "Required for action=status/populate/release -- absolute or cwd-relative path used to resolve the workspace" }),
1626
1629
  ),
1627
1630
  maxFiles: Type.Optional(
1628
1631
  Type.Number({ description: "action=status/populate only -- defaults to 500, the same bound the automatic first-touch scan uses" }),
@@ -1638,7 +1641,7 @@ export default function (pi: ExtensionAPI) {
1638
1641
  ),
1639
1642
  jobId: Type.Optional(Type.String({ description: "Required for action=wait/job_status -- a jobId returned by action=populate" })),
1640
1643
  }),
1641
- async execute(_toolCallId, params, signal): Promise<AgentToolResult<WorkspaceCacheToolDetails>> {
1644
+ async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<WorkspaceCacheToolDetails>> {
1642
1645
  if (params.action === "job_status") {
1643
1646
  if (!params.jobId) throw new Error("workspace_cache action=job_status requires jobId");
1644
1647
  const job = await cacheOperations.jobStatus(params.jobId);
@@ -1674,6 +1677,18 @@ export default function (pi: ExtensionAPI) {
1674
1677
  }
1675
1678
  if (!params.directory) throw new Error(`workspace_cache action=${params.action} requires directory`);
1676
1679
  const directory = resolve(cwd, params.directory);
1680
+ if (params.action === "release") {
1681
+ const release = await cacheOperations.release(directory, {
1682
+ toolName: "workspace_cache",
1683
+ toolCallId,
1684
+ signal,
1685
+ context: ctx,
1686
+ });
1687
+ return {
1688
+ content: [{ type: "text", text: formatWorkspaceReleaseModelContent(release) }],
1689
+ details: { action: "release", release },
1690
+ };
1691
+ }
1677
1692
  const maxFiles = params.maxFiles ?? 500;
1678
1693
  const maxSymbolsPerFile = params.maxSymbolsPerFile ?? 100;
1679
1694
  if (params.action === "status") {
@@ -1690,7 +1705,8 @@ export default function (pi: ExtensionAPI) {
1690
1705
  };
1691
1706
  },
1692
1707
  renderCall(args, theme, context) {
1693
- const action = args.action === "populate" || args.action === "wait" || args.action === "job_status" ? args.action : "status";
1708
+ const action =
1709
+ args.action === "populate" || args.action === "wait" || args.action === "job_status" || args.action === "release" ? args.action : "status";
1694
1710
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1695
1711
  text.setText(formatWorkspaceCacheCall(action, args, theme));
1696
1712
  return text;
@@ -1706,7 +1722,13 @@ export default function (pi: ExtensionAPI) {
1706
1722
  }
1707
1723
  const details = result.details as WorkspaceCacheToolDetails | undefined;
1708
1724
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1709
- text.setText(details?.action === "status" ? formatWorkspaceCacheStatusResult(details.status, theme) : formatJobSnapshotResult(details?.job, theme));
1725
+ text.setText(
1726
+ details?.action === "status"
1727
+ ? formatWorkspaceCacheStatusResult(details.status, theme)
1728
+ : details?.action === "release"
1729
+ ? formatWorkspaceReleaseResult(details.release, theme)
1730
+ : formatJobSnapshotResult(details?.job, theme),
1731
+ );
1710
1732
  return text;
1711
1733
  },
1712
1734
  });
@@ -2435,7 +2457,7 @@ export default function (pi: ExtensionAPI) {
2435
2457
  type ExternalSearchToolDetails =
2436
2458
  | { readonly action: "github_repos"; readonly result: GithubRepoSearchResult }
2437
2459
  | { readonly action: "npm_packages"; readonly result: { candidates: readonly NpmPackageCandidate[] } }
2438
- | { readonly action: "sourcegraph_code"; readonly result: { candidates: readonly SourcegraphCodeCandidate[] } };
2460
+ | { readonly action: "sourcegraph_code"; readonly result: SourcegraphCodeSearchResult };
2439
2461
 
2440
2462
  const externalSearchOperations = createExternalSearchOperations();
2441
2463
  registerLectorTool({
@@ -2522,7 +2544,7 @@ export default function (pi: ExtensionAPI) {
2522
2544
  const directories = params.directories.map((directory) => resolve(cwd, directory));
2523
2545
  const results = await crossWorkspaceSearchOperations.findSymbols(params.query, directories, params.timeoutMs, params.maxResults);
2524
2546
  return {
2525
- content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("find_symbols_across_projects"), results) }],
2547
+ content: [{ type: "text", text: formatFindSymbolsAcrossProjectsModelContent(results) }],
2526
2548
  details: { results },
2527
2549
  };
2528
2550
  },
@@ -2564,7 +2586,7 @@ export default function (pi: ExtensionAPI) {
2564
2586
  const directories = params.directories.map((directory) => resolve(cwd, directory));
2565
2587
  const results = await crossWorkspaceSearchOperations.searchText(params.query, directories, params.maxMatches, params.maxBytes, params.timeoutMs);
2566
2588
  return {
2567
- content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("search_code_across_projects"), results) }],
2589
+ content: [{ type: "text", text: formatSearchTextAcrossProjectsModelContent(results) }],
2568
2590
  details: { results },
2569
2591
  };
2570
2592
  },
@@ -89,6 +89,7 @@ export const LECTOR_TOOL_PRESENTATION_SPECS: Readonly<Record<string, ToolPresent
89
89
  populate: { title: "Populate Workspace Cache", family: "status" },
90
90
  wait: { title: "Wait for Cache Job", family: "status" },
91
91
  job_status: { title: "Cache Job Status", family: "status" },
92
+ release: { title: "Release Workspace", family: "mutation" },
92
93
  },
93
94
  },
94
95
  git: {
@@ -0,0 +1,21 @@
1
+ import type { OperationOutputs } from "@danypops/lector";
2
+ import type { LectorTheme } from "../lector-tui-theme.ts";
3
+
4
+ export type ReferenceBasedRenameOutcome = OperationOutputs["workspace.referenceBasedRename"];
5
+
6
+ /** Formats a successful reference-based rename with every fact required for guarded transaction revert. */
7
+ export function formatReferenceBasedRenameModelContent(outcome: ReferenceBasedRenameOutcome): string {
8
+ return [
9
+ `moved to ${outcome.movedTo}`,
10
+ outcome.filesUpdated.length === 0
11
+ ? "no other files referenced it"
12
+ : `updated imports in ${outcome.filesUpdated.length} file(s): ${outcome.filesUpdated.join(", ")}`,
13
+ `transaction ${outcome.transactionId}`,
14
+ ...outcome.caveats.map((caveat) => `caveat: ${caveat}`),
15
+ ].join("\n");
16
+ }
17
+
18
+ /** Formats the compact human mutation result while preserving its reusable transaction identity. */
19
+ export function formatReferenceBasedRenameResult(outcome: ReferenceBasedRenameOutcome, theme: LectorTheme): string {
20
+ return `${theme.fg("success", "moved")} ${theme.fg("accent", outcome.movedTo)} ${theme.fg("dim", `(${outcome.filesUpdated.length} import(s) updated, transaction ${outcome.transactionId})`)}`;
21
+ }
@@ -1,13 +1,15 @@
1
1
  import {
2
2
  type CacheResultCounts,
3
3
  type JobSnapshot,
4
+ type OperationOutputs,
4
5
  type PopulateSymbolGraphResult,
5
6
  remoteErrorIs,
6
7
  resolveLectorDaemonConnection,
7
8
  type WorkspaceCacheStatus,
8
9
  } from "@danypops/lector";
9
10
  import { connectPushChannel } from "@danypops/vehicle-client/daemon-client";
10
- import { lectorClient, withWorkspace, workspaceForProjectDirectory } from "../lector-client.ts";
11
+ import { forgetWorkspaceId, lectorClient, withWorkspace, workspaceForProjectDirectory } from "../lector-client.ts";
12
+ import { invokeLectorVehicleOperation, type LectorVehicleCall } from "../vehicle-client.ts";
11
13
 
12
14
  export interface JobWatchHandle {
13
15
  close(): void;
@@ -25,9 +27,11 @@ export type JobWatchOutcome = { readonly status: "subscribed"; readonly handle:
25
27
  * want today's exact contract.
26
28
  */
27
29
  const DEFAULT_POPULATE_RETRY_TIME_BUDGET_MS = 60_000;
30
+ const WORKSPACE_RELEASE_PERMISSIONS = ["workspace:write"];
28
31
 
29
32
  export interface WorkspaceCacheOperations {
30
33
  status(directory: string, maxFiles: number, maxSymbolsPerFile: number): Promise<WorkspaceCacheStatus>;
34
+ release(directory: string, call: LectorVehicleCall): Promise<OperationOutputs["workspace.release"]>;
31
35
  submit(
32
36
  directory: string,
33
37
  maxFiles: number,
@@ -39,6 +43,8 @@ export interface WorkspaceCacheOperations {
39
43
  watchJob?(jobId: string, onJob: (job: JobSnapshot<PopulateSymbolGraphResult>) => void): Promise<JobWatchOutcome>;
40
44
  }
41
45
 
46
+ export type WorkspaceCacheMonitorOperations = Omit<WorkspaceCacheOperations, "release">;
47
+
42
48
  export function createWorkspaceCacheOperations(ownerId?: string): WorkspaceCacheOperations {
43
49
  return {
44
50
  status(directory, maxFiles, maxSymbolsPerFile) {
@@ -50,6 +56,21 @@ export function createWorkspaceCacheOperations(ownerId?: string): WorkspaceCache
50
56
  },
51
57
  );
52
58
  },
59
+ release(directory, call) {
60
+ return withWorkspace(
61
+ () => workspaceForProjectDirectory(directory),
62
+ async ({ workspaceId, root }) => {
63
+ const result = await invokeLectorVehicleOperation<OperationOutputs["workspace.release"]>(
64
+ "workspace.release",
65
+ { workspaceId },
66
+ WORKSPACE_RELEASE_PERMISSIONS,
67
+ call,
68
+ );
69
+ forgetWorkspaceId(root);
70
+ return result;
71
+ },
72
+ );
73
+ },
53
74
  submit(directory, maxFiles, maxSymbolsPerFile, waitMs = 0, retryTimeBudgetMs = DEFAULT_POPULATE_RETRY_TIME_BUDGET_MS) {
54
75
  return withWorkspace(
55
76
  () => workspaceForProjectDirectory(directory),
@@ -159,7 +180,7 @@ export type JobCompletionOutcome =
159
180
 
160
181
  /** Waits on Vehicle push delivery and checks status on a bounded cadence when push is unavailable or disconnected. */
161
182
  export async function waitForJobCompletion(
162
- operations: WorkspaceCacheOperations,
183
+ operations: WorkspaceCacheMonitorOperations,
163
184
  jobId: string,
164
185
  options: WaitForJobCompletionOptions,
165
186
  ): Promise<JobCompletionOutcome> {
@@ -205,7 +226,7 @@ export async function waitForJobCompletion(
205
226
  }
206
227
 
207
228
  /** Drives one bounded session cache lifecycle; Pi event handlers only render its states. */
208
- export async function monitorWorkspaceCache(operations: WorkspaceCacheOperations, options: MonitorWorkspaceCacheOptions): Promise<void> {
229
+ export async function monitorWorkspaceCache(operations: WorkspaceCacheMonitorOperations, options: MonitorWorkspaceCacheOptions): Promise<void> {
209
230
  const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
210
231
  const initial = await operations.status(options.directory, options.maxFiles, options.maxSymbolsPerFile);
211
232
  if (!options.shouldContinue()) return;
@@ -1,8 +1,8 @@
1
- import type { CacheResultCounts, JobSnapshot, PopulateSymbolGraphResult, WorkspaceCacheStatus } from "@danypops/lector";
1
+ import type { CacheResultCounts, JobSnapshot, OperationOutputs, PopulateSymbolGraphResult, WorkspaceCacheStatus } from "@danypops/lector";
2
2
  import type { LectorTheme } from "../lector-tui-theme.ts";
3
3
  import { presentationTitle } from "../presentation/tool-presentation.ts";
4
4
 
5
- type WorkspaceCacheAction = "status" | "populate" | "wait" | "job_status";
5
+ type WorkspaceCacheAction = "status" | "populate" | "wait" | "job_status" | "release";
6
6
 
7
7
  export function formatWorkspaceCacheCall(
8
8
  action: WorkspaceCacheAction,
@@ -26,7 +26,33 @@ export function formatWorkspaceCacheCall(
26
26
 
27
27
  function formatResultCounts(result: CacheResultCounts): string {
28
28
  const failed = result.filesFailed > 0 ? `, ${result.filesFailed} failed` : "";
29
- return `${result.filesProcessed}/${result.filesAttempted} files${failed}, ${result.symbolsProcessed} symbols, ${result.nodesAdded} nodes, ${result.edgesAdded} edges`;
29
+ const reuse = result.filesReused === undefined ? "" : `, ${result.filesReused} reused, ${result.filesReprocessed ?? 0} reprocessed`;
30
+ const retries = result.staleRetries ? `, ${result.staleRetries} stale ${result.staleRetries === 1 ? "retry" : "retries"}` : "";
31
+ const coverage = result.sourceCoverage;
32
+ const scopes = coverage?.scopes
33
+ .slice(0, 3)
34
+ .map((entry) => `${entry.scope}:${entry.files}`)
35
+ .join(", ");
36
+ const omitted = coverage ? coverage.scopeOmittedCount + Math.max(0, coverage.scopes.length - 3) : 0;
37
+ const scopeSummary = scopes ? `; coverage ${scopes}${omitted > 0 ? ` (+${omitted} scopes)` : ""}${coverage?.truncated ? " [bounded]" : ""}` : "";
38
+ return `${result.filesProcessed}/${result.filesAttempted} files${failed}${reuse}${retries}, ${result.symbolsProcessed} symbols, ${result.nodesAdded} nodes, ${result.edgesAdded} edges${scopeSummary}`;
39
+ }
40
+
41
+ export function formatWorkspaceReleaseModelContent(outcome: OperationOutputs["workspace.release"]): string {
42
+ return [
43
+ `released workspace ${outcome.workspaceId}`,
44
+ `closed indexes: ${outcome.closedIndexes}`,
45
+ `closed graph: ${outcome.closedGraph}`,
46
+ `closed watch: ${outcome.closedWatch}`,
47
+ ].join("\n");
48
+ }
49
+
50
+ export function formatWorkspaceReleaseResult(outcome: OperationOutputs["workspace.release"] | undefined, theme: LectorTheme): string {
51
+ if (!outcome) return theme.fg("dim", "No result.");
52
+ return theme.fg(
53
+ "success",
54
+ `released ${outcome.workspaceId} -- ${outcome.closedIndexes} index(es), graph ${outcome.closedGraph ? "closed" : "idle"}, watch ${outcome.closedWatch ? "closed" : "idle"}`,
55
+ );
30
56
  }
31
57
 
32
58
  export function formatWorkspaceCacheStatusResult(status: WorkspaceCacheStatus | undefined, theme: LectorTheme): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Pi host adapter for Lector: overrides read/write/edit with a daemon-backed, hash-guarded filesystem",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,7 +22,7 @@
22
22
  "@danypops/vehicle-client-pi": "^0.45.0"
23
23
  },
24
24
  "dependencies": {
25
- "@danypops/lector": "^0.20.4",
25
+ "@danypops/lector": "^0.22.0",
26
26
  "@danypops/vehicle-client": "^0.10.8",
27
27
  "@danypops/vehicle-core": "^0.19.1",
28
28
  "malevich-tui-components": "^0.32.1",