@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
@@ -79,6 +79,7 @@ const StateHashCache_1 = require("../utils/StateHashCache");
79
79
  const TokenReceipt_1 = require("../utils/TokenReceipt");
80
80
  const CommandOutputDistiller_1 = require("../utils/CommandOutputDistiller");
81
81
  const PatchMatcher_1 = require("../utils/PatchMatcher");
82
+ const tasks_1 = require("./tasks");
82
83
  const WorkspaceSearch_1 = require("../utils/WorkspaceSearch");
83
84
  const LocalPrefilter_1 = require("../utils/LocalPrefilter");
84
85
  const ReadWorkspaceCache_1 = require("../utils/ReadWorkspaceCache");
@@ -110,7 +111,7 @@ function isProUser() {
110
111
  }
111
112
  const FREE_TOOLS = new Set([
112
113
  // Cache & Memory — the hook that shows instant value
113
- "smarter_cache", "state_hash_cache", "token_receipt", "search_memory", "store_memory", "get_project_history",
114
+ "smarter_cache", "state_hash_cache", "token_receipt", "search_memory", "store_memory", "downvote_memory", "get_project_history",
114
115
  // Token optimization — shows what they're saving
115
116
  "token_budget", "squeeze_prompt", "turbosqueeze",
116
117
  // Utility — just enough to function
@@ -180,6 +181,45 @@ async function tryElicitConfirmation(summary) {
180
181
  return { supported: false, confirmed: true };
181
182
  }
182
183
  }
184
+ /**
185
+ * Ask the client to pick one of several candidates via a typed (oneOf) elicitation form,
186
+ * instead of the plain confirm/deny shape tryElicitConfirmation uses. Same defensive
187
+ * contract: no support, a decline, or a malformed response all just mean "couldn't
188
+ * resolve it this way" — the caller falls back to its own error/default behavior, never
189
+ * blocks or throws.
190
+ */
191
+ async function tryElicitChoice(message, options) {
192
+ if (!mcpServerRef || options.length === 0)
193
+ return { supported: false, index: null };
194
+ try {
195
+ const result = await mcpServerRef.elicitInput({
196
+ message,
197
+ requestedSchema: {
198
+ type: "object",
199
+ properties: {
200
+ choice: {
201
+ type: "string",
202
+ title: "Which one?",
203
+ oneOf: options.map((opt, i) => ({ const: String(i), title: opt.title })),
204
+ },
205
+ },
206
+ required: ["choice"],
207
+ },
208
+ });
209
+ if (result.action !== "accept" || !result.content) {
210
+ return { supported: true, index: null };
211
+ }
212
+ const idx = Number(result.content.choice);
213
+ if (!Number.isInteger(idx) || idx < 0 || idx >= options.length) {
214
+ return { supported: true, index: null };
215
+ }
216
+ return { supported: true, index: idx };
217
+ }
218
+ catch (err) {
219
+ (0, utils_1.logWarn)("elicitation", "Client does not support elicitation (or the request failed) — no choice made");
220
+ return { supported: false, index: null };
221
+ }
222
+ }
183
223
  const toolDefinitions = [
184
224
  {
185
225
  name: "scrub_privacy",
@@ -196,7 +236,7 @@ const toolDefinitions = [
196
236
  {
197
237
  name: "search_memory",
198
238
  annotations: { readOnlyHint: true, openWorldHint: false },
199
- 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.",
239
+ 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.",
200
240
  inputSchema: {
201
241
  type: "object",
202
242
  properties: {
@@ -207,6 +247,10 @@ const toolDefinitions = [
207
247
  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.",
208
248
  default: 0.75,
209
249
  },
250
+ domain: {
251
+ type: "string",
252
+ 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.",
253
+ },
210
254
  },
211
255
  required: ["query"],
212
256
  },
@@ -259,10 +303,26 @@ const toolDefinitions = [
259
303
  },
260
304
  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.",
261
305
  },
306
+ domain: {
307
+ type: "string",
308
+ 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).",
309
+ },
262
310
  },
263
311
  required: ["query", "response"],
264
312
  },
265
313
  },
314
+ {
315
+ name: "downvote_memory",
316
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
317
+ 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.",
318
+ inputSchema: {
319
+ type: "object",
320
+ properties: {
321
+ id: { type: "string", description: "The memory's id, from a prior search_memory result." },
322
+ },
323
+ required: ["id"],
324
+ },
325
+ },
266
326
  {
267
327
  name: "get_routing_advice",
268
328
  annotations: { readOnlyHint: true, openWorldHint: false },
@@ -357,6 +417,7 @@ const toolDefinitions = [
357
417
  {
358
418
  name: "run_workspace_command",
359
419
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
420
+ execution: { taskSupport: "optional" },
360
421
  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.",
361
422
  inputSchema: {
362
423
  type: "object",
@@ -1390,6 +1451,7 @@ const toolHandlers = {
1390
1451
  scrub_privacy: handleScrubPrivacy,
1391
1452
  search_memory: handleSearchMemory,
1392
1453
  store_memory: handleStoreMemory,
1454
+ downvote_memory: handleDownvoteMemory,
1393
1455
  get_routing_advice: handleGetRoutingAdvice,
1394
1456
  auto_heal: handleAutoHeal,
1395
1457
  read_workspace_file: handleReadWorkspaceFile,
@@ -1539,7 +1601,7 @@ function setupToolsHandlers(server, onToolCall) {
1539
1601
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
1540
1602
  tools: toolDefinitionsArray,
1541
1603
  }));
1542
- server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
1604
+ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request, extra) => {
1543
1605
  const { name, arguments: args } = request.params;
1544
1606
  const startTime = Date.now();
1545
1607
  // ── Pro License Gate (granular: free tools work without license) ────────
@@ -1568,6 +1630,39 @@ function setupToolsHandlers(server, onToolCall) {
1568
1630
  });
1569
1631
  throw new Error(`Unknown tool: ${name}`);
1570
1632
  }
1633
+ // ── Task-augmented call: hand off to tasks.ts instead of awaiting inline ────
1634
+ // A task-unaware client never sends `request.params.task`, so this only triggers when
1635
+ // the caller actually asked for it — today's synchronous behavior is unchanged otherwise.
1636
+ if (request.params.task && tasks_1.TASK_CAPABLE_TOOLS.has(name)) {
1637
+ const ledgerBefore = (0, TokenReceipt_1.getLedgerLength)();
1638
+ return (0, tasks_1.runAsTask)(name, (args || {}), request, extra.requestId, handler, (result, error) => {
1639
+ if (error) {
1640
+ onToolCall?.({
1641
+ tool: name,
1642
+ args: args || {},
1643
+ result: "ERROR",
1644
+ latency: Date.now() - startTime,
1645
+ error: error instanceof Error ? error.message.substring(0, 100) : String(error).substring(0, 100),
1646
+ });
1647
+ return;
1648
+ }
1649
+ if ((0, TokenReceipt_1.getLedgerLength)() === ledgerBefore) {
1650
+ (0, TokenReceipt_1.recordReceiptEvent)(receiptTypeForTool(name), name, {
1651
+ tool: name,
1652
+ ...receiptLabelMeta(name, args),
1653
+ });
1654
+ }
1655
+ const tokensImpact = (0, utils_1.estimateTokensFromResult)(result);
1656
+ onToolCall?.({
1657
+ tool: name,
1658
+ args: args || {},
1659
+ result: "OK",
1660
+ latency: Date.now() - startTime,
1661
+ tokensImpact,
1662
+ });
1663
+ (0, reportSavings_1.reportCost)({ source: "toolResult", tokens: tokensImpact, toolName: name });
1664
+ });
1665
+ }
1571
1666
  try {
1572
1667
  // Sampled around the handler so a tool that logs its own, more specific event
1573
1668
  // (a cache hit, a miss) isn't double-counted by the generic entry below.
@@ -1603,6 +1698,9 @@ function setupToolsHandlers(server, onToolCall) {
1603
1698
  throw error;
1604
1699
  }
1605
1700
  });
1701
+ // Registered after ListTools/CallTool: tests and other callers that assume "ListTools is
1702
+ // first, CallTool is second" (see tests/unit/tool-regressions.test.ts) key off that order.
1703
+ (0, tasks_1.registerTaskHandlers)(server);
1606
1704
  }
1607
1705
  // ── Tool Implementations ────────────────────────────────────────────
1608
1706
  async function handleScrubPrivacy(args) {
@@ -1626,11 +1724,12 @@ async function handleSearchMemory(args) {
1626
1724
  // formatting below then presents it as a reusable memory. Match smarter_cache's
1627
1725
  // threshold so both paths agree on what counts as a hit.
1628
1726
  const minSimilarity = typeof args?.minSimilarity === "number" ? args.minSimilarity : DEFAULT_MEMORY_SIMILARITY_FLOOR;
1727
+ const domain = typeof args?.domain === "string" && args.domain.trim() ? args.domain.trim() : undefined;
1629
1728
  if (!query)
1630
1729
  throw new Error("Query is required");
1631
1730
  try {
1632
1731
  const brain = (0, TheBrainV2_1.getBrain)();
1633
- const results = brain.search(query, limit, minSimilarity, { projectId: (0, TheBrainV2_1.deriveProjectId)() });
1732
+ const results = brain.search(query, limit, minSimilarity, { projectId: (0, TheBrainV2_1.deriveProjectId)(), domain });
1634
1733
  if (results.length === 0) {
1635
1734
  (0, TokenReceipt_1.recordReceiptEvent)("reasoning", query.substring(0, 100), {
1636
1735
  tool: "search_memory",
@@ -1654,7 +1753,7 @@ async function handleSearchMemory(args) {
1654
1753
  const staleCount = r.claims.filter((c) => !c.fresh).length;
1655
1754
  return `\nClaims (${r.claims.length - staleCount}/${r.claims.length} still fresh — trust only the ✓ ones):\n${lines.join("\n")}`;
1656
1755
  };
1657
- 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)}`;
1756
+ 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)}`;
1658
1757
  if (fresh.length > 0) {
1659
1758
  // Only a fresh hit (or an untracked, purely conceptual entry) counts as a real
1660
1759
  // avoided re-investigation — this is the only case worth crediting as savings.
@@ -1702,11 +1801,15 @@ async function handleStoreMemory(args) {
1702
1801
  const claims = Array.isArray(args?.claims)
1703
1802
  ? args.claims.filter((c) => c?.text)
1704
1803
  : undefined;
1804
+ const explicitDomain = typeof args?.domain === "string" && args.domain.trim() ? args.domain.trim() : undefined;
1805
+ // Auto-derive from the first tracked file when the caller didn't say — see
1806
+ // deriveDomainForFile for why this is a best-effort tag, not a hard classification.
1807
+ const domain = explicitDomain || (filePaths && filePaths.length > 0 ? deriveDomainForFile(filePaths[0]) : undefined);
1705
1808
  if (!query || !responseText)
1706
1809
  throw new Error("Query and response are required");
1707
1810
  try {
1708
1811
  const brain = (0, TheBrainV2_1.getBrain)();
1709
- const storeRes = brain.store(query, responseText, provider, 0.92, filePaths, undefined, outcome, symbols, claims);
1812
+ const storeRes = brain.store(query, responseText, provider, 0.92, filePaths, undefined, outcome, symbols, claims, domain);
1710
1813
  // Storing a memory saves nothing — it only creates the chance of a saving later.
1711
1814
  // Crediting tokens here inflated the ledger on write and then credited the same
1712
1815
  // answer again on every read. The saving is booked by search_memory on a fresh hit.
@@ -1719,13 +1822,33 @@ async function handleStoreMemory(args) {
1719
1822
  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.` : "";
1720
1823
  const outcomeNote = outcome === "failed" ? " Tagged as a FAILED attempt — future searches will surface it as a warning, not a suggestion." : "";
1721
1824
  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.` : "";
1722
- return { content: [{ type: "text", text: `Success: Memory stored. ${storeRes.reason}${trackingNote}${symbolNote}${outcomeNote}${claimsNote}` }] };
1825
+ const domainNote = domain
1826
+ ? ` Tagged with domain '${domain}'${explicitDomain ? "" : " (auto-derived from filePaths)"} — domain-scoped searches will prefer it.`
1827
+ : "";
1828
+ const conflictsNote = storeRes.conflicts && storeRes.conflicts.length > 0
1829
+ ? `\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` +
1830
+ storeRes.conflicts.map((c) => ` - [${c.outcome}] ${c.id}: ${c.query.substring(0, 120)}`).join("\n")
1831
+ : "";
1832
+ return { content: [{ type: "text", text: `Success: Memory stored. ${storeRes.reason}${trackingNote}${symbolNote}${outcomeNote}${claimsNote}${domainNote}${conflictsNote}` }] };
1723
1833
  }
1724
1834
  catch (e) {
1725
1835
  (0, utils_1.logError)("store_memory", e);
1726
1836
  return { content: [{ type: "text", text: `Failed to store memory in local Brain: ${e.message}` }] };
1727
1837
  }
1728
1838
  }
1839
+ async function handleDownvoteMemory(args) {
1840
+ const id = args?.id;
1841
+ if (!id)
1842
+ throw new Error("id is required");
1843
+ try {
1844
+ const result = (0, TheBrainV2_1.getBrain)().downvote(id);
1845
+ return { content: [{ type: "text", text: result.message }] };
1846
+ }
1847
+ catch (e) {
1848
+ (0, utils_1.logError)("downvote_memory", e);
1849
+ return { content: [{ type: "text", text: `Failed to downvote memory: ${e.message}` }] };
1850
+ }
1851
+ }
1729
1852
  async function handleGetRoutingAdvice(args) {
1730
1853
  const prompt = args?.prompt;
1731
1854
  const intendedModel = args?.intended_model;
@@ -1964,6 +2087,30 @@ function stripLineNumberGutter(text) {
1964
2087
  return text;
1965
2088
  return lines.map((l) => l.replace(GUTTER, "")).join("\n");
1966
2089
  }
2090
+ // Splices one resolved match into the file and writes it. Shared by the normal
2091
+ // single-match path and the ambiguous-match-resolved-via-elicitation path below.
2092
+ async function applyMatch(match, filePath, resolved, originalContent, searchContent, replaceContent) {
2093
+ let updatedContent;
2094
+ if (match.strategy === "exact" && match.charStart !== undefined && match.charEnd !== undefined) {
2095
+ // Splice by character offset — the search block may be a mid-line fragment, and
2096
+ // a whole-line splice would discard everything else sharing that line.
2097
+ updatedContent = originalContent.slice(0, match.charStart) + replaceContent + originalContent.slice(match.charEnd);
2098
+ }
2099
+ else {
2100
+ const contentLines = originalContent.split("\n");
2101
+ const finalReplace = (0, PatchMatcher_1.reindentReplacement)(replaceContent, searchContent, contentLines[match.startLine]);
2102
+ const updatedLines = [...contentLines.slice(0, match.startLine), ...finalReplace.split("\n"), ...contentLines.slice(match.endLine + 1)];
2103
+ updatedContent = updatedLines.join("\n");
2104
+ }
2105
+ const elicited = await tryElicitConfirmation(`Apply a patch to ${filePath}? This replaces ${searchContent.length} char(s) with ${replaceContent.length} char(s) ` +
2106
+ `starting at line ${match.startLine + 1}.`);
2107
+ if (elicited.supported && !elicited.confirmed) {
2108
+ return { content: [{ type: "text", text: `Patch to ${filePath} was not applied: declined during confirmation.` }] };
2109
+ }
2110
+ fs_1.default.writeFileSync(resolved, updatedContent, "utf8");
2111
+ const note = match.strategy === "exact" ? "" : ` (matched via ${match.strategy}, score ${match.score.toFixed(2)})`;
2112
+ return { content: [{ type: "text", text: `Success: Patch successfully applied to ${filePath}${note}` }] };
2113
+ }
1967
2114
  async function handleApplyWorkspacePatch(args) {
1968
2115
  const filePath = args?.filePath;
1969
2116
  const rawSearch = args?.searchContent;
@@ -1986,6 +2133,15 @@ async function handleApplyWorkspacePatch(args) {
1986
2133
  const matchResult = (0, PatchMatcher_1.findMatch)(originalContent, searchContent);
1987
2134
  if (!Array.isArray(matchResult)) {
1988
2135
  if (matchResult.reason === "ambiguous") {
2136
+ const candidates = matchResult.candidates ?? [];
2137
+ if (candidates.length > 0) {
2138
+ const chosen = await tryElicitChoice(`${candidates.length} equally good matches found for the search content in ${filePath}. Which one should be patched?`, candidates.map((c) => ({
2139
+ title: `line ${c.startLine + 1}: ${c.matchedText.split("\n")[0].slice(0, 80)}`,
2140
+ })));
2141
+ if (chosen.index !== null) {
2142
+ return applyMatch(candidates[chosen.index], filePath, resolved, originalContent, searchContent, replaceContent);
2143
+ }
2144
+ }
1989
2145
  return {
1990
2146
  content: [
1991
2147
  {
@@ -2014,33 +2170,71 @@ async function handleApplyWorkspacePatch(args) {
2014
2170
  ],
2015
2171
  };
2016
2172
  }
2017
- const match = matchResult[0];
2018
- let updatedContent;
2019
- if (match.strategy === "exact" && match.charStart !== undefined && match.charEnd !== undefined) {
2020
- // Splice by character offset — the search block may be a mid-line fragment, and
2021
- // a whole-line splice would discard everything else sharing that line.
2022
- updatedContent = originalContent.slice(0, match.charStart) + replaceContent + originalContent.slice(match.charEnd);
2023
- }
2024
- else {
2025
- const contentLines = originalContent.split("\n");
2026
- const finalReplace = (0, PatchMatcher_1.reindentReplacement)(replaceContent, searchContent, contentLines[match.startLine]);
2027
- const updatedLines = [...contentLines.slice(0, match.startLine), ...finalReplace.split("\n"), ...contentLines.slice(match.endLine + 1)];
2028
- updatedContent = updatedLines.join("\n");
2029
- }
2030
- const elicited = await tryElicitConfirmation(`Apply a patch to ${filePath}? This replaces ${searchContent.length} char(s) with ${replaceContent.length} char(s) ` +
2031
- `starting at line ${match.startLine + 1}.`);
2032
- if (elicited.supported && !elicited.confirmed) {
2033
- return { content: [{ type: "text", text: `Patch to ${filePath} was not applied: declined during confirmation.` }] };
2034
- }
2035
- fs_1.default.writeFileSync(resolved, updatedContent, "utf8");
2036
- const note = match.strategy === "exact" ? "" : ` (matched via ${match.strategy}, score ${match.score.toFixed(2)})`;
2037
- return { content: [{ type: "text", text: `Success: Patch successfully applied to ${filePath}${note}` }] };
2173
+ return applyMatch(matchResult[0], filePath, resolved, originalContent, searchContent, replaceContent);
2038
2174
  }
2039
2175
  catch (err) {
2040
2176
  (0, utils_1.logError)("apply_workspace_patch", err);
2041
2177
  return { content: [{ type: "text", text: `Error applying patch: ${err.message}` }] };
2042
2178
  }
2043
2179
  }
2180
+ // Async replacement for spawnSync: a blocking syscall inside a `tools/call` handler
2181
+ // freezes the whole (single-threaded) MCP server for the run's entire duration — including
2182
+ // task-status polls for THIS run once it's task-augmented (see runAsTask in tasks.ts), which
2183
+ // would otherwise defeat the point of making the tool pollable. `spawn` keeps the event loop
2184
+ // free while the command runs. Shape of the resolved value mirrors spawnSync's return value
2185
+ // on the fields the caller actually reads, so the rest of handleRunWorkspaceCommand is unchanged.
2186
+ function spawnCollect(command, cwd, timeoutMs) {
2187
+ return new Promise((resolve) => {
2188
+ const child = (0, child_process_1.spawn)(command, {
2189
+ cwd,
2190
+ shell: true,
2191
+ env: { ...process.env },
2192
+ // spawnSync closed stdin immediately (no `input` option given), so a command reading
2193
+ // from stdin (e.g. `sed` with no file argument) saw EOF right away. Plain `spawn`
2194
+ // leaves stdin as an open pipe by default — nothing ever writes to or closes it — so
2195
+ // the same command hangs until the timeout kills it. "ignore" reproduces the old,
2196
+ // immediate-EOF behavior.
2197
+ stdio: ["ignore", "pipe", "pipe"],
2198
+ });
2199
+ let stdout = "";
2200
+ let stderr = "";
2201
+ let stdoutBytes = 0;
2202
+ let stderrBytes = 0;
2203
+ const MAX_BUFFER = 20 * 1024 * 1024;
2204
+ let timedOut = false;
2205
+ let settled = false;
2206
+ const timer = setTimeout(() => {
2207
+ timedOut = true;
2208
+ child.kill("SIGTERM");
2209
+ }, timeoutMs);
2210
+ child.stdout?.on("data", (chunk) => {
2211
+ if (stdoutBytes >= MAX_BUFFER)
2212
+ return;
2213
+ stdoutBytes += chunk.length;
2214
+ stdout += chunk.toString("utf8");
2215
+ });
2216
+ child.stderr?.on("data", (chunk) => {
2217
+ if (stderrBytes >= MAX_BUFFER)
2218
+ return;
2219
+ stderrBytes += chunk.length;
2220
+ stderr += chunk.toString("utf8");
2221
+ });
2222
+ child.on("error", (err) => {
2223
+ if (settled)
2224
+ return;
2225
+ settled = true;
2226
+ clearTimeout(timer);
2227
+ resolve({ stdout, stderr, status: null, timedOut, error: err });
2228
+ });
2229
+ child.on("close", (code) => {
2230
+ if (settled)
2231
+ return;
2232
+ settled = true;
2233
+ clearTimeout(timer);
2234
+ resolve({ stdout, stderr, status: code, timedOut });
2235
+ });
2236
+ });
2237
+ }
2044
2238
  async function handleRunWorkspaceCommand(args) {
2045
2239
  const command = args?.command;
2046
2240
  if (!command)
@@ -2070,10 +2264,10 @@ async function handleRunWorkspaceCommand(args) {
2070
2264
  // ── End allowlist check ────────────────────────────────────────────────
2071
2265
  const workspaceRoot = process.cwd();
2072
2266
  try {
2073
- // spawnSync rather than execSync: execSync leaves stderr attached to the parent unless
2074
- // stdio is overridden, which silently dropped the output of every tool that reports on
2075
- // stderr (jest and tsc both do) on a successful run. It also gives us stdout, stderr and
2076
- // the exit code through one code path instead of a throw-on-failure split.
2267
+ // spawn (via spawnCollect above) rather than execSync: execSync leaves stderr attached to
2268
+ // the parent unless stdio is overridden, which silently dropped the output of every tool
2269
+ // that reports on stderr (jest and tsc both do) on a successful run. It also gives us
2270
+ // stdout, stderr and the exit code through one code path instead of a throw-on-failure split.
2077
2271
  // 15s was below the runtime of the commands this tool exists for: a real `npm test` or
2078
2272
  // `tsc --noEmit` always tripped it, so the distillation pipeline never ran on the output
2079
2273
  // it was built for and the call was pure overhead before falling back to a plain shell.
@@ -2085,15 +2279,8 @@ async function handleRunWorkspaceCommand(args) {
2085
2279
  if (elicited.supported && !elicited.confirmed) {
2086
2280
  return { content: [{ type: "text", text: `Command was not run: declined during confirmation.\n\nCommand: ${command}` }] };
2087
2281
  }
2088
- const result = (0, child_process_1.spawnSync)(command, {
2089
- cwd: workspaceRoot,
2090
- shell: true,
2091
- encoding: "utf8",
2092
- timeout: timeoutMs,
2093
- maxBuffer: 20 * 1024 * 1024,
2094
- env: { ...process.env },
2095
- });
2096
- const timedOut = !!result.error && result.error.code === "ETIMEDOUT";
2282
+ const result = await spawnCollect(command, workspaceRoot, timeoutMs);
2283
+ const timedOut = result.timedOut;
2097
2284
  if (result.error && !timedOut) {
2098
2285
  return {
2099
2286
  content: [{ type: "text", text: `Command could not be executed: ${result.error.message}` }],
@@ -5006,6 +5193,37 @@ async function handleSmartFileSlice(args) {
5006
5193
  }
5007
5194
  }
5008
5195
  // ── Test Oracle ──────────────────────────────────────────────────────
5196
+ /**
5197
+ * Auto-capture a green test run into the Brain, tagged `outcome: 'confirmed'` and tracked
5198
+ * against the files actually changed. This is the auto-store hook: today store_memory only
5199
+ * fires when the agent remembers to call it, so working fixes routinely go uncaptured. A
5200
+ * passing suite over an uncommitted diff is real, cheap evidence something worked — reusing
5201
+ * it costs nothing store_memory wouldn't have cost anyway, and store()'s own dedup guard
5202
+ * means re-running the same green suite repeatedly just no-ops instead of piling up entries.
5203
+ *
5204
+ * Silent on a clean tree (nothing changed, nothing to attribute the pass to) or when the
5205
+ * repo isn't git-tracked. Returns a short suffix for resultText — this must never happen
5206
+ * invisibly, or a future search_memory hit would look reasoned-from-scratch instead of
5207
+ * traced back to an automated test run.
5208
+ */
5209
+ function autoStoreGreenTests(command, workspaceRoot) {
5210
+ try {
5211
+ const changed = (0, AffectedTests_1.changedFilesFromGit)(workspaceRoot);
5212
+ if (changed.length === 0)
5213
+ return "";
5214
+ const domain = deriveDomainForFile(changed[0]);
5215
+ const query = `tests passing after changes to ${changed.slice(0, 5).join(", ")}${changed.length > 5 ? ` and ${changed.length - 5} more file(s)` : ""}`;
5216
+ const response = `\`${command}\` passed with these files changed (uncommitted at capture time):\n${changed.join("\n")}`;
5217
+ const storeRes = (0, TheBrainV2_1.getBrain)().store(query, response, "test_oracle_auto", 0.92, changed, undefined, "confirmed", undefined, undefined, domain);
5218
+ if (!storeRes.stored)
5219
+ return "";
5220
+ 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.`;
5221
+ }
5222
+ catch {
5223
+ // Never let auto-capture break the actual test result the caller asked for.
5224
+ return "";
5225
+ }
5226
+ }
5009
5227
  async function handleTestOracle(args) {
5010
5228
  const command = args.command || "npm test";
5011
5229
  const workspaceRoot = process.cwd();
@@ -5016,6 +5234,7 @@ async function handleTestOracle(args) {
5016
5234
  try {
5017
5235
  const out = (0, child_process_1.execSync)(command, { cwd: workspaceRoot, encoding: "utf8", timeout: 30000 });
5018
5236
  resultText = `All tests passed!\n\nOutput:\n${out.substring(0, 1000)}`;
5237
+ resultText += autoStoreGreenTests(command, workspaceRoot);
5019
5238
  }
5020
5239
  catch (e) {
5021
5240
  const stdout = e.stdout || "";
@@ -5331,6 +5550,41 @@ async function handleFileIntentIndex(args) {
5331
5550
  }
5332
5551
  }
5333
5552
  const COG_MAP_FILE = path_1.default.join(os_1.default.homedir(), ".lemma-cache", "cog_map.json");
5553
+ /**
5554
+ * Best-effort domain for a repo-relative file path, so store_memory and the test-oracle
5555
+ * auto-store below don't require a human to type `domain` by hand every time.
5556
+ *
5557
+ * Prefers the persisted Cognitive Map (built via `cognitive_map` action "build") when it
5558
+ * has this exact file listed — that reflects an actual workspace scan. Falls back to the
5559
+ * same lightweight substring heuristic "build" uses, so a project that never ran "build"
5560
+ * still gets a reasonable tag instead of none at all. Returns undefined rather than
5561
+ * "general" when nothing matches — an absent domain stays visible to every domain-scoped
5562
+ * search (see BrainEntry.domain), while a wrong guess would actively mislead one.
5563
+ */
5564
+ function deriveDomainForFile(relPath) {
5565
+ const norm = relPath.replace(/^\//, "");
5566
+ if (fs_1.default.existsSync(COG_MAP_FILE)) {
5567
+ try {
5568
+ const map = JSON.parse(fs_1.default.readFileSync(COG_MAP_FILE, "utf8"));
5569
+ for (const node of Object.values(map.nodes || {})) {
5570
+ if (node.files.includes(norm))
5571
+ return node.domain;
5572
+ }
5573
+ }
5574
+ catch { /* fall through to heuristic */ }
5575
+ }
5576
+ if (norm.includes("mcp"))
5577
+ return "mcp";
5578
+ if (norm.includes("subconscious") || norm.includes("brain"))
5579
+ return "memory";
5580
+ if (norm.includes("security") || norm.includes("scrubber"))
5581
+ return "security";
5582
+ if (norm.includes("pr-review"))
5583
+ return "pr-review";
5584
+ if (norm.includes("utils"))
5585
+ return "utils";
5586
+ return undefined;
5587
+ }
5334
5588
  async function handleCognitiveMap(args) {
5335
5589
  const action = args.action;
5336
5590
  const domain = args.domain;