@nxuss/lemma 1.9.1 → 1.11.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.
Files changed (122) hide show
  1. package/README.md +24 -4
  2. package/dist/cjs/cli/lemma-proxy.d.ts.map +1 -1
  3. package/dist/cjs/cli/lemma-proxy.js +7 -2
  4. package/dist/cjs/cli/lemma-proxy.js.map +1 -1
  5. package/dist/cjs/mcp/tool-profiles.d.ts.map +1 -1
  6. package/dist/cjs/mcp/tool-profiles.js +43 -0
  7. package/dist/cjs/mcp/tool-profiles.js.map +1 -1
  8. package/dist/cjs/mcp/tools.d.ts.map +1 -1
  9. package/dist/cjs/mcp/tools.js +314 -146
  10. package/dist/cjs/mcp/tools.js.map +1 -1
  11. package/dist/cjs/subconscious/TheBrainV2.d.ts +27 -1
  12. package/dist/cjs/subconscious/TheBrainV2.d.ts.map +1 -1
  13. package/dist/cjs/subconscious/TheBrainV2.js +53 -1
  14. package/dist/cjs/subconscious/TheBrainV2.js.map +1 -1
  15. package/dist/cjs/utils/AffectedTests.d.ts +54 -0
  16. package/dist/cjs/utils/AffectedTests.d.ts.map +1 -0
  17. package/dist/cjs/utils/AffectedTests.js +195 -0
  18. package/dist/cjs/utils/AffectedTests.js.map +1 -0
  19. package/dist/cjs/utils/AstSymbolCache.d.ts +29 -0
  20. package/dist/cjs/utils/AstSymbolCache.d.ts.map +1 -0
  21. package/dist/cjs/utils/AstSymbolCache.js +66 -0
  22. package/dist/cjs/utils/AstSymbolCache.js.map +1 -0
  23. package/dist/cjs/utils/ContextSqueezer.d.ts +24 -4
  24. package/dist/cjs/utils/ContextSqueezer.d.ts.map +1 -1
  25. package/dist/cjs/utils/ContextSqueezer.js +32 -3
  26. package/dist/cjs/utils/ContextSqueezer.js.map +1 -1
  27. package/dist/cjs/utils/DepGraph.d.ts +28 -0
  28. package/dist/cjs/utils/DepGraph.d.ts.map +1 -0
  29. package/dist/cjs/utils/DepGraph.js +210 -0
  30. package/dist/cjs/utils/DepGraph.js.map +1 -0
  31. package/dist/cjs/utils/LocalPrefilter.d.ts +61 -0
  32. package/dist/cjs/utils/LocalPrefilter.d.ts.map +1 -0
  33. package/dist/cjs/utils/LocalPrefilter.js +169 -0
  34. package/dist/cjs/utils/LocalPrefilter.js.map +1 -0
  35. package/dist/cjs/utils/OutputDelta.d.ts +60 -0
  36. package/dist/cjs/utils/OutputDelta.d.ts.map +1 -0
  37. package/dist/cjs/utils/OutputDelta.js +226 -0
  38. package/dist/cjs/utils/OutputDelta.js.map +1 -0
  39. package/dist/cjs/utils/OutputLedger.d.ts +60 -0
  40. package/dist/cjs/utils/OutputLedger.d.ts.map +1 -0
  41. package/dist/cjs/utils/OutputLedger.js +134 -0
  42. package/dist/cjs/utils/OutputLedger.js.map +1 -0
  43. package/dist/cjs/utils/ReadWorkspaceCache.d.ts +8 -2
  44. package/dist/cjs/utils/ReadWorkspaceCache.d.ts.map +1 -1
  45. package/dist/cjs/utils/ReadWorkspaceCache.js +82 -4
  46. package/dist/cjs/utils/ReadWorkspaceCache.js.map +1 -1
  47. package/dist/cjs/utils/RoiLedger.d.ts +31 -0
  48. package/dist/cjs/utils/RoiLedger.d.ts.map +1 -0
  49. package/dist/cjs/utils/RoiLedger.js +113 -0
  50. package/dist/cjs/utils/RoiLedger.js.map +1 -0
  51. package/dist/cjs/utils/SharedLedgerStore.d.ts +26 -0
  52. package/dist/cjs/utils/SharedLedgerStore.d.ts.map +1 -0
  53. package/dist/cjs/utils/SharedLedgerStore.js +130 -0
  54. package/dist/cjs/utils/SharedLedgerStore.js.map +1 -0
  55. package/dist/cjs/utils/SpeculativeWarmer.d.ts +24 -0
  56. package/dist/cjs/utils/SpeculativeWarmer.d.ts.map +1 -0
  57. package/dist/cjs/utils/SpeculativeWarmer.js +73 -0
  58. package/dist/cjs/utils/SpeculativeWarmer.js.map +1 -0
  59. package/dist/cjs/utils/reportSavings.d.ts.map +1 -1
  60. package/dist/cjs/utils/reportSavings.js +11 -0
  61. package/dist/cjs/utils/reportSavings.js.map +1 -1
  62. package/dist/esm/cli/lemma-proxy.d.ts.map +1 -1
  63. package/dist/esm/cli/lemma-proxy.js +7 -2
  64. package/dist/esm/cli/lemma-proxy.js.map +1 -1
  65. package/dist/esm/mcp/tool-profiles.d.ts.map +1 -1
  66. package/dist/esm/mcp/tool-profiles.js +43 -0
  67. package/dist/esm/mcp/tool-profiles.js.map +1 -1
  68. package/dist/esm/mcp/tools.d.ts.map +1 -1
  69. package/dist/esm/mcp/tools.js +312 -144
  70. package/dist/esm/mcp/tools.js.map +1 -1
  71. package/dist/esm/subconscious/TheBrainV2.d.ts +27 -1
  72. package/dist/esm/subconscious/TheBrainV2.d.ts.map +1 -1
  73. package/dist/esm/subconscious/TheBrainV2.js +53 -1
  74. package/dist/esm/subconscious/TheBrainV2.js.map +1 -1
  75. package/dist/esm/utils/AffectedTests.d.ts +54 -0
  76. package/dist/esm/utils/AffectedTests.d.ts.map +1 -0
  77. package/dist/esm/utils/AffectedTests.js +186 -0
  78. package/dist/esm/utils/AffectedTests.js.map +1 -0
  79. package/dist/esm/utils/AstSymbolCache.d.ts +29 -0
  80. package/dist/esm/utils/AstSymbolCache.d.ts.map +1 -0
  81. package/dist/esm/utils/AstSymbolCache.js +58 -0
  82. package/dist/esm/utils/AstSymbolCache.js.map +1 -0
  83. package/dist/esm/utils/ContextSqueezer.d.ts +24 -4
  84. package/dist/esm/utils/ContextSqueezer.d.ts.map +1 -1
  85. package/dist/esm/utils/ContextSqueezer.js +32 -3
  86. package/dist/esm/utils/ContextSqueezer.js.map +1 -1
  87. package/dist/esm/utils/DepGraph.d.ts +28 -0
  88. package/dist/esm/utils/DepGraph.d.ts.map +1 -0
  89. package/dist/esm/utils/DepGraph.js +169 -0
  90. package/dist/esm/utils/DepGraph.js.map +1 -0
  91. package/dist/esm/utils/LocalPrefilter.d.ts +61 -0
  92. package/dist/esm/utils/LocalPrefilter.d.ts.map +1 -0
  93. package/dist/esm/utils/LocalPrefilter.js +159 -0
  94. package/dist/esm/utils/LocalPrefilter.js.map +1 -0
  95. package/dist/esm/utils/OutputDelta.d.ts +60 -0
  96. package/dist/esm/utils/OutputDelta.d.ts.map +1 -0
  97. package/dist/esm/utils/OutputDelta.js +221 -0
  98. package/dist/esm/utils/OutputDelta.js.map +1 -0
  99. package/dist/esm/utils/OutputLedger.d.ts +60 -0
  100. package/dist/esm/utils/OutputLedger.d.ts.map +1 -0
  101. package/dist/esm/utils/OutputLedger.js +125 -0
  102. package/dist/esm/utils/OutputLedger.js.map +1 -0
  103. package/dist/esm/utils/ReadWorkspaceCache.d.ts +8 -2
  104. package/dist/esm/utils/ReadWorkspaceCache.d.ts.map +1 -1
  105. package/dist/esm/utils/ReadWorkspaceCache.js +81 -4
  106. package/dist/esm/utils/ReadWorkspaceCache.js.map +1 -1
  107. package/dist/esm/utils/RoiLedger.d.ts +31 -0
  108. package/dist/esm/utils/RoiLedger.d.ts.map +1 -0
  109. package/dist/esm/utils/RoiLedger.js +104 -0
  110. package/dist/esm/utils/RoiLedger.js.map +1 -0
  111. package/dist/esm/utils/SharedLedgerStore.d.ts +26 -0
  112. package/dist/esm/utils/SharedLedgerStore.d.ts.map +1 -0
  113. package/dist/esm/utils/SharedLedgerStore.js +122 -0
  114. package/dist/esm/utils/SharedLedgerStore.js.map +1 -0
  115. package/dist/esm/utils/SpeculativeWarmer.d.ts +24 -0
  116. package/dist/esm/utils/SpeculativeWarmer.d.ts.map +1 -0
  117. package/dist/esm/utils/SpeculativeWarmer.js +66 -0
  118. package/dist/esm/utils/SpeculativeWarmer.js.map +1 -0
  119. package/dist/esm/utils/reportSavings.d.ts.map +1 -1
  120. package/dist/esm/utils/reportSavings.js +11 -0
  121. package/dist/esm/utils/reportSavings.js.map +1 -1
  122. package/package.json +1 -1
@@ -80,7 +80,14 @@ const TokenReceipt_1 = require("../utils/TokenReceipt");
80
80
  const CommandOutputDistiller_1 = require("../utils/CommandOutputDistiller");
81
81
  const PatchMatcher_1 = require("../utils/PatchMatcher");
82
82
  const WorkspaceSearch_1 = require("../utils/WorkspaceSearch");
83
+ const LocalPrefilter_1 = require("../utils/LocalPrefilter");
83
84
  const ReadWorkspaceCache_1 = require("../utils/ReadWorkspaceCache");
85
+ const OutputDelta_1 = require("../utils/OutputDelta");
86
+ const OutputLedger_1 = require("../utils/OutputLedger");
87
+ const DepGraph_1 = require("../utils/DepGraph");
88
+ const AstSymbolCache_1 = require("../utils/AstSymbolCache");
89
+ const SpeculativeWarmer_1 = require("../utils/SpeculativeWarmer");
90
+ const AffectedTests_1 = require("../utils/AffectedTests");
84
91
  const LineNumberMap_1 = require("../utils/LineNumberMap");
85
92
  const FileOutline_1 = require("../utils/FileOutline");
86
93
  const SearchCache_1 = require("../utils/SearchCache");
@@ -180,6 +187,30 @@ const toolDefinitions = [
180
187
  description: "Symbols (not whole files) this answer depends on. Freshness is then judged by that symbol's own source, so edits elsewhere in the same file don't stale this memory out.",
181
188
  },
182
189
  outcome: { type: "string", enum: ["confirmed", "failed"], description: "Default 'confirmed' (a verified working solution). Set 'failed' to record a dead end — an approach ruled out, not one to recommend." },
190
+ claims: {
191
+ type: "array",
192
+ items: {
193
+ type: "object",
194
+ properties: {
195
+ text: { type: "string", description: "One sub-statement from `response`, verifiable on its own (e.g. one sentence about one function)." },
196
+ filePaths: { type: "array", items: { type: "string" }, description: "Paths this specific claim depends on." },
197
+ symbols: {
198
+ type: "array",
199
+ items: {
200
+ type: "object",
201
+ properties: {
202
+ filePath: { type: "string" },
203
+ symbolName: { type: "string" },
204
+ },
205
+ required: ["filePath", "symbolName"],
206
+ },
207
+ description: "Symbols this specific claim depends on.",
208
+ },
209
+ },
210
+ required: ["text"],
211
+ },
212
+ description: "Split `response` into independently-verifiable sub-claims when it makes more than one assertion about different parts of the code. Each claim tracks its own filePaths/symbols, so one claim going stale (e.g. one function changed) doesn't discard the others that are still true. Skip this for a single-fact response — plain filePaths/symbols above already covers that case.",
213
+ },
183
214
  },
184
215
  required: ["query", "response"],
185
216
  },
@@ -208,7 +239,7 @@ const toolDefinitions = [
208
239
  },
209
240
  {
210
241
  name: "read_workspace_file",
211
- description: "Read a file inside the workspace. Compresses comments/whitespace and scrubs API keys. Every line carries its REAL line number in the original file (compression removes lines, it never renumbers them), so you can cite file:line and patch precisely without re-reading. The header lists which imports were compressed away. Use offset/limit to read a line range of a large file. If this exact view was already read this session and hasn't changed on disk, returns an UNCHANGED marker plus a symbol outline reuse your copy, or pass force:true if it's no longer in your context.",
242
+ description: "Read a file inside the workspace. Compresses comments/whitespace and scrubs API keys. Every line carries its REAL line number in the original file (compression removes lines, it never renumbers them), so you can cite file:line and patch precisely without re-reading. The header lists which imports were compressed away. Use offset/limit to read a line range of a large file. If this exact view was already read this session and hasn't changed on disk, returns an UNCHANGED marker plus a symbol outline. If it changed only slightly, returns a CHANGED marker with a unified diff to apply to the copy you already hold. Either way, pass force:true to get the full file when it is no longer in your context.",
212
243
  inputSchema: {
213
244
  type: "object",
214
245
  properties: {
@@ -416,7 +447,7 @@ const toolDefinitions = [
416
447
  },
417
448
  {
418
449
  name: "prune_conversation_history",
419
- description: "Compress conversation history by decimating old turns and compacting large code blocks in historical messages. Keeps recent context intact.",
450
+ description: "Compress conversation history by decimating old turns and compacting large code blocks in historical messages. Keeps recent context intact. Refuses to mutate anything by default because pruning invalidates the prompt-cache prefix, which is usually a net loss — pass force:true to actually apply it, or call without force to just get the estimated cache-invalidation cost back.",
420
451
  inputSchema: {
421
452
  type: "object",
422
453
  properties: {
@@ -425,6 +456,11 @@ const toolDefinitions = [
425
456
  items: { type: "object" },
426
457
  description: "Array of conversation messages with role + content",
427
458
  },
459
+ force: {
460
+ type: "boolean",
461
+ description: "Actually apply the prune despite the prompt-cache-invalidation cost. Default false — the call is a dry run that reports cacheInvalidationEstimate instead.",
462
+ default: false,
463
+ },
428
464
  },
429
465
  required: ["messages"],
430
466
  },
@@ -750,6 +786,17 @@ const toolDefinitions = [
750
786
  required: ["filePath"],
751
787
  },
752
788
  },
789
+ {
790
+ name: "affected_tests",
791
+ description: "Given the current diff (or an explicit file list), returns only the test files that transitively import the changed code, plus the exact command to run just those. Uses the TypeScript import graph — zero LLM calls. Run this instead of the full suite after an edit: fewer minutes, and far less test output in context. Reports honestly when the full suite is the right answer (unbounded change, runtime-only coupling, most of the suite affected).",
792
+ inputSchema: {
793
+ type: "object",
794
+ properties: {
795
+ files: { type: "array", items: { type: "string" }, description: "Explicit changed files, relative to workspace root. Omit to read the diff from git (working tree + untracked)." },
796
+ baseRef: { type: "string", description: "Additional git ref to diff against, e.g. 'main'. The working tree is always included." },
797
+ },
798
+ },
799
+ },
753
800
  {
754
801
  name: "refactor",
755
802
  description: "Declarative multi-file codemod engine. Rename symbols across the entire workspace or move files updating all imports. Uses TypeScript Compiler API — zero LLM calls. Supports dry-run diff preview and post-refactor tsc verification.",
@@ -1182,6 +1229,7 @@ const toolHandlers = {
1182
1229
  pr_status: handlePRStatus,
1183
1230
  generate_pr_workflow: handleGeneratePRWorkflow,
1184
1231
  depgraph: handleDepgraph,
1232
+ affected_tests: handleAffectedTests,
1185
1233
  refactor: handleRefactor,
1186
1234
  smart_file_slice: handleSmartFileSlice,
1187
1235
  test_oracle: handleTestOracle,
@@ -1282,6 +1330,7 @@ function receiptLabelMeta(name, args) {
1282
1330
  return meta;
1283
1331
  }
1284
1332
  function setupToolsHandlers(server, onToolCall) {
1333
+ (0, SpeculativeWarmer_1.registerSymbolExtractor)(extractSymbolsWithTsCompiler);
1285
1334
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
1286
1335
  tools: toolDefinitionsArray,
1287
1336
  }));
@@ -1391,7 +1440,16 @@ async function handleSearchMemory(args) {
1391
1440
  }
1392
1441
  const fresh = results.filter((r) => r.fresh);
1393
1442
  const stale = results.filter((r) => !r.fresh);
1394
- const formatResult = (r, i) => `Result ${i + 1} (Similarity: ${(r.similarity * 100).toFixed(1)}%)${r.outcome === "failed" ? "\n⚠️ TAGGED AS A FAILED ATTEMPT — this was tried before and did NOT work. Do not repeat it; treat this as a warning, not a suggestion." : ""}\nPrompt: ${r.query.substring(0, 300)}...\nResponse: ${typeof r.response === "string" ? r.response : JSON.stringify(r.response, null, 2)}`;
1443
+ const formatClaims = (r) => {
1444
+ if (!Array.isArray(r.claims) || r.claims.length === 0)
1445
+ return "";
1446
+ const lines = r.claims.map((c) => c.fresh
1447
+ ? ` ✓ fresh: ${c.text}`
1448
+ : ` ✗ STALE (${(c.staleFiles || []).join(", ")}): ${c.text}`);
1449
+ const staleCount = r.claims.filter((c) => !c.fresh).length;
1450
+ return `\nClaims (${r.claims.length - staleCount}/${r.claims.length} still fresh — trust only the ✓ ones):\n${lines.join("\n")}`;
1451
+ };
1452
+ const formatResult = (r, i) => `Result ${i + 1} (Similarity: ${(r.similarity * 100).toFixed(1)}%)${r.outcome === "failed" ? "\n⚠️ TAGGED AS A FAILED ATTEMPT — this was tried before and did NOT work. Do not repeat it; treat this as a warning, not a suggestion." : ""}\nPrompt: ${r.query.substring(0, 300)}...\nResponse: ${typeof r.response === "string" ? r.response : JSON.stringify(r.response, null, 2)}${formatClaims(r)}`;
1395
1453
  if (fresh.length > 0) {
1396
1454
  // Only a fresh hit (or an untracked, purely conceptual entry) counts as a real
1397
1455
  // avoided re-investigation — this is the only case worth crediting as savings.
@@ -1436,11 +1494,14 @@ async function handleStoreMemory(args) {
1436
1494
  const symbols = Array.isArray(args?.symbols)
1437
1495
  ? args.symbols.filter((s) => s?.filePath && s?.symbolName)
1438
1496
  : undefined;
1497
+ const claims = Array.isArray(args?.claims)
1498
+ ? args.claims.filter((c) => c?.text)
1499
+ : undefined;
1439
1500
  if (!query || !responseText)
1440
1501
  throw new Error("Query and response are required");
1441
1502
  try {
1442
1503
  const brain = (0, TheBrainV2_1.getBrain)();
1443
- const storeRes = brain.store(query, responseText, provider, 0.92, filePaths, undefined, outcome, symbols);
1504
+ const storeRes = brain.store(query, responseText, provider, 0.92, filePaths, undefined, outcome, symbols, claims);
1444
1505
  // Storing a memory saves nothing — it only creates the chance of a saving later.
1445
1506
  // Crediting tokens here inflated the ledger on write and then credited the same
1446
1507
  // answer again on every read. The saving is booked by search_memory on a fresh hit.
@@ -1452,7 +1513,8 @@ async function handleStoreMemory(args) {
1452
1513
  const trackingNote = filePaths && filePaths.length > 0 ? ` Tracking freshness against ${filePaths.length} file(s) — this memory auto-invalidates if they change.` : "";
1453
1514
  const symbolNote = symbols && symbols.length > 0 ? ` Tracking freshness against ${symbols.length} symbol(s) specifically — unrelated edits elsewhere in those files won't stale this out.` : "";
1454
1515
  const outcomeNote = outcome === "failed" ? " Tagged as a FAILED attempt — future searches will surface it as a warning, not a suggestion." : "";
1455
- return { content: [{ type: "text", text: `Success: Memory stored. ${storeRes.reason}${trackingNote}${symbolNote}${outcomeNote}` }] };
1516
+ const claimsNote = claims && claims.length > 0 ? ` Split into ${claims.length} independently-verifiable claim(s) — a future search can trust the ones still fresh even if another goes stale.` : "";
1517
+ return { content: [{ type: "text", text: `Success: Memory stored. ${storeRes.reason}${trackingNote}${symbolNote}${outcomeNote}${claimsNote}` }] };
1456
1518
  }
1457
1519
  catch (e) {
1458
1520
  (0, utils_1.logError)("store_memory", e);
@@ -1582,7 +1644,44 @@ async function handleReadWorkspaceFile(args) {
1582
1644
  }
1583
1645
  if (numbers)
1584
1646
  content = (0, LineNumberMap_1.withLineNumbers)(content, numbers);
1585
- (0, ReadWorkspaceCache_1.recordSent)(resolved, view, dedup.hash);
1647
+ // The file changed, but usually only slightly — the common case in an edit loop is a
1648
+ // few lines moving in a file the model already holds in full. Re-sending everything
1649
+ // charges for the whole file (and keeps charging, since tool results stay in the
1650
+ // transcript). A diff against the exact text we last emitted covers the same ground
1651
+ // for a fraction of the tokens; `shouldEmitDelta` declines whenever it wouldn't.
1652
+ if (!force) {
1653
+ const previousEmitted = (0, ReadWorkspaceCache_1.getLastEmitted)(resolved, view);
1654
+ if (previousEmitted !== null) {
1655
+ const delta = (0, OutputDelta_1.shouldEmitDelta)(previousEmitted, content);
1656
+ if (delta.emit) {
1657
+ (0, reportSavings_1.reportSavings)({
1658
+ source: "contextSqueeze",
1659
+ charsBefore: content.length,
1660
+ charsAfter: delta.body.length,
1661
+ toolName: "read_workspace_file",
1662
+ filePath,
1663
+ });
1664
+ (0, ReadWorkspaceCache_1.recordSent)(resolved, view, dedup.hash, content);
1665
+ (0, SpeculativeWarmer_1.warmNeighbors)(workspaceRoot, resolved);
1666
+ return {
1667
+ content: [
1668
+ {
1669
+ type: "text",
1670
+ text: [
1671
+ `CHANGED: ${filePath} — ${delta.hunkCount} hunk(s), +${delta.added}/-${delta.removed} since you last read it.`,
1672
+ `Apply this diff to the copy you already have. Line numbers are real positions in the file on disk.`,
1673
+ `If you no longer hold that copy, re-fetch with force:true.`,
1674
+ ``,
1675
+ delta.body,
1676
+ ].join("\n"),
1677
+ },
1678
+ ],
1679
+ };
1680
+ }
1681
+ }
1682
+ }
1683
+ (0, ReadWorkspaceCache_1.recordSent)(resolved, view, dedup.hash, content);
1684
+ (0, SpeculativeWarmer_1.warmNeighbors)(workspaceRoot, resolved);
1586
1685
  return { content: [{ type: "text", text: rangeHeader + content }] };
1587
1686
  }
1588
1687
  catch (err) {
@@ -1801,9 +1900,35 @@ async function handleRunWorkspaceCommand(args) {
1801
1900
  handle: distilled.handle,
1802
1901
  });
1803
1902
  }
1903
+ // A debug loop runs the same command repeatedly and the output barely moves between
1904
+ // runs. The header is part of the compared text on purpose: a pass/fail flip must never
1905
+ // be reported as "identical", even in the rare case where the body alone matches.
1906
+ // The footer is deliberately excluded and always re-appended — it carries this run's
1907
+ // `output_region` handle, which is different every time and must stay usable.
1908
+ const body = `${header}\n\nOutput:\n${distilled.text}`;
1909
+ const footer = (0, CommandOutputDistiller_1.buildDistillFooter)(distilled);
1910
+ const key = (0, OutputLedger_1.ledgerKey)("run_workspace_command", { command, cwd: workspaceRoot, raw: args?.raw === true });
1911
+ const ledger = (0, OutputLedger_1.checkOutput)(key, body, {
1912
+ label: `\`${command}\``,
1913
+ rerunHint: "The command did run again — this reports its result, it does not skip execution.",
1914
+ });
1915
+ (0, OutputLedger_1.recordOutput)(key, body);
1916
+ if (ledger.replacement) {
1917
+ (0, reportSavings_1.reportSavings)({
1918
+ source: "contextSqueeze",
1919
+ charsBefore: body.length,
1920
+ charsAfter: ledger.replacement.length,
1921
+ toolName: "run_workspace_command",
1922
+ });
1923
+ (0, TokenReceipt_1.recordReceiptEvent)("tool_call", `run_workspace_command ${ledger.status} vs. earlier run`, {
1924
+ command,
1925
+ savedChars: ledger.savedChars,
1926
+ });
1927
+ return { content: [{ type: "text", text: ledger.replacement + footer }] };
1928
+ }
1804
1929
  return {
1805
1930
  content: [
1806
- { type: "text", text: `${header}\n\nOutput:\n${distilled.text}${(0, CommandOutputDistiller_1.buildDistillFooter)(distilled)}` },
1931
+ { type: "text", text: body + footer },
1807
1932
  ],
1808
1933
  };
1809
1934
  }
@@ -1894,6 +2019,67 @@ async function handleListWorkspaceDir(args) {
1894
2019
  return { content: [{ type: "text", text: `Error listing directory: ${err.message}` }] };
1895
2020
  }
1896
2021
  }
2022
+ /** Files shown after a successful local ranking; the rest are named, not silently dropped. */
2023
+ const PREFILTER_FILES_SHOWN = 12;
2024
+ /**
2025
+ * Rank matching files with the local model and show only the top ones.
2026
+ *
2027
+ * Ranking happens per file, not per match: the model is choosing which files are worth
2028
+ * reading, and ranking 200 individual lines would ask it a question it cannot answer as
2029
+ * well. On any failure — disabled, no Ollama, unparseable reply — this returns the input
2030
+ * untouched, which is the behaviour with the feature off.
2031
+ */
2032
+ async function prefilterSearchMatches(query, matches) {
2033
+ const byFile = new Map();
2034
+ for (const m of matches) {
2035
+ const list = byFile.get(m.filePath);
2036
+ if (list)
2037
+ list.push(m);
2038
+ else
2039
+ byFile.set(m.filePath, [m]);
2040
+ }
2041
+ const totalFiles = byFile.size;
2042
+ const untouched = { matches, totalFiles, note: "", applied: false };
2043
+ if (totalFiles <= PREFILTER_FILES_SHOWN)
2044
+ return untouched;
2045
+ try {
2046
+ const candidates = [...byFile.entries()].map(([filePath, fileMatches]) => ({
2047
+ id: filePath,
2048
+ summary: `${filePath} (${fileMatches.length} match${fileMatches.length === 1 ? "" : "es"}): ${fileMatches[0].text.trim().slice(0, 120)}`,
2049
+ }));
2050
+ const ranked = await (0, LocalPrefilter_1.rankCandidates)(query, candidates);
2051
+ if (!ranked)
2052
+ return untouched;
2053
+ const shown = ranked.order.slice(0, PREFILTER_FILES_SHOWN);
2054
+ const withheld = ranked.order.slice(PREFILTER_FILES_SHOWN);
2055
+ if (withheld.length === 0)
2056
+ return untouched;
2057
+ const shownSet = new Set(shown);
2058
+ const kept = matches.filter((m) => shownSet.has(m.filePath));
2059
+ const note = [
2060
+ ``,
2061
+ `── ${withheld.length} more file(s) matched but were ranked lower by a local model (${ranked.model}) running on this machine:`,
2062
+ ...withheld.slice(0, 25).map((f) => ` ${f}`),
2063
+ ...(withheld.length > 25 ? [` … and ${withheld.length - 25} more`] : []),
2064
+ `This ranking is a local model's judgement, not a filter on relevance — if what you need`,
2065
+ `is not above, re-run this search with maxResults raised or a narrower query to see the`,
2066
+ `full results, or read one of the files listed here directly.`,
2067
+ ].join("\n");
2068
+ (0, reportSavings_1.reportSavings)({
2069
+ source: "contextSqueeze",
2070
+ charsBefore: matches.reduce((n, m) => n + m.text.length, 0),
2071
+ charsAfter: kept.reduce((n, m) => n + m.text.length, 0),
2072
+ toolName: "search_workspace",
2073
+ query: query.substring(0, 100),
2074
+ });
2075
+ return { matches: kept, totalFiles, note, applied: true };
2076
+ }
2077
+ catch (err) {
2078
+ // The prefilter is an optimization. It must never be the reason a search returns less.
2079
+ (0, utils_1.logWarn)("search_workspace", "local prefilter failed, returning unranked results");
2080
+ return untouched;
2081
+ }
2082
+ }
1897
2083
  async function handleSearchWorkspace(args) {
1898
2084
  const query = args?.query;
1899
2085
  const extension = args?.extension;
@@ -1904,29 +2090,62 @@ async function handleSearchWorkspace(args) {
1904
2090
  try {
1905
2091
  const extensions = (0, WorkspaceSearch_1.parseExtensionFilter)(extension);
1906
2092
  const cached = (0, SearchCache_1.getCachedSearch)(workspaceRoot, query, extensions, maxResults);
2093
+ // `SearchCache` spares the filesystem walk, but it still hands back the full result
2094
+ // text — and re-emitting an identical wall of matches costs exactly as many tokens as
2095
+ // producing it did. The ledger below is what turns that into a saving, so both the
2096
+ // cached and freshly-computed paths converge on one `resultText` first.
2097
+ let resultText;
1907
2098
  if (cached.text !== null) {
1908
2099
  (0, reportSavings_1.reportSavings)({ source: "cache", tokens: Math.floor(cached.text.length / 4), toolName: "search_workspace", query: query.substring(0, 100) });
1909
- return { content: [{ type: "text", text: cached.text }] };
1910
- }
1911
- const { matches, truncated } = (0, WorkspaceSearch_1.searchWorkspace)(workspaceRoot, query, extensions, maxResults);
1912
- if (matches.length === 0) {
1913
- const emptyText = `No matches found in the workspace for "${query}".`;
1914
- (0, SearchCache_1.setCachedSearch)(query, extensions, maxResults, cached.fingerprint, emptyText);
1915
- return { content: [{ type: "text", text: emptyText }] };
2100
+ resultText = cached.text;
1916
2101
  }
1917
- const grouped = (0, WorkspaceSearch_1.groupSearchResults)(matches, truncated, undefined, workspaceRoot);
1918
- let formatted = grouped.formatted;
1919
- try {
1920
- const scrubber = new SemanticScrubber_1.SemanticScrubber();
1921
- const { maskedPrompt } = scrubber.mask(formatted);
1922
- formatted = maskedPrompt;
2102
+ else {
2103
+ const { matches, truncated } = (0, WorkspaceSearch_1.searchWorkspace)(workspaceRoot, query, extensions, maxResults);
2104
+ if (matches.length === 0) {
2105
+ resultText = `No matches found in the workspace for "${query}".`;
2106
+ (0, SearchCache_1.setCachedSearch)(query, extensions, maxResults, cached.fingerprint, resultText);
2107
+ }
2108
+ else {
2109
+ // Opt-in: a local model ranks the matching files so the expensive model only reads
2110
+ // the ones likely to matter. It only ever reorders and truncates with disclosure —
2111
+ // and returns null on any doubt, in which case `matches` is used untouched.
2112
+ const prefilter = await prefilterSearchMatches(query, matches);
2113
+ const grouped = (0, WorkspaceSearch_1.groupSearchResults)(prefilter.matches, truncated, undefined, workspaceRoot);
2114
+ let formatted = grouped.formatted + prefilter.note;
2115
+ try {
2116
+ const scrubber = new SemanticScrubber_1.SemanticScrubber();
2117
+ const { maskedPrompt } = scrubber.mask(formatted);
2118
+ formatted = maskedPrompt;
2119
+ }
2120
+ catch (err) {
2121
+ (0, utils_1.logWarn)("search_workspace", "SemanticScrubber failed, returning raw results");
2122
+ }
2123
+ const truncationNote = grouped.truncated ? ` (truncated at ${maxResults} matches — narrow your query or extension filter for the rest)` : "";
2124
+ // The header reports what the search actually found, not what survived the
2125
+ // prefilter — otherwise a ranked-down file would vanish from the count as well as
2126
+ // from the list, and nothing would tell the reader it ever existed.
2127
+ resultText = `Found ${matches.length} matches across ${prefilter.totalFiles} files${truncationNote}:\n\n${formatted}`;
2128
+ // A ranked-down result is never cached: it would then be replayed to a later call
2129
+ // whose local model was unavailable, silently serving a subset as if it were the
2130
+ // whole search. Repeats are handled by the output ledger below instead.
2131
+ if (!prefilter.applied) {
2132
+ (0, SearchCache_1.setCachedSearch)(query, extensions, maxResults, cached.fingerprint, resultText);
2133
+ }
2134
+ }
1923
2135
  }
1924
- catch (err) {
1925
- (0, utils_1.logWarn)("search_workspace", "SemanticScrubber failed, returning raw results");
2136
+ const key = (0, OutputLedger_1.ledgerKey)("search_workspace", { query, extension: extension ?? null, maxResults, cwd: workspaceRoot });
2137
+ const ledger = (0, OutputLedger_1.checkOutput)(key, resultText, { label: `search for "${query}"` });
2138
+ (0, OutputLedger_1.recordOutput)(key, resultText);
2139
+ if (ledger.replacement) {
2140
+ (0, reportSavings_1.reportSavings)({
2141
+ source: "contextSqueeze",
2142
+ charsBefore: resultText.length,
2143
+ charsAfter: ledger.replacement.length,
2144
+ toolName: "search_workspace",
2145
+ query: query.substring(0, 100),
2146
+ });
2147
+ return { content: [{ type: "text", text: ledger.replacement }] };
1926
2148
  }
1927
- const truncationNote = grouped.truncated ? ` (truncated at ${maxResults} matches — narrow your query or extension filter for the rest)` : "";
1928
- const resultText = `Found ${grouped.totalMatches} matches across ${grouped.filesMatched} files${truncationNote}:\n\n${formatted}`;
1929
- (0, SearchCache_1.setCachedSearch)(query, extensions, maxResults, cached.fingerprint, resultText);
1930
2149
  return {
1931
2150
  content: [
1932
2151
  {
@@ -2200,7 +2419,7 @@ function walkDirForHologram(dir, rel, extensions, stats) {
2200
2419
  const ext = entry.name.split(".").pop() || "";
2201
2420
  if (extensions.includes(ext)) {
2202
2421
  stats.filesScanned++;
2203
- allSymbols = allSymbols.concat(extractSymbolsWithTsCompiler(fullPath, relPath, stats.parseErrors));
2422
+ allSymbols = allSymbols.concat((0, AstSymbolCache_1.getOrExtractSymbols)(fullPath, relPath, extractSymbolsWithTsCompiler, stats.parseErrors));
2204
2423
  }
2205
2424
  }
2206
2425
  }
@@ -2471,13 +2690,16 @@ async function handlePruneConversationHistory(args) {
2471
2690
  const messages = args?.messages;
2472
2691
  if (!messages)
2473
2692
  throw new Error("messages is required");
2693
+ const force = !!args?.force;
2474
2694
  try {
2475
- const result = (0, ContextSqueezer_1.pruneHistoryMessages)(messages);
2476
- (0, reportSavings_1.reportSavings)({
2477
- source: "historyPrune",
2478
- tokens: result.tokensSavedEstimate,
2479
- toolName: "prune_conversation_history",
2480
- });
2695
+ const result = (0, ContextSqueezer_1.pruneHistoryMessages)(messages, { force });
2696
+ if (result.applied) {
2697
+ (0, reportSavings_1.reportSavings)({
2698
+ source: "historyPrune",
2699
+ tokens: result.tokensSavedEstimate,
2700
+ toolName: "prune_conversation_history",
2701
+ });
2702
+ }
2481
2703
  return {
2482
2704
  content: [
2483
2705
  {
@@ -2485,6 +2707,9 @@ async function handlePruneConversationHistory(args) {
2485
2707
  text: JSON.stringify({
2486
2708
  messages: result.messages,
2487
2709
  tokensSavedEstimate: result.tokensSavedEstimate,
2710
+ cacheInvalidationEstimate: result.cacheInvalidationEstimate,
2711
+ applied: result.applied,
2712
+ ...(result.reason ? { reason: result.reason } : {}),
2488
2713
  }, null, 2),
2489
2714
  },
2490
2715
  ],
@@ -3812,113 +4037,9 @@ function generateAzurePipelineYaml(threshold) {
3812
4037
  ` AZURE_DEVOPS_TOKEN: \$(AZURE_DEVOPS_TOKEN)`,
3813
4038
  ].join("\n");
3814
4039
  }
3815
- function buildDepGraphMap(workspaceRoot) {
3816
- const map = new Map();
3817
- const IGNORE_DG = new Set(["node_modules", ".git", "dist", "chroma_data", ".lemma", "build", "coverage", ".next"]);
3818
- function walkDG(dir) {
3819
- let entries = [];
3820
- try {
3821
- entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
3822
- }
3823
- catch {
3824
- return;
3825
- }
3826
- for (const e of entries) {
3827
- if (IGNORE_DG.has(e.name))
3828
- continue;
3829
- const abs = path_1.default.join(dir, e.name);
3830
- if (e.isDirectory()) {
3831
- walkDG(abs);
3832
- }
3833
- else if (/\.(ts|tsx|js|jsx)$/.test(e.name) && !e.name.endsWith(".d.ts")) {
3834
- const rel = path_1.default.relative(workspaceRoot, abs).replace(/\\/g, "/");
3835
- map.set(rel, { rel, abs, imports: [], importedBy: [], externalImports: [], exports: [] });
3836
- }
3837
- }
3838
- }
3839
- walkDG(workspaceRoot);
3840
- for (const [, node] of map) {
3841
- try {
3842
- const src = fs_1.default.readFileSync(node.abs, "utf8");
3843
- const sf = ts.createSourceFile(node.abs, src, ts.ScriptTarget.Latest, true);
3844
- ts.forEachChild(sf, (n) => {
3845
- if (ts.isImportDeclaration(n) && ts.isStringLiteral(n.moduleSpecifier)) {
3846
- const spec = n.moduleSpecifier.text;
3847
- if (spec.startsWith(".")) {
3848
- const dir = path_1.default.dirname(node.abs);
3849
- let resolved = path_1.default.resolve(dir, spec);
3850
- for (const ext of [".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx", "/index.js"]) {
3851
- if (fs_1.default.existsSync(resolved + ext)) {
3852
- resolved += ext;
3853
- break;
3854
- }
3855
- }
3856
- const rel = path_1.default.relative(workspaceRoot, resolved).replace(/\\/g, "/");
3857
- if (!node.imports.includes(rel))
3858
- node.imports.push(rel);
3859
- }
3860
- else {
3861
- const pkg = spec.split("/")[0];
3862
- if (!node.externalImports.includes(pkg))
3863
- node.externalImports.push(pkg);
3864
- }
3865
- }
3866
- const hasExportMod = (nd) => ts.canHaveModifiers(nd) &&
3867
- (ts.getModifiers(nd) ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
3868
- if (hasExportMod(n)) {
3869
- if (ts.isFunctionDeclaration(n) && n.name)
3870
- node.exports.push(n.name.text);
3871
- else if (ts.isClassDeclaration(n) && n.name)
3872
- node.exports.push(n.name.text);
3873
- else if (ts.isVariableStatement(n)) {
3874
- for (const decl of n.declarationList.declarations) {
3875
- if (ts.isIdentifier(decl.name))
3876
- node.exports.push(decl.name.text);
3877
- }
3878
- }
3879
- else if (ts.isInterfaceDeclaration(n))
3880
- node.exports.push(n.name.text);
3881
- else if (ts.isTypeAliasDeclaration(n))
3882
- node.exports.push(n.name.text);
3883
- else if (ts.isEnumDeclaration(n))
3884
- node.exports.push(n.name.text);
3885
- }
3886
- if (ts.isExportDeclaration(n) && n.exportClause && ts.isNamedExports(n.exportClause)) {
3887
- for (const el of n.exportClause.elements)
3888
- node.exports.push(el.name.text);
3889
- }
3890
- });
3891
- }
3892
- catch { /* skip unparseable */ }
3893
- }
3894
- for (const [, node] of map) {
3895
- for (const imp of node.imports) {
3896
- const target = map.get(imp);
3897
- if (target && !target.importedBy.includes(node.rel))
3898
- target.importedBy.push(node.rel);
3899
- }
3900
- }
3901
- return map;
3902
- }
3903
- function bfsDepGraph(map, startRel, direction, maxDepth) {
3904
- const visited = new Map();
3905
- const queue = [{ rel: startRel, depth: 0 }];
3906
- while (queue.length > 0) {
3907
- const item = queue.shift();
3908
- if (visited.has(item.rel) || item.depth > maxDepth)
3909
- continue;
3910
- const node = map.get(item.rel);
3911
- if (!node)
3912
- continue;
3913
- visited.set(item.rel, { node, depth: item.depth });
3914
- if (item.depth < maxDepth) {
3915
- const neighbors = direction === "forward" ? node.imports : node.importedBy;
3916
- for (const n of neighbors)
3917
- queue.push({ rel: n, depth: item.depth + 1 });
3918
- }
3919
- }
3920
- return visited;
3921
- }
4040
+ // ═══════════════════════════════════════════════════════════════════════════════
4041
+ // ─── depgraph ─────────────────────────────────────────────────────────────────
4042
+ // ═══════════════════════════════════════════════════════════════════════════════
3922
4043
  function detectDepCycles(map, subset) {
3923
4044
  const cycles = [];
3924
4045
  const visited = new Set();
@@ -4091,13 +4212,13 @@ async function handleDepgraph(args) {
4091
4212
  try {
4092
4213
  const { resolved } = (0, utils_1.safeResolvePath)(workspaceRoot, filePath);
4093
4214
  const relTarget = path_1.default.relative(workspaceRoot, resolved).replace(/\\/g, "/");
4094
- const map = buildDepGraphMap(workspaceRoot);
4215
+ const map = (0, DepGraph_1.buildDepGraphMap)(workspaceRoot);
4095
4216
  const targetNode = map.get(relTarget);
4096
4217
  if (!targetNode) {
4097
4218
  return { content: [{ type: "text", text: `File not found in workspace graph: ${relTarget}\nCheck path is correct and has .ts/.tsx/.js/.jsx extension.` }] };
4098
4219
  }
4099
- const forwardVisited = bfsDepGraph(map, relTarget, "forward", depth);
4100
- const backwardVisited = showReverse ? bfsDepGraph(map, relTarget, "backward", depth) : new Map();
4220
+ const forwardVisited = (0, DepGraph_1.bfsDepGraph)(map, relTarget, "forward", depth);
4221
+ const backwardVisited = showReverse ? (0, DepGraph_1.bfsDepGraph)(map, relTarget, "backward", depth) : new Map();
4101
4222
  const allRelInScope = new Set([...forwardVisited.keys(), ...(showReverse ? backwardVisited.keys() : [])]);
4102
4223
  const cycles = detectCircular ? detectDepCycles(map, allRelInScope) : [];
4103
4224
  let output;
@@ -4123,13 +4244,46 @@ async function handleDepgraph(args) {
4123
4244
  output = formatDGText(targetNode, forwardVisited, backwardVisited, cycles, includeExternal, showReverse);
4124
4245
  }
4125
4246
  const stats = `\n\n📊 Stats: ${forwardVisited.size} nodos forward, ${backwardVisited.size} nodos reverse, depth=${depth}`;
4126
- return { content: [{ type: "text", text: output + (format === "json" ? "" : stats) }] };
4247
+ const finalText = output + (format === "json" ? "" : stats);
4248
+ const key = (0, OutputLedger_1.ledgerKey)("depgraph", { file: relTarget, depth, format, showReverse, includeExternal, detectCircular, cwd: workspaceRoot });
4249
+ const ledger = (0, OutputLedger_1.checkOutput)(key, finalText, { label: `depgraph for ${relTarget}` });
4250
+ (0, OutputLedger_1.recordOutput)(key, finalText);
4251
+ if (ledger.replacement) {
4252
+ (0, reportSavings_1.reportSavings)({ source: "contextSqueeze", charsBefore: finalText.length, charsAfter: ledger.replacement.length, toolName: "depgraph", filePath: relTarget });
4253
+ return { content: [{ type: "text", text: ledger.replacement }] };
4254
+ }
4255
+ return { content: [{ type: "text", text: finalText }] };
4127
4256
  }
4128
4257
  catch (err) {
4129
4258
  (0, utils_1.logError)("depgraph", err);
4130
4259
  return { content: [{ type: "text", text: `depgraph failed: ${err.message}` }] };
4131
4260
  }
4132
4261
  }
4262
+ async function handleAffectedTests(args) {
4263
+ const workspaceRoot = process.cwd();
4264
+ try {
4265
+ const files = Array.isArray(args?.files) ? args.files.filter((f) => typeof f === "string") : undefined;
4266
+ const result = (0, AffectedTests_1.findAffectedTests)({ workspaceRoot, files, baseRef: args?.baseRef });
4267
+ const text = (0, AffectedTests_1.formatAffectedTests)(result);
4268
+ // Worth recording even when it narrows nothing: the receipt is the record of what this
4269
+ // tool actually did, and "recommended the full suite" is as much a result as a subset.
4270
+ (0, TokenReceipt_1.recordReceiptEvent)("tool_call", "affected_tests", {
4271
+ changed: result.changed.length,
4272
+ affected: result.affected.length,
4273
+ totalTests: result.totalTests,
4274
+ narrowed: result.command !== null,
4275
+ });
4276
+ return { content: [{ type: "text", text }] };
4277
+ }
4278
+ catch (err) {
4279
+ (0, utils_1.logError)("affected_tests", err);
4280
+ // Failing here must never look like "nothing is affected" — that would talk a caller
4281
+ // out of running tests at all.
4282
+ return {
4283
+ content: [{ type: "text", text: `affected_tests failed: ${err.message}\nFall back to running the full suite.` }],
4284
+ };
4285
+ }
4286
+ }
4133
4287
  // ═══════════════════════════════════════════════════════════════════════════════
4134
4288
  // ─── refactor ─────────────────────────────────────────────────────────────────
4135
4289
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -4627,10 +4781,13 @@ async function handleSmartFileSlice(args) {
4627
4781
  async function handleTestOracle(args) {
4628
4782
  const command = args.command || "npm test";
4629
4783
  const workspaceRoot = process.cwd();
4630
- // Run tests, check errors
4784
+ // A failing test suite is re-run more than any other command in a session, and the
4785
+ // failure list barely moves between runs. Both branches funnel into one `resultText` so
4786
+ // the ledger can answer a repeat with a diff instead of the same stack traces again.
4787
+ let resultText;
4631
4788
  try {
4632
4789
  const out = (0, child_process_1.execSync)(command, { cwd: workspaceRoot, encoding: "utf8", timeout: 30000 });
4633
- return { content: [{ type: "text", text: `All tests passed!\n\nOutput:\n${out.substring(0, 1000)}` }] };
4790
+ resultText = `All tests passed!\n\nOutput:\n${out.substring(0, 1000)}`;
4634
4791
  }
4635
4792
  catch (e) {
4636
4793
  const stdout = e.stdout || "";
@@ -4640,8 +4797,19 @@ async function handleTestOracle(args) {
4640
4797
  .split("\n")
4641
4798
  .filter((l) => l.includes("fail") || l.includes("Error") || l.includes("at ") || l.includes("✗") || l.includes("Stack"));
4642
4799
  const summary = failureLines.slice(0, 80).join("\n") || `Test execution failed:\n${stdout.substring(0, 800)}\n${stderr.substring(0, 800)}`;
4643
- return { content: [{ type: "text", text: `Tests Failed. Compressed failures:\n\n${summary}` }] };
4800
+ resultText = `Tests Failed. Compressed failures:\n\n${summary}`;
4801
+ }
4802
+ const key = (0, OutputLedger_1.ledgerKey)("test_oracle", { command, cwd: workspaceRoot });
4803
+ const ledger = (0, OutputLedger_1.checkOutput)(key, resultText, {
4804
+ label: `\`${command}\``,
4805
+ rerunHint: "The suite did run again — this reports its result, it does not skip execution.",
4806
+ });
4807
+ (0, OutputLedger_1.recordOutput)(key, resultText);
4808
+ if (ledger.replacement) {
4809
+ (0, reportSavings_1.reportSavings)({ source: "contextSqueeze", charsBefore: resultText.length, charsAfter: ledger.replacement.length, toolName: "test_oracle" });
4810
+ return { content: [{ type: "text", text: ledger.replacement }] };
4644
4811
  }
4812
+ return { content: [{ type: "text", text: resultText }] };
4645
4813
  }
4646
4814
  // ── Schema Extract ───────────────────────────────────────────────────
4647
4815
  async function handleSchemaExtract(args) {