@danypops/pi-lector 0.13.11 → 0.15.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,148 @@
1
+ import type { JsonValue } from "@danypops/vehicle-core";
2
+ import type { AgentToolResult, AgentToolUpdateCallback, Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ import type { TSchema } from "typebox";
5
+ import { boundModelContentText, DEFAULT_MODEL_CONTENT_BYTES } from "./model-content.ts";
6
+ import { type PresentationFamily, presentationFamily } from "./tool-presentation.ts";
7
+
8
+ export const LECTOR_PRESENTATION_SCHEMA = "pi-lector.presentation/v1";
9
+ export const DEFAULT_LECTOR_PRESENTATION_MAX_BYTES = 128 * 1024;
10
+
11
+ interface LectorPresentationBase {
12
+ readonly schema: typeof LECTOR_PRESENTATION_SCHEMA;
13
+ readonly tool: string;
14
+ readonly action: string | null;
15
+ readonly payload: JsonValue;
16
+ }
17
+
18
+ /** Versioned presentation variants persisted independently from model-facing content. */
19
+ export type LectorToolPresentation = {
20
+ readonly [Family in PresentationFamily]: LectorPresentationBase & { readonly family: Family };
21
+ }[PresentationFamily];
22
+
23
+ export type LectorPresentationEnvelope = LectorToolPresentation;
24
+
25
+ function isRecord(value: unknown): value is Record<string, unknown> {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+
29
+ function isJsonValue(value: unknown): value is JsonValue {
30
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
31
+ if (typeof value === "number") return Number.isFinite(value);
32
+ if (Array.isArray(value)) return value.every(isJsonValue);
33
+ if (isRecord(value)) return Object.values(value).every(isJsonValue);
34
+ return false;
35
+ }
36
+
37
+ function serializedJsonValue(value: unknown): JsonValue {
38
+ if (value === undefined) return null;
39
+ let serialized: string;
40
+ try {
41
+ serialized = JSON.stringify(value, (_key, candidate: unknown) => {
42
+ if (typeof candidate === "number" && !Number.isFinite(candidate)) throw new TypeError("non-finite number");
43
+ if (typeof candidate === "bigint" || typeof candidate === "function" || typeof candidate === "symbol") {
44
+ throw new TypeError(`unsupported ${typeof candidate}`);
45
+ }
46
+ return candidate;
47
+ });
48
+ } catch (error) {
49
+ throw new TypeError(`presentation details must be JSON serializable: ${error instanceof Error ? error.message : String(error)}`);
50
+ }
51
+ const parsed: unknown = JSON.parse(serialized);
52
+ if (!isJsonValue(parsed)) throw new TypeError("presentation details must contain only JSON values");
53
+ return parsed;
54
+ }
55
+
56
+ /** Projects one tool's renderer details into the versioned, serializable session boundary. */
57
+ export function projectLectorPresentation(
58
+ tool: string,
59
+ details: unknown,
60
+ maxBytes = DEFAULT_LECTOR_PRESENTATION_MAX_BYTES,
61
+ actionOverride?: string,
62
+ ): LectorPresentationEnvelope {
63
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new TypeError("presentation maxBytes must be a positive integer");
64
+ const action = actionOverride ?? (isRecord(details) && typeof details.action === "string" ? details.action : null);
65
+ const envelope: LectorPresentationEnvelope = {
66
+ schema: LECTOR_PRESENTATION_SCHEMA,
67
+ tool,
68
+ action,
69
+ family: presentationFamily(tool, action ?? undefined),
70
+ payload: serializedJsonValue(details),
71
+ };
72
+ const bytes = Buffer.byteLength(JSON.stringify(envelope), "utf8");
73
+ if (bytes > maxBytes) throw new RangeError(`presentation details exceed ${maxBytes} bytes (${bytes} observed)`);
74
+ return envelope;
75
+ }
76
+
77
+ /** Validates the shared envelope. Domain renderers retain responsibility for validating their payload variant. */
78
+ export function parseLectorPresentation(details: unknown, expectedTool: string): JsonValue | undefined {
79
+ if (!isRecord(details)) return undefined;
80
+ if (details.schema !== LECTOR_PRESENTATION_SCHEMA || details.tool !== expectedTool || !("payload" in details)) return undefined;
81
+ if (details.action !== null && typeof details.action !== "string") return undefined;
82
+ if (details.family !== presentationFamily(expectedTool, typeof details.action === "string" ? details.action : undefined)) return undefined;
83
+ try {
84
+ return serializedJsonValue(details.payload);
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
89
+
90
+ function fallbackText(result: AgentToolResult<unknown>, theme: Theme): Text {
91
+ const content = result.content
92
+ .filter((block): block is Extract<(typeof result.content)[number], { type: "text" }> => block.type === "text")
93
+ .map((block) => block.text)
94
+ .join("\n");
95
+ return new Text(theme.fg("toolOutput", content || "No result."), 0, 0);
96
+ }
97
+
98
+ export interface LectorPresentationOptions {
99
+ readonly maxBytes?: number;
100
+ readonly maxModelContentBytes?: number;
101
+ }
102
+
103
+ /** Wraps a production tool so persisted renderer details cross one validated, bounded envelope. */
104
+ export function withLectorPresentation<TParams extends TSchema, TDetails, TState>(
105
+ tool: ToolDefinition<TParams, TDetails, TState>,
106
+ options: LectorPresentationOptions = {},
107
+ ): ToolDefinition<TParams, LectorPresentationEnvelope, TState> {
108
+ const maxBytes = options.maxBytes ?? DEFAULT_LECTOR_PRESENTATION_MAX_BYTES;
109
+ const maxModelContentBytes = options.maxModelContentBytes ?? DEFAULT_MODEL_CONTENT_BYTES;
110
+ return {
111
+ ...tool,
112
+ async execute(toolCallId, params, signal, onUpdate, context) {
113
+ const parameterRecord = params as Record<string, unknown>;
114
+ const action =
115
+ typeof parameterRecord.action === "string"
116
+ ? parameterRecord.action
117
+ : typeof parameterRecord.direction === "string"
118
+ ? parameterRecord.direction
119
+ : undefined;
120
+ const boundedContent = (content: AgentToolResult<unknown>["content"]): AgentToolResult<unknown>["content"] =>
121
+ content.map((block) => (block.type === "text" ? { ...block, text: boundModelContentText(block.text, maxModelContentBytes) } : block));
122
+ const wrappedUpdate: AgentToolUpdateCallback<TDetails> | undefined = onUpdate
123
+ ? (update) =>
124
+ onUpdate({
125
+ ...update,
126
+ content: boundedContent(update.content),
127
+ details: projectLectorPresentation(tool.name, update.details, maxBytes, action),
128
+ })
129
+ : undefined;
130
+ const result = await tool.execute(toolCallId, params, signal, wrappedUpdate, context);
131
+ return {
132
+ ...result,
133
+ content: boundedContent(result.content),
134
+ details: projectLectorPresentation(tool.name, result.details, maxBytes, action),
135
+ };
136
+ },
137
+ renderResult(result, renderOptions, theme, context) {
138
+ const payload = parseLectorPresentation(result.details, tool.name);
139
+ if (payload === undefined && !renderOptions.isPartial && !context.isError) return fallbackText(result, theme);
140
+ if (!tool.renderResult) return fallbackText(result, theme);
141
+ // The envelope is the runtime validation boundary. Each existing domain renderer narrows
142
+ // its own payload variant; this is the single shared assertion that reconnects its generic.
143
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
144
+ const domainDetails = payload as TDetails;
145
+ return tool.renderResult({ ...result, details: domainDetails }, renderOptions, theme, context);
146
+ },
147
+ };
148
+ }
@@ -0,0 +1,171 @@
1
+ export type PresentationFamily =
2
+ | "source"
3
+ | "markdown"
4
+ | "symbols"
5
+ | "locations"
6
+ | "diagnostics"
7
+ | "diff"
8
+ | "mutation"
9
+ | "status"
10
+ | "table"
11
+ | "tree"
12
+ | "candidates"
13
+ | "semantic-text";
14
+
15
+ interface PresentationPathSpec {
16
+ readonly title: string;
17
+ readonly family: PresentationFamily;
18
+ }
19
+
20
+ interface ToolPresentationSpec extends PresentationPathSpec {
21
+ readonly actions?: Readonly<Record<string, PresentationPathSpec>>;
22
+ }
23
+
24
+ export const LECTOR_TOOL_PRESENTATION_SPECS: Readonly<Record<string, ToolPresentationSpec>> = {
25
+ read: { title: "Read File", family: "source" },
26
+ write: { title: "Write File", family: "mutation" },
27
+ edit: { title: "Edit File", family: "diff" },
28
+ find_symbols: { title: "Find Symbols", family: "symbols" },
29
+ localize_context: { title: "Localize Context", family: "candidates" },
30
+ go_to_definition: { title: "Go to Definition", family: "locations" },
31
+ go_to_implementation: { title: "Go to Implementation", family: "locations" },
32
+ find_references: { title: "Find References", family: "locations" },
33
+ hover: { title: "Hover", family: "markdown" },
34
+ document_symbols: { title: "Document Symbols", family: "tree" },
35
+ diagnostics: { title: "Diagnostics", family: "diagnostics" },
36
+ code_action_preview: { title: "Preview Code Actions", family: "candidates" },
37
+ code_action_apply: { title: "Apply Code Action", family: "mutation" },
38
+ diagnostic_delta: { title: "Diagnostic Delta", family: "diagnostics" },
39
+ call_hierarchy: {
40
+ title: "Call Hierarchy",
41
+ family: "tree",
42
+ actions: {
43
+ prepare: { title: "Prepare Call Hierarchy", family: "symbols" },
44
+ incoming: { title: "Incoming Calls", family: "tree" },
45
+ outgoing: { title: "Outgoing Calls", family: "tree" },
46
+ },
47
+ },
48
+ type_hierarchy: {
49
+ title: "Type Hierarchy",
50
+ family: "tree",
51
+ actions: {
52
+ prepare: { title: "Prepare Type Hierarchy", family: "symbols" },
53
+ supertypes: { title: "Supertypes", family: "tree" },
54
+ subtypes: { title: "Subtypes", family: "tree" },
55
+ },
56
+ },
57
+ impact_analysis: { title: "Impact Analysis", family: "tree" },
58
+ reference_based_rename: { title: "Rename File by References", family: "mutation" },
59
+ rename: {
60
+ title: "Rename Symbol",
61
+ family: "mutation",
62
+ actions: {
63
+ prepare: { title: "Prepare Rename", family: "semantic-text" },
64
+ apply: { title: "Rename Symbol", family: "mutation" },
65
+ },
66
+ },
67
+ symbol_annotations: {
68
+ title: "Symbol Annotations",
69
+ family: "semantic-text",
70
+ actions: {
71
+ create: { title: "Create Symbol Annotation", family: "mutation" },
72
+ get: { title: "Show Symbol Annotation", family: "markdown" },
73
+ list: { title: "List Symbol Annotations", family: "table" },
74
+ refresh: { title: "Refresh Symbol Annotation", family: "mutation" },
75
+ scrub: { title: "Scrub Symbol Annotation", family: "mutation" },
76
+ restore: { title: "Restore Symbol Annotation", family: "mutation" },
77
+ contain: { title: "Contain Symbol Annotation", family: "mutation" },
78
+ uncontain: { title: "Uncontain Symbol Annotation", family: "mutation" },
79
+ tree: { title: "Annotation Tree", family: "tree" },
80
+ },
81
+ },
82
+ reachable_from: { title: "Reachable Symbols", family: "tree" },
83
+ workspace_map: { title: "Workspace Map", family: "symbols" },
84
+ workspace_cache: {
85
+ title: "Workspace Cache",
86
+ family: "status",
87
+ actions: {
88
+ status: { title: "Workspace Cache Status", family: "status" },
89
+ populate: { title: "Populate Workspace Cache", family: "status" },
90
+ wait: { title: "Wait for Cache Job", family: "status" },
91
+ job_status: { title: "Cache Job Status", family: "status" },
92
+ release: { title: "Release Workspace", family: "mutation" },
93
+ },
94
+ },
95
+ git: {
96
+ title: "Git",
97
+ family: "semantic-text",
98
+ actions: {
99
+ status: { title: "Git Status", family: "status" },
100
+ log: { title: "Git Log", family: "table" },
101
+ diff: { title: "Git Diff", family: "diff" },
102
+ "compare-symbol": { title: "Compare Symbol", family: "diff" },
103
+ show: { title: "Show File at Git Ref", family: "source" },
104
+ "grep-ref": { title: "Search Git Ref", family: "locations" },
105
+ "grep-history": { title: "Search Git History", family: "locations" },
106
+ "ls-ref": { title: "List Files at Git Ref", family: "table" },
107
+ "is-ancestor": { title: "Check Git Ancestry", family: "semantic-text" },
108
+ "worktree-add": { title: "Create Git Worktree", family: "mutation" },
109
+ "worktree-remove": { title: "Remove Git Worktree", family: "mutation" },
110
+ },
111
+ },
112
+ search_code: { title: "Search Code", family: "locations" },
113
+ find_files: { title: "Find Files", family: "table" },
114
+ line_edit: { title: "Edit Lines", family: "diff" },
115
+ apply_patch: { title: "Apply Patch", family: "diff" },
116
+ mutation_history: {
117
+ title: "Mutation History",
118
+ family: "table",
119
+ actions: {
120
+ list: { title: "Mutation History", family: "table" },
121
+ revert: { title: "Revert Mutation", family: "mutation" },
122
+ "revert-transaction": { title: "Revert Transaction", family: "mutation" },
123
+ },
124
+ },
125
+ package_source: {
126
+ title: "Package Source",
127
+ family: "semantic-text",
128
+ actions: {
129
+ resolve: { title: "Resolve Package Source", family: "semantic-text" },
130
+ list: { title: "List Package Sources", family: "table" },
131
+ remove: { title: "Remove Package Source", family: "mutation" },
132
+ clean: { title: "Clean Package Sources", family: "mutation" },
133
+ },
134
+ },
135
+ repo_cache: {
136
+ title: "Repository Cache",
137
+ family: "semantic-text",
138
+ actions: {
139
+ fetch: { title: "Fetch Repository", family: "status" },
140
+ list: { title: "List Repository Cache", family: "table" },
141
+ evict: { title: "Evict Repository", family: "mutation" },
142
+ },
143
+ },
144
+ external_search: {
145
+ title: "External Search",
146
+ family: "candidates",
147
+ actions: {
148
+ github_repos: { title: "Search GitHub Repositories", family: "candidates" },
149
+ npm_packages: { title: "Search npm Packages", family: "candidates" },
150
+ sourcegraph_code: { title: "Search Public Code", family: "candidates" },
151
+ },
152
+ },
153
+ find_symbols_across_projects: { title: "Find Symbols Across Projects", family: "symbols" },
154
+ search_code_across_projects: { title: "Search Code Across Projects", family: "locations" },
155
+ };
156
+
157
+ export function presentationTitle(toolName: string, action?: string): string {
158
+ const spec = LECTOR_TOOL_PRESENTATION_SPECS[toolName];
159
+ if (!spec) throw new Error(`no presentation specification for ${toolName}`);
160
+ return (action ? spec.actions?.[action]?.title : undefined) ?? spec.title;
161
+ }
162
+
163
+ export function presentationFamily(toolName: string, action?: string): PresentationFamily {
164
+ const spec = LECTOR_TOOL_PRESENTATION_SPECS[toolName];
165
+ if (!spec) throw new Error(`no presentation specification for ${toolName}`);
166
+ return (action ? spec.actions?.[action]?.family : undefined) ?? spec.family;
167
+ }
168
+
169
+ export function presentationPathCount(): number {
170
+ return Object.values(LECTOR_TOOL_PRESENTATION_SPECS).reduce((count, spec) => count + (spec.actions ? Object.keys(spec.actions).length : 1), 0);
171
+ }
@@ -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
+ }
@@ -2,6 +2,7 @@ import type { CachedRepositoryEntry, CachedRepositoryPage, RepoFetchResult } fro
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
3
  import type { TableColumn } from "malevich-tui-components";
4
4
  import type { LectorTheme } from "../lector-tui-theme.ts";
5
+ import { presentationTitle } from "../presentation/tool-presentation.ts";
5
6
 
6
7
  /** Table has no row-count bound of its own; a cache can grow arbitrarily large even though repo_cache's own `maxResults` bounds any one page, so the display itself still needs a cap independent of that. */
7
8
  export const REPO_CACHE_VISIBLE_ROWS = 20;
@@ -17,16 +18,16 @@ export function formatRepoCacheCall(
17
18
  args: { owner?: unknown; repo?: unknown; ref?: unknown; host?: unknown; text?: unknown },
18
19
  theme: LectorTheme,
19
20
  ): string {
20
- const label = theme.fg("toolTitle", theme.bold("repo_cache"));
21
+ const label = theme.fg("toolTitle", theme.bold(presentationTitle("repo_cache", action)));
21
22
  if (action === "list") {
22
23
  const filter = typeof args.text === "string" && args.text.length > 0 ? args.text : typeof args.repo === "string" ? args.repo : "";
23
- return `${label} ${theme.fg("accent", "list")}${filter ? ` ${theme.fg("dim", filter)}` : ""}`;
24
+ return `${label}${filter ? ` ${theme.fg("accent", filter)}` : ""}`;
24
25
  }
25
26
  const host = typeof args.host === "string" && args.host.length > 0 ? args.host : "github.com";
26
27
  const owner = typeof args.owner === "string" ? args.owner : "";
27
28
  const repo = typeof args.repo === "string" ? args.repo : "";
28
29
  const ref = typeof args.ref === "string" ? `@${args.ref}` : "";
29
- return `${label} ${theme.fg("accent", action)} ${theme.fg("dim", `${host}/${owner}/${repo}${ref}`)}`;
30
+ return `${label} ${theme.fg("accent", `${host}/${owner}/${repo}${ref}`)}`;
30
31
  }
31
32
 
32
33
  export function formatRepoFetchResult(result: (RepoFetchResult & { workspaceId: string }) | undefined, theme: LectorTheme): string {
@@ -2,13 +2,14 @@ import type { TextSearchResult } from "@danypops/lector";
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
3
  import { renderTruncatedList } from "malevich-tui-components";
4
4
  import type { LectorTheme } from "../lector-tui-theme.ts";
5
+ import { presentationTitle } from "../presentation/tool-presentation.ts";
5
6
 
6
7
  const DEFAULT_VISIBLE_MATCHES = 20;
7
8
 
8
9
  export function formatSearchCall(args: { directory?: unknown; query?: unknown }, theme: LectorTheme): string {
9
10
  const directory = typeof args.directory === "string" ? args.directory : "";
10
11
  const query = typeof args.query === "string" ? args.query : "";
11
- return `${theme.fg("toolTitle", theme.bold("search_code"))} ${theme.fg("accent", `"${query}"`)} ${theme.fg("dim", directory)}`;
12
+ return `${theme.fg("toolTitle", theme.bold(presentationTitle("search_code")))} ${theme.fg("accent", `"${query}"`)} ${theme.fg("dim", directory)}`;
12
13
  }
13
14
 
14
15
  export function formatSearchResult(result: TextSearchResult | undefined, expanded: boolean, theme: LectorTheme): string {
@@ -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,17 +1,18 @@
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
+ import { presentationTitle } from "../presentation/tool-presentation.ts";
3
4
 
4
- type WorkspaceCacheAction = "status" | "populate" | "wait" | "job_status";
5
+ type WorkspaceCacheAction = "status" | "populate" | "wait" | "job_status" | "release";
5
6
 
6
7
  export function formatWorkspaceCacheCall(
7
8
  action: WorkspaceCacheAction,
8
9
  args: { directory?: unknown; maxFiles?: unknown; maxSymbolsPerFile?: unknown; jobId?: unknown },
9
10
  theme: LectorTheme,
10
11
  ): string {
11
- const label = theme.fg("toolTitle", theme.bold("workspace_cache"));
12
+ const label = theme.fg("toolTitle", theme.bold(presentationTitle("workspace_cache", action)));
12
13
  if (action === "job_status" || action === "wait") {
13
14
  const jobId = typeof args.jobId === "string" ? args.jobId : "";
14
- return `${label} ${theme.fg("accent", action)} ${theme.fg("dim", jobId)}`;
15
+ return `${label} ${theme.fg("accent", jobId)}`;
15
16
  }
16
17
  const directory = typeof args.directory === "string" ? args.directory : "";
17
18
  const maxFiles = typeof args.maxFiles === "number" ? String(args.maxFiles) : "default";
@@ -20,7 +21,7 @@ export function formatWorkspaceCacheCall(
20
21
  action === "populate" && (typeof args.maxFiles === "number" || typeof args.maxSymbolsPerFile === "number")
21
22
  ? theme.fg("dim", ` (maxFiles=${maxFiles}, maxSymbolsPerFile=${maxSymbolsPerFile})`)
22
23
  : "";
23
- return `${label} ${theme.fg("accent", action)} ${theme.fg("dim", directory)}${bounds}`;
24
+ return `${label} ${theme.fg("accent", directory)}${bounds}`;
24
25
  }
25
26
 
26
27
  function formatResultCounts(result: CacheResultCounts): string {
@@ -28,6 +29,23 @@ function formatResultCounts(result: CacheResultCounts): string {
28
29
  return `${result.filesProcessed}/${result.filesAttempted} files${failed}, ${result.symbolsProcessed} symbols, ${result.nodesAdded} nodes, ${result.edgesAdded} edges`;
29
30
  }
30
31
 
32
+ export function formatWorkspaceReleaseModelContent(outcome: OperationOutputs["workspace.release"]): string {
33
+ return [
34
+ `released workspace ${outcome.workspaceId}`,
35
+ `closed indexes: ${outcome.closedIndexes}`,
36
+ `closed graph: ${outcome.closedGraph}`,
37
+ `closed watch: ${outcome.closedWatch}`,
38
+ ].join("\n");
39
+ }
40
+
41
+ export function formatWorkspaceReleaseResult(outcome: OperationOutputs["workspace.release"] | undefined, theme: LectorTheme): string {
42
+ if (!outcome) return theme.fg("dim", "No result.");
43
+ return theme.fg(
44
+ "success",
45
+ `released ${outcome.workspaceId} -- ${outcome.closedIndexes} index(es), graph ${outcome.closedGraph ? "closed" : "idle"}, watch ${outcome.closedWatch ? "closed" : "idle"}`,
46
+ );
47
+ }
48
+
31
49
  export function formatWorkspaceCacheStatusResult(status: WorkspaceCacheStatus | undefined, theme: LectorTheme): string {
32
50
  if (!status) return theme.fg("dim", "No result.");
33
51
  if (status.status === "not-cached") return theme.fg("warning", `not cached (${status.reason})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.13.11",
3
+ "version": "0.15.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.21.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",