@danypops/pi-lector 0.9.4 → 0.9.6

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.
@@ -1,5 +1,5 @@
1
1
  import type { SymbolSearchResult, TextSearchResult, WorkspaceQueryOutcome } from "@danypops/lector";
2
- import { lectorClient, workspaceForDirectory } from "./lector-client.ts";
2
+ import { lectorClient, workspaceForProjectDirectory } from "./lector-client.ts";
3
3
 
4
4
  /**
5
5
  * Fans out across explicitly-named directories only -- never the daemon's own "every registered
@@ -10,34 +10,79 @@ import { lectorClient, workspaceForDirectory } from "./lector-client.ts";
10
10
  * required, same "no implicit fallback" convention as find_symbols/search_code.
11
11
  */
12
12
  export interface CrossWorkspaceSearchOperations {
13
- findSymbols(query: string, directories: readonly string[], timeoutMs?: number): Promise<readonly WorkspaceQueryOutcome<SymbolSearchResult>[]>;
13
+ findSymbols(query: string, directories: readonly string[], timeoutMs?: number): Promise<readonly CrossWorkspaceOutcome<SymbolSearchResult>[]>;
14
14
  searchText(
15
15
  query: string,
16
16
  directories: readonly string[],
17
17
  maxMatches: number,
18
18
  maxBytes: number,
19
19
  timeoutMs?: number,
20
- ): Promise<readonly WorkspaceQueryOutcome<TextSearchResult>[]>;
20
+ ): Promise<readonly CrossWorkspaceOutcome<TextSearchResult>[]>;
21
+ }
22
+
23
+ /**
24
+ * One caller-supplied directory's own outcome, labeled back by that literal directory (never
25
+ * just a workspaceId hash) so a caller can tell which of their own inputs a result belongs to.
26
+ * `collapsedWith` lists any OTHER requested directories that resolved to this same workspaceId --
27
+ * empty when this directory got its own distinct scope, as it should for a real monorepo
28
+ * subproject. A caller must be able to tell "two of my inputs turned out to be one workspace"
29
+ * apart from "these are genuinely two separate results" -- silently duplicating one payload
30
+ * under two different-looking entries is exactly the bug this exists to prevent.
31
+ */
32
+ export interface CrossWorkspaceOutcome<T> {
33
+ readonly directory: string;
34
+ readonly workspaceId: string;
35
+ readonly collapsedWith: readonly string[];
36
+ readonly outcome: WorkspaceQueryOutcome<T>;
21
37
  }
22
38
 
23
39
  async function resolveWorkspaceIds(directories: readonly string[]): Promise<readonly string[]> {
24
- const resolved = await Promise.all(directories.map((directory) => workspaceForDirectory(directory)));
40
+ const resolved = await Promise.all(directories.map((directory) => workspaceForProjectDirectory(directory)));
25
41
  return resolved.map((r) => r.workspaceId);
26
42
  }
27
43
 
44
+ /**
45
+ * Zips the daemon's own outcomes back onto the literal directories that produced them, and
46
+ * computes collapsedWith. The daemon's search.symbols/search.text handlers map workspaceIds to
47
+ * results 1:1, in order, with no deduplication of their own (confirmed by reading service.ts's
48
+ * crossFindSymbols/crossSearchText: `targets.map(...)` over the exact `workspaceIds` array
49
+ * given) -- so a length mismatch here means that contract broke, not a normal runtime condition
50
+ * to paper over with an unsafe cast.
51
+ */
52
+ function zipOutcomes<T>(
53
+ directories: readonly string[],
54
+ workspaceIds: readonly string[],
55
+ outcomes: readonly WorkspaceQueryOutcome<T>[],
56
+ ): readonly CrossWorkspaceOutcome<T>[] {
57
+ if (outcomes.length !== directories.length) {
58
+ throw new Error(
59
+ `Lector's search fan-out returned ${outcomes.length} outcome(s) for ${directories.length} requested directories -- expected exactly one outcome per directory, in order`,
60
+ );
61
+ }
62
+ return directories.map((directory, index) => {
63
+ const workspaceId = workspaceIds[index];
64
+ const outcome = outcomes[index];
65
+ if (workspaceId === undefined || outcome === undefined) {
66
+ throw new Error(`Lector's search fan-out is missing a workspaceId/outcome for directory "${directory}"`);
67
+ }
68
+ const collapsedWith = directories.filter((_, otherIndex) => otherIndex !== index && workspaceIds[otherIndex] === workspaceId);
69
+ return { directory, workspaceId, collapsedWith, outcome };
70
+ });
71
+ }
72
+
28
73
  export function createLectorCrossWorkspaceSearchOperations(): CrossWorkspaceSearchOperations {
29
74
  return {
30
75
  async findSymbols(query, directories, timeoutMs) {
31
76
  const workspaceIds = await resolveWorkspaceIds(directories);
32
77
  const client = await lectorClient();
33
78
  const { results } = await client.call("search.symbols", { query, workspaceIds, timeoutMs });
34
- return results;
79
+ return zipOutcomes(directories, workspaceIds, results);
35
80
  },
36
81
  async searchText(query, directories, maxMatches, maxBytes, timeoutMs) {
37
82
  const workspaceIds = await resolveWorkspaceIds(directories);
38
83
  const client = await lectorClient();
39
84
  const { results } = await client.call("search.text", { query, maxMatches, maxBytes, workspaceIds, timeoutMs });
40
- return results;
85
+ return zipOutcomes(directories, workspaceIds, results);
41
86
  },
42
87
  };
43
88
  }
@@ -1,6 +1,7 @@
1
- import type { SymbolSearchResult, TextSearchResult, WorkspaceQueryOutcome } from "@danypops/lector";
1
+ import type { SymbolSearchResult, TextSearchResult } from "@danypops/lector";
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
3
  import { renderTruncatedList } from "malevich-tui-components";
4
+ import type { CrossWorkspaceOutcome } from "./cross-workspace-search-operations.ts";
4
5
  import { describeFindSymbolSources } from "./find-symbols-rendering.ts";
5
6
  import type { LectorTheme } from "./lector-tui-theme.ts";
6
7
 
@@ -12,21 +13,31 @@ export function formatCrossWorkspaceCall(args: { directories?: unknown; query?:
12
13
  return `${theme.fg("accent", `"${query}"`)} ${theme.fg("dim", `across ${directories.length} project(s)`)}`;
13
14
  }
14
15
 
15
- function formatOutcomeHeader(outcome: WorkspaceQueryOutcome<unknown>, theme: LectorTheme): string {
16
- if (outcome.status === "ready") return theme.fg("accent", outcome.workspaceId);
17
- if (outcome.status === "loading") return `${theme.fg("warning", outcome.workspaceId)} ${theme.fg("warning", `-- still loading: ${outcome.message}`)}`;
18
- return `${theme.fg("error", outcome.workspaceId)} ${theme.fg("error", `-- ${outcome.message}`)}`;
16
+ function formatOutcomeHeader(entry: CrossWorkspaceOutcome<unknown>, theme: LectorTheme): string {
17
+ const { outcome } = entry;
18
+ const label = theme.fg("accent", entry.directory);
19
+ const lines: string[] = [];
20
+ if (outcome.status === "ready") lines.push(label);
21
+ else if (outcome.status === "loading") lines.push(`${label} ${theme.fg("warning", `-- still loading: ${outcome.message}`)}`);
22
+ else lines.push(`${label} ${theme.fg("error", `-- ${outcome.message}`)}`);
23
+ // Surfaced explicitly, never silently -- two distinct inputs resolving to one workspace means
24
+ // one of their own result payloads below is a real duplicate of the other's, not two independent answers.
25
+ if (entry.collapsedWith.length > 0) {
26
+ lines.push(theme.fg("warning", ` resolved to the same workspace as: ${entry.collapsedWith.join(", ")}`));
27
+ }
28
+ return lines.join("\n");
19
29
  }
20
30
 
21
31
  export function formatFindSymbolsAcrossProjectsResult(
22
- results: readonly WorkspaceQueryOutcome<SymbolSearchResult>[] | undefined,
32
+ results: readonly CrossWorkspaceOutcome<SymbolSearchResult>[] | undefined,
23
33
  expanded: boolean,
24
34
  theme: LectorTheme,
25
35
  ): string {
26
36
  if (!results || results.length === 0) return theme.fg("dim", "No projects to search.");
27
37
  const lines: string[] = [];
28
- for (const outcome of results) {
29
- lines.push(formatOutcomeHeader(outcome, theme));
38
+ for (const entry of results) {
39
+ lines.push(formatOutcomeHeader(entry, theme));
40
+ const { outcome } = entry;
30
41
  if (outcome.status !== "ready") continue;
31
42
  lines.push(
32
43
  theme.fg("muted", ` ${outcome.result.provenance.fidelity} via ${outcome.result.provenance.backend}${outcome.result.truncated ? " (truncated)" : ""}`),
@@ -50,14 +61,15 @@ export function formatFindSymbolsAcrossProjectsResult(
50
61
  }
51
62
 
52
63
  export function formatSearchTextAcrossProjectsResult(
53
- results: readonly WorkspaceQueryOutcome<TextSearchResult>[] | undefined,
64
+ results: readonly CrossWorkspaceOutcome<TextSearchResult>[] | undefined,
54
65
  expanded: boolean,
55
66
  theme: LectorTheme,
56
67
  ): string {
57
68
  if (!results || results.length === 0) return theme.fg("dim", "No projects to search.");
58
69
  const lines: string[] = [];
59
- for (const outcome of results) {
60
- lines.push(formatOutcomeHeader(outcome, theme));
70
+ for (const entry of results) {
71
+ lines.push(formatOutcomeHeader(entry, theme));
72
+ const { outcome } = entry;
61
73
  if (outcome.status !== "ready") continue;
62
74
  if (outcome.result.matches.length === 0) {
63
75
  lines.push(theme.fg("dim", " no matches"));
@@ -9,6 +9,7 @@ import type {
9
9
  GithubRepoSearchResult,
10
10
  Hover,
11
11
  IntelligenceProvenance,
12
+ JobSnapshot,
12
13
  LineEdit,
13
14
  LineEditOutcome,
14
15
  MutationHistoryEntry,
@@ -16,15 +17,16 @@ import type {
16
17
  OperationOutputs,
17
18
  PackageEcosystem,
18
19
  PackageSourceOperationResult,
20
+ PopulateSymbolGraphResult,
19
21
  RepoFetchResult,
20
22
  SourcegraphCodeCandidate,
21
23
  SymbolAnnotation,
22
24
  SymbolNode,
23
25
  SymbolSearchResult,
24
26
  TextSearchResult,
27
+ WorkspaceCacheStatus,
25
28
  WorkspaceLocation,
26
29
  WorkspaceMapResult,
27
- WorkspaceQueryOutcome,
28
30
  } from "@danypops/lector";
29
31
  import { DEFAULT_EXTERNAL_SEARCH_MAX_RESULTS, PACKAGE_ECOSYSTEMS } from "@danypops/lector";
30
32
  import {
@@ -65,7 +67,7 @@ import {
65
67
  formatWorkspaceMapCall,
66
68
  formatWorkspaceMapResult,
67
69
  } from "./code-intelligence-rendering.ts";
68
- import { createLectorCrossWorkspaceSearchOperations } from "./cross-workspace-search-operations.ts";
70
+ import { type CrossWorkspaceOutcome, createLectorCrossWorkspaceSearchOperations } from "./cross-workspace-search-operations.ts";
69
71
  import { formatCrossWorkspaceCall, formatFindSymbolsAcrossProjectsResult, formatSearchTextAcrossProjectsResult } from "./cross-workspace-search-rendering.ts";
70
72
  import { createLectorEditOperations } from "./edit-operations.ts";
71
73
  import { createExternalSearchOperations } from "./external-search-operations.ts";
@@ -125,6 +127,7 @@ import {
125
127
  describeCacheState,
126
128
  monitorWorkspaceCache,
127
129
  } from "./workspace-cache-operations.ts";
130
+ import { formatJobSnapshotResult, formatWorkspaceCacheCall, formatWorkspaceCacheStatusResult } from "./workspace-cache-rendering.ts";
128
131
  import { createLectorWriteOperations } from "./write-operations.ts";
129
132
 
130
133
  function describeIntelligenceSource(provenance: IntelligenceProvenance): string {
@@ -650,7 +653,7 @@ export default function (pi: ExtensionAPI) {
650
653
  "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.",
651
654
  promptSnippet: "Move a file and update every import that references it",
652
655
  promptGuidelines: [
653
- "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.",
656
+ "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, use workspace_cache(action=populate, maxFiles, maxSymbolsPerFile) for a larger scan, then retry -- no need to shell out to the CLI.",
654
657
  "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.",
655
658
  ],
656
659
  parameters: Type.Object({
@@ -779,10 +782,11 @@ export default function (pi: ExtensionAPI) {
779
782
  name: "symbol_annotations",
780
783
  label: "Symbol Annotations",
781
784
  description:
782
- '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.',
785
+ '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, bounded to the first 500 files/100 symbols each -- use workspace_cache(action=populate) with larger bounds for a symbol outside that). 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.',
783
786
  promptSnippet: "Attach, read, or invalidate narrative annotations on the symbol graph",
784
787
  promptGuidelines: [
785
788
  "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.",
789
+ "UnknownAnnotationAnchor on a real, existing symbol usually means the graph's default 500-file auto-scan never reached that file -- check workspace_cache(action=status) and populate with larger bounds before assuming the position itself is wrong.",
786
790
  "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.",
787
791
  "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.",
788
792
  "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.",
@@ -944,10 +948,11 @@ export default function (pi: ExtensionAPI) {
944
948
  name: "reachable_from",
945
949
  label: "Reachable From",
946
950
  description:
947
- "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.",
951
+ "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, bounded to the first 500 files/100 symbols each; if the position you need falls outside that, this returns an empty result rather than an error -- use workspace_cache(action=populate) with larger bounds, not just a retry.",
948
952
  promptSnippet: "Find symbols reachable from a position, up to N hops, via the persisted graph",
949
953
  promptGuidelines: [
950
954
  "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.",
955
+ "An empty result on a real, existing symbol usually means the graph's default 500-file auto-scan never reached that file, not that nothing is reachable -- check with workspace_cache(action=status) and populate with larger bounds if so.",
951
956
  ],
952
957
  parameters: Type.Object({
953
958
  ...positionParameters,
@@ -992,11 +997,11 @@ export default function (pi: ExtensionAPI) {
992
997
  name: "workspace_map",
993
998
  label: "Workspace Map",
994
999
  description:
995
- "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.",
1000
+ "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, bounded to the first 500 files/100 symbols each; a workspace bigger than that needs workspace_cache(action=populate) with larger bounds for full coverage.",
996
1001
  promptSnippet: "Get a ranked, signature-only overview of the workspace's most central symbols",
997
1002
  promptGuidelines: [
998
1003
  "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.",
999
- "A budget-truncated result means real symbols were left out, not that the workspace only has this many -- raise maxEntries/maxBytes for more.",
1004
+ "A budget-truncated result means real symbols were left out, not that the workspace only has this many -- raise maxEntries/maxBytes for more. An empty result instead means the graph itself never covered this workspace -- check workspace_cache(action=status).",
1000
1005
  ],
1001
1006
  parameters: Type.Object({
1002
1007
  path: Type.String({ description: "Absolute or cwd-relative path used to resolve which workspace to map" }),
@@ -1039,6 +1044,80 @@ export default function (pi: ExtensionAPI) {
1039
1044
  },
1040
1045
  });
1041
1046
 
1047
+ interface WorkspaceCacheToolDetails {
1048
+ readonly action: "status" | "populate" | "job_status";
1049
+ readonly status?: WorkspaceCacheStatus;
1050
+ readonly job?: JobSnapshot<PopulateSymbolGraphResult>;
1051
+ }
1052
+
1053
+ pi.registerTool({
1054
+ name: "workspace_cache",
1055
+ label: "Workspace Cache",
1056
+ description:
1057
+ "Checks or drives population of the workspace's persisted symbol graph -- the store reachable_from, symbol_annotations (anchor resolution), reference_based_rename, and workspace_map all read from, separate from the live language-server index find_symbols/hover/go_to_definition use. action=status reports not-cached/caching/partial/cached for the given bounds, without starting any work. action=populate explicitly requests a scan (optionally larger than the default 500-file/100-symbol auto-scan every workspace gets on first touch) and waits up to waitMs for it to finish, returning a job snapshot either way. action=job_status polls a job returned by populate that didn't finish within its own wait.",
1058
+ promptSnippet: "Check or force-populate the workspace's persisted symbol graph",
1059
+ promptGuidelines: [
1060
+ "Use action=populate with a larger maxFiles/maxSymbolsPerFile before relying on reachable_from/symbol_annotations/reference_based_rename against a workspace bigger than the default 500-file auto-scan -- their own errors (empty results, UnknownAnnotationAnchor, ReferenceBasedRenameRequiresFreshGraph) usually mean the graph never reached the files you need, not that population is simply still catching up.",
1061
+ "action=populate returns immediately once its own waitMs elapses even if the job is still running -- check the returned job's status and poll with action=job_status (the same jobId) rather than assuming a non-succeeded result means failure.",
1062
+ ],
1063
+ parameters: Type.Object({
1064
+ action: Type.Union([Type.Literal("status"), Type.Literal("populate"), Type.Literal("job_status")]),
1065
+ directory: Type.Optional(
1066
+ Type.String({ description: "Required for action=status/populate -- absolute or cwd-relative path used to resolve the workspace" }),
1067
+ ),
1068
+ maxFiles: Type.Optional(
1069
+ Type.Number({ description: "action=status/populate only -- defaults to 500, the same bound the automatic first-touch scan uses" }),
1070
+ ),
1071
+ maxSymbolsPerFile: Type.Optional(
1072
+ Type.Number({ description: "action=status/populate only -- defaults to 100, the same bound the automatic first-touch scan uses" }),
1073
+ ),
1074
+ waitMs: Type.Optional(
1075
+ Type.Number({
1076
+ description:
1077
+ "action=populate only -- how long to wait for the job to finish before returning its current snapshot; defaults to 3000, capped by the daemon at 30000",
1078
+ }),
1079
+ ),
1080
+ jobId: Type.Optional(Type.String({ description: "Required for action=job_status -- a jobId returned by a prior action=populate call" })),
1081
+ }),
1082
+ async execute(_toolCallId, params): Promise<AgentToolResult<WorkspaceCacheToolDetails>> {
1083
+ if (params.action === "job_status") {
1084
+ if (!params.jobId) throw new Error("workspace_cache action=job_status requires jobId");
1085
+ const job = await codeIntelligenceOperations.jobStatus(params.jobId);
1086
+ return { content: [{ type: "text", text: JSON.stringify(job) }], details: { action: "job_status", job } };
1087
+ }
1088
+ if (!params.directory) throw new Error(`workspace_cache action=${params.action} requires directory`);
1089
+ const directory = resolve(cwd, params.directory);
1090
+ const maxFiles = params.maxFiles ?? 500;
1091
+ const maxSymbolsPerFile = params.maxSymbolsPerFile ?? 100;
1092
+ if (params.action === "status") {
1093
+ const status = await cacheOperations.status(directory, maxFiles, maxSymbolsPerFile);
1094
+ return { content: [{ type: "text", text: JSON.stringify(status) }], details: { action: "status", status } };
1095
+ }
1096
+ const job = await codeIntelligenceOperations.populateSymbolGraph(directory, maxFiles, maxSymbolsPerFile, params.waitMs ?? 3_000);
1097
+ return { content: [{ type: "text", text: JSON.stringify(job) }], details: { action: "populate", job } };
1098
+ },
1099
+ renderCall(args, theme, context) {
1100
+ const action = args.action === "populate" || args.action === "job_status" ? args.action : "status";
1101
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1102
+ text.setText(formatWorkspaceCacheCall(action, args, theme));
1103
+ return text;
1104
+ },
1105
+ renderResult(result, { isPartial }, theme, context) {
1106
+ if (isPartial) return new Text(theme.fg("warning", "Checking workspace cache..."), 0, 0);
1107
+ if (context.isError) {
1108
+ const errorText = result.content
1109
+ .filter((block) => block.type === "text")
1110
+ .map((block) => block.text)
1111
+ .join("\n");
1112
+ return new Text(theme.fg("error", errorText || "workspace_cache failed"), 0, 0);
1113
+ }
1114
+ const details = result.details as WorkspaceCacheToolDetails | undefined;
1115
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1116
+ text.setText(details?.action === "status" ? formatWorkspaceCacheStatusResult(details.status, theme) : formatJobSnapshotResult(details?.job, theme));
1117
+ return text;
1118
+ },
1119
+ });
1120
+
1042
1121
  const gitOperations = createLectorGitOperations();
1043
1122
  pi.registerTool({
1044
1123
  name: "git",
@@ -1645,7 +1724,7 @@ export default function (pi: ExtensionAPI) {
1645
1724
  name: "find_symbols_across_projects",
1646
1725
  label: "Find Symbols Across Projects",
1647
1726
  description:
1648
- "Fans out a symbol-name search across several explicitly-named project directories at once (e.g. several fetched repos, or a handful of related local projects) and reports one outcome per project -- ready with real results, loading (a project's language server is still cold-starting; retry shortly), or error. Directories are required and explicit -- never every project this daemon happens to have registered, which can include unrelated projects from other concurrent sessions.",
1727
+ "Fans out a symbol-name search across several explicitly-named project directories at once (e.g. several fetched repos, or a handful of related local projects) and reports one outcome per project -- ready with real results, loading (a project's language server is still cold-starting; retry shortly), or error. Directories are required and explicit -- never every project this daemon happens to have registered, which can include unrelated projects from other concurrent sessions. Each directory resolves to its OWN nearest project root (package.json/tsconfig.json/go.mod/Cargo.toml/...), not the outer repo's git root -- sibling packages under one monorepo stay distinct scopes rather than collapsing into one. A result's collapsedWith lists any other requested directories that genuinely did resolve to the same workspace; empty means it got its own.",
1649
1728
  promptSnippet: "Search for a symbol name across several projects at once",
1650
1729
  parameters: Type.Object({
1651
1730
  directories: Type.Array(Type.String(), { description: "Project directories to search, each absolute or relative to the current working directory" }),
@@ -1671,7 +1750,7 @@ export default function (pi: ExtensionAPI) {
1671
1750
  .join("\n");
1672
1751
  return new Text(theme.fg("error", errorText || "find_symbols_across_projects failed"), 0, 0);
1673
1752
  }
1674
- const details = result.details as { results?: readonly WorkspaceQueryOutcome<SymbolSearchResult>[] } | undefined;
1753
+ const details = result.details as { results?: readonly CrossWorkspaceOutcome<SymbolSearchResult>[] } | undefined;
1675
1754
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1676
1755
  text.setText(formatFindSymbolsAcrossProjectsResult(details?.results, expanded, theme));
1677
1756
  return text;
@@ -1682,7 +1761,7 @@ export default function (pi: ExtensionAPI) {
1682
1761
  name: "search_code_across_projects",
1683
1762
  label: "Search Code Across Projects",
1684
1763
  description:
1685
- "Fans out a ripgrep-backed text/regex search across several explicitly-named project directories at once and reports one outcome per project. Directories are required and explicit -- never every project this daemon happens to have registered, which can include unrelated projects from other concurrent sessions.",
1764
+ "Fans out a ripgrep-backed text/regex search across several explicitly-named project directories at once and reports one outcome per project. Directories are required and explicit -- never every project this daemon happens to have registered, which can include unrelated projects from other concurrent sessions. Each directory resolves to its OWN nearest project root (package.json/tsconfig.json/go.mod/Cargo.toml/...), not the outer repo's git root -- sibling packages under one monorepo stay distinct scopes rather than collapsing into one. A result's collapsedWith lists any other requested directories that genuinely did resolve to the same workspace; empty means it got its own.",
1686
1765
  promptSnippet: "Search for a pattern across several projects at once",
1687
1766
  parameters: Type.Object({
1688
1767
  directories: Type.Array(Type.String(), { description: "Project directories to search, each absolute or relative to the current working directory" }),
@@ -1710,7 +1789,7 @@ export default function (pi: ExtensionAPI) {
1710
1789
  .join("\n");
1711
1790
  return new Text(theme.fg("error", errorText || "search_code_across_projects failed"), 0, 0);
1712
1791
  }
1713
- const details = result.details as { results?: readonly WorkspaceQueryOutcome<TextSearchResult>[] } | undefined;
1792
+ const details = result.details as { results?: readonly CrossWorkspaceOutcome<TextSearchResult>[] } | undefined;
1714
1793
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1715
1794
  text.setText(formatSearchTextAcrossProjectsResult(details?.results, expanded, theme));
1716
1795
  return text;
@@ -3,6 +3,7 @@ import { dirname, extname, parse } from "node:path";
3
3
  import {
4
4
  connectLectorClient,
5
5
  descriptorForExtension,
6
+ LANGUAGE_SERVER_DESCRIPTORS,
6
7
  type LectorClient,
7
8
  type OperationInputs,
8
9
  type OperationName,
@@ -142,6 +143,30 @@ export function workspaceForCodeIntelligencePath(absolutePath: string): Promise<
142
143
  return workspaceForRoot(root);
143
144
  }
144
145
 
146
+ /** Every known language's own rootMarkers, deduplicated -- see workspaceForProjectDirectory. */
147
+ const ALL_PROJECT_ROOT_MARKERS: readonly string[] = [...new Set(LANGUAGE_SERVER_DESCRIPTORS.flatMap((descriptor) => descriptor.rootMarkers))];
148
+
149
+ /**
150
+ * Resolves a caller-supplied directory to its OWN nearest project root -- never the outer repo's
151
+ * git root -- so distinct sibling packages under one monorepo stay distinct workspaces. Unlike
152
+ * workspaceForDirectory (used by find_symbols/read/write, where one canonical workspaceId per
153
+ * repo is exactly the point), this is for a tool whose entire premise is comparing *different*
154
+ * scopes (find_symbols_across_projects, search_code_across_projects): collapsing two sibling
155
+ * packages into the same workspaceId there silently duplicates one package's own results under
156
+ * the other's name, with no error at all -- confirmed live against this monorepo
157
+ * (packages/lector and packages/pi-lector both resolved to the same workspaceId).
158
+ *
159
+ * Unlike workspaceForCodeIntelligencePath, there is no single file (and therefore no known
160
+ * extension) to pick one specific language's markers from -- a caller-supplied directory could
161
+ * be any language, so this checks the union of every known language's rootMarkers. Falls back to
162
+ * the nearest git root, then the directory itself, exactly as nearestProjectRoot already does
163
+ * internally (it appends ".git" to whatever marker list it's given).
164
+ */
165
+ export function workspaceForProjectDirectory(directory: string): Promise<ResolvedWorkspace> {
166
+ const root = nearestProjectRoot(directory, ALL_PROJECT_ROOT_MARKERS) ?? directory;
167
+ return workspaceForRoot(root);
168
+ }
169
+
145
170
  /**
146
171
  * For an operation whose `path` genuinely means "the project/workspace itself"
147
172
  * (populateSymbolGraph, workspaceMap, hasWarmIndex) rather than one specific file
@@ -0,0 +1,45 @@
1
+ import type { JobSnapshot, PopulateSymbolGraphResult, WorkspaceCacheStatus } from "@danypops/lector";
2
+ import type { LectorTheme } from "./lector-tui-theme.ts";
3
+
4
+ type WorkspaceCacheAction = "status" | "populate" | "job_status";
5
+
6
+ export function formatWorkspaceCacheCall(
7
+ action: WorkspaceCacheAction,
8
+ args: { directory?: unknown; maxFiles?: unknown; maxSymbolsPerFile?: unknown; jobId?: unknown },
9
+ theme: LectorTheme,
10
+ ): string {
11
+ const label = theme.fg("toolTitle", theme.bold("workspace_cache"));
12
+ if (action === "job_status") {
13
+ const jobId = typeof args.jobId === "string" ? args.jobId : "";
14
+ return `${label} ${theme.fg("accent", "job_status")} ${theme.fg("dim", jobId)}`;
15
+ }
16
+ const directory = typeof args.directory === "string" ? args.directory : "";
17
+ const maxFiles = typeof args.maxFiles === "number" ? String(args.maxFiles) : "default";
18
+ const maxSymbolsPerFile = typeof args.maxSymbolsPerFile === "number" ? String(args.maxSymbolsPerFile) : "default";
19
+ const bounds =
20
+ action === "populate" && (typeof args.maxFiles === "number" || typeof args.maxSymbolsPerFile === "number")
21
+ ? theme.fg("dim", ` (maxFiles=${maxFiles}, maxSymbolsPerFile=${maxSymbolsPerFile})`)
22
+ : "";
23
+ return `${label} ${theme.fg("accent", action)} ${theme.fg("dim", directory)}${bounds}`;
24
+ }
25
+
26
+ function formatResultCounts(result: PopulateSymbolGraphResult): string {
27
+ const failed = result.filesFailed > 0 ? `, ${result.filesFailed} failed` : "";
28
+ return `${result.filesProcessed}/${result.filesAttempted} files${failed}, ${result.symbolsProcessed} symbols, ${result.nodesAdded} nodes, ${result.edgesAdded} edges`;
29
+ }
30
+
31
+ export function formatWorkspaceCacheStatusResult(status: WorkspaceCacheStatus | undefined, theme: LectorTheme): string {
32
+ if (!status) return theme.fg("dim", "No result.");
33
+ if (status.status === "not-cached") return theme.fg("warning", `not cached (${status.reason})`);
34
+ if (status.status === "caching") return theme.fg("accent", `caching (job ${status.jobId})`);
35
+ if (status.status === "partial") return theme.fg("warning", `partial -- ${formatResultCounts(status.generation.result)}`);
36
+ return theme.fg("success", `cached -- ${formatResultCounts(status.generation.result)}`);
37
+ }
38
+
39
+ export function formatJobSnapshotResult(job: JobSnapshot<PopulateSymbolGraphResult> | undefined, theme: LectorTheme): string {
40
+ if (!job) return theme.fg("dim", "No result.");
41
+ if (job.status === "queued") return theme.fg("dim", `queued (job ${job.id})`);
42
+ if (job.status === "running") return theme.fg("accent", `running (job ${job.id})`);
43
+ if (job.status === "failed") return theme.fg("error", `failed (job ${job.id}): ${job.error.code}: ${job.error.message}`);
44
+ return theme.fg("success", `succeeded (job ${job.id}) -- ${formatResultCounts(job.result)}`);
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
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",