@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.
- package/LICENSE +21 -0
- package/README.md +14 -0
- package/extension/src/code-intelligence-hints.ts +38 -0
- package/extension/src/code-intelligence-operations.ts +178 -0
- package/extension/src/code-intelligence-rendering.ts +237 -0
- package/extension/src/cross-workspace-search-operations.ts +47 -0
- package/extension/src/cross-workspace-search-rendering.ts +66 -0
- package/extension/src/edit-operations.ts +77 -0
- package/extension/src/find-symbols-operations.ts +34 -0
- package/extension/src/find-symbols-rendering.ts +54 -0
- package/extension/src/git-operations.ts +46 -0
- package/extension/src/git-rendering.ts +64 -0
- package/extension/src/index.ts +943 -0
- package/extension/src/lector-client.ts +175 -0
- package/extension/src/lector-tui-theme.ts +35 -0
- package/extension/src/nearest-workspace-root.ts +34 -0
- package/extension/src/read-operations.ts +73 -0
- package/extension/src/repo-fetch-operations.ts +20 -0
- package/extension/src/repo-fetch-rendering.ts +21 -0
- package/extension/src/search-operations.ts +24 -0
- package/extension/src/search-rendering.ts +21 -0
- package/extension/src/workspace-cache-operations.ts +97 -0
- package/extension/src/workspace-relative-path.ts +17 -0
- package/extension/src/write-operations.ts +61 -0
- package/package.json +40 -0
|
@@ -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
|
+
}
|