@danypops/pi-lector 0.1.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,175 @@
1
+ import { dirname, parse } from "node:path";
2
+ import {
3
+ connectLectorClient,
4
+ type LectorClient,
5
+ type OperationInputs,
6
+ type OperationName,
7
+ type OperationOutputs,
8
+ remoteErrorIs,
9
+ type WorkspaceId,
10
+ } from "@danypops/lector";
11
+ import { nearestGitRoot } from "./nearest-workspace-root.ts";
12
+
13
+ /**
14
+ * Lazily connects to a running Lector daemon and caches, per project root,
15
+ * the workspaceId that root registers under. Never auto-spawns the daemon:
16
+ * a clear "start it with `lector serve`" error is preferable to guessing
17
+ * at a lifecycle the user didn't ask for. A failed connection attempt is
18
+ * not cached, so the very next tool call retries once the daemon is
19
+ * actually running.
20
+ *
21
+ * The daemon binds a new random port on every restart. A client resolved
22
+ * once and cached for the rest of the session would otherwise point at a
23
+ * dead port after any later restart -- lectorClient()'s returned .call()
24
+ * detects that on the failing call itself (not just the first connection
25
+ * attempt) and retries once against a freshly re-resolved client, matching
26
+ * the pattern already proven in this house's papyrusClient()/callService().
27
+ */
28
+
29
+ type ClientConnector = () => Promise<LectorClient>;
30
+
31
+ let connector: ClientConnector = () => connectLectorClient();
32
+ let cachedClient: Promise<LectorClient> | undefined;
33
+ const workspaceIdByRoot = new Map<string, WorkspaceId>();
34
+
35
+ async function resolveClient(): Promise<LectorClient> {
36
+ if (!cachedClient) {
37
+ cachedClient = connector().catch((error: unknown) => {
38
+ cachedClient = undefined;
39
+ throw error;
40
+ });
41
+ }
42
+ return cachedClient;
43
+ }
44
+
45
+ /**
46
+ * True when `error` means the connection itself is bad (the daemon
47
+ * restarted on a new port since this client was cached, or died outright)
48
+ * -- worth invalidating the cache and retrying once. False for a genuine
49
+ * domain-level rejection (e.g. UnknownWorkspace), which a retry cannot fix
50
+ * and would only mask.
51
+ */
52
+ function isStaleConnectionError(error: unknown): boolean {
53
+ if (error instanceof TypeError) return true; // fetch()'s own connection-refused/DNS-failure shape
54
+ if (!(error instanceof Error)) return false;
55
+ if (error.name === "AbortError" || error.name === "TimeoutError") return true;
56
+ return /fetch failed|unable to connect|network|socket|ECONNRESET|ECONNREFUSED|connection refused/i.test(error.message);
57
+ }
58
+
59
+ export interface RetryingLectorClient {
60
+ call<Name extends OperationName>(operation: Name, input: OperationInputs[Name]): Promise<OperationOutputs[Name]>;
61
+ }
62
+
63
+ export async function lectorClient(): Promise<RetryingLectorClient> {
64
+ return {
65
+ async call(operation, input) {
66
+ for (let attempt = 0; attempt < 2; attempt++) {
67
+ const client = await resolveClient();
68
+ try {
69
+ return await client.call(operation, input);
70
+ } catch (error) {
71
+ cachedClient = undefined;
72
+ if (attempt === 1 || !isStaleConnectionError(error)) throw error;
73
+ }
74
+ }
75
+ throw new Error("Lector daemon client retry exhausted");
76
+ },
77
+ };
78
+ }
79
+
80
+ export interface ResolvedWorkspace {
81
+ workspaceId: WorkspaceId;
82
+ /** The registered root the target path is relative to -- a git root or the filesystem root, never a fixed session cwd. */
83
+ root: string;
84
+ }
85
+
86
+ async function workspaceForRoot(root: string): Promise<ResolvedWorkspace> {
87
+ const cached = workspaceIdByRoot.get(root);
88
+ if (cached) return { workspaceId: cached, root };
89
+ const client = await lectorClient();
90
+ const { workspaceId } = await client.call("workspace.registerPath", { path: root });
91
+ workspaceIdByRoot.set(root, workspaceId);
92
+ return { workspaceId, root };
93
+ }
94
+
95
+ /**
96
+ * Resolve (and cache) the Lector workspace for whatever project actually
97
+ * contains this absolute FILE path -- never a session's original cwd.
98
+ * Files under the same repo share one cached workspace+id; a path under a
99
+ * different repo (or outside any repo entirely) gets its own, registered
100
+ * on demand via workspace.registerPath. This is what makes read/write/edit
101
+ * work for *any* absolute path in one session, exactly like Pi's built-in
102
+ * tools always have -- not just paths under wherever the session started.
103
+ *
104
+ * Falls back to the filesystem root when no enclosing git repo exists:
105
+ * unlike workspaceForDirectory, any absolute path is fair game here (a
106
+ * dotfile in $HOME, a /tmp scratch file), so there is no smaller sensible
107
+ * boundary to prefer over "the whole filesystem" -- this is what pi's own
108
+ * built-in read/write/edit already allow.
109
+ */
110
+ export function workspaceForPath(absolutePath: string): Promise<ResolvedWorkspace> {
111
+ const directory = dirname(absolutePath);
112
+ const root = nearestGitRoot(directory) ?? parse(directory).root;
113
+ return workspaceForRoot(root);
114
+ }
115
+
116
+ /**
117
+ * Same resolution, starting from a directory (e.g. a symbol query's cwd)
118
+ * rather than a file's own path -- but falls back to the directory itself,
119
+ * never the filesystem root, when no enclosing git repo exists. Widening a
120
+ * find_symbols query's scope to the entire filesystem just because a
121
+ * project isn't a git repo would be both wrong (nothing meaningful to find
122
+ * outside the project) and unbounded (scanning the whole disk).
123
+ */
124
+ export function workspaceForDirectory(directory: string): Promise<ResolvedWorkspace> {
125
+ const root = nearestGitRoot(directory) ?? directory;
126
+ return workspaceForRoot(root);
127
+ }
128
+
129
+ /**
130
+ * For any operation that spawns a real language server (find_symbols,
131
+ * goToDefinition, documentSymbols, diagnostics, ...) -- never workspaceForPath,
132
+ * whose filesystem-root fallback would point a real server at scanning the
133
+ * whole disk. Falls back to the file's own containing directory instead,
134
+ * same bound as workspaceForDirectory.
135
+ */
136
+ export function workspaceForCodeIntelligencePath(absolutePath: string): Promise<ResolvedWorkspace> {
137
+ return workspaceForDirectory(dirname(absolutePath));
138
+ }
139
+
140
+ /**
141
+ * Resolves a workspace via `resolve`, then calls `perform` with it. A daemon
142
+ * restart wipes its in-memory workspace registry (workspace ids are not
143
+ * persisted across restarts by design), but this module's own workspaceId
144
+ * cache does not know that on its own -- a call through a stale cached id
145
+ * fails with UnknownWorkspace even though the underlying files on disk
146
+ * never changed. On exactly that failure, the stale cache entry is dropped
147
+ * and the whole flow (resolve, then perform) retries once against a
148
+ * freshly re-registered workspace -- re-registering the same root always
149
+ * yields the same workspaceId (deriveWorkspaceId is a deterministic hash
150
+ * of the path), so this is a safe, idempotent recovery, not a guess.
151
+ */
152
+ export async function withWorkspace<T>(resolve: () => Promise<ResolvedWorkspace>, perform: (resolved: ResolvedWorkspace) => Promise<T>): Promise<T> {
153
+ for (let attempt = 0; attempt < 2; attempt++) {
154
+ const resolved = await resolve();
155
+ try {
156
+ return await perform(resolved);
157
+ } catch (error) {
158
+ if (attempt === 1 || !remoteErrorIs(error, "UnknownWorkspace")) throw error;
159
+ workspaceIdByRoot.delete(resolved.root);
160
+ }
161
+ }
162
+ throw new Error("Lector workspace resolution retry exhausted");
163
+ }
164
+
165
+ export function setLectorClientConnectorForTests(value: ClientConnector): void {
166
+ cachedClient = undefined;
167
+ workspaceIdByRoot.clear();
168
+ connector = value;
169
+ }
170
+
171
+ export function resetLectorClientForTests(): void {
172
+ cachedClient = undefined;
173
+ workspaceIdByRoot.clear();
174
+ connector = () => connectLectorClient();
175
+ }
@@ -0,0 +1,35 @@
1
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * The minimal slice of pi-coding-agent's real Theme class every Lector-tool
5
+ * rendering module in this package actually uses -- not the full class
6
+ * (15+ methods, private fields). Narrower, and a plain pass-through object
7
+ * satisfies it directly in tests with no cast, since Theme's real fg/bold
8
+ * already structurally match. Shared across find_symbols, document_symbols,
9
+ * go_to_definition, find_references, and hover's rendering so each doesn't
10
+ * redeclare the same interface.
11
+ */
12
+ export interface LectorTheme {
13
+ fg(color: ThemeColor, text: string): string;
14
+ bold(text: string): string;
15
+ }
16
+
17
+ /** Maps a symbol/declaration kind to the closest semantic syntax-highlighting color already in the theme palette. */
18
+ const KIND_COLOR: Record<string, ThemeColor> = {
19
+ function: "syntaxFunction",
20
+ method: "syntaxFunction",
21
+ class: "syntaxType",
22
+ interface: "syntaxType",
23
+ "type-alias": "syntaxType",
24
+ enum: "syntaxType",
25
+ variable: "syntaxVariable",
26
+ };
27
+
28
+ export function colorForKind(kind: string): ThemeColor {
29
+ return KIND_COLOR[kind] ?? "muted";
30
+ }
31
+
32
+ /** `path:line:character`, dimmed -- the one consistent way every Lector tool renders a file position. */
33
+ export function formatLocation(theme: LectorTheme, path: string, line: number, character: number): string {
34
+ return theme.fg("dim", `${path}:${line}:${character}`);
35
+ }
@@ -0,0 +1,34 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, parse } from "node:path";
3
+
4
+ /**
5
+ * The nearest enclosing git repository root starting from (and including)
6
+ * a given directory, or undefined if none is found (e.g. /tmp scratch
7
+ * files, dotfiles outside any repo). Callers choose their own fallback --
8
+ * see lector-client.ts's workspaceForPath (falls back to the filesystem
9
+ * root: any absolute path is fair game for read/write/edit, exactly as
10
+ * Pi's built-in tools already allow) vs. workspaceForDirectory (falls back
11
+ * to the directory itself: widening a symbol-search scope all the way to
12
+ * the entire filesystem when a project isn't a git repo would be absurd).
13
+ *
14
+ * This -- not a Pi session's original cwd -- is Lector's real workspace
15
+ * granularity. A session routinely touches many unrelated repos, sibling
16
+ * projects, and scratch paths in one run; pi's built-in read/write/edit
17
+ * tools have never restricted which absolute path can be touched, and
18
+ * Lector must not either. (Real, shipped bug this fixes: read/write/edit
19
+ * hard-locked to whatever directory the session happened to start in,
20
+ * refusing every legitimate path outside it with a "Lector-registered
21
+ * workspace root" error -- discovered live, in a separate session, working
22
+ * against a completely different, unrelated repository.)
23
+ */
24
+ export function nearestGitRoot(startDirectory: string): string | undefined {
25
+ let dir = startDirectory;
26
+ const fsRoot = parse(dir).root;
27
+ while (dir !== fsRoot) {
28
+ if (existsSync(join(dir, ".git"))) return dir;
29
+ const parent = dirname(dir);
30
+ if (parent === dir) break; // defensive: dirname must be strictly ascending
31
+ dir = parent;
32
+ }
33
+ return existsSync(join(fsRoot, ".git")) ? fsRoot : undefined;
34
+ }
@@ -0,0 +1,73 @@
1
+ import { constants } from "node:fs";
2
+ import { access as fsAccess, readFile as fsReadFile } from "node:fs/promises";
3
+ import type { ReadOperations } from "@earendil-works/pi-coding-agent";
4
+ import { lectorClient, withWorkspace, workspaceForPath } from "./lector-client.ts";
5
+ import { toWorkspaceRelativePath } from "./workspace-relative-path.ts";
6
+
7
+ /**
8
+ * Lector's core domain (RawRead/ExpectedHashEdit) is deliberately text-only
9
+ * for now -- binary content is an open design question. Images are the one
10
+ * case pi's built-in read tool must still handle correctly, so they read
11
+ * directly from the local filesystem, bypassing Lector entirely, rather
12
+ * than corrupting binary bytes through a text round-trip. Extension-based,
13
+ * not content-sniffed: good enough to match pi's own defaultReadOperations
14
+ * behavior for the common case without depending on pi-coding-agent's
15
+ * internal detector.
16
+ */
17
+ const IMAGE_MIME_TYPES_BY_EXTENSION: Record<string, string> = {
18
+ ".jpg": "image/jpeg",
19
+ ".jpeg": "image/jpeg",
20
+ ".png": "image/png",
21
+ ".gif": "image/gif",
22
+ ".webp": "image/webp",
23
+ ".bmp": "image/bmp",
24
+ };
25
+
26
+ function imageMimeTypeFor(absolutePath: string): string | undefined {
27
+ const dot = absolutePath.lastIndexOf(".");
28
+ if (dot === -1) return undefined;
29
+ return IMAGE_MIME_TYPES_BY_EXTENSION[absolutePath.slice(dot).toLowerCase()];
30
+ }
31
+
32
+ /**
33
+ * ReadOperations backed by a running Lector daemon for text; images bypass
34
+ * Lector (see above). The workspace for each call is resolved from the
35
+ * absolute path being read, not a fixed cwd -- see workspaceForPath.
36
+ */
37
+ export function createLectorReadOperations(): ReadOperations {
38
+ return {
39
+ async readFile(absolutePath) {
40
+ if (imageMimeTypeFor(absolutePath)) return fsReadFile(absolutePath);
41
+
42
+ return withWorkspace(
43
+ () => workspaceForPath(absolutePath),
44
+ async ({ workspaceId, root }) => {
45
+ const client = await lectorClient();
46
+ const relativePath = toWorkspaceRelativePath(root, absolutePath);
47
+ const { content } = await client.call("workspace.rawRead", { workspaceId, path: relativePath });
48
+ return Buffer.from(content, "utf-8");
49
+ },
50
+ );
51
+ },
52
+
53
+ async access(absolutePath) {
54
+ if (imageMimeTypeFor(absolutePath)) {
55
+ await fsAccess(absolutePath, constants.R_OK);
56
+ return;
57
+ }
58
+
59
+ // workspace.rawRead itself rejects a missing entry (WorkspaceEntryNotFound) -- exactly
60
+ // the "not accessible" signal pi's read/edit tools expect access() to throw for.
61
+ await withWorkspace(
62
+ () => workspaceForPath(absolutePath),
63
+ async ({ workspaceId, root }) => {
64
+ const client = await lectorClient();
65
+ const relativePath = toWorkspaceRelativePath(root, absolutePath);
66
+ await client.call("workspace.rawRead", { workspaceId, path: relativePath });
67
+ },
68
+ );
69
+ },
70
+
71
+ detectImageMimeType: (absolutePath) => Promise.resolve(imageMimeTypeFor(absolutePath)),
72
+ };
73
+ }
@@ -0,0 +1,20 @@
1
+ import type { RepoFetchResult } from "@danypops/lector";
2
+ import { lectorClient } from "./lector-client.ts";
3
+
4
+ /**
5
+ * Thin wrapper over repo.fetch. No `directory`/workspaceForDirectory resolution here -- unlike
6
+ * every other tool in this extension, this one doesn't target an existing local directory, it
7
+ * creates a new registered workspace from a fetched external repo.
8
+ */
9
+ export interface RepoFetchOperations {
10
+ fetch(host: string, owner: string, repo: string, ref: string | null): Promise<RepoFetchResult & { workspaceId: string }>;
11
+ }
12
+
13
+ export function createLectorRepoFetchOperations(): RepoFetchOperations {
14
+ return {
15
+ async fetch(host, owner, repo, ref) {
16
+ const client = await lectorClient();
17
+ return client.call("repo.fetch", { host, owner, repo, ref });
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,21 @@
1
+ import type { RepoFetchResult } from "@danypops/lector";
2
+ import type { LectorTheme } from "./lector-tui-theme.ts";
3
+
4
+ export function formatRepoFetchCall(args: { owner?: unknown; repo?: unknown; ref?: unknown; host?: unknown }, theme: LectorTheme): string {
5
+ const host = typeof args.host === "string" && args.host.length > 0 ? args.host : "github.com";
6
+ const owner = typeof args.owner === "string" ? args.owner : "";
7
+ const repo = typeof args.repo === "string" ? args.repo : "";
8
+ const ref = typeof args.ref === "string" ? `@${args.ref}` : "";
9
+ return `${theme.fg("toolTitle", theme.bold("repo_fetch"))} ${theme.fg("accent", `${host}/${owner}/${repo}${ref}`)}`;
10
+ }
11
+
12
+ export function formatRepoFetchResult(result: (RepoFetchResult & { workspaceId: string }) | undefined, theme: LectorTheme): string {
13
+ if (!result) return theme.fg("dim", "No result.");
14
+ const lines = [
15
+ `${theme.fg("accent", result.workspaceId)} ${result.fromCache ? theme.fg("dim", "(from cache)") : theme.fg("toolTitle", "(fetched)")} -- ${result.path}`,
16
+ ];
17
+ if (result.refFallbackOccurred) {
18
+ lines.push(theme.fg("warning", `requested ref not found; fell back to the default branch (resolved: ${result.resolvedRef})`));
19
+ }
20
+ return lines.join("\n");
21
+ }
@@ -0,0 +1,24 @@
1
+ import type { TextSearchResult } from "@danypops/lector";
2
+ import { lectorClient, withWorkspace, workspaceForDirectory } from "./lector-client.ts";
3
+
4
+ /**
5
+ * Thin wrapper over workspace.searchText. `directory` is required, same convention as
6
+ * find_symbols/git operations -- no implicit "whatever the session's cwd is" fallback.
7
+ */
8
+ export interface SearchOperations {
9
+ search(query: string, directory: string, maxMatches: number, maxBytes: number): Promise<TextSearchResult>;
10
+ }
11
+
12
+ export function createLectorSearchOperations(): SearchOperations {
13
+ return {
14
+ async search(query, directory, maxMatches, maxBytes) {
15
+ return withWorkspace(
16
+ () => workspaceForDirectory(directory),
17
+ async ({ workspaceId }) => {
18
+ const client = await lectorClient();
19
+ return client.call("workspace.searchText", { workspaceId, query, maxMatches, maxBytes });
20
+ },
21
+ );
22
+ },
23
+ };
24
+ }
@@ -0,0 +1,21 @@
1
+ import type { TextSearchResult } from "@danypops/lector";
2
+ import { keyHint } from "@earendil-works/pi-coding-agent";
3
+ import type { LectorTheme } from "./lector-tui-theme.ts";
4
+
5
+ const DEFAULT_VISIBLE_MATCHES = 20;
6
+
7
+ export function formatSearchCall(args: { directory?: unknown; query?: unknown }, theme: LectorTheme): string {
8
+ const directory = typeof args.directory === "string" ? args.directory : "";
9
+ const query = typeof args.query === "string" ? args.query : "";
10
+ return `${theme.fg("toolTitle", theme.bold("search_code"))} ${theme.fg("accent", `"${query}"`)} ${theme.fg("dim", directory)}`;
11
+ }
12
+
13
+ export function formatSearchResult(result: TextSearchResult | undefined, expanded: boolean, theme: LectorTheme): string {
14
+ if (!result || result.matches.length === 0) return theme.fg("dim", "No matches found.");
15
+ const displayCount = expanded ? result.matches.length : Math.min(DEFAULT_VISIBLE_MATCHES, result.matches.length);
16
+ const lines = result.matches.slice(0, displayCount).map((match) => `${theme.fg("accent", match.path)}:${match.lineNumber}: ${match.line.replace(/\n$/, "")}`);
17
+ const remaining = result.matches.length - displayCount;
18
+ if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
19
+ if (result.truncated) lines.push(theme.fg("warning", "(search itself was truncated by maxMatches/maxBytes -- results are incomplete)"));
20
+ return lines.join("\n");
21
+ }
@@ -0,0 +1,97 @@
1
+ import type { JobSnapshot, PopulateSymbolGraphResult, WorkspaceCacheStatus } from "@danypops/lector";
2
+ import { lectorClient, withWorkspace, workspaceForDirectory } from "./lector-client.ts";
3
+
4
+ export interface WorkspaceCacheOperations {
5
+ status(directory: string, maxFiles: number, maxSymbolsPerFile: number): Promise<WorkspaceCacheStatus>;
6
+ submit(directory: string, maxFiles: number, maxSymbolsPerFile: number): Promise<JobSnapshot<PopulateSymbolGraphResult>>;
7
+ jobStatus(jobId: string): Promise<JobSnapshot<PopulateSymbolGraphResult>>;
8
+ }
9
+
10
+ export function createWorkspaceCacheOperations(): WorkspaceCacheOperations {
11
+ return {
12
+ status(directory, maxFiles, maxSymbolsPerFile) {
13
+ return withWorkspace(
14
+ () => workspaceForDirectory(directory),
15
+ async ({ workspaceId }) => {
16
+ const client = await lectorClient();
17
+ return client.call("workspace.cacheStatus", { workspaceId, maxFiles, maxSymbolsPerFile });
18
+ },
19
+ );
20
+ },
21
+ submit(directory, maxFiles, maxSymbolsPerFile) {
22
+ return withWorkspace(
23
+ () => workspaceForDirectory(directory),
24
+ async ({ workspaceId }) => {
25
+ const client = await lectorClient();
26
+ const { job } = await client.call("job.submit", {
27
+ operation: "workspace.populateSymbolGraph",
28
+ input: { workspaceId, maxFiles, maxSymbolsPerFile },
29
+ waitMs: 0,
30
+ });
31
+ return job;
32
+ },
33
+ );
34
+ },
35
+ async jobStatus(jobId) {
36
+ const client = await lectorClient();
37
+ const { job } = await client.call("job.status", { jobId });
38
+ return job;
39
+ },
40
+ };
41
+ }
42
+
43
+ export type CachePresentationState =
44
+ | { readonly status: "not-cached"; readonly reason: string }
45
+ | { readonly status: "caching"; readonly jobId: string }
46
+ | { readonly status: "finished-caching"; readonly job: JobSnapshot<PopulateSymbolGraphResult> & { readonly status: "succeeded" } }
47
+ | { readonly status: "cached" };
48
+
49
+ export interface MonitorWorkspaceCacheOptions {
50
+ readonly directory: string;
51
+ readonly maxFiles: number;
52
+ readonly maxSymbolsPerFile: number;
53
+ readonly pollIntervalMs: number;
54
+ readonly maxPolls: number;
55
+ readonly shouldContinue: () => boolean;
56
+ readonly onState: (state: CachePresentationState) => void;
57
+ readonly sleep?: (ms: number) => Promise<void>;
58
+ }
59
+
60
+ /** Drives one bounded session cache lifecycle; Pi event handlers only render its states. */
61
+ export async function monitorWorkspaceCache(operations: WorkspaceCacheOperations, options: MonitorWorkspaceCacheOptions): Promise<void> {
62
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
63
+ const initial = await operations.status(options.directory, options.maxFiles, options.maxSymbolsPerFile);
64
+ if (!options.shouldContinue()) return;
65
+ if (initial.status === "cached") {
66
+ options.onState({ status: "cached" });
67
+ return;
68
+ }
69
+
70
+ let jobId: string;
71
+ if (initial.status === "caching") {
72
+ jobId = initial.jobId;
73
+ } else {
74
+ options.onState({ status: "not-cached", reason: initial.reason });
75
+ const submitted = await operations.submit(options.directory, options.maxFiles, options.maxSymbolsPerFile);
76
+ if (submitted.status === "failed") throw new Error(`${submitted.error.code}: ${submitted.error.message}`);
77
+ if (submitted.status === "succeeded") {
78
+ options.onState({ status: "finished-caching", job: submitted });
79
+ options.onState({ status: "cached" });
80
+ return;
81
+ }
82
+ jobId = submitted.id;
83
+ }
84
+ options.onState({ status: "caching", jobId });
85
+
86
+ for (let poll = 0; poll < options.maxPolls && options.shouldContinue(); poll++) {
87
+ await sleep(options.pollIntervalMs);
88
+ if (!options.shouldContinue()) return;
89
+ const job = await operations.jobStatus(jobId);
90
+ if (job.status === "failed") throw new Error(`${job.error.code}: ${job.error.message}`);
91
+ if (job.status === "succeeded") {
92
+ options.onState({ status: "finished-caching", job });
93
+ options.onState({ status: "cached" });
94
+ return;
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,17 @@
1
+ import { relative } from "node:path";
2
+
3
+ /**
4
+ * Convert an absolute path pi's tools pass to Lector's workspace-relative
5
+ * form. The workspace root *is* the tool's cwd (registered via
6
+ * workspace.registerPath), so this is a plain node:path relative() --
7
+ * LocalFilesystemWorkspace independently rejects anything that still
8
+ * escapes its own root, but failing fast here gives a clearer error at the
9
+ * Pi-tool boundary instead of a generic PathEscapesWorkspaceRoot later.
10
+ */
11
+ export function toWorkspaceRelativePath(cwd: string, absolutePath: string): string {
12
+ const rel = relative(cwd, absolutePath);
13
+ if (rel.startsWith("..")) {
14
+ throw new Error(`path "${absolutePath}" is outside the Lector-registered workspace root "${cwd}"`);
15
+ }
16
+ return rel;
17
+ }
@@ -0,0 +1,61 @@
1
+ import { type ContentHash, remoteErrorIs } from "@danypops/lector";
2
+ import type { WriteOperations } from "@earendil-works/pi-coding-agent";
3
+ import { lectorClient, withWorkspace, workspaceForPath } from "./lector-client.ts";
4
+ import { toWorkspaceRelativePath } from "./workspace-relative-path.ts";
5
+
6
+ const MAX_STALE_HASH_RETRIES = 3;
7
+
8
+ /**
9
+ * WriteOperations backed by Lector's hash-guarded exactEdit. The workspace
10
+ * for each call is resolved from the absolute path being written, not a
11
+ * fixed cwd -- see workspaceForPath.
12
+ *
13
+ * pi's write tool is unconditional overwrite by its own documented contract
14
+ * ("Creates the file if it doesn't exist, overwrites if it does"), so unlike
15
+ * edit, a stale hash here does not mean the model's intent is now wrong --
16
+ * it means a concurrent external change landed between our read and our
17
+ * write, and the model still wants its content to be exactly what it asked
18
+ * for. Retried transparently (bounded) by re-observing the current hash and
19
+ * retrying, rather than surfacing StaleExpectedHash to a caller whose tool
20
+ * contract never mentioned hashes at all.
21
+ */
22
+ export function createLectorWriteOperations(): WriteOperations {
23
+ async function currentHash(client: Awaited<ReturnType<typeof lectorClient>>, workspaceId: string, relativePath: string): Promise<ContentHash | null> {
24
+ try {
25
+ const current = await client.call("workspace.rawRead", { workspaceId, path: relativePath });
26
+ return current.hash;
27
+ } catch {
28
+ return null; // no existing entry -- exactEdit's create semantics
29
+ }
30
+ }
31
+
32
+ return {
33
+ async writeFile(absolutePath, content) {
34
+ await withWorkspace(
35
+ () => workspaceForPath(absolutePath),
36
+ async ({ workspaceId, root }) => {
37
+ const client = await lectorClient();
38
+ const relativePath = toWorkspaceRelativePath(root, absolutePath);
39
+
40
+ let expectedHash = await currentHash(client, workspaceId, relativePath);
41
+
42
+ for (let attempt = 0; attempt < MAX_STALE_HASH_RETRIES; attempt++) {
43
+ try {
44
+ await client.call("workspace.exactEdit", { workspaceId, path: relativePath, expectedHash, content });
45
+ return;
46
+ } catch (error) {
47
+ if (!remoteErrorIs(error, "StaleExpectedHash") || attempt === MAX_STALE_HASH_RETRIES - 1) throw error;
48
+ expectedHash = await currentHash(client, workspaceId, relativePath);
49
+ }
50
+ }
51
+ },
52
+ );
53
+ },
54
+
55
+ async mkdir() {
56
+ // LocalFilesystemWorkspace's writeEntry already creates parent directories
57
+ // (mkdir(dirname(absolute), { recursive: true })) as part of every write --
58
+ // there is nothing left for this hook to do.
59
+ },
60
+ };
61
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@danypops/pi-lector",
3
+ "version": "0.1.0",
4
+ "description": "Pi host adapter for Lector: overrides read/write/edit with a daemon-backed, hash-guarded filesystem",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "keywords": ["pi-package"],
8
+ "scripts": {
9
+ "test": "bun test",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "pi": {
13
+ "extensions": ["extension/src/index.ts"]
14
+ },
15
+ "peerDependencies": {
16
+ "@earendil-works/pi-coding-agent": "*",
17
+ "@earendil-works/pi-tui": "*",
18
+ "typebox": "*"
19
+ },
20
+ "dependencies": {
21
+ "@danypops/lector": "^0.1.0"
22
+ },
23
+ "devDependencies": {
24
+ "@earendil-works/pi-ai": "^0.81.1",
25
+ "@earendil-works/pi-coding-agent": "^0.81.1",
26
+ "@earendil-works/pi-tui": "^0.81.1",
27
+ "bun-types": "latest",
28
+ "typebox": "^1.3.6",
29
+ "typescript": "^5.7.3"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/DanyPops/lector.git",
34
+ "directory": "packages/pi-lector"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "files": ["extension", "README.md", "LICENSE"]
40
+ }