@danypops/pi-lector 0.6.0 → 0.8.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/README.md CHANGED
@@ -12,11 +12,17 @@ pi install npm:@danypops/pi-lector
12
12
 
13
13
  Symbol and semantic tool results identify their backend and fidelity. `typescript-language-server` results are semantic; compiler or parser fallback results are structural and list their limitations.
14
14
 
15
- `populate_symbol_graph` submits bounded background work and waits briefly. If the
16
- graph is still loading, it returns a job id immediately; `job_status` polls that id
17
- later without forcing the agent into a blocking or blind polling loop.
15
+ Every workspace's symbol graph auto-populates in the background the moment any tool
16
+ first touches it -- reachable_from, workspace_map, reference_based_rename, and
17
+ symbol_annotations all return an empty or refusing result rather than an error if
18
+ it's still building, instead of requiring an explicit "start indexing" call.
18
19
 
19
20
  When a session starts inside a Git repository, pi-lector checks a durable, bounded
20
21
  source-content manifest without blocking startup. The footer reports not cached,
21
- caching, or cached; completion also emits a one-shot notification. Session shutdown
22
- stops polling, and the agent receives each state transition once in its context.
22
+ caching, or cached across every workspace touched so far this session; completion
23
+ also emits a one-shot notification. Session shutdown stops polling, and the agent
24
+ receives each state transition once in its context.
25
+
26
+ For an explicit, custom-bound population outside of Pi (a larger scan than the
27
+ default 500 files / 100 symbols per file), use `lector workspace populate-symbol-graph`
28
+ and `lector job status` directly.
@@ -37,7 +37,14 @@ export interface CodeIntelligenceOperations {
37
37
  prepareCallHierarchy(path: string, line: number, character: number): Promise<OperationOutputs["workspace.prepareCallHierarchy"]>;
38
38
  incomingCalls(path: string, line: number, character: number): Promise<OperationOutputs["workspace.incomingCalls"]>;
39
39
  outgoingCalls(path: string, line: number, character: number): Promise<OperationOutputs["workspace.outgoingCalls"]>;
40
+ /**
41
+ * Not exposed as a standalone Pi tool -- every workspace auto-populates on first touch via
42
+ * monitorWorkspaceCache (workspace-cache-operations.ts). Kept here as an internal capability
43
+ * for tests that need a populated graph. For an explicit/custom-bound population outside
44
+ * Pi entirely, `lector workspace populate-symbol-graph` calls the daemon directly.
45
+ */
40
46
  populateSymbolGraph(path: string, maxFiles: number, maxSymbolsPerFile: number, waitMs?: number): Promise<JobSnapshot<PopulateSymbolGraphResult>>;
47
+ /** Not exposed as a standalone Pi tool -- see populateSymbolGraph. */
41
48
  jobStatus(jobId: string): Promise<JobSnapshot<PopulateSymbolGraphResult>>;
42
49
  reachableFrom(path: string, line: number, character: number, maxDepth: number, kind?: SymbolEdgeKind): Promise<readonly SymbolNode[]>;
43
50
  /** Never spawns a symbol index -- safe to call opportunistically (e.g. before deciding whether to enrich a result). */
@@ -5,9 +5,7 @@ import type {
5
5
  Hover,
6
6
  IncomingCall,
7
7
  IntelligenceProvenance,
8
- JobSnapshot,
9
8
  OutgoingCall,
10
- PopulateSymbolGraphResult,
11
9
  SymbolNode,
12
10
  WorkspaceLocation,
13
11
  WorkspaceMapResult,
@@ -213,29 +211,6 @@ export function formatCallHierarchyResult(details: CallHierarchyToolDetails | un
213
211
  return formatOutgoingCallsResult(details.calls, expanded, theme);
214
212
  }
215
213
 
216
- export function formatPopulateSymbolGraphCall(args: { path?: unknown; maxFiles?: unknown; maxSymbolsPerFile?: unknown }, theme: LectorTheme): string {
217
- const path = typeof args.path === "string" ? args.path : "";
218
- return `${theme.fg("toolTitle", theme.bold("populate_symbol_graph"))} ${theme.fg("accent", path)}`;
219
- }
220
-
221
- export function describePopulateSymbolGraphJob(job: JobSnapshot<PopulateSymbolGraphResult>): string {
222
- if (job.status === "queued") return `Source workspace is registered; symbol graph is queued and still loading (job ${job.id}). Poll job_status.`;
223
- if (job.status === "running") return `Source workspace is registered; symbol graph is still loading (job ${job.id}). Poll job_status.`;
224
- if (job.status === "failed") return `Job ${job.id} failed [${job.error.code}] -- ${job.error.message}`;
225
- const result = job.result;
226
- const counts = `${result.filesProcessed}/${result.filesAttempted} files, ${result.symbolsProcessed} symbol${result.symbolsProcessed === 1 ? "" : "s"}, ${result.nodesAdded} node${result.nodesAdded === 1 ? "" : "s"}, ${result.edgesAdded} edge${result.edgesAdded === 1 ? "" : "s"}`;
227
- if (result.completeness === "complete") return `Job ${job.id} cached ${counts}`;
228
- const first = result.failures[0];
229
- const failure = first ? ` First failure: ${first.path} [${first.code} via ${first.provenance.backend}] ${first.message}` : "";
230
- return `Job ${job.id} partially cached ${counts}; ${result.filesFailed} failed file${result.filesFailed === 1 ? "" : "s"} (${result.failureCount} failed operations).${failure}`;
231
- }
232
-
233
- export function formatPopulateSymbolGraphResult(job: JobSnapshot<PopulateSymbolGraphResult> | undefined, theme: LectorTheme): string {
234
- if (!job) return theme.fg("dim", "No job result.");
235
- const color = job.status === "failed" ? "error" : job.status === "succeeded" ? "muted" : "warning";
236
- return theme.fg(color, describePopulateSymbolGraphJob(job));
237
- }
238
-
239
214
  export function formatReachableFromCall(args: { path?: unknown; line?: unknown; character?: unknown; maxDepth?: unknown }, theme: LectorTheme): string {
240
215
  const base = formatPositionalCall("reachable_from", args, theme);
241
216
  const maxDepth = typeof args.maxDepth === "number" ? args.maxDepth : "?";
@@ -243,7 +218,8 @@ export function formatReachableFromCall(args: { path?: unknown; line?: unknown;
243
218
  }
244
219
 
245
220
  export function formatReachableFromResult(symbols: readonly SymbolNode[] | undefined, expanded: boolean, theme: LectorTheme): string {
246
- if (!symbols || symbols.length === 0) return theme.fg("dim", "Nothing reachable at this position (has the graph been populated for this workspace?).");
221
+ if (!symbols || symbols.length === 0)
222
+ return theme.fg("dim", "Nothing reachable at this position (the workspace's symbol graph may still be populating in the background -- retry shortly).");
247
223
 
248
224
  const displayCount = expanded ? symbols.length : Math.min(symbols.length, DEFAULT_VISIBLE_CALLS);
249
225
  const lines = [theme.fg("muted", `${symbols.length} reachable symbol${symbols.length === 1 ? "" : "s"}:`)];
@@ -261,7 +237,8 @@ export function formatWorkspaceMapCall(args: { path?: unknown; maxEntries?: unkn
261
237
  }
262
238
 
263
239
  export function formatWorkspaceMapResult(result: WorkspaceMapResult | undefined, expanded: boolean, theme: LectorTheme): string {
264
- if (!result || result.entries.length === 0) return theme.fg("dim", "No ranked symbols (has the graph been populated for this workspace?).");
240
+ if (!result || result.entries.length === 0)
241
+ return theme.fg("dim", "No ranked symbols (the workspace's symbol graph may still be populating in the background -- retry shortly).");
265
242
 
266
243
  const displayCount = expanded ? result.entries.length : Math.min(result.entries.length, DEFAULT_VISIBLE_SYMBOLS);
267
244
  const lines = [
@@ -9,14 +9,12 @@ import type {
9
9
  GithubRepoSearchResult,
10
10
  Hover,
11
11
  IntelligenceProvenance,
12
- JobSnapshot,
13
12
  LineEdit,
14
13
  LineEditOutcome,
15
14
  MutationHistoryEntry,
16
15
  NpmPackageCandidate,
17
16
  OperationOutputs,
18
17
  PackageSourceOperationResult,
19
- PopulateSymbolGraphResult,
20
18
  RepoFetchResult,
21
19
  SourcegraphCodeCandidate,
22
20
  SymbolAnnotation,
@@ -27,6 +25,7 @@ import type {
27
25
  WorkspaceMapResult,
28
26
  WorkspaceQueryOutcome,
29
27
  } from "@danypops/lector";
28
+ import { DEFAULT_EXTERNAL_SEARCH_MAX_RESULTS } from "@danypops/lector";
30
29
  import {
31
30
  type AgentToolResult,
32
31
  createEditToolDefinition,
@@ -41,7 +40,6 @@ import { formatApplyPatchCall, formatApplyPatchResult } from "./apply-patch-rend
41
40
  import { createLectorCodeIntelligenceOperations } from "./code-intelligence-operations.ts";
42
41
  import {
43
42
  type CallHierarchyToolDetails,
44
- describePopulateSymbolGraphJob,
45
43
  formatCallHierarchyCall,
46
44
  formatCallHierarchyResult,
47
45
  formatDiagnosticsCall,
@@ -56,8 +54,6 @@ import {
56
54
  formatGoToImplementationResult,
57
55
  formatHoverCall,
58
56
  formatHoverResult,
59
- formatPopulateSymbolGraphCall,
60
- formatPopulateSymbolGraphResult,
61
57
  formatReachableFromCall,
62
58
  formatReachableFromResult,
63
59
  formatWorkspaceMapCall,
@@ -83,7 +79,7 @@ import { setNewWorkspaceObserver } from "./lector-client.ts";
83
79
  import { createLectorLineEditOperations } from "./line-edit-operations.ts";
84
80
  import { formatLineEditCall, formatLineEditResult } from "./line-edit-rendering.ts";
85
81
  import { createMutationHistoryOperations } from "./mutation-history-operations.ts";
86
- import { nearestGitRoot } from "./nearest-workspace-root.ts";
82
+ import { isFilesystemRoot, nearestGitRoot } from "./nearest-workspace-root.ts";
87
83
  import { createLectorPackageSourceOperations } from "./package-source-operations.ts";
88
84
  import { formatPackageSourceCall, formatPackageSourceResult } from "./package-source-rendering.ts";
89
85
  import { createLectorReadOperations } from "./read-operations.ts";
@@ -176,8 +172,16 @@ export default function (pi: ExtensionAPI) {
176
172
  uiContext.ui.setStatus("lector-cache", uiContext.ui.theme.fg(worst, `Lector: ${summary}`));
177
173
  }
178
174
 
179
- /** Starts (or restarts, on a stale generation) monitoring one workspace root's cache lifecycle -- shared by session_start's own cwd root and every later root a tool call first touches. */
175
+ /**
176
+ * Starts (or restarts, on a stale generation) monitoring one workspace root's cache
177
+ * lifecycle -- shared by session_start's own cwd root and every later root a tool call
178
+ * first touches. Refuses a bare filesystem root outright: workspaceForPath's own
179
+ * intentional fallback for a raw read/write of a file outside any git repo can register
180
+ * exactly this as a "new workspace", and auto-populating it would attempt a full
181
+ * filesystem-wide symbol-graph scan -- confirmed live as a real, previously-shipped bug.
182
+ */
180
183
  function startMonitoringRoot(root: string, ctx: Parameters<Parameters<ExtensionAPI["on"]>[1]>[1]): void {
184
+ if (isFilesystemRoot(root)) return;
181
185
  if (monitoringRoots.has(root)) return;
182
186
  monitoringRoots.add(root);
183
187
  const thisGeneration = sessionGeneration;
@@ -614,87 +618,6 @@ export default function (pi: ExtensionAPI) {
614
618
  },
615
619
  });
616
620
 
617
- pi.registerTool({
618
- name: "populate_symbol_graph",
619
- label: "Populate Symbol Graph",
620
- description:
621
- "Walk a workspace's real call relationships into a persisted graph, so reachable_from can answer multi-hop questions (transitive callers, reachability) without chaining many find_references/call_hierarchy calls by hand. Run this once before reachable_from.",
622
- promptSnippet: "Populate a workspace's symbol graph for multi-hop queries",
623
- promptGuidelines: [
624
- "Run populate_symbol_graph once for a workspace before using reachable_from against it; an unpopulated workspace's graph is empty, not an error.",
625
- "populate_symbol_graph waits briefly, then returns a job id with an explicit still-loading state instead of blocking the turn. Use job_status later; do not spin in a blind polling loop.",
626
- "maxFiles and maxSymbolsPerFile are both required and bound the scan explicitly -- a symbol-dense file (many interfaces/properties) can easily exceed a small maxSymbolsPerFile before reaching the functions/methods that actually matter.",
627
- ],
628
- parameters: Type.Object({
629
- path: Type.String({ description: "Any absolute or cwd-relative path inside the workspace to populate" }),
630
- maxFiles: Type.Number({ description: "Maximum number of source files to scan" }),
631
- maxSymbolsPerFile: Type.Number({ description: "Maximum number of declarations to process per file" }),
632
- initialWaitMs: Type.Optional(Type.Number({ description: "Bounded initial wait before returning a still-loading job; defaults to 500, maximum 30000" })),
633
- }),
634
- async execute(_toolCallId, params) {
635
- const path = resolve(cwd, params.path);
636
- const job = await codeIntelligenceOperations.populateSymbolGraph(path, params.maxFiles, params.maxSymbolsPerFile, params.initialWaitMs);
637
- return {
638
- content: [{ type: "text", text: describePopulateSymbolGraphJob(job) }],
639
- details: { job },
640
- };
641
- },
642
- renderCall(args, theme, context) {
643
- const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
644
- text.setText(formatPopulateSymbolGraphCall(args, theme));
645
- return text;
646
- },
647
- renderResult(result, { isPartial }, theme, context) {
648
- if (isPartial) return new Text(theme.fg("warning", "Populating symbol graph..."), 0, 0);
649
- if (context.isError) {
650
- const errorText = result.content
651
- .filter((block) => block.type === "text")
652
- .map((block) => block.text)
653
- .join("\n");
654
- return new Text(theme.fg("error", errorText || "populate_symbol_graph failed"), 0, 0);
655
- }
656
- const details = result.details as { job?: JobSnapshot<PopulateSymbolGraphResult> } | undefined;
657
- const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
658
- text.setText(formatPopulateSymbolGraphResult(details?.job, theme));
659
- return text;
660
- },
661
- });
662
-
663
- pi.registerTool({
664
- name: "job_status",
665
- label: "Job Status",
666
- description:
667
- "Poll one process-lifetime Lector background job. Returns queued/running with an actionable still-loading state, succeeded with the bounded result, or failed with a stable error code and message. Jobs are bounded and do not survive daemon restart; an unknown id explains expiry/restart rather than returning empty data.",
668
- promptSnippet: "Poll a Lector background job by id",
669
- parameters: Type.Object({
670
- jobId: Type.String({ description: "Job id returned by populate_symbol_graph" }),
671
- }),
672
- async execute(_toolCallId, params) {
673
- const job = await codeIntelligenceOperations.jobStatus(params.jobId);
674
- return { content: [{ type: "text", text: describePopulateSymbolGraphJob(job) }], details: { job } };
675
- },
676
- renderCall(args, theme, context) {
677
- const jobId = typeof args.jobId === "string" ? args.jobId : "";
678
- const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
679
- text.setText(`${theme.fg("toolTitle", theme.bold("job_status"))} ${theme.fg("accent", jobId)}`);
680
- return text;
681
- },
682
- renderResult(result, { isPartial }, theme, context) {
683
- if (isPartial) return new Text(theme.fg("warning", "Checking background job..."), 0, 0);
684
- if (context.isError) {
685
- const errorText = result.content
686
- .filter((block) => block.type === "text")
687
- .map((block) => block.text)
688
- .join("\n");
689
- return new Text(theme.fg("error", errorText || "job_status failed"), 0, 0);
690
- }
691
- const details = result.details as { job?: JobSnapshot<PopulateSymbolGraphResult> } | undefined;
692
- const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
693
- text.setText(formatPopulateSymbolGraphResult(details?.job, theme));
694
- return text;
695
- },
696
- });
697
-
698
621
  pi.registerTool({
699
622
  name: "reference_based_rename",
700
623
  label: "Reference-Based Rename",
@@ -702,7 +625,7 @@ export default function (pi: ExtensionAPI) {
702
625
  "Move/rename a file and rewrite every static import/export specifier the workspace's own populated symbol graph knows references it -- atomically, rolled back entirely on any failure. Non-LSP: uses find_references + a real parse of import/export declarations, not a language server's own rename. Refuses outright (touches nothing) unless the workspace's symbol graph is fully populated and current for the given bounds -- a partial rename that silently misses a reference is worse than refusing (Sourcegraph's CodeScaleBench finding). Does not follow dynamic import(expr)/require(expr) or any plain string reference to the file -- always check the returned caveats.",
703
626
  promptSnippet: "Move a file and update every import that references it",
704
627
  promptGuidelines: [
705
- "Run populate_symbol_graph for this workspace first if cache_status/has_warm_index doesn't already show a fully cached (never partial) graph -- reference_based_rename refuses outright otherwise.",
628
+ "The workspace's symbol graph auto-populates in the background (default bounds: 500 files, 100 symbols/file) the first time this workspace is touched. If this refuses because the graph isn't populated at the requested maxFiles/maxSymbolsPerFile, run `lector workspace populate-symbol-graph <path> --max-files <n> --max-symbols-per-file <n>` via bash for a larger scan, then retry.",
706
629
  "Always read the returned caveats: this never rewrites a dynamic import(expr)/require(expr) or a plain string reference to the old path, even if one exists.",
707
630
  ],
708
631
  parameters: Type.Object({
@@ -831,10 +754,10 @@ export default function (pi: ExtensionAPI) {
831
754
  name: "symbol_annotations",
832
755
  label: "Symbol Annotations",
833
756
  description:
834
- 'Agent-authored narrative content anchored to one or more symbols in the workspace\'s persisted graph -- e.g. a "user story dataflow" note spanning every symbol touched end-to-end. Every anchor must resolve to a real, currently-known symbol (run populate_symbol_graph first). get/list/tree live-check staleness against the current graph/workspace on every call and persist a correction before returning, so a returned status never disagrees with reality -- a stale annotation must be refreshed (re-authored and re-anchored) or scrubbed (soft-deleted, restorable) by an explicit decision; Lector never rewrites the narrative itself. contain/uncontain build a reusable, nestable structure on top of plain annotations: a container (e.g. a "data flow") can contain other annotations -- including per-symbol notes shared by more than one container (DRY reuse) or another container one level deeper (nested data flows) -- without duplicating their content. tree reads a whole bounded subtree in one call. Actions: create, get, list, refresh, scrub, restore, contain, uncontain, tree.',
757
+ 'Agent-authored narrative content anchored to one or more symbols in the workspace\'s persisted graph -- e.g. a "user story dataflow" note spanning every symbol touched end-to-end. Every anchor must resolve to a real, currently-known symbol (the workspace\'s symbol graph auto-populates in the background on first touch). get/list/tree live-check staleness against the current graph/workspace on every call and persist a correction before returning, so a returned status never disagrees with reality -- a stale annotation must be refreshed (re-authored and re-anchored) or scrubbed (soft-deleted, restorable) by an explicit decision; Lector never rewrites the narrative itself. contain/uncontain build a reusable, nestable structure on top of plain annotations: a container (e.g. a "data flow") can contain other annotations -- including per-symbol notes shared by more than one container (DRY reuse) or another container one level deeper (nested data flows) -- without duplicating their content. tree reads a whole bounded subtree in one call. Actions: create, get, list, refresh, scrub, restore, contain, uncontain, tree.',
835
758
  promptSnippet: "Attach, read, or invalidate narrative annotations on the symbol graph",
836
759
  promptGuidelines: [
837
- "Resolve real anchor positions first (find_symbols/document_symbols/go_to_definition) -- an anchor position must match populate_symbol_graph's own recorded position for that symbol, not just any occurrence of its name.",
760
+ "Resolve real anchor positions first (find_symbols/document_symbols/go_to_definition) -- an anchor position must match the workspace's own symbol graph's recorded position for that symbol, not just any occurrence of its name.",
838
761
  "A stale annotation's body may no longer describe the code accurately -- read it, decide whether to refresh (re-author) or scrub (remove), never trust it as-is.",
839
762
  "Prefer reusing an existing per-symbol annotation as a shared child of several containers over re-authoring the same explanation in each -- that reuse is the reason contain/uncontain exist.",
840
763
  "contain/uncontain are idempotent (containing an already-contained child, or uncontaining an already-absent relationship, is a no-op, not an error) and reject a cycle up front rather than accepting one.",
@@ -996,7 +919,7 @@ export default function (pi: ExtensionAPI) {
996
919
  name: "reachable_from",
997
920
  label: "Reachable From",
998
921
  description:
999
- "Every symbol reachable from an exact file position by following the workspace's persisted call graph up to maxDepth hops -- transitive callers/reachability that would otherwise require chaining many find_references/call_hierarchy calls by hand. Requires populate_symbol_graph to have been run for this workspace first.",
922
+ "Every symbol reachable from an exact file position by following the workspace's persisted call graph up to maxDepth hops -- transitive callers/reachability that would otherwise require chaining many find_references/call_hierarchy calls by hand. The workspace's symbol graph auto-populates in the background the first time this workspace is touched; if it's still building, this returns an empty result rather than an error -- wait a moment and retry.",
1000
923
  promptSnippet: "Find symbols reachable from a position, up to N hops, via the persisted graph",
1001
924
  promptGuidelines: [
1002
925
  "Use reachable_from for multi-hop questions (does A eventually call C through B); use call_hierarchy (direction=incoming/outgoing) for a single direct hop live against the language server.",
@@ -1044,7 +967,7 @@ export default function (pi: ExtensionAPI) {
1044
967
  name: "workspace_map",
1045
968
  label: "Workspace Map",
1046
969
  description:
1047
- "A ranked, budget-bounded summary of the workspace's most structurally central symbols (aider-repomap-shaped) -- signature-only, highest-ranked first by PageRank over the populated call/reference graph, not full file dumps. Use when orienting in an unfamiliar or large codebase instead of reading many files one by one. Requires populate_symbol_graph to have been run for this workspace first; returns empty otherwise.",
970
+ "A ranked, budget-bounded summary of the workspace's most structurally central symbols (aider-repomap-shaped) -- signature-only, highest-ranked first by PageRank over the populated call/reference graph, not full file dumps. Use when orienting in an unfamiliar or large codebase instead of reading many files one by one. The workspace's symbol graph auto-populates in the background the first time this workspace is touched; if it's still building, this returns empty rather than an error -- wait a moment and retry.",
1048
971
  promptSnippet: "Get a ranked, signature-only overview of the workspace's most central symbols",
1049
972
  promptGuidelines: [
1050
973
  "Prefer this over reading many files to get oriented in a large or unfamiliar codebase -- it surfaces the most-referenced symbols first, not an arbitrary file order.",
@@ -1062,7 +985,7 @@ export default function (pi: ExtensionAPI) {
1062
985
  const result = await codeIntelligenceOperations.workspaceMap(path, params.maxNodes, params.maxEdges, params.maxEntries, params.maxBytes);
1063
986
  const text =
1064
987
  result.entries.length === 0
1065
- ? "No ranked symbols (has the graph been populated for this workspace?)."
988
+ ? "No ranked symbols (the workspace's symbol graph may still be populating in the background -- retry shortly)."
1066
989
  : result.entries
1067
990
  .map(
1068
991
  (entry) => `${entry.kind} ${entry.name} -- ${entry.path}:${entry.line}:${entry.character}${entry.signature ? ` -- ${entry.signature}` : ""}`,
@@ -1550,7 +1473,7 @@ export default function (pi: ExtensionAPI) {
1550
1473
  maxResults: Type.Optional(Type.Number({ description: "Maximum candidates to return (default 20)" })),
1551
1474
  }),
1552
1475
  async execute(_toolCallId, params): Promise<AgentToolResult<ExternalSearchToolDetails>> {
1553
- const maxResults = params.maxResults ?? 20;
1476
+ const maxResults = params.maxResults ?? DEFAULT_EXTERNAL_SEARCH_MAX_RESULTS;
1554
1477
  if (params.action === "github_repos") {
1555
1478
  const result = await externalSearchOperations.githubRepos(params.query, maxResults);
1556
1479
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "github_repos", result } };
@@ -1,6 +1,5 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { dirname, extname, parse } from "node:path";
3
- import { createRetryingClient, type RetryingClient } from "@danypops/daemon-kit/pi-client";
4
3
  import {
5
4
  connectLectorClient,
6
5
  descriptorForExtension,
@@ -11,6 +10,7 @@ import {
11
10
  remoteErrorIs,
12
11
  type WorkspaceId,
13
12
  } from "@danypops/lector";
13
+ import { createRetryingClient, type RetryingClient } from "@danypops/vehicle-client/daemon-client";
14
14
  import { nearestGitRoot, nearestProjectRoot } from "./nearest-workspace-root.ts";
15
15
 
16
16
  /**
@@ -2,39 +2,60 @@ import { existsSync } from "node:fs";
2
2
  import { dirname, join, parse } from "node:path";
3
3
 
4
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.)
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) => existsSync(join(dir, marker)))) return dir;
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 markers.some((marker) => existsSync(join(fsRoot, marker))) ? fsRoot : undefined;
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.6.0",
3
+ "version": "0.8.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",
@@ -18,8 +18,8 @@
18
18
  "typebox": "*"
19
19
  },
20
20
  "dependencies": {
21
- "@danypops/daemon-kit": "^0.22.1",
22
- "@danypops/lector": "^0.7.0"
21
+ "@danypops/vehicle-client": "^0.1.1",
22
+ "@danypops/lector": "^0.9.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@earendil-works/pi-ai": "^0.81.1",