@danypops/pi-lector 0.13.11 → 0.15.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.
@@ -4,7 +4,6 @@ import { resolve } from "node:path";
4
4
  import type {
5
5
  CachedRepositoryPage,
6
6
  ContentHash,
7
- ContextBundleResult,
8
7
  Diagnostic,
9
8
  DocumentSymbolEntry,
10
9
  EditOutcome,
@@ -83,6 +82,7 @@ import {
83
82
  formatWorkspaceMapCall,
84
83
  formatWorkspaceMapResult,
85
84
  } from "./code-intelligence/rendering.ts";
85
+ import { formatFindSymbolsAcrossProjectsModelContent, formatSearchTextAcrossProjectsModelContent } from "./cross-workspace-search/model-content.ts";
86
86
  import { type CrossWorkspaceOutcome, createLectorCrossWorkspaceSearchOperations } from "./cross-workspace-search/operations.ts";
87
87
  import { formatCrossWorkspaceCall, formatFindSymbolsAcrossProjectsResult, formatSearchTextAcrossProjectsResult } from "./cross-workspace-search/rendering.ts";
88
88
  import { createLectorEditOperations } from "./edit/operations.ts";
@@ -121,8 +121,16 @@ import {
121
121
  PACKAGE_SOURCE_LIST_VISIBLE_ROWS,
122
122
  packageSourceListMoreLine,
123
123
  } from "./package-source/rendering.ts";
124
+ import { formatSemanticModelContent } from "./presentation/model-content.ts";
125
+ import { withLectorPresentation } from "./presentation/presentation-contract.ts";
126
+ import { presentationTitle } from "./presentation/tool-presentation.ts";
124
127
  import { createLectorReadOperations } from "./read/operations.ts";
125
128
  import { createReferenceBasedRenameOperations } from "./reference-based-rename/operations.ts";
129
+ import {
130
+ formatReferenceBasedRenameModelContent,
131
+ formatReferenceBasedRenameResult,
132
+ type ReferenceBasedRenameOutcome,
133
+ } from "./reference-based-rename/rendering.ts";
126
134
  import { createRenameOperations } from "./rename/operations.ts";
127
135
  import { createRepoCacheEvictOperations } from "./repo-cache/evict-operations.ts";
128
136
  import { createRepoCacheListOperations } from "./repo-cache/list-operations.ts";
@@ -152,7 +160,13 @@ import {
152
160
  monitorWorkspaceCache,
153
161
  waitForJobCompletion,
154
162
  } from "./workspace-cache/operations.ts";
155
- import { formatJobSnapshotResult, formatWorkspaceCacheCall, formatWorkspaceCacheStatusResult } from "./workspace-cache/rendering.ts";
163
+ import {
164
+ formatJobSnapshotResult,
165
+ formatWorkspaceCacheCall,
166
+ formatWorkspaceCacheStatusResult,
167
+ formatWorkspaceReleaseModelContent,
168
+ formatWorkspaceReleaseResult,
169
+ } from "./workspace-cache/rendering.ts";
156
170
  import { createLectorWriteOperations } from "./write/operations.ts";
157
171
 
158
172
  function describeIntelligenceSource(provenance: IntelligenceProvenance): string {
@@ -189,7 +203,7 @@ export default function (pi: ExtensionAPI) {
189
203
  const customToolNames = new Set<string>();
190
204
  function registerLectorTool<TParams extends TSchema, TDetails = unknown, TState = unknown>(tool: ToolDefinition<TParams, TDetails, TState>): void {
191
205
  customToolNames.add(tool.name);
192
- pi.registerTool(tool);
206
+ pi.registerTool(withLectorPresentation(tool));
193
207
  }
194
208
  pi.on("tool_result", (event) => {
195
209
  if (!customToolNames.has(event.toolName)) return;
@@ -471,7 +485,11 @@ export default function (pi: ExtensionAPI) {
471
485
  return { content: [{ type: "text", text }], details: result };
472
486
  },
473
487
  renderCall(args, theme) {
474
- return new Text(theme.fg("toolTitle", `localize ${typeof args.query === "string" ? args.query : "context"}`), 0, 0);
488
+ return new Text(
489
+ `${theme.fg("toolTitle", theme.bold(presentationTitle("localize_context")))} ${theme.fg("accent", typeof args.query === "string" ? `"${args.query}"` : "")}`,
490
+ 0,
491
+ 0,
492
+ );
475
493
  },
476
494
  renderResult(result, { isPartial }, theme, context) {
477
495
  if (isPartial) return new Text(theme.fg("warning", "Localizing..."), 0, 0);
@@ -482,8 +500,11 @@ export default function (pi: ExtensionAPI) {
482
500
  .join("\n");
483
501
  return new Text(theme.fg("error", errorText || "localize_context failed"), 0, 0);
484
502
  }
485
- const details = result.details as ContextBundleResult | undefined;
486
- return new Text(details ? `${details.candidates.length} candidates · graph ${details.completeness.graph}` : "Localization complete", 0, 0);
503
+ const contentText = result.content
504
+ .filter((block) => block.type === "text")
505
+ .map((block) => block.text)
506
+ .join("\n");
507
+ return new Text(contentText || "Localization complete", 0, 0);
487
508
  },
488
509
  });
489
510
 
@@ -1181,21 +1202,14 @@ export default function (pi: ExtensionAPI) {
1181
1202
  const fromPath = resolve(cwd, params.fromPath);
1182
1203
  const toPath = resolve(cwd, params.toPath);
1183
1204
  const outcome = await referenceBasedRenameOperations.rename(fromPath, toPath, params.maxFiles, params.maxSymbolsPerFile);
1184
- const lines = [
1185
- `moved to ${outcome.movedTo}`,
1186
- outcome.filesUpdated.length === 0
1187
- ? "no other files referenced it"
1188
- : `updated imports in ${outcome.filesUpdated.length} file(s): ${outcome.filesUpdated.join(", ")}`,
1189
- ...outcome.caveats.map((caveat) => `caveat: ${caveat}`),
1190
- ];
1191
- return { content: [{ type: "text", text: lines.join("\n") }], details: { outcome } };
1205
+ return { content: [{ type: "text", text: formatReferenceBasedRenameModelContent(outcome) }], details: { outcome } };
1192
1206
  },
1193
1207
  renderCall(args, theme, context) {
1194
1208
  const fromPath = typeof args.fromPath === "string" ? args.fromPath : "";
1195
1209
  const toPath = typeof args.toPath === "string" ? args.toPath : "";
1196
1210
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1197
1211
  text.setText(
1198
- `${theme.fg("toolTitle", theme.bold("reference_based_rename"))} ${theme.fg("accent", fromPath)} ${theme.fg("dim", "->")} ${theme.fg("accent", toPath)}`,
1212
+ `${theme.fg("toolTitle", theme.bold(presentationTitle("reference_based_rename")))} ${theme.fg("accent", fromPath)} ${theme.fg("dim", "->")} ${theme.fg("accent", toPath)}`,
1199
1213
  );
1200
1214
  return text;
1201
1215
  },
@@ -1208,13 +1222,9 @@ export default function (pi: ExtensionAPI) {
1208
1222
  .join("\n");
1209
1223
  return new Text(theme.fg("error", errorText || "reference_based_rename failed"), 0, 0);
1210
1224
  }
1211
- const details = result.details as { outcome?: { movedTo: string; filesUpdated: readonly string[] } } | undefined;
1225
+ const details = result.details as { outcome?: ReferenceBasedRenameOutcome } | undefined;
1212
1226
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1213
- text.setText(
1214
- details?.outcome
1215
- ? `${theme.fg("success", "moved")} ${theme.fg("accent", details.outcome.movedTo)} ${theme.fg("dim", `(${details.outcome.filesUpdated.length} import(s) updated)`)}`
1216
- : theme.fg("success", "rename complete"),
1217
- );
1227
+ text.setText(details?.outcome ? formatReferenceBasedRenameResult(details.outcome, theme) : theme.fg("success", "rename complete"));
1218
1228
  return text;
1219
1229
  },
1220
1230
  });
@@ -1260,7 +1270,7 @@ export default function (pi: ExtensionAPI) {
1260
1270
  const action = typeof args.action === "string" ? args.action : "";
1261
1271
  const path = typeof args.path === "string" ? args.path : "";
1262
1272
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1263
- text.setText(`${theme.fg("toolTitle", theme.bold("rename"))} ${theme.fg("dim", action)} ${theme.fg("accent", path)}`);
1273
+ text.setText(`${theme.fg("toolTitle", theme.bold(presentationTitle("rename", action)))} ${theme.fg("accent", path)}`);
1264
1274
  return text;
1265
1275
  },
1266
1276
  renderResult(result, { isPartial }, theme, context) {
@@ -1441,10 +1451,10 @@ export default function (pi: ExtensionAPI) {
1441
1451
  ? ` ${args.rootId}`
1442
1452
  : "";
1443
1453
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1444
- text.setText(`${theme.fg("toolTitle", theme.bold("symbol_annotations"))} ${theme.fg("accent", action)}${theme.fg("dim", id)}`);
1454
+ text.setText(`${theme.fg("toolTitle", theme.bold(presentationTitle("symbol_annotations", action)))}${theme.fg("accent", id)}`);
1445
1455
  return text;
1446
1456
  },
1447
- renderResult(result, { isPartial }, theme, context) {
1457
+ renderResult(result, { expanded, isPartial }, theme, context) {
1448
1458
  if (isPartial) return new Text(theme.fg("warning", "Working on annotation..."), 0, 0);
1449
1459
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1450
1460
  if (context.isError) {
@@ -1457,11 +1467,11 @@ export default function (pi: ExtensionAPI) {
1457
1467
  }
1458
1468
  const details = result.details as SymbolAnnotationToolDetails | undefined;
1459
1469
  if (details?.annotations) {
1460
- text.setText(formatAnnotationListSummary(details.annotations, theme));
1470
+ text.setText(expanded ? details.annotations.map(formatAnnotationDetail).join("\n\n") : formatAnnotationListSummary(details.annotations, theme));
1461
1471
  return text;
1462
1472
  }
1463
1473
  if (details?.annotation) {
1464
- text.setText(formatAnnotationSummary(details.annotation, theme));
1474
+ text.setText(expanded ? formatAnnotationDetail(details.annotation) : formatAnnotationSummary(details.annotation, theme));
1465
1475
  return text;
1466
1476
  }
1467
1477
  if (details?.scrubbed !== undefined) {
@@ -1595,25 +1605,27 @@ export default function (pi: ExtensionAPI) {
1595
1605
  });
1596
1606
 
1597
1607
  interface WorkspaceCacheToolDetails {
1598
- readonly action: "status" | "populate" | "wait" | "job_status";
1608
+ readonly action: "status" | "populate" | "wait" | "job_status" | "release";
1599
1609
  readonly status?: WorkspaceCacheStatus;
1600
1610
  readonly job?: JobSnapshot<PopulateSymbolGraphResult>;
1611
+ readonly release?: OperationOutputs["workspace.release"];
1601
1612
  }
1602
1613
 
1603
1614
  registerLectorTool({
1604
1615
  name: "workspace_cache",
1605
1616
  label: "Workspace Cache",
1606
1617
  description:
1607
- "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 work. action=populate requests a scan and briefly waits for fast completion; a source file changing mid-scan (e.g. a concurrent edit or rename) is retried automatically in the background for up to a minute before surfacing as a real failure, no manual re-run needed. action=wait subscribes to daemon job completion, with bounded status polling only when push delivery is unavailable. action=job_status is a point-in-time diagnostic read.",
1618
+ "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 work. action=populate requests a scan and briefly waits for fast completion; a source file changing mid-scan (e.g. a concurrent edit or rename) is retried automatically in the background for up to a minute before surfacing as a real failure, no manual re-run needed. action=wait subscribes to daemon job completion, with bounded status polling only when push delivery is unavailable. action=job_status is a point-in-time diagnostic read. action=release safely closes idle workspace resources and unregisters it so fetched/package source cleanup can proceed.",
1608
1619
  promptSnippet: "Check or force-populate the workspace's persisted symbol graph",
1609
1620
  promptGuidelines: [
1610
1621
  "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.",
1611
1622
  "When action=populate returns a queued/running job, call action=wait once with that jobId; do not run shell sleep or manually poll job_status.",
1623
+ "Use action=release after finishing with a fetched or package-source workspace so package_source remove/clean and repo_cache evict can reclaim it; active leases, jobs, or watches fail closed.",
1612
1624
  ],
1613
1625
  parameters: Type.Object({
1614
- action: Type.Union([Type.Literal("status"), Type.Literal("populate"), Type.Literal("wait"), Type.Literal("job_status")]),
1626
+ action: Type.Union([Type.Literal("status"), Type.Literal("populate"), Type.Literal("wait"), Type.Literal("job_status"), Type.Literal("release")]),
1615
1627
  directory: Type.Optional(
1616
- Type.String({ description: "Required for action=status/populate -- absolute or cwd-relative path used to resolve the workspace" }),
1628
+ Type.String({ description: "Required for action=status/populate/release -- absolute or cwd-relative path used to resolve the workspace" }),
1617
1629
  ),
1618
1630
  maxFiles: Type.Optional(
1619
1631
  Type.Number({ description: "action=status/populate only -- defaults to 500, the same bound the automatic first-touch scan uses" }),
@@ -1629,11 +1641,14 @@ export default function (pi: ExtensionAPI) {
1629
1641
  ),
1630
1642
  jobId: Type.Optional(Type.String({ description: "Required for action=wait/job_status -- a jobId returned by action=populate" })),
1631
1643
  }),
1632
- async execute(_toolCallId, params, signal): Promise<AgentToolResult<WorkspaceCacheToolDetails>> {
1644
+ async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<WorkspaceCacheToolDetails>> {
1633
1645
  if (params.action === "job_status") {
1634
1646
  if (!params.jobId) throw new Error("workspace_cache action=job_status requires jobId");
1635
1647
  const job = await cacheOperations.jobStatus(params.jobId);
1636
- return { content: [{ type: "text", text: JSON.stringify(job) }], details: { action: "job_status", job } };
1648
+ return {
1649
+ content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("workspace_cache", "job_status"), job) }],
1650
+ details: { action: "job_status", job },
1651
+ };
1637
1652
  }
1638
1653
  if (params.action === "wait") {
1639
1654
  if (!params.jobId) throw new Error("workspace_cache action=wait requires jobId");
@@ -1655,21 +1670,43 @@ export default function (pi: ExtensionAPI) {
1655
1670
  );
1656
1671
  }
1657
1672
  const job = outcome.kind === "terminal" ? outcome.job : await cacheOperations.jobStatus(params.jobId);
1658
- return { content: [{ type: "text", text: JSON.stringify(job) }], details: { action: "wait", job } };
1673
+ return {
1674
+ content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("workspace_cache", "wait"), job) }],
1675
+ details: { action: "wait", job },
1676
+ };
1659
1677
  }
1660
1678
  if (!params.directory) throw new Error(`workspace_cache action=${params.action} requires directory`);
1661
1679
  const directory = resolve(cwd, params.directory);
1680
+ if (params.action === "release") {
1681
+ const release = await cacheOperations.release(directory, {
1682
+ toolName: "workspace_cache",
1683
+ toolCallId,
1684
+ signal,
1685
+ context: ctx,
1686
+ });
1687
+ return {
1688
+ content: [{ type: "text", text: formatWorkspaceReleaseModelContent(release) }],
1689
+ details: { action: "release", release },
1690
+ };
1691
+ }
1662
1692
  const maxFiles = params.maxFiles ?? 500;
1663
1693
  const maxSymbolsPerFile = params.maxSymbolsPerFile ?? 100;
1664
1694
  if (params.action === "status") {
1665
1695
  const status = await cacheOperations.status(directory, maxFiles, maxSymbolsPerFile);
1666
- return { content: [{ type: "text", text: JSON.stringify(status) }], details: { action: "status", status } };
1696
+ return {
1697
+ content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("workspace_cache", "status"), status) }],
1698
+ details: { action: "status", status },
1699
+ };
1667
1700
  }
1668
1701
  const job = await cacheOperations.submit(directory, maxFiles, maxSymbolsPerFile, params.waitMs ?? 3_000);
1669
- return { content: [{ type: "text", text: JSON.stringify(job) }], details: { action: "populate", job } };
1702
+ return {
1703
+ content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("workspace_cache", "populate"), job) }],
1704
+ details: { action: "populate", job },
1705
+ };
1670
1706
  },
1671
1707
  renderCall(args, theme, context) {
1672
- const action = args.action === "populate" || args.action === "wait" || args.action === "job_status" ? args.action : "status";
1708
+ const action =
1709
+ args.action === "populate" || args.action === "wait" || args.action === "job_status" || args.action === "release" ? args.action : "status";
1673
1710
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1674
1711
  text.setText(formatWorkspaceCacheCall(action, args, theme));
1675
1712
  return text;
@@ -1685,7 +1722,13 @@ export default function (pi: ExtensionAPI) {
1685
1722
  }
1686
1723
  const details = result.details as WorkspaceCacheToolDetails | undefined;
1687
1724
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1688
- text.setText(details?.action === "status" ? formatWorkspaceCacheStatusResult(details.status, theme) : formatJobSnapshotResult(details?.job, theme));
1725
+ text.setText(
1726
+ details?.action === "status"
1727
+ ? formatWorkspaceCacheStatusResult(details.status, theme)
1728
+ : details?.action === "release"
1729
+ ? formatWorkspaceReleaseResult(details.release, theme)
1730
+ : formatJobSnapshotResult(details?.job, theme),
1731
+ );
1689
1732
  return text;
1690
1733
  },
1691
1734
  });
@@ -1764,7 +1807,7 @@ export default function (pi: ExtensionAPI) {
1764
1807
  if (params.action === "status") {
1765
1808
  const summary = await gitOperations.status(directory, vehicleCall);
1766
1809
  const details: GitToolDetails = { action: "status", summary };
1767
- return { content: [{ type: "text", text: JSON.stringify(summary) }], details };
1810
+ return { content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("git", "status"), summary) }], details };
1768
1811
  }
1769
1812
  if (params.action === "log") {
1770
1813
  if (params.maxCount === undefined) throw new Error("git action=log requires maxCount");
@@ -1785,18 +1828,18 @@ export default function (pi: ExtensionAPI) {
1785
1828
  if (params.maxBytes === undefined) throw new Error("git action=compare-symbol requires maxBytes");
1786
1829
  const comparison = await gitOperations.compareSymbol(directory, params.path, params.symbol, params.fromRef, params.toRef, params.maxBytes);
1787
1830
  const details: GitToolDetails = { action: "compare-symbol", comparison };
1788
- return { content: [{ type: "text", text: JSON.stringify(comparison) }], details };
1831
+ return { content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("git", "compare-symbol"), comparison) }], details };
1789
1832
  }
1790
1833
  if (params.action === "worktree-add") {
1791
1834
  if (!params.ref) throw new Error("git action=worktree-add requires ref");
1792
1835
  const worktreeAdd = await gitOperations.worktreeAdd(directory, params.ref, params.forceRefresh, vehicleCall);
1793
1836
  const details: GitToolDetails = { action: "worktree-add", worktreeAdd };
1794
- return { content: [{ type: "text", text: JSON.stringify(worktreeAdd) }], details };
1837
+ return { content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("git", "worktree-add"), worktreeAdd) }], details };
1795
1838
  }
1796
1839
  if (params.action === "worktree-remove") {
1797
1840
  const worktreeRemove = await gitOperations.worktreeRemove(directory, vehicleCall);
1798
1841
  const details: GitToolDetails = { action: "worktree-remove", worktreeRemove };
1799
- return { content: [{ type: "text", text: JSON.stringify(worktreeRemove) }], details };
1842
+ return { content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("git", "worktree-remove"), worktreeRemove) }], details };
1800
1843
  }
1801
1844
  if (params.action === "show") {
1802
1845
  if (!params.ref || !params.path) throw new Error("git action=show requires ref and path");
@@ -1809,7 +1852,7 @@ export default function (pi: ExtensionAPI) {
1809
1852
  if (params.maxMatches === undefined || params.maxBytes === undefined) throw new Error("git action=grep-ref requires maxMatches and maxBytes");
1810
1853
  const grep = await gitOperations.grep(directory, params.ref, params.pattern, params.pathspecs, params.maxMatches, params.maxBytes, vehicleCall);
1811
1854
  const details: GitToolDetails = { action: "grep-ref", grep };
1812
- return { content: [{ type: "text", text: JSON.stringify(grep) }], details };
1855
+ return { content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("git", "grep-ref"), grep) }], details };
1813
1856
  }
1814
1857
  if (params.action === "grep-history") {
1815
1858
  if (!params.pattern) throw new Error("git action=grep-history requires pattern");
@@ -1835,20 +1878,20 @@ export default function (pi: ExtensionAPI) {
1835
1878
  vehicleCall,
1836
1879
  );
1837
1880
  const details: GitToolDetails = { action: "grep-history", historyGrep };
1838
- return { content: [{ type: "text", text: JSON.stringify(historyGrep) }], details };
1881
+ return { content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("git", "grep-history"), historyGrep) }], details };
1839
1882
  }
1840
1883
  if (params.action === "ls-ref") {
1841
1884
  if (!params.ref) throw new Error("git action=ls-ref requires ref");
1842
1885
  if (params.maxResults === undefined) throw new Error("git action=ls-ref requires maxResults");
1843
1886
  const listFiles = await gitOperations.listFiles(directory, params.ref, params.pathspecs, params.maxResults, vehicleCall);
1844
1887
  const details: GitToolDetails = { action: "ls-ref", listFiles };
1845
- return { content: [{ type: "text", text: JSON.stringify(listFiles) }], details };
1888
+ return { content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("git", "ls-ref"), listFiles) }], details };
1846
1889
  }
1847
1890
  if (params.action === "is-ancestor") {
1848
1891
  if (!params.ancestorRef || !params.ref) throw new Error("git action=is-ancestor requires ancestorRef and ref");
1849
1892
  const result = await gitOperations.isAncestor(directory, params.ancestorRef, params.ref, vehicleCall);
1850
1893
  const details: GitToolDetails = { action: "is-ancestor", isAncestor: { ancestorRef: params.ancestorRef, ref: params.ref, result } };
1851
- return { content: [{ type: "text", text: JSON.stringify({ isAncestor: result }) }], details };
1894
+ return { content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("git", "is-ancestor"), { isAncestor: result }) }], details };
1852
1895
  }
1853
1896
  throw new Error(`unknown git action: ${String(params.action)}`);
1854
1897
  },
@@ -2134,7 +2177,7 @@ export default function (pi: ExtensionAPI) {
2134
2177
  const action = typeof args.action === "string" ? args.action : "";
2135
2178
  const path = typeof args.path === "string" ? args.path : "";
2136
2179
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
2137
- text.setText(`${theme.fg("toolTitle", theme.bold("mutation_history"))} ${theme.fg("accent", action)} ${theme.fg("dim", path)}`);
2180
+ text.setText(`${theme.fg("toolTitle", theme.bold(presentationTitle("mutation_history", action)))} ${theme.fg("accent", path)}`);
2138
2181
  return text;
2139
2182
  },
2140
2183
  renderResult(result, { isPartial }, theme, context) {
@@ -2210,7 +2253,10 @@ export default function (pi: ExtensionAPI) {
2210
2253
  maxResults: params.maxResults,
2211
2254
  cursor: params.cursor,
2212
2255
  });
2213
- return { content: [{ type: "text", text: JSON.stringify(page) }], details: { action: "list", page } };
2256
+ return {
2257
+ content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("package_source", "list"), page) }],
2258
+ details: { action: "list", page },
2259
+ };
2214
2260
  }
2215
2261
  if (params.action === "remove") {
2216
2262
  if (!params.name || !params.resolvedVersion) throw new Error("package_source action=remove requires ecosystem, name, and resolvedVersion");
@@ -2220,11 +2266,17 @@ export default function (pi: ExtensionAPI) {
2220
2266
  params.name,
2221
2267
  params.resolvedVersion,
2222
2268
  );
2223
- return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "remove", result } };
2269
+ return {
2270
+ content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("package_source", "remove"), result) }],
2271
+ details: { action: "remove", result },
2272
+ };
2224
2273
  }
2225
2274
  if (params.action === "clean") {
2226
2275
  const result = await packageSourceOperations.clean(optionalPackageEcosystem(params.ecosystem));
2227
- return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "clean", result } };
2276
+ return {
2277
+ content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("package_source", "clean"), result) }],
2278
+ details: { action: "clean", result },
2279
+ };
2228
2280
  }
2229
2281
  if (!params.directory || !params.name) throw new Error("package_source action=resolve requires directory and name");
2230
2282
  const directory = resolve(cwd, params.directory);
@@ -2235,7 +2287,10 @@ export default function (pi: ExtensionAPI) {
2235
2287
  params.registry ?? null,
2236
2288
  optionalPackageEcosystem(params.ecosystem),
2237
2289
  );
2238
- return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "resolve", result } };
2290
+ return {
2291
+ content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("package_source", "resolve"), result) }],
2292
+ details: { action: "resolve", result },
2293
+ };
2239
2294
  },
2240
2295
  renderCall(args, theme, context) {
2241
2296
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
@@ -2332,12 +2387,18 @@ export default function (pi: ExtensionAPI) {
2332
2387
  params.forceRefresh,
2333
2388
  vehicleCall,
2334
2389
  );
2335
- return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "fetch", result } };
2390
+ return {
2391
+ content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("repo_cache", "fetch"), result) }],
2392
+ details: { action: "fetch", result },
2393
+ };
2336
2394
  }
2337
2395
  if (params.action === "evict") {
2338
2396
  if (!params.owner || !params.repo) throw new Error("repo_cache action=evict requires owner and repo");
2339
2397
  const result = await repoCacheEvictOperations.evict(params.host ?? "github.com", params.owner, params.repo, params.ref ?? null, vehicleCall);
2340
- return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "evict", result } };
2398
+ return {
2399
+ content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("repo_cache", "evict"), result) }],
2400
+ details: { action: "evict", result },
2401
+ };
2341
2402
  }
2342
2403
  if (params.maxResults === undefined) throw new Error("repo_cache action=list requires maxResults");
2343
2404
  const page = await repoCacheListOperations.list(
@@ -2346,7 +2407,10 @@ export default function (pi: ExtensionAPI) {
2346
2407
  params.cursor,
2347
2408
  vehicleCall,
2348
2409
  );
2349
- return { content: [{ type: "text", text: JSON.stringify(page) }], details: { action: "list", page } };
2410
+ return {
2411
+ content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("repo_cache", "list"), page) }],
2412
+ details: { action: "list", page },
2413
+ };
2350
2414
  },
2351
2415
  renderCall(args, theme, context) {
2352
2416
  const action = args.action === "list" || args.action === "evict" ? args.action : "fetch";
@@ -2419,14 +2483,23 @@ export default function (pi: ExtensionAPI) {
2419
2483
  };
2420
2484
  if (params.action === "github_repos") {
2421
2485
  const result = await externalSearchOperations.githubRepos(params.query, maxResults, vehicleCall);
2422
- return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "github_repos", result } };
2486
+ return {
2487
+ content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("external_search", "github_repos"), result) }],
2488
+ details: { action: "github_repos", result },
2489
+ };
2423
2490
  }
2424
2491
  if (params.action === "npm_packages") {
2425
2492
  const result = await externalSearchOperations.npmPackages(params.query, maxResults, vehicleCall);
2426
- return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "npm_packages", result } };
2493
+ return {
2494
+ content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("external_search", "npm_packages"), result) }],
2495
+ details: { action: "npm_packages", result },
2496
+ };
2427
2497
  }
2428
2498
  const result = await externalSearchOperations.sourcegraphCode(params.query, maxResults, vehicleCall);
2429
- return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "sourcegraph_code", result } };
2499
+ return {
2500
+ content: [{ type: "text", text: formatSemanticModelContent(presentationTitle("external_search", "sourcegraph_code"), result) }],
2501
+ details: { action: "sourcegraph_code", result },
2502
+ };
2430
2503
  },
2431
2504
  renderCall(args, theme, context) {
2432
2505
  const action = args.action === "npm_packages" || args.action === "sourcegraph_code" ? args.action : "github_repos";
@@ -2434,7 +2507,7 @@ export default function (pi: ExtensionAPI) {
2434
2507
  text.setText(formatExternalSearchCall(action, args, theme));
2435
2508
  return text;
2436
2509
  },
2437
- renderResult(result, { isPartial }, theme, context) {
2510
+ renderResult(result, { expanded, isPartial }, theme, context) {
2438
2511
  if (isPartial) return new Text(theme.fg("warning", "Searching..."), 0, 0);
2439
2512
  if (context.isError) {
2440
2513
  const errorText = result.content
@@ -2445,9 +2518,9 @@ export default function (pi: ExtensionAPI) {
2445
2518
  }
2446
2519
  const details = result.details as ExternalSearchToolDetails | undefined;
2447
2520
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
2448
- if (details?.action === "npm_packages") text.setText(formatNpmPackageSearchResult(details.result, theme));
2449
- else if (details?.action === "sourcegraph_code") text.setText(formatSourcegraphCodeSearchResult(details.result, theme));
2450
- else text.setText(formatGithubRepoSearchResult(details?.action === "github_repos" ? details.result : undefined, theme));
2521
+ if (details?.action === "npm_packages") text.setText(formatNpmPackageSearchResult(details.result, expanded, theme));
2522
+ else if (details?.action === "sourcegraph_code") text.setText(formatSourcegraphCodeSearchResult(details.result, expanded, theme));
2523
+ else text.setText(formatGithubRepoSearchResult(details?.action === "github_repos" ? details.result : undefined, expanded, theme));
2451
2524
  return text;
2452
2525
  },
2453
2526
  });
@@ -2470,11 +2543,14 @@ export default function (pi: ExtensionAPI) {
2470
2543
  async execute(_toolCallId, params) {
2471
2544
  const directories = params.directories.map((directory) => resolve(cwd, directory));
2472
2545
  const results = await crossWorkspaceSearchOperations.findSymbols(params.query, directories, params.timeoutMs, params.maxResults);
2473
- return { content: [{ type: "text", text: JSON.stringify(results) }], details: { results } };
2546
+ return {
2547
+ content: [{ type: "text", text: formatFindSymbolsAcrossProjectsModelContent(results) }],
2548
+ details: { results },
2549
+ };
2474
2550
  },
2475
2551
  renderCall(args, theme, context) {
2476
2552
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
2477
- text.setText(formatCrossWorkspaceCall(args, theme));
2553
+ text.setText(formatCrossWorkspaceCall("find_symbols_across_projects", args, theme));
2478
2554
  return text;
2479
2555
  },
2480
2556
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -2509,11 +2585,14 @@ export default function (pi: ExtensionAPI) {
2509
2585
  async execute(_toolCallId, params) {
2510
2586
  const directories = params.directories.map((directory) => resolve(cwd, directory));
2511
2587
  const results = await crossWorkspaceSearchOperations.searchText(params.query, directories, params.maxMatches, params.maxBytes, params.timeoutMs);
2512
- return { content: [{ type: "text", text: JSON.stringify(results) }], details: { results } };
2588
+ return {
2589
+ content: [{ type: "text", text: formatSearchTextAcrossProjectsModelContent(results) }],
2590
+ details: { results },
2591
+ };
2513
2592
  },
2514
2593
  renderCall(args, theme, context) {
2515
2594
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
2516
- text.setText(formatCrossWorkspaceCall(args, theme));
2595
+ text.setText(formatCrossWorkspaceCall("search_code_across_projects", args, theme));
2517
2596
  return text;
2518
2597
  },
2519
2598
  renderResult(result, { expanded, isPartial }, theme, context) {
@@ -1,10 +1,11 @@
1
1
  import type { LineEditOutcome } from "@danypops/lector";
2
2
  import type { LectorTheme } from "../lector-tui-theme.ts";
3
+ import { presentationTitle } from "../presentation/tool-presentation.ts";
3
4
 
4
5
  export function formatLineEditCall(args: { path?: unknown; edits?: unknown }, theme: LectorTheme): string {
5
6
  const path = typeof args.path === "string" ? args.path : "";
6
7
  const count = Array.isArray(args.edits) ? args.edits.length : 0;
7
- return `${theme.fg("toolTitle", theme.bold("line_edit"))} ${theme.fg("accent", path)} ${theme.fg("dim", `(${count} edit${count === 1 ? "" : "s"})`)}`;
8
+ return `${theme.fg("toolTitle", theme.bold(presentationTitle("line_edit")))} ${theme.fg("accent", path)} ${theme.fg("dim", `(${count} edit${count === 1 ? "" : "s"})`)}`;
8
9
  }
9
10
 
10
11
  export function formatLineEditResult(result: LineEditOutcome | undefined, theme: LectorTheme): string {
@@ -2,6 +2,7 @@ import type { PackageSourceListEntry, PackageSourceOperationResult } from "@dany
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
3
  import { renderTruncatedList, type TableColumn } from "malevich-tui-components";
4
4
  import type { LectorTheme } from "../lector-tui-theme.ts";
5
+ import { presentationTitle } from "../presentation/tool-presentation.ts";
5
6
 
6
7
  const DEFAULT_VISIBLE_CANDIDATES = 5;
7
8
 
@@ -14,18 +15,18 @@ export function formatPackageSourceCall(
14
15
  args: { action?: unknown; directory?: unknown; name?: unknown; version?: unknown; ecosystem?: unknown; resolvedVersion?: unknown; text?: unknown },
15
16
  theme: LectorTheme,
16
17
  ): string {
17
- const label = theme.fg("toolTitle", theme.bold("package_source"));
18
18
  const action: PackageSourceAction = args.action === "list" || args.action === "remove" || args.action === "clean" ? args.action : "resolve";
19
+ const label = theme.fg("toolTitle", theme.bold(presentationTitle("package_source", action)));
19
20
  if (action === "list") {
20
21
  const text = typeof args.text === "string" && args.text.length > 0 ? ` ${theme.fg("dim", args.text)}` : "";
21
- return `${label} ${theme.fg("accent", "list")}${text}`;
22
+ return `${label}${text}`;
22
23
  }
23
24
  if (action === "remove" || action === "clean") {
24
25
  const name = typeof args.name === "string" ? args.name : "";
25
26
  const version = typeof args.resolvedVersion === "string" ? `@${args.resolvedVersion}` : "";
26
27
  const ecosystem = typeof args.ecosystem === "string" ? args.ecosystem : "";
27
28
  const identity = name ? `${name}${version}` : ecosystem;
28
- return `${label} ${theme.fg("accent", action)}${identity ? ` ${theme.fg("dim", identity)}` : ""}`;
29
+ return `${label}${identity ? ` ${theme.fg("accent", identity)}` : ""}`;
29
30
  }
30
31
  const name = typeof args.name === "string" ? args.name : "";
31
32
  const version = typeof args.version === "string" ? `@${args.version}` : "";
@@ -0,0 +1,62 @@
1
+ export const DEFAULT_MODEL_CONTENT_BYTES = 32_768;
2
+ const MAX_COLLECTION_ENTRIES = 24;
3
+ const MAX_DEPTH = 4;
4
+
5
+ function scalarText(value: unknown): string | undefined {
6
+ if (value === null) return "none";
7
+ if (typeof value === "string") return value;
8
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
9
+ return undefined;
10
+ }
11
+
12
+ function appendSemanticLines(lines: string[], value: unknown, path: string, depth: number): void {
13
+ const scalar = scalarText(value);
14
+ if (scalar !== undefined) {
15
+ lines.push(`${path}: ${scalar}`);
16
+ return;
17
+ }
18
+ if (depth >= MAX_DEPTH) {
19
+ lines.push(`${path}: [nested value omitted]`);
20
+ return;
21
+ }
22
+ if (Array.isArray(value)) {
23
+ lines.push(`${path} (${value.length})`);
24
+ for (const [index, entry] of value.slice(0, MAX_COLLECTION_ENTRIES).entries()) appendSemanticLines(lines, entry, `${path}[${index}]`, depth + 1);
25
+ if (value.length > MAX_COLLECTION_ENTRIES) lines.push(`${path}: ${value.length - MAX_COLLECTION_ENTRIES} more entries omitted`);
26
+ return;
27
+ }
28
+ if (typeof value === "object" && value !== null) {
29
+ const entries = Object.entries(value).slice(0, MAX_COLLECTION_ENTRIES);
30
+ if (entries.length === 0) lines.push(`${path}: none`);
31
+ for (const [key, entry] of entries) appendSemanticLines(lines, entry, path ? `${path}.${key}` : key, depth + 1);
32
+ if (Object.keys(value).length > MAX_COLLECTION_ENTRIES) lines.push(`${path || "result"}: additional fields omitted`);
33
+ return;
34
+ }
35
+ lines.push(`${path}: unavailable`);
36
+ }
37
+
38
+ /** Bounds UTF-8 model-facing text independently from presentation details. */
39
+ export function boundModelContentText(full: string, maxBytes = DEFAULT_MODEL_CONTENT_BYTES): string {
40
+ if (!Number.isInteger(maxBytes) || maxBytes < 64) throw new Error("model content maxBytes must be an integer of at least 64");
41
+ if (Buffer.byteLength(full, "utf8") <= maxBytes) return full;
42
+ const suffix = "\n[model content truncated]";
43
+ const budget = maxBytes - Buffer.byteLength(suffix, "utf8");
44
+ let bytes = Buffer.from(full, "utf8").subarray(0, Math.max(0, budget));
45
+ let prefix = "";
46
+ while (bytes.length > 0) {
47
+ try {
48
+ prefix = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
49
+ break;
50
+ } catch {
51
+ bytes = bytes.subarray(0, -1);
52
+ }
53
+ }
54
+ return `${prefix}${suffix}`;
55
+ }
56
+
57
+ /** Formats an operation outcome as bounded, semantic plain text for model consumption. */
58
+ export function formatSemanticModelContent(title: string, value: unknown, maxBytes = DEFAULT_MODEL_CONTENT_BYTES): string {
59
+ const lines = [title];
60
+ appendSemanticLines(lines, value, "", 0);
61
+ return boundModelContentText(lines.join("\n"), maxBytes);
62
+ }