@danypops/pi-lector 0.12.6 → 0.12.7

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,46 @@
1
+ import type { RetryingClientDiagnosticEvent } from "@danypops/vehicle-client/daemon-client";
2
+
3
+ /**
4
+ * Formats one createRetryingClient diagnostic event as a single, compact line -- the same real
5
+ * RCA gap @danypops/vehicle-client's own onEvent hook was added to close: a scrubbed "connector
6
+ * unavailable" error at the tool-call boundary otherwise gives no way to tell, after the fact,
7
+ * whether it was a genuine fresh connect() failure, a circuit-breaker short-circuit (no connect
8
+ * attempted at all), or an in-flight operation's stale-connection retry. Never includes a stack
9
+ * trace -- name and message only, matching this house's other client-diagnostic channels
10
+ * (@danypops/vehicle-client-pi's own client-diagnostics.ts).
11
+ */
12
+ /** Never risks Object's default `[object Object]` stringification for a non-Error, non-string throw. */
13
+ function describeError(error: unknown): { name: string; message: string } {
14
+ if (error instanceof Error) return { name: error.name, message: error.message };
15
+ if (typeof error === "string") return { name: "string", message: error };
16
+ if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") return { name: typeof error, message: String(error) };
17
+ return { name: typeof error, message: "(unprintable value)" };
18
+ }
19
+
20
+ export function formatClientDiagnosticEvent(event: RetryingClientDiagnosticEvent): string {
21
+ const parts = [`[lector-client] ${event.type}`];
22
+ if (event.attempt !== undefined) parts.push(`attempt=${event.attempt}`);
23
+ if (event.consecutiveFailures !== undefined) parts.push(`consecutiveFailures=${event.consecutiveFailures}`);
24
+ if (event.operationId !== undefined) parts.push(`operationId=${event.operationId}`);
25
+ if (event.error !== undefined) {
26
+ const { name, message } = describeError(event.error);
27
+ parts.push(`error=${name}: ${message}`);
28
+ }
29
+ return parts.join(" ");
30
+ }
31
+
32
+ /**
33
+ * Logs a createRetryingClient diagnostic event to stderr, only when LECTOR_CLIENT_DIAG is set --
34
+ * zero cost and zero output for every session that never opts in, matching the env-gated
35
+ * convention @danypops/vehicle-client-pi's own VEHICLE_CLIENT_DIAG already established in this
36
+ * house. Never throws: onEvent's own contract requires it stay side-effect-light, and a broken
37
+ * local console (or a formatting bug) must not take down a real client call.
38
+ */
39
+ export function logClientDiagnosticEvent(event: RetryingClientDiagnosticEvent): void {
40
+ if (!process.env.LECTOR_CLIENT_DIAG) return;
41
+ try {
42
+ console.error(formatClientDiagnosticEvent(event));
43
+ } catch {
44
+ // best-effort only -- see doc comment above.
45
+ }
46
+ }
@@ -1,3 +1,5 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
1
3
  import { resolve } from "node:path";
2
4
  import type {
3
5
  CachedRepositoryPage,
@@ -44,7 +46,7 @@ import { Type } from "typebox";
44
46
  /** Real ANSI-aware measurement for Table -- Malevich's own default is ASCII-only, unsafe against theme-styled cell/header text. */
45
47
  const tableMeasure: TextMeasure = { visibleWidth, truncateToWidth };
46
48
 
47
- import { isFilesystemRoot } from "@danypops/lector";
49
+ import { classifyAutoPopulationRoot, isFilesystemRoot } from "@danypops/lector";
48
50
  import { createLectorApplyPatchOperations } from "./apply-patch/operations.ts";
49
51
  import { formatApplyPatchCall, formatApplyPatchResult } from "./apply-patch/rendering.ts";
50
52
  import { createLectorCodeIntelligenceOperations } from "./code-intelligence/operations.ts";
@@ -214,10 +216,23 @@ export default function (pi: ExtensionAPI) {
214
216
  * intentional fallback for a raw read/write of a file outside any git repo can register
215
217
  * exactly this as a "new workspace", and auto-populating it would attempt a full
216
218
  * filesystem-wide symbol-graph scan -- confirmed live as a real, previously-shipped bug.
219
+ *
220
+ * Also short-circuits a broad host directory (home directory, an XDG config/cache/data
221
+ * root, a dotfile directory) the exact same way workspace.populateSymbolGraph's own
222
+ * server-side gate would refuse it -- avoids the round trip (and the queue-behind-real-
223
+ * projects ergonomics this caused live for ~/.pi/agent) entirely, using the identical
224
+ * classification Lector itself uses so the two never drift. A readdir failure (permission,
225
+ * race) is treated as "can't tell, don't block" -- the server-side gate is still authoritative.
217
226
  */
218
227
  function startMonitoringRoot(root: string, ctx: Parameters<Parameters<ExtensionAPI["on"]>[1]>[1]): void {
219
228
  if (isFilesystemRoot(root)) return;
220
229
  if (monitoringRoots.has(root)) return;
230
+ try {
231
+ const topLevelEntries = readdirSync(root);
232
+ if (classifyAutoPopulationRoot({ rootPath: root, homeDir: homedir(), topLevelEntries }) === "broad-non-project") return;
233
+ } catch {
234
+ // Can't read it here -- let the server-side gate be the authoritative answer.
235
+ }
221
236
  monitoringRoots.add(root);
222
237
  const thisGeneration = sessionGeneration;
223
238
  void monitorWorkspaceCache(cacheOperations, {
@@ -11,6 +11,7 @@ import {
11
11
  type WorkspaceResolutionRequest,
12
12
  } from "@danypops/lector";
13
13
  import { createRetryingClient, isLikelyStaleConnectionError, type RetryingClient } from "@danypops/vehicle-client/daemon-client";
14
+ import { logClientDiagnosticEvent } from "./client-diagnostics.ts";
14
15
 
15
16
  /**
16
17
  * Lazily connects to a running Lector daemon and caches, per resolution request, the
@@ -44,6 +45,9 @@ let connector: ClientConnector = () => connectLectorClient();
44
45
  const retryingClient: RetryingClient<LectorClient> = createRetryingClient(() => connector(), {
45
46
  label: "Lector",
46
47
  isStaleConnectionError: (error) => error instanceof LectorDaemonUnavailable || isLikelyStaleConnectionError(error),
48
+ // Purely observational, opt-in via LECTOR_CLIENT_DIAG -- see client-diagnostics.ts's own doc
49
+ // comment for the exact RCA gap this closes.
50
+ onEvent: logClientDiagnosticEvent,
47
51
  });
48
52
 
49
53
  /**
@@ -220,6 +224,38 @@ export function workspaceForPathOrDirectory(path: string): Promise<ResolvedWorks
220
224
  return resolveWorkspace({ strategy: "path-or-directory", path });
221
225
  }
222
226
 
227
+ /** Raised by workspaceForAnnotationPath for a path that does not exist on disk at all -- a distinct, explicit failure the caller must handle, never silently guessed as "must be a file, take its dirname()." */
228
+ export class AnnotationPathDoesNotExist extends Error {
229
+ constructor(readonly path: string) {
230
+ super(`"${path}" does not exist -- a symbol-annotation scope must be a real project directory or an existing source file`);
231
+ this.name = "AnnotationPathDoesNotExist";
232
+ }
233
+ }
234
+
235
+ /**
236
+ * For symbol-annotation's own `path` parameter, which genuinely means "which workspace does this
237
+ * belong to" and can honestly be either an existing project directory or one specific source
238
+ * file -- unlike workspaceForCodeIntelligencePath (files only, dirname() unconditional), a real
239
+ * project directory resolves via its own language markers, not its parent's. A genuinely
240
+ * nonexistent path throws AnnotationPathDoesNotExist rather than being silently treated as a file.
241
+ */
242
+ export async function workspaceForAnnotationPath(path: string): Promise<ResolvedWorkspace> {
243
+ const request: WorkspaceResolutionRequest = { strategy: "code-intelligence-path-or-directory", path };
244
+ const key = requestCacheKey(request);
245
+ const cached = resolutionCache.get(key);
246
+ if (cached) return cached;
247
+ const client = await lectorClient();
248
+ const outcome = await client.callOnce("workspace.resolvePath", request);
249
+ if (!outcome.found) {
250
+ if (outcome.reason === "nonexistent-path") throw new AnnotationPathDoesNotExist(path);
251
+ throw new Error(`workspace.resolvePath unexpectedly reported not-found for code-intelligence-path-or-directory: ${path}`);
252
+ }
253
+ const resolved: ResolvedWorkspace = { workspaceId: outcome.workspaceId, root: outcome.root };
254
+ resolutionCache.set(key, resolved);
255
+ if (outcome.created) onNewWorkspace?.(outcome.root);
256
+ return resolved;
257
+ }
258
+
223
259
  /**
224
260
  * The nearest ancestor of an already-resolved project root whose own package.json declares that
225
261
  * project as a workspace member via npm/yarn/bun's "workspaces" field -- undefined (no
@@ -1,5 +1,5 @@
1
1
  import type { OperationInputs, OperationOutputs } from "@danypops/lector";
2
- import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath } from "../lector-client.ts";
2
+ import { lectorClient, withWorkspace, workspaceForAnnotationPath } from "../lector-client.ts";
3
3
 
4
4
  /** A bare symbol position an anchor is given as -- symbolNodeId and the anchor's baseline file hash are derived server-side, never supplied by the caller. */
5
5
  export interface AnnotationAnchorInput {
@@ -9,10 +9,12 @@ export interface AnnotationAnchorInput {
9
9
  }
10
10
 
11
11
  /**
12
- * Thin wrappers over Lector's annotation operations. Anchored to a workspace
13
- * via the first anchor's own path (workspaceForCodeIntelligencePath) --
14
- * every operation that needs a workspace already has at least one real
15
- * anchor position or an id whose workspace the caller already knows.
12
+ * Thin wrappers over Lector's annotation operations. Every operation resolves its workspace from
13
+ * its own `path` parameter via workspaceForAnnotationPath -- a real project directory or an
14
+ * existing source file, never dirname()'d unconditionally the way a plain code-intelligence
15
+ * path is. `path` here means "which workspace this call belongs to," not necessarily one of the
16
+ * operation's own anchors (create/refresh's anchors carry their own, separately-validated paths
17
+ * server-side).
16
18
  */
17
19
  export interface SymbolAnnotationOperations {
18
20
  create(
@@ -46,7 +48,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
46
48
  return {
47
49
  async create(path, subtype, title, body, anchors) {
48
50
  return withWorkspace(
49
- () => workspaceForCodeIntelligencePath(path),
51
+ () => workspaceForAnnotationPath(path),
50
52
  async ({ workspaceId }) => {
51
53
  const client = await lectorClient();
52
54
  return client.callOnce("workspace.createAnnotation", { workspaceId, subtype, title, body, anchors });
@@ -55,7 +57,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
55
57
  },
56
58
  async get(path, id) {
57
59
  return withWorkspace(
58
- () => workspaceForCodeIntelligencePath(path),
60
+ () => workspaceForAnnotationPath(path),
59
61
  async ({ workspaceId }) => {
60
62
  const client = await lectorClient();
61
63
  return client.call("workspace.getAnnotation", { workspaceId, id });
@@ -64,7 +66,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
64
66
  },
65
67
  async list(path, options = {}) {
66
68
  return withWorkspace(
67
- () => workspaceForCodeIntelligencePath(path),
69
+ () => workspaceForAnnotationPath(path),
68
70
  async ({ workspaceId }) => {
69
71
  const client = await lectorClient();
70
72
  return client.call("workspace.listAnnotations", {
@@ -79,7 +81,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
79
81
  },
80
82
  async refresh(path, id, subtype, title, body, anchors) {
81
83
  return withWorkspace(
82
- () => workspaceForCodeIntelligencePath(path),
84
+ () => workspaceForAnnotationPath(path),
83
85
  async ({ workspaceId }) => {
84
86
  const client = await lectorClient();
85
87
  return client.callOnce("workspace.refreshAnnotation", { workspaceId, id, subtype, title, body, anchors });
@@ -88,7 +90,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
88
90
  },
89
91
  async scrub(path, id) {
90
92
  return withWorkspace(
91
- () => workspaceForCodeIntelligencePath(path),
93
+ () => workspaceForAnnotationPath(path),
92
94
  async ({ workspaceId }) => {
93
95
  const client = await lectorClient();
94
96
  return client.callOnce("workspace.scrubAnnotation", { workspaceId, id });
@@ -97,7 +99,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
97
99
  },
98
100
  async restore(path, id) {
99
101
  return withWorkspace(
100
- () => workspaceForCodeIntelligencePath(path),
102
+ () => workspaceForAnnotationPath(path),
101
103
  async ({ workspaceId }) => {
102
104
  const client = await lectorClient();
103
105
  return client.callOnce("workspace.restoreAnnotation", { workspaceId, id });
@@ -106,7 +108,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
106
108
  },
107
109
  async contain(path, parentId, childId) {
108
110
  return withWorkspace(
109
- () => workspaceForCodeIntelligencePath(path),
111
+ () => workspaceForAnnotationPath(path),
110
112
  async ({ workspaceId }) => {
111
113
  const client = await lectorClient();
112
114
  return client.callOnce("workspace.containAnnotation", { workspaceId, parentId, childId });
@@ -115,7 +117,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
115
117
  },
116
118
  async uncontain(path, parentId, childId) {
117
119
  return withWorkspace(
118
- () => workspaceForCodeIntelligencePath(path),
120
+ () => workspaceForAnnotationPath(path),
119
121
  async ({ workspaceId }) => {
120
122
  const client = await lectorClient();
121
123
  return client.callOnce("workspace.uncontainAnnotation", { workspaceId, parentId, childId });
@@ -124,7 +126,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
124
126
  },
125
127
  async tree(path, rootId, maxDepth) {
126
128
  return withWorkspace(
127
- () => workspaceForCodeIntelligencePath(path),
129
+ () => workspaceForAnnotationPath(path),
128
130
  async ({ workspaceId }) => {
129
131
  const client = await lectorClient();
130
132
  return client.call("workspace.annotationTree", { workspaceId, rootId, maxDepth });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.12.6",
3
+ "version": "0.12.7",
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,9 +18,9 @@
18
18
  "typebox": "*"
19
19
  },
20
20
  "dependencies": {
21
- "@danypops/vehicle-client": "^0.2.0",
21
+ "@danypops/vehicle-client": "^0.8.2",
22
22
  "@danypops/lector": "^0.18.0",
23
- "malevich-tui-components": "^0.19.0",
23
+ "malevich-tui-components": "^0.25.0",
24
24
  "picomatch": "^4.0.5"
25
25
  },
26
26
  "devDependencies": {