@danypops/pi-lector 0.12.12 → 0.12.14

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.
@@ -3,9 +3,13 @@ import { lectorClient, withWorkspace, workspaceForDirectory } from "../lector-cl
3
3
  import { invokeLectorVehicleOperation, type LectorVehicleCall } from "../vehicle-client.ts";
4
4
 
5
5
  type SymbolComparison = OperationOutputs["workspace.compareSymbolAcrossVersions"];
6
+ type GitWorktreeAddResult = OperationOutputs["workspace.gitWorktreeAdd"];
7
+ type GitWorktreeRemoveResult = OperationOutputs["workspace.gitWorktreeRemove"];
6
8
 
7
9
  /** Matches GIT_READ_PERMISSIONS' own declared value server-side (git/operation-registration.ts). */
8
10
  const GIT_READ_PERMISSIONS = ["workspace:read"];
11
+ /** Matches GIT_WORKTREE_WRITE_PERMISSIONS' own declared value server-side (git/operation-registration.ts). */
12
+ const GIT_WORKTREE_WRITE_PERMISSIONS = ["workspace:write"];
9
13
 
10
14
  /**
11
15
  * Thin wrappers over Lector's read-only git operations. `directory` is
@@ -24,6 +28,15 @@ export interface GitOperations {
24
28
  log(directory: string, maxCount: number, call: LectorVehicleCall): Promise<readonly GitLogEntry[]>;
25
29
  diff(directory: string, ref: string | undefined, maxBytes: number, call: LectorVehicleCall): Promise<GitDiffResult>;
26
30
  compareSymbol(directory: string, path: string, symbolName: string, fromRef: string, toRef: string | undefined, maxBytes: number): Promise<SymbolComparison>;
31
+ /**
32
+ * Materializes a real, disposable, read-only project at `ref` via a detached git worktree.
33
+ * The returned `path` is a real directory every other pi-lector tool already accepts as its
34
+ * own `directory` argument (find_symbols, search_code, git itself, ...) -- no separate
35
+ * workspaceId ever needs to reach the agent.
36
+ */
37
+ worktreeAdd(directory: string, ref: string, forceRefresh: boolean | undefined, call: LectorVehicleCall): Promise<GitWorktreeAddResult>;
38
+ /** `directory` is the worktree's own path (worktreeAdd's returned `path`), not the source repo's -- it resolves to the exact same workspace via the same git-root walk every other tool already uses. */
39
+ worktreeRemove(directory: string, call: LectorVehicleCall): Promise<GitWorktreeRemoveResult>;
27
40
  }
28
41
 
29
42
  export function createLectorGitOperations(): GitOperations {
@@ -63,5 +76,24 @@ export function createLectorGitOperations(): GitOperations {
63
76
  },
64
77
  );
65
78
  },
79
+ async worktreeAdd(directory, ref, forceRefresh, call) {
80
+ return withWorkspace(
81
+ () => workspaceForDirectory(directory),
82
+ ({ workspaceId }) =>
83
+ invokeLectorVehicleOperation<GitWorktreeAddResult>(
84
+ "workspace.gitWorktreeAdd",
85
+ { workspaceId, ref, forceRefresh },
86
+ GIT_WORKTREE_WRITE_PERMISSIONS,
87
+ call,
88
+ ),
89
+ );
90
+ },
91
+ async worktreeRemove(directory, call) {
92
+ return withWorkspace(
93
+ () => workspaceForDirectory(directory),
94
+ ({ workspaceId }) =>
95
+ invokeLectorVehicleOperation<GitWorktreeRemoveResult>("workspace.gitWorktreeRemove", { workspaceId }, GIT_WORKTREE_WRITE_PERMISSIONS, call),
96
+ );
97
+ },
66
98
  };
67
99
  }
@@ -11,9 +11,11 @@ const DEFAULT_VISIBLE_FILES = 20;
11
11
  const DEFAULT_VISIBLE_COMMITS = 10;
12
12
  const DEFAULT_VISIBLE_DIFF_LINES = 60;
13
13
 
14
- export type GitAction = "status" | "log" | "diff" | "compare-symbol";
14
+ export type GitAction = "status" | "log" | "diff" | "compare-symbol" | "worktree-add" | "worktree-remove";
15
15
 
16
16
  type SymbolComparison = OperationOutputs["workspace.compareSymbolAcrossVersions"];
17
+ type GitWorktreeAddResult = OperationOutputs["workspace.gitWorktreeAdd"];
18
+ type GitWorktreeRemoveResult = OperationOutputs["workspace.gitWorktreeRemove"];
17
19
 
18
20
  export interface GitToolDetails {
19
21
  readonly action: GitAction;
@@ -21,6 +23,8 @@ export interface GitToolDetails {
21
23
  readonly entries?: readonly GitLogEntry[];
22
24
  readonly result?: GitDiffResult;
23
25
  readonly comparison?: SymbolComparison;
26
+ readonly worktreeAdd?: GitWorktreeAddResult;
27
+ readonly worktreeRemove?: GitWorktreeRemoveResult;
24
28
  }
25
29
 
26
30
  export function formatGitCall(
@@ -40,11 +44,24 @@ export function formatGitCall(
40
44
  return `${theme.fg("toolTitle", theme.bold("git"))} ${theme.fg("muted", action)} ${theme.fg("accent", directory)}${ref}`;
41
45
  }
42
46
 
47
+ function formatGitWorktreeAddResult(result: GitWorktreeAddResult | undefined, theme: LectorTheme): string {
48
+ if (!result) return theme.fg("dim", "No result.");
49
+ const verb = result.created ? "created" : "reused existing";
50
+ return [theme.fg("accent", `${verb} worktree at ${result.ref} (${result.commit.slice(0, 8)})`), theme.fg("muted", result.path)].join("\n");
51
+ }
52
+
53
+ function formatGitWorktreeRemoveResult(result: GitWorktreeRemoveResult | undefined, theme: LectorTheme): string {
54
+ if (!result) return theme.fg("dim", "No result.");
55
+ return theme.fg("accent", "worktree removed");
56
+ }
57
+
43
58
  export function formatGitResult(details: GitToolDetails | undefined, expanded: boolean, theme: LectorTheme): string {
44
59
  if (!details) return theme.fg("dim", "No result.");
45
60
  if (details.action === "status") return formatGitStatusResult(details.summary, expanded, theme);
46
61
  if (details.action === "log") return formatGitLogResult(details.entries, expanded, theme);
47
62
  if (details.action === "compare-symbol") return formatCompareSymbolResult(details.comparison, expanded, theme);
63
+ if (details.action === "worktree-add") return formatGitWorktreeAddResult(details.worktreeAdd, theme);
64
+ if (details.action === "worktree-remove") return formatGitWorktreeRemoveResult(details.worktreeRemove, theme);
48
65
  return formatGitDiffResult(details.result, expanded, theme);
49
66
  }
50
67
 
@@ -216,7 +216,7 @@ export default function (pi: ExtensionAPI) {
216
216
  * first touches. Refuses a bare filesystem root outright: workspaceForPath's own
217
217
  * intentional fallback for a raw read/write of a file outside any git repo can register
218
218
  * exactly this as a "new workspace", and auto-populating it would attempt a full
219
- * filesystem-wide symbol-graph scan -- confirmed live as a real, previously-shipped bug.
219
+ * filesystem-wide symbol-graph scan.
220
220
  *
221
221
  * Also short-circuits a broad host directory (home directory, an XDG config/cache/data
222
222
  * root, a dotfile directory) the exact same way workspace.populateSymbolGraph's own
@@ -1246,17 +1246,21 @@ export default function (pi: ExtensionAPI) {
1246
1246
  name: "git",
1247
1247
  label: "Git",
1248
1248
  description:
1249
- "Working tree status, recent commit log, unified diff, and one symbol's own declaration diff across two versions, for a real git repository, in one tool. Fails clearly if `directory` is not inside a git repository. ACTIONS: status (working tree state, ahead/behind tracking), log (recent commits, bounded by maxCount), diff (unified diff against `ref`, defaulting to HEAD, bounded by maxBytes), compare-symbol (a named symbol's own declaration text diffed between fromRef and toRef, or fromRef and the current working tree when toRef is omitted -- tree-sitter syntactic tier only, TypeScript/JavaScript files only, no project-aware cross-reference resolution).",
1250
- promptSnippet: "Show a repository's status, log, diff, or one symbol's diff across versions",
1249
+ "Working tree status, recent commit log, unified diff, one symbol's own declaration diff across two versions, and a real disposable checkout at another ref, for a real git repository, in one tool. Fails clearly if `directory` is not inside a git repository. ACTIONS: status (working tree state, ahead/behind tracking), log (recent commits, bounded by maxCount), diff (unified diff against `ref`, defaulting to HEAD, bounded by maxBytes), compare-symbol (a named symbol's own declaration text diffed between fromRef and toRef, or fromRef and the current working tree when toRef is omitted -- tree-sitter syntactic tier only, TypeScript/JavaScript files only, no project-aware cross-reference resolution), worktree-add (materializes `ref` as a real, read-only project via a detached git worktree and returns its own `directory` -- pass that straight to find_symbols/search_code/this tool itself for full semantic queries against another branch/commit, not just text), worktree-remove (releases and deletes a worktree-add-created checkout -- `directory` is that checkout's own returned directory, not the source repo's).",
1250
+ promptSnippet: "Show a repository's status, log, diff, one symbol's diff across versions, or a real checkout at another ref",
1251
1251
  promptGuidelines: [
1252
1252
  "maxCount is required for action=log; maxBytes is required for action=diff/compare-symbol -- every bounded query needs its bound stated explicitly, never defaulted silently.",
1253
1253
  "path, symbol, and fromRef are required for action=compare-symbol; toRef is optional and means 'the current working tree' when omitted.",
1254
+ "ref is required for action=worktree-add. A repeated worktree-add for the same (directory, ref) reuses the existing checkout unless forceRefresh is set -- use that when ref is a branch that may have moved.",
1255
+ "action=worktree-remove's directory is worktree-add's own returned directory, never the source repo's -- always call it once done with a worktree to reclaim disk.",
1254
1256
  ],
1255
1257
  parameters: Type.Object({
1256
- action: Type.String({ description: "status | log | diff | compare-symbol" }),
1258
+ action: Type.String({ description: "status | log | diff | compare-symbol | worktree-add | worktree-remove" }),
1257
1259
  directory: Type.String({ description: "Directory inside the repository to check, absolute or relative to the current working directory" }),
1258
1260
  maxCount: Type.Optional(Type.Number({ description: "Maximum number of commits to return, most recent first -- required for action=log" })),
1259
- ref: Type.Optional(Type.String({ description: "Ref to diff against; defaults to HEAD -- only used for action=diff" })),
1261
+ ref: Type.Optional(
1262
+ Type.String({ description: "Ref to diff against (defaults to HEAD) for action=diff, or to check out for action=worktree-add (required)" }),
1263
+ ),
1260
1264
  maxBytes: Type.Optional(
1261
1265
  Type.Number({ description: "Maximum diff/comparison size in bytes before truncating -- required for action=diff/compare-symbol" }),
1262
1266
  ),
@@ -1266,6 +1270,11 @@ export default function (pi: ExtensionAPI) {
1266
1270
  toRef: Type.Optional(
1267
1271
  Type.String({ description: "Git ref for the 'after' version; omit to compare against the current working tree -- action=compare-symbol only" }),
1268
1272
  ),
1273
+ forceRefresh: Type.Optional(
1274
+ Type.Boolean({
1275
+ description: "action=worktree-add only: recreate an already-reused worktree at ref's current tip instead of returning the existing one",
1276
+ }),
1277
+ ),
1269
1278
  }),
1270
1279
  async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<GitToolDetails>> {
1271
1280
  const directory = resolve(cwd, params.directory);
@@ -1296,6 +1305,17 @@ export default function (pi: ExtensionAPI) {
1296
1305
  const details: GitToolDetails = { action: "compare-symbol", comparison };
1297
1306
  return { content: [{ type: "text", text: JSON.stringify(comparison) }], details };
1298
1307
  }
1308
+ if (params.action === "worktree-add") {
1309
+ if (!params.ref) throw new Error("git action=worktree-add requires ref");
1310
+ const worktreeAdd = await gitOperations.worktreeAdd(directory, params.ref, params.forceRefresh, vehicleCall);
1311
+ const details: GitToolDetails = { action: "worktree-add", worktreeAdd };
1312
+ return { content: [{ type: "text", text: JSON.stringify(worktreeAdd) }], details };
1313
+ }
1314
+ if (params.action === "worktree-remove") {
1315
+ const worktreeRemove = await gitOperations.worktreeRemove(directory, vehicleCall);
1316
+ const details: GitToolDetails = { action: "worktree-remove", worktreeRemove };
1317
+ return { content: [{ type: "text", text: JSON.stringify(worktreeRemove) }], details };
1318
+ }
1299
1319
  throw new Error(`unknown git action: ${String(params.action)}`);
1300
1320
  },
1301
1321
  renderCall(args, theme, context) {
@@ -1697,7 +1717,17 @@ export default function (pi: ExtensionAPI) {
1697
1717
  if (details?.action === "list") text.setText(formatPackageSourceListResult(details.page, theme));
1698
1718
  else if (details?.action === "remove") text.setText(formatPackageSourceRemoveResult(details.result, theme));
1699
1719
  else if (details?.action === "clean") text.setText(formatPackageSourceCleanResult(details.result, theme));
1700
- else text.setText(formatPackageSourceResult(details?.action === "resolve" ? details.result : undefined, expanded, theme));
1720
+ else if (details?.action === "resolve") text.setText(formatPackageSourceResult(details.result, expanded, theme));
1721
+ else {
1722
+ // A malformed/unknown-shaped details blob (e.g. a transcript row predating this
1723
+ // schema) has no recognized action -- fail closed to the model-facing content
1724
+ // channel first, only falling to the generic message if that's empty too.
1725
+ const contentText = result.content
1726
+ .filter((block) => block.type === "text")
1727
+ .map((block) => block.text)
1728
+ .join("\n");
1729
+ text.setText(contentText || formatPackageSourceResult(undefined, expanded, theme));
1730
+ }
1701
1731
  return text;
1702
1732
  },
1703
1733
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.12.12",
3
+ "version": "0.12.14",
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",
@@ -30,6 +30,7 @@
30
30
  },
31
31
  "devDependencies": {
32
32
  "@danypops/vehicle-client-pi": "^0.43.0",
33
+ "@danypops/vehicle-conformance": "^0.4.0",
33
34
  "@danypops/pi-extension-harness": "^0.2.0",
34
35
  "@danypops/pi-tui-harness": "^0.0.1",
35
36
  "@earendil-works/pi-ai": "^0.81.1",