@danypops/pi-lector 0.5.0 → 0.7.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,26 @@
|
|
|
1
|
+
import type { GithubRepoSearchResult, NpmPackageCandidate, SourcegraphCodeCandidate } from "@danypops/lector";
|
|
2
|
+
import { lectorClient } from "./lector-client.ts";
|
|
3
|
+
|
|
4
|
+
/** Thin wrapper over search.githubRepos/search.npmPackages/search.sourcegraphCode -- explicit-query discovery inputs shaped for repo_cache/package_source, never open-ended discovery/trending. */
|
|
5
|
+
export interface ExternalSearchOperations {
|
|
6
|
+
githubRepos(query: string, maxResults: number): Promise<GithubRepoSearchResult>;
|
|
7
|
+
npmPackages(query: string, maxResults: number): Promise<{ candidates: readonly NpmPackageCandidate[] }>;
|
|
8
|
+
sourcegraphCode(query: string, maxResults: number): Promise<{ candidates: readonly SourcegraphCodeCandidate[] }>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createExternalSearchOperations(): ExternalSearchOperations {
|
|
12
|
+
return {
|
|
13
|
+
async githubRepos(query, maxResults) {
|
|
14
|
+
const client = await lectorClient();
|
|
15
|
+
return client.call("search.githubRepos", { query, maxResults });
|
|
16
|
+
},
|
|
17
|
+
async npmPackages(query, maxResults) {
|
|
18
|
+
const client = await lectorClient();
|
|
19
|
+
return client.call("search.npmPackages", { query, maxResults });
|
|
20
|
+
},
|
|
21
|
+
async sourcegraphCode(query, maxResults) {
|
|
22
|
+
const client = await lectorClient();
|
|
23
|
+
return client.call("search.sourcegraphCode", { query, maxResults });
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { GithubRepoSearchResult, NpmPackageCandidate, SourcegraphCodeCandidate } from "@danypops/lector";
|
|
2
|
+
import type { LectorTheme } from "./lector-tui-theme.ts";
|
|
3
|
+
|
|
4
|
+
type ExternalSearchAction = "github_repos" | "npm_packages" | "sourcegraph_code";
|
|
5
|
+
|
|
6
|
+
export function formatExternalSearchCall(action: ExternalSearchAction, args: { query?: unknown }, theme: LectorTheme): string {
|
|
7
|
+
const label = theme.fg("toolTitle", theme.bold("external_search"));
|
|
8
|
+
const query = typeof args.query === "string" ? args.query : "";
|
|
9
|
+
return `${label} ${theme.fg("accent", action)} ${theme.fg("dim", query)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatGithubRepoSearchResult(result: GithubRepoSearchResult | undefined, theme: LectorTheme): string {
|
|
13
|
+
if (!result) return theme.fg("dim", "No result.");
|
|
14
|
+
const count = result.candidates.length;
|
|
15
|
+
const summary = count === 0 ? theme.fg("dim", "no repositories matched") : theme.fg("success", `${count} repositor${count === 1 ? "y" : "ies"}`);
|
|
16
|
+
return result.authenticated ? summary : `${summary} ${theme.fg("warning", "(unauthenticated -- lower rate limit)")}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatNpmPackageSearchResult(result: { candidates: readonly NpmPackageCandidate[] } | undefined, theme: LectorTheme): string {
|
|
20
|
+
const count = result?.candidates.length ?? 0;
|
|
21
|
+
return count === 0 ? theme.fg("dim", "no packages matched") : theme.fg("success", `${count} package${count === 1 ? "" : "s"}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatSourcegraphCodeSearchResult(result: { candidates: readonly SourcegraphCodeCandidate[] } | undefined, theme: LectorTheme): string {
|
|
25
|
+
const count = result?.candidates.length ?? 0;
|
|
26
|
+
return count === 0 ? theme.fg("dim", "no code matches") : theme.fg("success", `${count} match${count === 1 ? "" : "es"}`);
|
|
27
|
+
}
|
package/extension/src/index.ts
CHANGED
|
@@ -6,16 +6,19 @@ import type {
|
|
|
6
6
|
DocumentSymbolEntry,
|
|
7
7
|
EditOutcome,
|
|
8
8
|
FindFilesResult,
|
|
9
|
+
GithubRepoSearchResult,
|
|
9
10
|
Hover,
|
|
10
11
|
IntelligenceProvenance,
|
|
11
12
|
JobSnapshot,
|
|
12
13
|
LineEdit,
|
|
13
14
|
LineEditOutcome,
|
|
14
15
|
MutationHistoryEntry,
|
|
16
|
+
NpmPackageCandidate,
|
|
15
17
|
OperationOutputs,
|
|
16
18
|
PackageSourceOperationResult,
|
|
17
19
|
PopulateSymbolGraphResult,
|
|
18
20
|
RepoFetchResult,
|
|
21
|
+
SourcegraphCodeCandidate,
|
|
19
22
|
SymbolAnnotation,
|
|
20
23
|
SymbolNode,
|
|
21
24
|
SymbolSearchResult,
|
|
@@ -24,6 +27,7 @@ import type {
|
|
|
24
27
|
WorkspaceMapResult,
|
|
25
28
|
WorkspaceQueryOutcome,
|
|
26
29
|
} from "@danypops/lector";
|
|
30
|
+
import { DEFAULT_EXTERNAL_SEARCH_MAX_RESULTS } from "@danypops/lector";
|
|
27
31
|
import {
|
|
28
32
|
type AgentToolResult,
|
|
29
33
|
createEditToolDefinition,
|
|
@@ -63,6 +67,13 @@ import {
|
|
|
63
67
|
import { createLectorCrossWorkspaceSearchOperations } from "./cross-workspace-search-operations.ts";
|
|
64
68
|
import { formatCrossWorkspaceCall, formatFindSymbolsAcrossProjectsResult, formatSearchTextAcrossProjectsResult } from "./cross-workspace-search-rendering.ts";
|
|
65
69
|
import { createLectorEditOperations } from "./edit-operations.ts";
|
|
70
|
+
import { createExternalSearchOperations } from "./external-search-operations.ts";
|
|
71
|
+
import {
|
|
72
|
+
formatExternalSearchCall,
|
|
73
|
+
formatGithubRepoSearchResult,
|
|
74
|
+
formatNpmPackageSearchResult,
|
|
75
|
+
formatSourcegraphCodeSearchResult,
|
|
76
|
+
} from "./external-search-rendering.ts";
|
|
66
77
|
import { createLectorFindFilesOperations } from "./find-files-operations.ts";
|
|
67
78
|
import { formatFindFilesCall, formatFindFilesResult } from "./find-files-rendering.ts";
|
|
68
79
|
import { createLectorFindSymbolsOperations } from "./find-symbols-operations.ts";
|
|
@@ -73,7 +84,7 @@ import { setNewWorkspaceObserver } from "./lector-client.ts";
|
|
|
73
84
|
import { createLectorLineEditOperations } from "./line-edit-operations.ts";
|
|
74
85
|
import { formatLineEditCall, formatLineEditResult } from "./line-edit-rendering.ts";
|
|
75
86
|
import { createMutationHistoryOperations } from "./mutation-history-operations.ts";
|
|
76
|
-
import { nearestGitRoot } from "./nearest-workspace-root.ts";
|
|
87
|
+
import { isFilesystemRoot, nearestGitRoot } from "./nearest-workspace-root.ts";
|
|
77
88
|
import { createLectorPackageSourceOperations } from "./package-source-operations.ts";
|
|
78
89
|
import { formatPackageSourceCall, formatPackageSourceResult } from "./package-source-rendering.ts";
|
|
79
90
|
import { createLectorReadOperations } from "./read-operations.ts";
|
|
@@ -166,8 +177,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
166
177
|
uiContext.ui.setStatus("lector-cache", uiContext.ui.theme.fg(worst, `Lector: ${summary}`));
|
|
167
178
|
}
|
|
168
179
|
|
|
169
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* Starts (or restarts, on a stale generation) monitoring one workspace root's cache
|
|
182
|
+
* lifecycle -- shared by session_start's own cwd root and every later root a tool call
|
|
183
|
+
* first touches. Refuses a bare filesystem root outright: workspaceForPath's own
|
|
184
|
+
* intentional fallback for a raw read/write of a file outside any git repo can register
|
|
185
|
+
* exactly this as a "new workspace", and auto-populating it would attempt a full
|
|
186
|
+
* filesystem-wide symbol-graph scan -- confirmed live as a real, previously-shipped bug.
|
|
187
|
+
*/
|
|
170
188
|
function startMonitoringRoot(root: string, ctx: Parameters<Parameters<ExtensionAPI["on"]>[1]>[1]): void {
|
|
189
|
+
if (isFilesystemRoot(root)) return;
|
|
171
190
|
if (monitoringRoots.has(root)) return;
|
|
172
191
|
monitoringRoots.add(root);
|
|
173
192
|
const thisGeneration = sessionGeneration;
|
|
@@ -1520,6 +1539,62 @@ export default function (pi: ExtensionAPI) {
|
|
|
1520
1539
|
},
|
|
1521
1540
|
});
|
|
1522
1541
|
|
|
1542
|
+
type ExternalSearchToolDetails =
|
|
1543
|
+
| { readonly action: "github_repos"; readonly result: GithubRepoSearchResult }
|
|
1544
|
+
| { readonly action: "npm_packages"; readonly result: { candidates: readonly NpmPackageCandidate[] } }
|
|
1545
|
+
| { readonly action: "sourcegraph_code"; readonly result: { candidates: readonly SourcegraphCodeCandidate[] } };
|
|
1546
|
+
|
|
1547
|
+
const externalSearchOperations = createExternalSearchOperations();
|
|
1548
|
+
pi.registerTool({
|
|
1549
|
+
name: "external_search",
|
|
1550
|
+
label: "External Search",
|
|
1551
|
+
description:
|
|
1552
|
+
"Finds real prior art before writing new code -- explicit-query search only, never open-ended discovery/trending. action=github_repos searches GitHub repositories by name/description/topic (GitHub's own relevance ranking); candidates are shaped as direct repo_cache(action=fetch) inputs (host/owner/repo). Unauthenticated is rate-limited to 10 req/min; configure GITHUB_TOKEN on the daemon host for 30/min. action=npm_packages searches the public npm registry; candidates are shaped as direct package_source inputs (name, plus the version already returned). action=sourcegraph_code searches code content across public GitHub via sourcegraph.com -- \"which repos actually contain code matching X\", a genuinely different mode than the other two (which search names/metadata, not file contents); each candidate's repository field feeds repo_cache(action=fetch) once split on '/'. Every source is cached for a short TTL -- an identical query moments apart is served from cache, not re-fetched.",
|
|
1553
|
+
promptSnippet: "Search GitHub repos, npm packages, or code content for prior art",
|
|
1554
|
+
parameters: Type.Object({
|
|
1555
|
+
action: Type.Union([Type.Literal("github_repos"), Type.Literal("npm_packages"), Type.Literal("sourcegraph_code")]),
|
|
1556
|
+
query: Type.String({
|
|
1557
|
+
description: "The search query -- repo/package name or description keywords for github_repos/npm_packages, code content for sourcegraph_code",
|
|
1558
|
+
}),
|
|
1559
|
+
maxResults: Type.Optional(Type.Number({ description: "Maximum candidates to return (default 20)" })),
|
|
1560
|
+
}),
|
|
1561
|
+
async execute(_toolCallId, params): Promise<AgentToolResult<ExternalSearchToolDetails>> {
|
|
1562
|
+
const maxResults = params.maxResults ?? DEFAULT_EXTERNAL_SEARCH_MAX_RESULTS;
|
|
1563
|
+
if (params.action === "github_repos") {
|
|
1564
|
+
const result = await externalSearchOperations.githubRepos(params.query, maxResults);
|
|
1565
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "github_repos", result } };
|
|
1566
|
+
}
|
|
1567
|
+
if (params.action === "npm_packages") {
|
|
1568
|
+
const result = await externalSearchOperations.npmPackages(params.query, maxResults);
|
|
1569
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "npm_packages", result } };
|
|
1570
|
+
}
|
|
1571
|
+
const result = await externalSearchOperations.sourcegraphCode(params.query, maxResults);
|
|
1572
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "sourcegraph_code", result } };
|
|
1573
|
+
},
|
|
1574
|
+
renderCall(args, theme, context) {
|
|
1575
|
+
const action = args.action === "npm_packages" || args.action === "sourcegraph_code" ? args.action : "github_repos";
|
|
1576
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
1577
|
+
text.setText(formatExternalSearchCall(action, args, theme));
|
|
1578
|
+
return text;
|
|
1579
|
+
},
|
|
1580
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
1581
|
+
if (isPartial) return new Text(theme.fg("warning", "Searching..."), 0, 0);
|
|
1582
|
+
if (context.isError) {
|
|
1583
|
+
const errorText = result.content
|
|
1584
|
+
.filter((block) => block.type === "text")
|
|
1585
|
+
.map((block) => block.text)
|
|
1586
|
+
.join("\n");
|
|
1587
|
+
return new Text(theme.fg("error", errorText || "external_search failed"), 0, 0);
|
|
1588
|
+
}
|
|
1589
|
+
const details = result.details as ExternalSearchToolDetails | undefined;
|
|
1590
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
1591
|
+
if (details?.action === "npm_packages") text.setText(formatNpmPackageSearchResult(details.result, theme));
|
|
1592
|
+
else if (details?.action === "sourcegraph_code") text.setText(formatSourcegraphCodeSearchResult(details.result, theme));
|
|
1593
|
+
else text.setText(formatGithubRepoSearchResult(details?.action === "github_repos" ? details.result : undefined, theme));
|
|
1594
|
+
return text;
|
|
1595
|
+
},
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1523
1598
|
const crossWorkspaceSearchOperations = createLectorCrossWorkspaceSearchOperations();
|
|
1524
1599
|
pi.registerTool({
|
|
1525
1600
|
name: "find_symbols_across_projects",
|
|
@@ -2,39 +2,60 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join, parse } from "node:path";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* The
|
|
6
|
-
* a
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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.)
|
|
5
|
+
* The bare filesystem root is never a legitimate discovered project root, even if it happens
|
|
6
|
+
* to contain a marker file (a stray `git init /`, a leftover `package.json`) -- matches the
|
|
7
|
+
* same convention already established elsewhere in this house (oculus/survey/rust_scanner.go's
|
|
8
|
+
* findCrateRoot, oculus/locator/match.go's effectiveParent: reaching "/" during a walk-up means
|
|
9
|
+
* "not found", never "found here"). Confirmed live: without this, a Lector daemon registered
|
|
10
|
+
* "/" as a workspace and a background job attempted to symbol-graph the entire filesystem.
|
|
11
|
+
* `exists` is injectable so a test can simulate "a marker exists at the filesystem root"
|
|
12
|
+
* without ever touching the real one.
|
|
23
13
|
*/
|
|
24
|
-
function walkUpForMarkers(startDirectory: string, markers: readonly string[]): string | undefined {
|
|
14
|
+
function walkUpForMarkers(startDirectory: string, markers: readonly string[], exists: (path: string) => boolean = existsSync): string | undefined {
|
|
25
15
|
let dir = startDirectory;
|
|
26
16
|
const fsRoot = parse(dir).root;
|
|
27
17
|
while (dir !== fsRoot) {
|
|
28
|
-
if (markers.some((marker) =>
|
|
18
|
+
if (markers.some((marker) => exists(join(dir, marker)))) return dir;
|
|
29
19
|
const parent = dirname(dir);
|
|
30
20
|
if (parent === dir) break; // defensive: dirname must be strictly ascending
|
|
31
21
|
dir = parent;
|
|
32
22
|
}
|
|
33
|
-
return
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The nearest enclosing git repository root starting from (and including) a given directory,
|
|
28
|
+
* or undefined if none is found (e.g. /tmp scratch files, dotfiles outside any repo, or the
|
|
29
|
+
* walk reaching the filesystem root without a match). Callers choose their own fallback -- see
|
|
30
|
+
* lector-client.ts's workspaceForPath (falls back to the filesystem root: any absolute path is
|
|
31
|
+
* fair game for read/write/edit, exactly as Pi's built-in tools already allow) vs.
|
|
32
|
+
* workspaceForDirectory (falls back to the directory itself: widening a symbol-search scope all
|
|
33
|
+
* the way to the entire filesystem when a project isn't a git repo would be absurd).
|
|
34
|
+
*
|
|
35
|
+
* This -- not a Pi session's original cwd -- is Lector's real workspace granularity. A session
|
|
36
|
+
* routinely touches many unrelated repos, sibling projects, and scratch paths in one run; Pi's
|
|
37
|
+
* built-in read/write/edit tools have never restricted which absolute path can be touched, and
|
|
38
|
+
* Lector must not either. (Real, shipped bug this fixes: read/write/edit hard-locked to
|
|
39
|
+
* whatever directory the session happened to start in, refusing every legitimate path outside
|
|
40
|
+
* it with a "Lector-registered workspace root" error -- discovered live, in a separate session,
|
|
41
|
+
* working against a completely different, unrelated repository.)
|
|
42
|
+
*
|
|
43
|
+
* `exists` is injectable for tests -- see walkUpForMarkers.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* True for the bare filesystem root itself ("/" on Linux/macOS, "C:\\" on Windows) -- the one
|
|
47
|
+
* path a caller must never treat as a real project to auto-index. workspaceForPath's own
|
|
48
|
+
* intentional fallback for a raw read/write of a file outside any git repo can still produce
|
|
49
|
+
* this value; callers that trigger background work (auto-population, cache monitoring) off a
|
|
50
|
+
* newly-registered workspace must check this explicitly rather than assuming
|
|
51
|
+
* nearestGitRoot/nearestProjectRoot are the only paths that can hand them a workspace root.
|
|
52
|
+
*/
|
|
53
|
+
export function isFilesystemRoot(path: string): boolean {
|
|
54
|
+
return parse(path).root === path;
|
|
34
55
|
}
|
|
35
56
|
|
|
36
|
-
export function nearestGitRoot(startDirectory: string): string | undefined {
|
|
37
|
-
return walkUpForMarkers(startDirectory, [".git"]);
|
|
57
|
+
export function nearestGitRoot(startDirectory: string, exists: (path: string) => boolean = existsSync): string | undefined {
|
|
58
|
+
return walkUpForMarkers(startDirectory, [".git"], exists);
|
|
38
59
|
}
|
|
39
60
|
|
|
40
61
|
/**
|
|
@@ -45,6 +66,6 @@ export function nearestGitRoot(startDirectory: string): string | undefined {
|
|
|
45
66
|
* misattributes its whole project to the repo root, handing the language server the wrong
|
|
46
67
|
* rootUri (and, for TypeScript, the wrong tsconfig.json) even though a closer one exists.
|
|
47
68
|
*/
|
|
48
|
-
export function nearestProjectRoot(startDirectory: string, rootMarkers: readonly string[]): string | undefined {
|
|
49
|
-
return walkUpForMarkers(startDirectory, [...rootMarkers, ".git"]);
|
|
69
|
+
export function nearestProjectRoot(startDirectory: string, rootMarkers: readonly string[], exists: (path: string) => boolean = existsSync): string | undefined {
|
|
70
|
+
return walkUpForMarkers(startDirectory, [...rootMarkers, ".git"], exists);
|
|
50
71
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-lector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@danypops/daemon-kit": "^0.22.1",
|
|
22
|
-
"@danypops/lector": "^0.
|
|
22
|
+
"@danypops/lector": "^0.8.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@earendil-works/pi-ai": "^0.81.1",
|