@nxuss/lemma 1.12.0 → 1.14.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 (36) hide show
  1. package/README.md +12 -8
  2. package/dist/cjs/mcp/index.js +3 -0
  3. package/dist/cjs/mcp/index.js.map +1 -1
  4. package/dist/cjs/mcp/tasks.d.ts +20 -0
  5. package/dist/cjs/mcp/tasks.d.ts.map +1 -0
  6. package/dist/cjs/mcp/tasks.js +71 -0
  7. package/dist/cjs/mcp/tasks.js.map +1 -0
  8. package/dist/cjs/mcp/tools.d.ts.map +1 -1
  9. package/dist/cjs/mcp/tools.js +295 -41
  10. package/dist/cjs/mcp/tools.js.map +1 -1
  11. package/dist/cjs/subconscious/TheBrainV2.d.ts +50 -1
  12. package/dist/cjs/subconscious/TheBrainV2.d.ts.map +1 -1
  13. package/dist/cjs/subconscious/TheBrainV2.js +149 -8
  14. package/dist/cjs/subconscious/TheBrainV2.js.map +1 -1
  15. package/dist/cjs/utils/PatchMatcher.d.ts +1 -0
  16. package/dist/cjs/utils/PatchMatcher.d.ts.map +1 -1
  17. package/dist/cjs/utils/PatchMatcher.js +4 -4
  18. package/dist/cjs/utils/PatchMatcher.js.map +1 -1
  19. package/dist/esm/mcp/index.js +3 -0
  20. package/dist/esm/mcp/index.js.map +1 -1
  21. package/dist/esm/mcp/tasks.d.ts +20 -0
  22. package/dist/esm/mcp/tasks.d.ts.map +1 -0
  23. package/dist/esm/mcp/tasks.js +66 -0
  24. package/dist/esm/mcp/tasks.js.map +1 -0
  25. package/dist/esm/mcp/tools.d.ts.map +1 -1
  26. package/dist/esm/mcp/tools.js +297 -43
  27. package/dist/esm/mcp/tools.js.map +1 -1
  28. package/dist/esm/subconscious/TheBrainV2.d.ts +50 -1
  29. package/dist/esm/subconscious/TheBrainV2.d.ts.map +1 -1
  30. package/dist/esm/subconscious/TheBrainV2.js +149 -8
  31. package/dist/esm/subconscious/TheBrainV2.js.map +1 -1
  32. package/dist/esm/utils/PatchMatcher.d.ts +1 -0
  33. package/dist/esm/utils/PatchMatcher.d.ts.map +1 -1
  34. package/dist/esm/utils/PatchMatcher.js +4 -4
  35. package/dist/esm/utils/PatchMatcher.js.map +1 -1
  36. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
- import { execSync, execFileSync, spawnSync } from "child_process";
4
+ import { execSync, execFileSync, spawn } from "child_process";
5
5
  import os from "os";
6
6
  import axios from "axios";
7
7
  import * as ts from "typescript";
@@ -36,6 +36,7 @@ import { lookupStateHash, storeStateHash } from "../utils/StateHashCache.js";
36
36
  import { recordReceiptEvent, getReceiptSummary, getLedgerLength } from "../utils/TokenReceipt.js";
37
37
  import { distillCommandOutput, buildDistillFooter, readRegion } from "../utils/CommandOutputDistiller.js";
38
38
  import { findMatch, reindentReplacement } from "../utils/PatchMatcher.js";
39
+ import { registerTaskHandlers, runAsTask, TASK_CAPABLE_TOOLS } from "./tasks.js";
39
40
  import { searchWorkspace, groupSearchResults, parseExtensionFilter } from "../utils/WorkspaceSearch.js";
40
41
  import { rankCandidates } from "../utils/LocalPrefilter.js";
41
42
  import { checkAlreadySent, recordSent, getLastEmitted } from "../utils/ReadWorkspaceCache.js";
@@ -44,7 +45,7 @@ import { ledgerKey, checkOutput, recordOutput as recordLedgerOutput } from "../u
44
45
  import { buildDepGraphMap, bfsDepGraph } from "../utils/DepGraph.js";
45
46
  import { getOrExtractSymbols } from "../utils/AstSymbolCache.js";
46
47
  import { warmNeighbors, registerSymbolExtractor } from "../utils/SpeculativeWarmer.js";
47
- import { findAffectedTests, formatAffectedTests } from "../utils/AffectedTests.js";
48
+ import { findAffectedTests, formatAffectedTests, changedFilesFromGit } from "../utils/AffectedTests.js";
48
49
  import { mapSqueezedToOriginal, withLineNumbers, sequentialNumbers } from "../utils/LineNumberMap.js";
49
50
  import { fileOutline, formatOutline } from "../utils/FileOutline.js";
50
51
  import { getCachedSearch, setCachedSearch } from "../utils/SearchCache.js";
@@ -67,7 +68,7 @@ function isProUser() {
67
68
  }
68
69
  const FREE_TOOLS = new Set([
69
70
  // Cache & Memory — the hook that shows instant value
70
- "smarter_cache", "state_hash_cache", "token_receipt", "search_memory", "store_memory", "get_project_history",
71
+ "smarter_cache", "state_hash_cache", "token_receipt", "search_memory", "store_memory", "downvote_memory", "get_project_history",
71
72
  // Token optimization — shows what they're saving
72
73
  "token_budget", "squeeze_prompt", "turbosqueeze",
73
74
  // Utility — just enough to function
@@ -137,6 +138,45 @@ async function tryElicitConfirmation(summary) {
137
138
  return { supported: false, confirmed: true };
138
139
  }
139
140
  }
141
+ /**
142
+ * Ask the client to pick one of several candidates via a typed (oneOf) elicitation form,
143
+ * instead of the plain confirm/deny shape tryElicitConfirmation uses. Same defensive
144
+ * contract: no support, a decline, or a malformed response all just mean "couldn't
145
+ * resolve it this way" — the caller falls back to its own error/default behavior, never
146
+ * blocks or throws.
147
+ */
148
+ async function tryElicitChoice(message, options) {
149
+ if (!mcpServerRef || options.length === 0)
150
+ return { supported: false, index: null };
151
+ try {
152
+ const result = await mcpServerRef.elicitInput({
153
+ message,
154
+ requestedSchema: {
155
+ type: "object",
156
+ properties: {
157
+ choice: {
158
+ type: "string",
159
+ title: "Which one?",
160
+ oneOf: options.map((opt, i) => ({ const: String(i), title: opt.title })),
161
+ },
162
+ },
163
+ required: ["choice"],
164
+ },
165
+ });
166
+ if (result.action !== "accept" || !result.content) {
167
+ return { supported: true, index: null };
168
+ }
169
+ const idx = Number(result.content.choice);
170
+ if (!Number.isInteger(idx) || idx < 0 || idx >= options.length) {
171
+ return { supported: true, index: null };
172
+ }
173
+ return { supported: true, index: idx };
174
+ }
175
+ catch (err) {
176
+ logWarn("elicitation", "Client does not support elicitation (or the request failed) — no choice made");
177
+ return { supported: false, index: null };
178
+ }
179
+ }
140
180
  const toolDefinitions = [
141
181
  {
142
182
  name: "scrub_privacy",
@@ -153,7 +193,7 @@ const toolDefinitions = [
153
193
  {
154
194
  name: "search_memory",
155
195
  annotations: { readOnlyHint: true, openWorldHint: false },
156
- description: "Search Lemma's semantic memory (The Brain) before investigating something from scratch — retrieves past solutions, fixes, and context from ALL your projects globally. Results are split into 'fresh' (safe to reuse — either untracked general knowledge, or every file it depended on still hashes the same) and 'stale' (a similar question was answered before, but a tracked file changed since — re-verify against current state before reusing). Never treat a stale result as current.",
196
+ description: "Search Lemma's semantic memory (The Brain) before investigating something from scratch — retrieves past solutions, fixes, and context from ALL your projects globally. Results are split into 'fresh' (safe to reuse — either untracked general knowledge, or every file it depended on still hashes the same) and 'stale' (a similar question was answered before, but a tracked file changed since — re-verify against current state before reusing). Never treat a stale result as current. If a returned memory turns out wrong once acted on, call downvote_memory with its id.",
157
197
  inputSchema: {
158
198
  type: "object",
159
199
  properties: {
@@ -164,6 +204,10 @@ const toolDefinitions = [
164
204
  description: "Similarity floor (0.0-1.0) a memory must clear to be returned. Lowering this surfaces loosely related memories that are usually noise — leave it alone unless a known-relevant memory is being filtered out.",
165
205
  default: 0.75,
166
206
  },
207
+ domain: {
208
+ type: "string",
209
+ description: "Prefer memories tagged with this domain (e.g. 'auth', 'billing' — matches cognitive_map domains). Soft preference, not a filter: an untagged or cross-domain memory can still be the best match.",
210
+ },
167
211
  },
168
212
  required: ["query"],
169
213
  },
@@ -216,10 +260,26 @@ const toolDefinitions = [
216
260
  },
217
261
  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.",
218
262
  },
263
+ domain: {
264
+ type: "string",
265
+ description: "Tag this memory with a domain (e.g. 'auth', 'billing' — matches cognitive_map domains) so domain-scoped searches prefer it. Optional: when omitted and filePaths is set, Lemma auto-derives a domain from the first file via cognitive_map (or a lightweight path heuristic if the map was never built).",
266
+ },
219
267
  },
220
268
  required: ["query", "response"],
221
269
  },
222
270
  },
271
+ {
272
+ name: "downvote_memory",
273
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
274
+ description: "Tell the Brain a specific memory from a prior search_memory result was wrong or misleading in practice — not just irrelevant to skip, but actually acted on and it didn't hold up. Lowers that memory's future ranking and moves it toward eviction sooner, without deleting it (it may still be right for a different query later). Use the `id` from the search_memory result you're downvoting.",
275
+ inputSchema: {
276
+ type: "object",
277
+ properties: {
278
+ id: { type: "string", description: "The memory's id, from a prior search_memory result." },
279
+ },
280
+ required: ["id"],
281
+ },
282
+ },
223
283
  {
224
284
  name: "get_routing_advice",
225
285
  annotations: { readOnlyHint: true, openWorldHint: false },
@@ -314,6 +374,7 @@ const toolDefinitions = [
314
374
  {
315
375
  name: "run_workspace_command",
316
376
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
377
+ execution: { taskSupport: "optional" },
317
378
  description: "Execute a bash command in the workspace root. Default timeout 120s — raise it with timeoutMs for a full test or build run (max 600s). Captures both stdout and stderr, and returns whatever was produced even if the command times out. Long test/build/lint output is distilled deterministically — for jest, vitest and tsc it returns the failing tests or diagnostics with their locations and reasons, dropping code frames and node_modules stack frames; anything else falls back to head/tail plus error-matching lines. The complete output is always stored first and the reply carries a handle: nothing is lost, use output_region to retrieve any part verbatim. Pass raw:true to skip distillation.",
318
379
  inputSchema: {
319
380
  type: "object",
@@ -1347,6 +1408,7 @@ const toolHandlers = {
1347
1408
  scrub_privacy: handleScrubPrivacy,
1348
1409
  search_memory: handleSearchMemory,
1349
1410
  store_memory: handleStoreMemory,
1411
+ downvote_memory: handleDownvoteMemory,
1350
1412
  get_routing_advice: handleGetRoutingAdvice,
1351
1413
  auto_heal: handleAutoHeal,
1352
1414
  read_workspace_file: handleReadWorkspaceFile,
@@ -1496,7 +1558,7 @@ export function setupToolsHandlers(server, onToolCall) {
1496
1558
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
1497
1559
  tools: toolDefinitionsArray,
1498
1560
  }));
1499
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
1561
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1500
1562
  const { name, arguments: args } = request.params;
1501
1563
  const startTime = Date.now();
1502
1564
  // ── Pro License Gate (granular: free tools work without license) ────────
@@ -1525,6 +1587,39 @@ export function setupToolsHandlers(server, onToolCall) {
1525
1587
  });
1526
1588
  throw new Error(`Unknown tool: ${name}`);
1527
1589
  }
1590
+ // ── Task-augmented call: hand off to tasks.ts instead of awaiting inline ────
1591
+ // A task-unaware client never sends `request.params.task`, so this only triggers when
1592
+ // the caller actually asked for it — today's synchronous behavior is unchanged otherwise.
1593
+ if (request.params.task && TASK_CAPABLE_TOOLS.has(name)) {
1594
+ const ledgerBefore = getLedgerLength();
1595
+ return runAsTask(name, (args || {}), request, extra.requestId, handler, (result, error) => {
1596
+ if (error) {
1597
+ onToolCall?.({
1598
+ tool: name,
1599
+ args: args || {},
1600
+ result: "ERROR",
1601
+ latency: Date.now() - startTime,
1602
+ error: error instanceof Error ? error.message.substring(0, 100) : String(error).substring(0, 100),
1603
+ });
1604
+ return;
1605
+ }
1606
+ if (getLedgerLength() === ledgerBefore) {
1607
+ recordReceiptEvent(receiptTypeForTool(name), name, {
1608
+ tool: name,
1609
+ ...receiptLabelMeta(name, args),
1610
+ });
1611
+ }
1612
+ const tokensImpact = estimateTokensFromResult(result);
1613
+ onToolCall?.({
1614
+ tool: name,
1615
+ args: args || {},
1616
+ result: "OK",
1617
+ latency: Date.now() - startTime,
1618
+ tokensImpact,
1619
+ });
1620
+ reportCost({ source: "toolResult", tokens: tokensImpact, toolName: name });
1621
+ });
1622
+ }
1528
1623
  try {
1529
1624
  // Sampled around the handler so a tool that logs its own, more specific event
1530
1625
  // (a cache hit, a miss) isn't double-counted by the generic entry below.
@@ -1560,6 +1655,9 @@ export function setupToolsHandlers(server, onToolCall) {
1560
1655
  throw error;
1561
1656
  }
1562
1657
  });
1658
+ // Registered after ListTools/CallTool: tests and other callers that assume "ListTools is
1659
+ // first, CallTool is second" (see tests/unit/tool-regressions.test.ts) key off that order.
1660
+ registerTaskHandlers(server);
1563
1661
  }
1564
1662
  // ── Tool Implementations ────────────────────────────────────────────
1565
1663
  async function handleScrubPrivacy(args) {
@@ -1583,11 +1681,12 @@ async function handleSearchMemory(args) {
1583
1681
  // formatting below then presents it as a reusable memory. Match smarter_cache's
1584
1682
  // threshold so both paths agree on what counts as a hit.
1585
1683
  const minSimilarity = typeof args?.minSimilarity === "number" ? args.minSimilarity : DEFAULT_MEMORY_SIMILARITY_FLOOR;
1684
+ const domain = typeof args?.domain === "string" && args.domain.trim() ? args.domain.trim() : undefined;
1586
1685
  if (!query)
1587
1686
  throw new Error("Query is required");
1588
1687
  try {
1589
1688
  const brain = getBrain();
1590
- const results = brain.search(query, limit, minSimilarity, { projectId: deriveProjectId() });
1689
+ const results = brain.search(query, limit, minSimilarity, { projectId: deriveProjectId(), domain });
1591
1690
  if (results.length === 0) {
1592
1691
  recordReceiptEvent("reasoning", query.substring(0, 100), {
1593
1692
  tool: "search_memory",
@@ -1611,7 +1710,7 @@ async function handleSearchMemory(args) {
1611
1710
  const staleCount = r.claims.filter((c) => !c.fresh).length;
1612
1711
  return `\nClaims (${r.claims.length - staleCount}/${r.claims.length} still fresh — trust only the ✓ ones):\n${lines.join("\n")}`;
1613
1712
  };
1614
- 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)}`;
1713
+ const formatResult = (r, i) => `Result ${i + 1} (id: ${r.id}, 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)}`;
1615
1714
  if (fresh.length > 0) {
1616
1715
  // Only a fresh hit (or an untracked, purely conceptual entry) counts as a real
1617
1716
  // avoided re-investigation — this is the only case worth crediting as savings.
@@ -1659,11 +1758,15 @@ async function handleStoreMemory(args) {
1659
1758
  const claims = Array.isArray(args?.claims)
1660
1759
  ? args.claims.filter((c) => c?.text)
1661
1760
  : undefined;
1761
+ const explicitDomain = typeof args?.domain === "string" && args.domain.trim() ? args.domain.trim() : undefined;
1762
+ // Auto-derive from the first tracked file when the caller didn't say — see
1763
+ // deriveDomainForFile for why this is a best-effort tag, not a hard classification.
1764
+ const domain = explicitDomain || (filePaths && filePaths.length > 0 ? deriveDomainForFile(filePaths[0]) : undefined);
1662
1765
  if (!query || !responseText)
1663
1766
  throw new Error("Query and response are required");
1664
1767
  try {
1665
1768
  const brain = getBrain();
1666
- const storeRes = brain.store(query, responseText, provider, 0.92, filePaths, undefined, outcome, symbols, claims);
1769
+ const storeRes = brain.store(query, responseText, provider, 0.92, filePaths, undefined, outcome, symbols, claims, domain);
1667
1770
  // Storing a memory saves nothing — it only creates the chance of a saving later.
1668
1771
  // Crediting tokens here inflated the ledger on write and then credited the same
1669
1772
  // answer again on every read. The saving is booked by search_memory on a fresh hit.
@@ -1676,13 +1779,33 @@ async function handleStoreMemory(args) {
1676
1779
  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.` : "";
1677
1780
  const outcomeNote = outcome === "failed" ? " Tagged as a FAILED attempt — future searches will surface it as a warning, not a suggestion." : "";
1678
1781
  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.` : "";
1679
- return { content: [{ type: "text", text: `Success: Memory stored. ${storeRes.reason}${trackingNote}${symbolNote}${outcomeNote}${claimsNote}` }] };
1782
+ const domainNote = domain
1783
+ ? ` Tagged with domain '${domain}'${explicitDomain ? "" : " (auto-derived from filePaths)"} — domain-scoped searches will prefer it.`
1784
+ : "";
1785
+ const conflictsNote = storeRes.conflicts && storeRes.conflicts.length > 0
1786
+ ? `\n\n⚠️ ${storeRes.conflicts.length} existing ${storeRes.conflicts.length === 1 ? "memory shares" : "memories share"} a symbol with this one but disagree on outcome — review before trusting either:\n` +
1787
+ storeRes.conflicts.map((c) => ` - [${c.outcome}] ${c.id}: ${c.query.substring(0, 120)}`).join("\n")
1788
+ : "";
1789
+ return { content: [{ type: "text", text: `Success: Memory stored. ${storeRes.reason}${trackingNote}${symbolNote}${outcomeNote}${claimsNote}${domainNote}${conflictsNote}` }] };
1680
1790
  }
1681
1791
  catch (e) {
1682
1792
  logError("store_memory", e);
1683
1793
  return { content: [{ type: "text", text: `Failed to store memory in local Brain: ${e.message}` }] };
1684
1794
  }
1685
1795
  }
1796
+ async function handleDownvoteMemory(args) {
1797
+ const id = args?.id;
1798
+ if (!id)
1799
+ throw new Error("id is required");
1800
+ try {
1801
+ const result = getBrain().downvote(id);
1802
+ return { content: [{ type: "text", text: result.message }] };
1803
+ }
1804
+ catch (e) {
1805
+ logError("downvote_memory", e);
1806
+ return { content: [{ type: "text", text: `Failed to downvote memory: ${e.message}` }] };
1807
+ }
1808
+ }
1686
1809
  async function handleGetRoutingAdvice(args) {
1687
1810
  const prompt = args?.prompt;
1688
1811
  const intendedModel = args?.intended_model;
@@ -1921,6 +2044,30 @@ function stripLineNumberGutter(text) {
1921
2044
  return text;
1922
2045
  return lines.map((l) => l.replace(GUTTER, "")).join("\n");
1923
2046
  }
2047
+ // Splices one resolved match into the file and writes it. Shared by the normal
2048
+ // single-match path and the ambiguous-match-resolved-via-elicitation path below.
2049
+ async function applyMatch(match, filePath, resolved, originalContent, searchContent, replaceContent) {
2050
+ let updatedContent;
2051
+ if (match.strategy === "exact" && match.charStart !== undefined && match.charEnd !== undefined) {
2052
+ // Splice by character offset — the search block may be a mid-line fragment, and
2053
+ // a whole-line splice would discard everything else sharing that line.
2054
+ updatedContent = originalContent.slice(0, match.charStart) + replaceContent + originalContent.slice(match.charEnd);
2055
+ }
2056
+ else {
2057
+ const contentLines = originalContent.split("\n");
2058
+ const finalReplace = reindentReplacement(replaceContent, searchContent, contentLines[match.startLine]);
2059
+ const updatedLines = [...contentLines.slice(0, match.startLine), ...finalReplace.split("\n"), ...contentLines.slice(match.endLine + 1)];
2060
+ updatedContent = updatedLines.join("\n");
2061
+ }
2062
+ const elicited = await tryElicitConfirmation(`Apply a patch to ${filePath}? This replaces ${searchContent.length} char(s) with ${replaceContent.length} char(s) ` +
2063
+ `starting at line ${match.startLine + 1}.`);
2064
+ if (elicited.supported && !elicited.confirmed) {
2065
+ return { content: [{ type: "text", text: `Patch to ${filePath} was not applied: declined during confirmation.` }] };
2066
+ }
2067
+ fs.writeFileSync(resolved, updatedContent, "utf8");
2068
+ const note = match.strategy === "exact" ? "" : ` (matched via ${match.strategy}, score ${match.score.toFixed(2)})`;
2069
+ return { content: [{ type: "text", text: `Success: Patch successfully applied to ${filePath}${note}` }] };
2070
+ }
1924
2071
  async function handleApplyWorkspacePatch(args) {
1925
2072
  const filePath = args?.filePath;
1926
2073
  const rawSearch = args?.searchContent;
@@ -1943,6 +2090,15 @@ async function handleApplyWorkspacePatch(args) {
1943
2090
  const matchResult = findMatch(originalContent, searchContent);
1944
2091
  if (!Array.isArray(matchResult)) {
1945
2092
  if (matchResult.reason === "ambiguous") {
2093
+ const candidates = matchResult.candidates ?? [];
2094
+ if (candidates.length > 0) {
2095
+ const chosen = await tryElicitChoice(`${candidates.length} equally good matches found for the search content in ${filePath}. Which one should be patched?`, candidates.map((c) => ({
2096
+ title: `line ${c.startLine + 1}: ${c.matchedText.split("\n")[0].slice(0, 80)}`,
2097
+ })));
2098
+ if (chosen.index !== null) {
2099
+ return applyMatch(candidates[chosen.index], filePath, resolved, originalContent, searchContent, replaceContent);
2100
+ }
2101
+ }
1946
2102
  return {
1947
2103
  content: [
1948
2104
  {
@@ -1971,33 +2127,71 @@ async function handleApplyWorkspacePatch(args) {
1971
2127
  ],
1972
2128
  };
1973
2129
  }
1974
- const match = matchResult[0];
1975
- let updatedContent;
1976
- if (match.strategy === "exact" && match.charStart !== undefined && match.charEnd !== undefined) {
1977
- // Splice by character offset — the search block may be a mid-line fragment, and
1978
- // a whole-line splice would discard everything else sharing that line.
1979
- updatedContent = originalContent.slice(0, match.charStart) + replaceContent + originalContent.slice(match.charEnd);
1980
- }
1981
- else {
1982
- const contentLines = originalContent.split("\n");
1983
- const finalReplace = reindentReplacement(replaceContent, searchContent, contentLines[match.startLine]);
1984
- const updatedLines = [...contentLines.slice(0, match.startLine), ...finalReplace.split("\n"), ...contentLines.slice(match.endLine + 1)];
1985
- updatedContent = updatedLines.join("\n");
1986
- }
1987
- const elicited = await tryElicitConfirmation(`Apply a patch to ${filePath}? This replaces ${searchContent.length} char(s) with ${replaceContent.length} char(s) ` +
1988
- `starting at line ${match.startLine + 1}.`);
1989
- if (elicited.supported && !elicited.confirmed) {
1990
- return { content: [{ type: "text", text: `Patch to ${filePath} was not applied: declined during confirmation.` }] };
1991
- }
1992
- fs.writeFileSync(resolved, updatedContent, "utf8");
1993
- const note = match.strategy === "exact" ? "" : ` (matched via ${match.strategy}, score ${match.score.toFixed(2)})`;
1994
- return { content: [{ type: "text", text: `Success: Patch successfully applied to ${filePath}${note}` }] };
2130
+ return applyMatch(matchResult[0], filePath, resolved, originalContent, searchContent, replaceContent);
1995
2131
  }
1996
2132
  catch (err) {
1997
2133
  logError("apply_workspace_patch", err);
1998
2134
  return { content: [{ type: "text", text: `Error applying patch: ${err.message}` }] };
1999
2135
  }
2000
2136
  }
2137
+ // Async replacement for spawnSync: a blocking syscall inside a `tools/call` handler
2138
+ // freezes the whole (single-threaded) MCP server for the run's entire duration — including
2139
+ // task-status polls for THIS run once it's task-augmented (see runAsTask in tasks.ts), which
2140
+ // would otherwise defeat the point of making the tool pollable. `spawn` keeps the event loop
2141
+ // free while the command runs. Shape of the resolved value mirrors spawnSync's return value
2142
+ // on the fields the caller actually reads, so the rest of handleRunWorkspaceCommand is unchanged.
2143
+ function spawnCollect(command, cwd, timeoutMs) {
2144
+ return new Promise((resolve) => {
2145
+ const child = spawn(command, {
2146
+ cwd,
2147
+ shell: true,
2148
+ env: { ...process.env },
2149
+ // spawnSync closed stdin immediately (no `input` option given), so a command reading
2150
+ // from stdin (e.g. `sed` with no file argument) saw EOF right away. Plain `spawn`
2151
+ // leaves stdin as an open pipe by default — nothing ever writes to or closes it — so
2152
+ // the same command hangs until the timeout kills it. "ignore" reproduces the old,
2153
+ // immediate-EOF behavior.
2154
+ stdio: ["ignore", "pipe", "pipe"],
2155
+ });
2156
+ let stdout = "";
2157
+ let stderr = "";
2158
+ let stdoutBytes = 0;
2159
+ let stderrBytes = 0;
2160
+ const MAX_BUFFER = 20 * 1024 * 1024;
2161
+ let timedOut = false;
2162
+ let settled = false;
2163
+ const timer = setTimeout(() => {
2164
+ timedOut = true;
2165
+ child.kill("SIGTERM");
2166
+ }, timeoutMs);
2167
+ child.stdout?.on("data", (chunk) => {
2168
+ if (stdoutBytes >= MAX_BUFFER)
2169
+ return;
2170
+ stdoutBytes += chunk.length;
2171
+ stdout += chunk.toString("utf8");
2172
+ });
2173
+ child.stderr?.on("data", (chunk) => {
2174
+ if (stderrBytes >= MAX_BUFFER)
2175
+ return;
2176
+ stderrBytes += chunk.length;
2177
+ stderr += chunk.toString("utf8");
2178
+ });
2179
+ child.on("error", (err) => {
2180
+ if (settled)
2181
+ return;
2182
+ settled = true;
2183
+ clearTimeout(timer);
2184
+ resolve({ stdout, stderr, status: null, timedOut, error: err });
2185
+ });
2186
+ child.on("close", (code) => {
2187
+ if (settled)
2188
+ return;
2189
+ settled = true;
2190
+ clearTimeout(timer);
2191
+ resolve({ stdout, stderr, status: code, timedOut });
2192
+ });
2193
+ });
2194
+ }
2001
2195
  async function handleRunWorkspaceCommand(args) {
2002
2196
  const command = args?.command;
2003
2197
  if (!command)
@@ -2027,10 +2221,10 @@ async function handleRunWorkspaceCommand(args) {
2027
2221
  // ── End allowlist check ────────────────────────────────────────────────
2028
2222
  const workspaceRoot = process.cwd();
2029
2223
  try {
2030
- // spawnSync rather than execSync: execSync leaves stderr attached to the parent unless
2031
- // stdio is overridden, which silently dropped the output of every tool that reports on
2032
- // stderr (jest and tsc both do) on a successful run. It also gives us stdout, stderr and
2033
- // the exit code through one code path instead of a throw-on-failure split.
2224
+ // spawn (via spawnCollect above) rather than execSync: execSync leaves stderr attached to
2225
+ // the parent unless stdio is overridden, which silently dropped the output of every tool
2226
+ // that reports on stderr (jest and tsc both do) on a successful run. It also gives us
2227
+ // stdout, stderr and the exit code through one code path instead of a throw-on-failure split.
2034
2228
  // 15s was below the runtime of the commands this tool exists for: a real `npm test` or
2035
2229
  // `tsc --noEmit` always tripped it, so the distillation pipeline never ran on the output
2036
2230
  // it was built for and the call was pure overhead before falling back to a plain shell.
@@ -2042,15 +2236,8 @@ async function handleRunWorkspaceCommand(args) {
2042
2236
  if (elicited.supported && !elicited.confirmed) {
2043
2237
  return { content: [{ type: "text", text: `Command was not run: declined during confirmation.\n\nCommand: ${command}` }] };
2044
2238
  }
2045
- const result = spawnSync(command, {
2046
- cwd: workspaceRoot,
2047
- shell: true,
2048
- encoding: "utf8",
2049
- timeout: timeoutMs,
2050
- maxBuffer: 20 * 1024 * 1024,
2051
- env: { ...process.env },
2052
- });
2053
- const timedOut = !!result.error && result.error.code === "ETIMEDOUT";
2239
+ const result = await spawnCollect(command, workspaceRoot, timeoutMs);
2240
+ const timedOut = result.timedOut;
2054
2241
  if (result.error && !timedOut) {
2055
2242
  return {
2056
2243
  content: [{ type: "text", text: `Command could not be executed: ${result.error.message}` }],
@@ -4963,6 +5150,37 @@ async function handleSmartFileSlice(args) {
4963
5150
  }
4964
5151
  }
4965
5152
  // ── Test Oracle ──────────────────────────────────────────────────────
5153
+ /**
5154
+ * Auto-capture a green test run into the Brain, tagged `outcome: 'confirmed'` and tracked
5155
+ * against the files actually changed. This is the auto-store hook: today store_memory only
5156
+ * fires when the agent remembers to call it, so working fixes routinely go uncaptured. A
5157
+ * passing suite over an uncommitted diff is real, cheap evidence something worked — reusing
5158
+ * it costs nothing store_memory wouldn't have cost anyway, and store()'s own dedup guard
5159
+ * means re-running the same green suite repeatedly just no-ops instead of piling up entries.
5160
+ *
5161
+ * Silent on a clean tree (nothing changed, nothing to attribute the pass to) or when the
5162
+ * repo isn't git-tracked. Returns a short suffix for resultText — this must never happen
5163
+ * invisibly, or a future search_memory hit would look reasoned-from-scratch instead of
5164
+ * traced back to an automated test run.
5165
+ */
5166
+ function autoStoreGreenTests(command, workspaceRoot) {
5167
+ try {
5168
+ const changed = changedFilesFromGit(workspaceRoot);
5169
+ if (changed.length === 0)
5170
+ return "";
5171
+ const domain = deriveDomainForFile(changed[0]);
5172
+ const query = `tests passing after changes to ${changed.slice(0, 5).join(", ")}${changed.length > 5 ? ` and ${changed.length - 5} more file(s)` : ""}`;
5173
+ const response = `\`${command}\` passed with these files changed (uncommitted at capture time):\n${changed.join("\n")}`;
5174
+ const storeRes = getBrain().store(query, response, "test_oracle_auto", 0.92, changed, undefined, "confirmed", undefined, undefined, domain);
5175
+ if (!storeRes.stored)
5176
+ return "";
5177
+ return `\n\n[Lemma] Auto-captured this green state in Brain (${changed.length} file(s), tagged confirmed) — search_memory will surface it, and will flag it stale the moment any of these files change again.`;
5178
+ }
5179
+ catch {
5180
+ // Never let auto-capture break the actual test result the caller asked for.
5181
+ return "";
5182
+ }
5183
+ }
4966
5184
  async function handleTestOracle(args) {
4967
5185
  const command = args.command || "npm test";
4968
5186
  const workspaceRoot = process.cwd();
@@ -4973,6 +5191,7 @@ async function handleTestOracle(args) {
4973
5191
  try {
4974
5192
  const out = execSync(command, { cwd: workspaceRoot, encoding: "utf8", timeout: 30000 });
4975
5193
  resultText = `All tests passed!\n\nOutput:\n${out.substring(0, 1000)}`;
5194
+ resultText += autoStoreGreenTests(command, workspaceRoot);
4976
5195
  }
4977
5196
  catch (e) {
4978
5197
  const stdout = e.stdout || "";
@@ -5288,6 +5507,41 @@ async function handleFileIntentIndex(args) {
5288
5507
  }
5289
5508
  }
5290
5509
  const COG_MAP_FILE = path.join(os.homedir(), ".lemma-cache", "cog_map.json");
5510
+ /**
5511
+ * Best-effort domain for a repo-relative file path, so store_memory and the test-oracle
5512
+ * auto-store below don't require a human to type `domain` by hand every time.
5513
+ *
5514
+ * Prefers the persisted Cognitive Map (built via `cognitive_map` action "build") when it
5515
+ * has this exact file listed — that reflects an actual workspace scan. Falls back to the
5516
+ * same lightweight substring heuristic "build" uses, so a project that never ran "build"
5517
+ * still gets a reasonable tag instead of none at all. Returns undefined rather than
5518
+ * "general" when nothing matches — an absent domain stays visible to every domain-scoped
5519
+ * search (see BrainEntry.domain), while a wrong guess would actively mislead one.
5520
+ */
5521
+ function deriveDomainForFile(relPath) {
5522
+ const norm = relPath.replace(/^\//, "");
5523
+ if (fs.existsSync(COG_MAP_FILE)) {
5524
+ try {
5525
+ const map = JSON.parse(fs.readFileSync(COG_MAP_FILE, "utf8"));
5526
+ for (const node of Object.values(map.nodes || {})) {
5527
+ if (node.files.includes(norm))
5528
+ return node.domain;
5529
+ }
5530
+ }
5531
+ catch { /* fall through to heuristic */ }
5532
+ }
5533
+ if (norm.includes("mcp"))
5534
+ return "mcp";
5535
+ if (norm.includes("subconscious") || norm.includes("brain"))
5536
+ return "memory";
5537
+ if (norm.includes("security") || norm.includes("scrubber"))
5538
+ return "security";
5539
+ if (norm.includes("pr-review"))
5540
+ return "pr-review";
5541
+ if (norm.includes("utils"))
5542
+ return "utils";
5543
+ return undefined;
5544
+ }
5291
5545
  async function handleCognitiveMap(args) {
5292
5546
  const action = args.action;
5293
5547
  const domain = args.domain;