@nxuss/lemma 1.11.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 (80) hide show
  1. package/README.md +98 -821
  2. package/dist/cjs/cli/lemma-proxy.d.ts.map +1 -1
  3. package/dist/cjs/cli/lemma-proxy.js +14 -0
  4. package/dist/cjs/cli/lemma-proxy.js.map +1 -1
  5. package/dist/cjs/infra/mcp-tools.d.ts +7 -0
  6. package/dist/cjs/infra/mcp-tools.d.ts.map +1 -1
  7. package/dist/cjs/infra/mcp-tools.js +22 -0
  8. package/dist/cjs/infra/mcp-tools.js.map +1 -1
  9. package/dist/cjs/mcp/index.js +35 -7
  10. package/dist/cjs/mcp/index.js.map +1 -1
  11. package/dist/cjs/mcp/prompts.d.ts +1 -0
  12. package/dist/cjs/mcp/prompts.d.ts.map +1 -1
  13. package/dist/cjs/mcp/prompts.js +98 -0
  14. package/dist/cjs/mcp/prompts.js.map +1 -1
  15. package/dist/cjs/mcp/resources.d.ts +2 -0
  16. package/dist/cjs/mcp/resources.d.ts.map +1 -1
  17. package/dist/cjs/mcp/resources.js +26 -0
  18. package/dist/cjs/mcp/resources.js.map +1 -1
  19. package/dist/cjs/mcp/tasks.d.ts +20 -0
  20. package/dist/cjs/mcp/tasks.d.ts.map +1 -0
  21. package/dist/cjs/mcp/tasks.js +71 -0
  22. package/dist/cjs/mcp/tasks.js.map +1 -0
  23. package/dist/cjs/mcp/tool-profiles.d.ts.map +1 -1
  24. package/dist/cjs/mcp/tool-profiles.js +29 -50
  25. package/dist/cjs/mcp/tool-profiles.js.map +1 -1
  26. package/dist/cjs/mcp/tools.d.ts.map +1 -1
  27. package/dist/cjs/mcp/tools.js +575 -93
  28. package/dist/cjs/mcp/tools.js.map +1 -1
  29. package/dist/cjs/proxy/Gatekeeper.d.ts +29 -0
  30. package/dist/cjs/proxy/Gatekeeper.d.ts.map +1 -1
  31. package/dist/cjs/proxy/Gatekeeper.js +122 -1
  32. package/dist/cjs/proxy/Gatekeeper.js.map +1 -1
  33. package/dist/cjs/subconscious/TheBrainV2.d.ts +50 -1
  34. package/dist/cjs/subconscious/TheBrainV2.d.ts.map +1 -1
  35. package/dist/cjs/subconscious/TheBrainV2.js +149 -8
  36. package/dist/cjs/subconscious/TheBrainV2.js.map +1 -1
  37. package/dist/cjs/utils/PatchMatcher.d.ts +1 -0
  38. package/dist/cjs/utils/PatchMatcher.d.ts.map +1 -1
  39. package/dist/cjs/utils/PatchMatcher.js +4 -4
  40. package/dist/cjs/utils/PatchMatcher.js.map +1 -1
  41. package/dist/esm/cli/lemma-proxy.d.ts.map +1 -1
  42. package/dist/esm/cli/lemma-proxy.js +14 -0
  43. package/dist/esm/cli/lemma-proxy.js.map +1 -1
  44. package/dist/esm/infra/mcp-tools.d.ts +7 -0
  45. package/dist/esm/infra/mcp-tools.d.ts.map +1 -1
  46. package/dist/esm/infra/mcp-tools.js +22 -0
  47. package/dist/esm/infra/mcp-tools.js.map +1 -1
  48. package/dist/esm/mcp/index.js +37 -9
  49. package/dist/esm/mcp/index.js.map +1 -1
  50. package/dist/esm/mcp/prompts.d.ts +1 -0
  51. package/dist/esm/mcp/prompts.d.ts.map +1 -1
  52. package/dist/esm/mcp/prompts.js +95 -1
  53. package/dist/esm/mcp/prompts.js.map +1 -1
  54. package/dist/esm/mcp/resources.d.ts +2 -0
  55. package/dist/esm/mcp/resources.d.ts.map +1 -1
  56. package/dist/esm/mcp/resources.js +27 -2
  57. package/dist/esm/mcp/resources.js.map +1 -1
  58. package/dist/esm/mcp/tasks.d.ts +20 -0
  59. package/dist/esm/mcp/tasks.d.ts.map +1 -0
  60. package/dist/esm/mcp/tasks.js +66 -0
  61. package/dist/esm/mcp/tasks.js.map +1 -0
  62. package/dist/esm/mcp/tool-profiles.d.ts.map +1 -1
  63. package/dist/esm/mcp/tool-profiles.js +29 -50
  64. package/dist/esm/mcp/tool-profiles.js.map +1 -1
  65. package/dist/esm/mcp/tools.d.ts.map +1 -1
  66. package/dist/esm/mcp/tools.js +577 -95
  67. package/dist/esm/mcp/tools.js.map +1 -1
  68. package/dist/esm/proxy/Gatekeeper.d.ts +29 -0
  69. package/dist/esm/proxy/Gatekeeper.d.ts.map +1 -1
  70. package/dist/esm/proxy/Gatekeeper.js +120 -0
  71. package/dist/esm/proxy/Gatekeeper.js.map +1 -1
  72. package/dist/esm/subconscious/TheBrainV2.d.ts +50 -1
  73. package/dist/esm/subconscious/TheBrainV2.d.ts.map +1 -1
  74. package/dist/esm/subconscious/TheBrainV2.js +149 -8
  75. package/dist/esm/subconscious/TheBrainV2.js.map +1 -1
  76. package/dist/esm/utils/PatchMatcher.d.ts +1 -0
  77. package/dist/esm/utils/PatchMatcher.d.ts.map +1 -1
  78. package/dist/esm/utils/PatchMatcher.js +4 -4
  79. package/dist/esm/utils/PatchMatcher.js.map +1 -1
  80. package/package.json +2 -2
@@ -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
@@ -135,9 +136,94 @@ const PRO_GATE_MESSAGE = [
135
136
  "🚀 Get Lemma Pro:",
136
137
  " https://lemma.nxus.studio/upgrade",
137
138
  ].join("\n");
139
+ // ── Elicitation (spec 2026-07-28's recommended pattern for destructive actions) ────
140
+ //
141
+ // Set once by setupToolsHandlers so the standalone handler functions below — which don't
142
+ // otherwise see the Server instance — can ask the connected client to confirm before a
143
+ // mutating tool runs.
144
+ let mcpServerRef = null;
145
+ /**
146
+ * Ask the client to confirm a destructive action before it happens.
147
+ *
148
+ * Mirrors the defensive shape of trySamplingContext() in src/mcp/index.ts: most clients
149
+ * today don't support elicitation, so any failure (unsupported capability, timeout,
150
+ * malformed response) must fall back to "proceed without confirmation" rather than block
151
+ * or error out the tool. This is a courtesy prompt for clients that support it, not a
152
+ * security boundary — the allowlist/path-safety checks each handler already does are that.
153
+ */
154
+ async function tryElicitConfirmation(summary) {
155
+ if (!mcpServerRef)
156
+ return { supported: false, confirmed: true };
157
+ try {
158
+ const result = await mcpServerRef.elicitInput({
159
+ message: summary,
160
+ requestedSchema: {
161
+ type: "object",
162
+ properties: {
163
+ confirm: {
164
+ type: "boolean",
165
+ title: "Proceed?",
166
+ description: "Confirm this action should be applied.",
167
+ },
168
+ },
169
+ required: ["confirm"],
170
+ },
171
+ });
172
+ if (result.action !== "accept") {
173
+ // "decline" or "cancel" — the user (or client policy) said no.
174
+ return { supported: true, confirmed: false };
175
+ }
176
+ const confirmed = result.content?.confirm !== false;
177
+ return { supported: true, confirmed };
178
+ }
179
+ catch (err) {
180
+ (0, utils_1.logWarn)("elicitation", "Client does not support elicitation (or the request failed) — proceeding without confirmation");
181
+ return { supported: false, confirmed: true };
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
+ }
138
223
  const toolDefinitions = [
139
224
  {
140
225
  name: "scrub_privacy",
226
+ annotations: { readOnlyHint: true, openWorldHint: false },
141
227
  description: "Mask sensitive data (PII, API Keys, Credentials) from a text block using Lemma's Privacy Firewall.",
142
228
  inputSchema: {
143
229
  type: "object",
@@ -149,7 +235,8 @@ const toolDefinitions = [
149
235
  },
150
236
  {
151
237
  name: "search_memory",
152
- 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.",
238
+ annotations: { readOnlyHint: true, openWorldHint: false },
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.",
153
240
  inputSchema: {
154
241
  type: "object",
155
242
  properties: {
@@ -160,12 +247,17 @@ const toolDefinitions = [
160
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.",
161
248
  default: 0.75,
162
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
+ },
163
254
  },
164
255
  required: ["query"],
165
256
  },
166
257
  },
167
258
  {
168
259
  name: "store_memory",
260
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
169
261
  description: "Persist a technical solution, bug fix, architecture decision, or key fact into Lemma's Brain — so future questions on the same topic (even phrased differently) don't require re-investigating the repo. Pass filePaths for anything derived from specific files (e.g. 'where is X implemented', 'how does Y work') so the memory auto-invalidates the moment those files change, instead of risking a stale answer being reused. If the answer is really about one function/class rather than the whole file, pass `symbols` instead (or in addition) so an unrelated edit elsewhere in that file doesn't stale it out. Pass outcome='failed' for an approach that was tried and did NOT work — that's just as worth remembering as a fix, so the Brain can warn 'already tried, didn't work' instead of only ever suggesting reuse.",
170
262
  inputSchema: {
171
263
  type: "object",
@@ -211,12 +303,29 @@ const toolDefinitions = [
211
303
  },
212
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.",
213
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
+ },
214
310
  },
215
311
  required: ["query", "response"],
216
312
  },
217
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
+ },
218
326
  {
219
327
  name: "get_routing_advice",
328
+ annotations: { readOnlyHint: true, openWorldHint: false },
220
329
  description: "Analyzes a prompt and suggests the best model based on Lemma's Complexity Router.",
221
330
  inputSchema: {
222
331
  type: "object",
@@ -226,9 +335,20 @@ const toolDefinitions = [
226
335
  },
227
336
  required: ["prompt"],
228
337
  },
338
+ outputSchema: {
339
+ type: "object",
340
+ properties: {
341
+ recommendedModel: { type: "string", description: "The model the router recommends" },
342
+ complexity: { type: "string", enum: ["low", "high"], description: "Estimated complexity of the prompt" },
343
+ intendedModel: { type: ["string", "null"], description: "The model passed in intended_model, or null if omitted" },
344
+ switched: { type: "boolean", description: "true if recommendedModel differs from intendedModel" },
345
+ },
346
+ required: ["recommendedModel", "complexity", "switched"],
347
+ },
229
348
  },
230
349
  {
231
350
  name: "auto_heal",
351
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
232
352
  description: "Diagnose and auto-heal the latest local server crash registered in Lemma's context logs.",
233
353
  inputSchema: {
234
354
  type: "object",
@@ -239,6 +359,7 @@ const toolDefinitions = [
239
359
  },
240
360
  {
241
361
  name: "read_workspace_file",
362
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
242
363
  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.",
243
364
  inputSchema: {
244
365
  type: "object",
@@ -255,6 +376,7 @@ const toolDefinitions = [
255
376
  },
256
377
  {
257
378
  name: "write_workspace_file",
379
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
258
380
  description: "Write full contents to a file inside the workspace. Creates parent directories automatically.",
259
381
  inputSchema: {
260
382
  type: "object",
@@ -267,6 +389,7 @@ const toolDefinitions = [
267
389
  },
268
390
  {
269
391
  name: "create_workspace_file",
392
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
270
393
  description: "Create a NEW file in the workspace. Fails immediately if the file already exists — use write_workspace_file or apply_workspace_patch to modify existing files. Creates parent directories automatically. Returns a minimal token-free ACK: no file content is echoed back, saving provider output tokens.",
271
394
  inputSchema: {
272
395
  type: "object",
@@ -279,6 +402,7 @@ const toolDefinitions = [
279
402
  },
280
403
  {
281
404
  name: "apply_workspace_patch",
405
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
282
406
  description: "Apply a smart search-and-replace patch to an existing file. Falls back through whitespace-normalized, indent-anchored, and fuzzy matching if the exact block isn't found — so a searchContent built from a compacted read still applies. Safe against duplicate matches. On total failure, the error includes a diff against the closest candidate block.",
283
407
  inputSchema: {
284
408
  type: "object",
@@ -292,6 +416,8 @@ const toolDefinitions = [
292
416
  },
293
417
  {
294
418
  name: "run_workspace_command",
419
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
420
+ execution: { taskSupport: "optional" },
295
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.",
296
422
  inputSchema: {
297
423
  type: "object",
@@ -313,6 +439,7 @@ const toolDefinitions = [
313
439
  },
314
440
  {
315
441
  name: "output_region",
442
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
316
443
  description: "Retrieve any part of a command output previously stored by run_workspace_command, verbatim. This is what makes distillation lossless — use it when the distilled view isn't enough. Pass all:true for the whole output, section for one named block (a failing test title or a file path, as listed in the distilled view), or startLine/endLine for a range.",
317
444
  inputSchema: {
318
445
  type: "object",
@@ -328,6 +455,7 @@ const toolDefinitions = [
328
455
  },
329
456
  {
330
457
  name: "list_workspace_dir",
458
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
331
459
  description: "List files and subdirectories recursively to navigate the repository structure.",
332
460
  inputSchema: {
333
461
  type: "object",
@@ -339,6 +467,7 @@ const toolDefinitions = [
339
467
  },
340
468
  {
341
469
  name: "search_workspace",
470
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
342
471
  description: "Perform a fast local text search (grep) across all files in the project workspace. Respects .gitignore, skips binaries and files over 500KB, groups results by file (capped per file), and stops at maxResults. For code files (.ts/.tsx/.js/.jsx), matches inside the same function/class/method are collapsed into one entry with its signature and line range instead of raw duplicate lines. Repeating an identical query while the workspace is unchanged returns the cached result instantly instead of re-scanning.",
343
472
  inputSchema: {
344
473
  type: "object",
@@ -352,6 +481,7 @@ const toolDefinitions = [
352
481
  },
353
482
  {
354
483
  name: "squeeze_prompt",
484
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
355
485
  description: "Compress code blocks, comments, and boilerplate in any prompt. Saves up to 80% tokens.",
356
486
  inputSchema: {
357
487
  type: "object",
@@ -364,6 +494,7 @@ const toolDefinitions = [
364
494
  },
365
495
  {
366
496
  name: "get_project_onboarding",
497
+ annotations: { readOnlyHint: true, openWorldHint: false },
367
498
  description: "Fetch dynamic architectural and stack overview of the current project in markdown.",
368
499
  inputSchema: {
369
500
  type: "object",
@@ -372,6 +503,7 @@ const toolDefinitions = [
372
503
  },
373
504
  {
374
505
  name: "get_project_history",
506
+ annotations: { readOnlyHint: true, openWorldHint: false },
375
507
  description: "Answers 'what have we done in this project': merges recent git commits, session checkpoints, and The Brain's memories (decisions, fixes, prior PR reviews) for the current project into one narrative. Use this instead of piecing the same picture together from search_memory + git log + reading checkpoint files separately.",
376
508
  inputSchema: {
377
509
  type: "object",
@@ -382,6 +514,7 @@ const toolDefinitions = [
382
514
  },
383
515
  {
384
516
  name: "get_ast_hologram",
517
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
385
518
  description: "Generate a dense, token-efficient Holographic AST Map of the workspace using the TypeScript compiler. Returns structured JSON of all exported symbols with file paths and line numbers.",
386
519
  inputSchema: {
387
520
  type: "object",
@@ -398,6 +531,7 @@ const toolDefinitions = [
398
531
  },
399
532
  {
400
533
  name: "validate_patch_sandbox",
534
+ annotations: { readOnlyHint: true, openWorldHint: false },
401
535
  description: "Validate proposed code in isolated sandbox via tsc + syntax check before applying.",
402
536
  inputSchema: {
403
537
  type: "object",
@@ -410,6 +544,7 @@ const toolDefinitions = [
410
544
  },
411
545
  {
412
546
  name: "query_hybrid_consensus",
547
+ annotations: { readOnlyHint: true, openWorldHint: false },
413
548
  description: "Search The Brain before reasoning. Brain HIT returns cached answer instantly. Brain MISS proceeds to cloud.",
414
549
  inputSchema: {
415
550
  type: "object",
@@ -423,6 +558,7 @@ const toolDefinitions = [
423
558
  },
424
559
  {
425
560
  name: "get_telepathic_hints",
561
+ annotations: { readOnlyHint: true, openWorldHint: false },
426
562
  description: "Surface relevant past solutions from The Brain based on the active file path.",
427
563
  inputSchema: {
428
564
  type: "object",
@@ -435,6 +571,7 @@ const toolDefinitions = [
435
571
  },
436
572
  {
437
573
  name: "summarize_long_text",
574
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
438
575
  description: "Summarize long text locally using Ollama. Compresses verbose content into concise summaries, saving context window for subsequent turns.",
439
576
  inputSchema: {
440
577
  type: "object",
@@ -447,6 +584,7 @@ const toolDefinitions = [
447
584
  },
448
585
  {
449
586
  name: "prune_conversation_history",
587
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
450
588
  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.",
451
589
  inputSchema: {
452
590
  type: "object",
@@ -467,6 +605,7 @@ const toolDefinitions = [
467
605
  },
468
606
  {
469
607
  name: "diff_only",
608
+ annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: false },
470
609
  description: "Compute a compact line-by-line diff between the last known state of a file and its current content. Tracks file state in memory per session.",
471
610
  inputSchema: {
472
611
  type: "object",
@@ -479,6 +618,7 @@ const toolDefinitions = [
479
618
  },
480
619
  {
481
620
  name: "batch_tool_calls",
621
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
482
622
  description: "Execute multiple tools in a single MCP call. Accepts an array of { tool, args } and runs them in parallel via Promise.all. Results include per-tool success/error status.",
483
623
  inputSchema: {
484
624
  type: "object",
@@ -501,6 +641,7 @@ const toolDefinitions = [
501
641
  },
502
642
  {
503
643
  name: "turbosqueeze",
644
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
504
645
  description: "COMPRIME prompts hasta 90%. Elimina comentarios, imports redundantes, whitespace excesivo, y compacta JSON. Usar ANTES de enviar código largo al LLM para maximizar ahorro de tokens.",
505
646
  inputSchema: {
506
647
  type: "object",
@@ -515,6 +656,7 @@ const toolDefinitions = [
515
656
  },
516
657
  {
517
658
  name: "get_symbol_surgical_context",
659
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
518
660
  description: "Extrae quirúrgicamente la implementación de un símbolo específico (clase, función, interfaz) y las firmas (pero no la implementación completa) de todas sus dependencias locales del workspace. Ahorra hasta 90% de tokens.",
519
661
  inputSchema: {
520
662
  type: "object",
@@ -527,6 +669,7 @@ const toolDefinitions = [
527
669
  },
528
670
  {
529
671
  name: "wormhole_squeeze",
672
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
530
673
  description: "Comprime código a formato WORMHOLE súper denso usando tokens de palabra clave y abreviación de variables reversible. Ideal para alimentar contextos de lectura al LLM.",
531
674
  inputSchema: {
532
675
  type: "object",
@@ -538,6 +681,7 @@ const toolDefinitions = [
538
681
  },
539
682
  {
540
683
  name: "generate_executive_roi_report",
684
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
541
685
  description: "Genera un reporte ejecutivo en formato Markdown de Retorno de Inversión (ROI), dinero ahorrado en API, horas de desarrollo recuperadas y proyecciones de escala para la gerencia.",
542
686
  inputSchema: {
543
687
  type: "object",
@@ -550,6 +694,7 @@ const toolDefinitions = [
550
694
  },
551
695
  {
552
696
  name: "surgical_ast_insert",
697
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
553
698
  description: "Inserta quirúrgicamente código (métodos, propiedades, funciones) en una clase, interfaz o ámbito de archivo de TypeScript utilizando el AST Compiler. Evita escribir diffs de búsqueda y reemplazo grandes o reescribir todo el archivo. Ahorra 95% de tokens de salida.",
554
699
  inputSchema: {
555
700
  type: "object",
@@ -565,6 +710,7 @@ const toolDefinitions = [
565
710
  },
566
711
  {
567
712
  name: "local_semantic_autofix",
713
+ annotations: { readOnlyHint: true, openWorldHint: false },
568
714
  description: "Intenta resolver errores de compilación o ejecución localmente consultando The Brain por tracebacks/mensajes similares. Si hay coincidencia, devuelve la solución/parche instantáneamente sin gastar tokens de LLM.",
569
715
  inputSchema: {
570
716
  type: "object",
@@ -576,6 +722,7 @@ const toolDefinitions = [
576
722
  },
577
723
  {
578
724
  name: "compress_context",
725
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
579
726
  description: "Comprime el historial de conversación: resume turns antiguos vía Ollama, mantiene solo los últimos N intactos. Reduce contexto 70-80%. USAR antes de cada turno extendido.",
580
727
  inputSchema: {
581
728
  type: "object",
@@ -592,6 +739,7 @@ const toolDefinitions = [
592
739
  },
593
740
  {
594
741
  name: "smarter_cache",
742
+ annotations: { readOnlyHint: true, openWorldHint: false },
595
743
  description: "Cache predictivo con threshold bajo (75%). Busca en The Brain antes de cualquier razonamiento. Si hay hit >= 75%, devuelve respuesta instantánea sin gastar tokens en LLM.",
596
744
  inputSchema: {
597
745
  type: "object",
@@ -602,9 +750,25 @@ const toolDefinitions = [
602
750
  },
603
751
  required: ["query"],
604
752
  },
753
+ outputSchema: {
754
+ type: "object",
755
+ properties: {
756
+ hit: { type: "boolean", description: "true si hubo un hit por encima del threshold" },
757
+ source: { type: "string", enum: ["lemma-brain", "llm-call-required", "cache-unavailable"], description: "De dónde salió (o no) la respuesta" },
758
+ similarity: { type: "number", description: "Similitud del mejor match (0.0-1.0). Ausente en la rama de error." },
759
+ threshold: { type: "number", description: "Threshold usado. Ausente en la rama de error." },
760
+ answer: { type: ["string", "null"], description: "Respuesta cacheada si hit=true; null si no. Ausente en la rama de error." },
761
+ tokensSaved: { type: "number", description: "Solo presente cuando hit=true" },
762
+ tokensSavedFormatted: { type: "string", description: "Solo presente cuando hit=true" },
763
+ hint: { type: "string", description: "Sugerencia de siguiente paso; presente en miss y en error" },
764
+ error: { type: "string", description: "Mensaje de error; solo presente si el brain local falló" },
765
+ },
766
+ required: ["hit", "source"],
767
+ },
605
768
  },
606
769
  {
607
770
  name: "state_hash_cache",
771
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
608
772
  description: "Cache de razonamiento con invalidación EXACTA (no probabilística): la respuesta queda atada al sha256 del contenido de los archivos de los que depende. Si esos archivos no cambiaron, el hit es matemáticamente válido, no una adivinanza por similitud. Usa action='lookup' antes de razonar sobre una pregunta que dependa de archivos específicos, y action='store' después de responder para cachearla.",
609
773
  inputSchema: {
610
774
  type: "object",
@@ -616,9 +780,24 @@ const toolDefinitions = [
616
780
  },
617
781
  required: ["action", "query", "filePaths"],
618
782
  },
783
+ outputSchema: {
784
+ type: "object",
785
+ properties: {
786
+ status: { type: "string", enum: ["hit", "miss", "stored"], description: "Resultado de lookup (hit/miss) o de store (stored)" },
787
+ answer: { type: "string", description: "Solo presente cuando status='hit'" },
788
+ note: { type: "string", description: "Solo presente cuando status='hit'" },
789
+ tokensSaved: { type: "number", description: "Solo presente cuando status='hit'" },
790
+ reason: { type: "string", description: "Solo presente cuando status='miss'" },
791
+ hint: { type: "string", description: "Solo presente cuando status='miss'" },
792
+ id: { type: "string", description: "Solo presente cuando status='stored'" },
793
+ filesTracked: { type: "array", items: { type: "string" }, description: "Solo presente cuando status='stored'" },
794
+ },
795
+ required: ["status"],
796
+ },
619
797
  },
620
798
  {
621
799
  name: "token_receipt",
800
+ annotations: { readOnlyHint: true, openWorldHint: false },
622
801
  description: "Recibo auditable de la sesión: de dónde vino cada respuesta (cache exacto, cache semántico, lectura de archivo, o razonamiento fresco). No es una estadística de 'ahorro' — es una bitácora verificable para que el usuario pueda comprobar que no se le está mintiendo con un cache hit inventado. Llamar con action='summary' para ver el reporte.",
623
802
  inputSchema: {
624
803
  type: "object",
@@ -627,9 +806,42 @@ const toolDefinitions = [
627
806
  limit: { type: "number", description: "Cuántos eventos recientes incluir en el detalle", default: 20 },
628
807
  },
629
808
  },
809
+ outputSchema: {
810
+ type: "object",
811
+ properties: {
812
+ totalEvents: { type: "number", description: "Total de eventos registrados en la sesión" },
813
+ byType: {
814
+ type: "object",
815
+ properties: {
816
+ exact_cache_hit: { type: "number" },
817
+ semantic_cache_hit: { type: "number" },
818
+ file_read: { type: "number" },
819
+ reasoning: { type: "number" },
820
+ tool_call: { type: "number" },
821
+ },
822
+ description: "Conteo de eventos por tipo de origen",
823
+ },
824
+ recentEvents: {
825
+ type: "array",
826
+ items: {
827
+ type: "object",
828
+ properties: {
829
+ type: { type: "string" },
830
+ label: { type: "string" },
831
+ timestamp: { type: "number" },
832
+ meta: { type: "object" },
833
+ },
834
+ },
835
+ description: "Últimos `limit` eventos, en orden cronológico",
836
+ },
837
+ note: { type: "string" },
838
+ },
839
+ required: ["totalEvents", "byType", "recentEvents"],
840
+ },
630
841
  },
631
842
  {
632
843
  name: "token_budget",
844
+ annotations: { readOnlyHint: true, openWorldHint: false },
633
845
  description: "Muestra el dashboard de consumo: multiplicador actual de membresía, tokens ahorrados, tools usadas, y recomendaciones para optimizar más. Consultar periódicamente para auto-regular consumo.",
634
846
  inputSchema: {
635
847
  type: "object",
@@ -638,6 +850,7 @@ const toolDefinitions = [
638
850
  },
639
851
  {
640
852
  name: "entropy_score",
853
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
641
854
  description: "Calcula la entropía matemática (complejidad) de uno o más archivos usando el TypeScript Compiler API. Sin LLM. Sin tokens. Devuelve: cyclomatic complexity, nesting depth, ratio de 'any', tamaño de funciones, y un score compuesto 0-100 (0=limpio, 100=caos puro). Usa esto para identificar qué archivos necesitan refactor ANTES de tocarlos.",
642
855
  inputSchema: {
643
856
  type: "object",
@@ -649,6 +862,7 @@ const toolDefinitions = [
649
862
  },
650
863
  {
651
864
  name: "coupling_radar",
865
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
652
866
  description: "Construye un grafo de acoplamiento entre módulos analizando imports/exports con el TypeScript Compiler. Sin LLM. Sin tokens. Detecta: ciclos de dependencia, módulos 'dios' (importados por todo), islas muertas (nadie los importa), y fan-in/fan-out por módulo. Esencial antes de refactors grandes.",
653
867
  inputSchema: {
654
868
  type: "object",
@@ -660,6 +874,7 @@ const toolDefinitions = [
660
874
  },
661
875
  {
662
876
  name: "pattern_fossil",
877
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
663
878
  description: "Detecta código zombie: patrones que ya fueron reemplazados en la mayoría del codebase pero siguen vivos en archivos viejos. Sin LLM. Sin tokens. Detecta: callbacks vs async/await, var vs let/const, require() vs import, any vs generics. Devuelve porcentaje de adopción del patrón nuevo vs. los fósiles que quedan.",
664
879
  inputSchema: {
665
880
  type: "object",
@@ -670,6 +885,7 @@ const toolDefinitions = [
670
885
  },
671
886
  {
672
887
  name: "git_heatmap_risk",
888
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
673
889
  description: "Analiza el historial de git para identificar zonas de alto riesgo. Sin LLM. Sin tokens. Calcula: churn rate (frecuencia de cambios), co-edición oculta (archivos que siempre cambian juntos = acoplamiento implícito), y un risk score compuesto por archivo. Detecta los archivos que estadísticamente tienen más probabilidad de tener un bug.",
674
890
  inputSchema: {
675
891
  type: "object",
@@ -681,6 +897,7 @@ const toolDefinitions = [
681
897
  },
682
898
  {
683
899
  name: "precrime_static",
900
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
684
901
  description: "MINORITY REPORT para tu código. Combina entropy_score + coupling_radar + git_heatmap_risk en un predictor de riesgo compuesto. Sin LLM. Sin tokens. Devuelve un ranking de archivos y funciones con mayor probabilidad de causar un bug, con justificación matemática de cada factor. Úsalo antes de un deploy o un PR review.",
685
902
  inputSchema: {
686
903
  type: "object",
@@ -693,6 +910,7 @@ const toolDefinitions = [
693
910
  },
694
911
  {
695
912
  name: "semantic_dedup_guard",
913
+ annotations: { readOnlyHint: true, openWorldHint: false },
696
914
  description: "Firewall anti-redundancia para The Brain. Antes de hacer store_memory, pasa el contenido por aquí. Consulta ChromaDB localmente sin gastar tokens. Si hay un hit >= 92% → rechaza el store y devuelve el duplicado. Si 75-91% → advierte y muestra el similar. Mantiene The Brain denso y limpio.",
697
915
  inputSchema: {
698
916
  type: "object",
@@ -706,6 +924,7 @@ const toolDefinitions = [
706
924
  },
707
925
  {
708
926
  name: "dead_export_necromancer",
927
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
709
928
  description: "Resucita el código muerto. Usa el TypeScript Compiler para mapear TODOS los exports del workspace y los cruza contra TODOS los imports. Lo que se exporta pero nadie importa = código zombie que está inflando tu contexto y desperdiciando tokens. Sin LLM. Sin tokens. Devuelve lista de exports muertos con estimación de tokens desperdiciados.",
710
929
  inputSchema: {
711
930
  type: "object",
@@ -716,6 +935,7 @@ const toolDefinitions = [
716
935
  },
717
936
  {
718
937
  name: "review_diff",
938
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
719
939
  description: "Analyze a raw git diff locally for regressions, security issues, code quality, and static analysis. Zero-cost static analysis using local TypeScript compiler API — no LLM calls. Returns score, verdict, and detailed findings with suggestions.",
720
940
  inputSchema: {
721
941
  type: "object",
@@ -729,6 +949,7 @@ const toolDefinitions = [
729
949
  },
730
950
  {
731
951
  name: "review_pr",
952
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
732
953
  description: "Analyze a Pull Request via GitHub, Azure DevOps, or GitLab API. Detects regressions, security issues, code quality problems, and breaking changes. Posts results as PR comments and can auto-approve if quality threshold is met. Pro license required.",
733
954
  inputSchema: {
734
955
  type: "object",
@@ -745,6 +966,7 @@ const toolDefinitions = [
745
966
  },
746
967
  {
747
968
  name: "pr_status",
969
+ annotations: { readOnlyHint: true, openWorldHint: true },
748
970
  description: "Get the status and detailed results of a previously reviewed PR. Returns score, verdict, findings, and approval status.",
749
971
  inputSchema: {
750
972
  type: "object",
@@ -758,6 +980,7 @@ const toolDefinitions = [
758
980
  },
759
981
  {
760
982
  name: "generate_pr_workflow",
983
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
761
984
  description: "Generate a CI/CD workflow file (GitHub Actions or Azure Pipelines) that runs Lemma PR Review on every pull request. Writes the file to the workspace.",
762
985
  inputSchema: {
763
986
  type: "object",
@@ -772,6 +995,7 @@ const toolDefinitions = [
772
995
  },
773
996
  {
774
997
  name: "depgraph",
998
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
775
999
  description: "Builds a real-time dependency graph for any file in the workspace using the TypeScript Compiler API. Shows what a file imports, what imports it (reverse deps), and what it exports. Zero LLM calls. Essential before any refactor to understand blast radius.",
776
1000
  inputSchema: {
777
1001
  type: "object",
@@ -788,6 +1012,7 @@ const toolDefinitions = [
788
1012
  },
789
1013
  {
790
1014
  name: "affected_tests",
1015
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
791
1016
  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
1017
  inputSchema: {
793
1018
  type: "object",
@@ -796,9 +1021,24 @@ const toolDefinitions = [
796
1021
  baseRef: { type: "string", description: "Additional git ref to diff against, e.g. 'main'. The working tree is always included." },
797
1022
  },
798
1023
  },
1024
+ outputSchema: {
1025
+ type: "object",
1026
+ properties: {
1027
+ changed: { type: "array", items: { type: "string" }, description: "Files the diff touched, repo-relative" },
1028
+ directTests: { type: "array", items: { type: "string" }, description: "Changed files that are themselves tests" },
1029
+ affected: { type: "array", items: { type: "string" }, description: "Test files that transitively import a changed file" },
1030
+ totalTests: { type: "number", description: "Total test files in the repo" },
1031
+ runner: { type: "string", enum: ["jest", "vitest", "unknown"] },
1032
+ command: { type: ["string", "null"], description: "Command to run just the affected tests, or null if the full suite is recommended" },
1033
+ fullSuiteReason: { type: ["string", "null"], description: "Why the full suite is recommended instead, when command is null" },
1034
+ ungraphed: { type: "array", items: { type: "string" }, description: "Changed files absent from the import graph — blast radius unknown" },
1035
+ },
1036
+ required: ["changed", "directTests", "affected", "totalTests", "runner", "command", "fullSuiteReason", "ungraphed"],
1037
+ },
799
1038
  },
800
1039
  {
801
1040
  name: "refactor",
1041
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
802
1042
  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.",
803
1043
  inputSchema: {
804
1044
  type: "object",
@@ -816,6 +1056,7 @@ const toolDefinitions = [
816
1056
  },
817
1057
  {
818
1058
  name: "smart_file_slice",
1059
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
819
1060
  description: "Read only the lines semánticamente relevantes around a search query in a file, avoiding sending the entire file context. Returns lines around the matches.",
820
1061
  inputSchema: {
821
1062
  type: "object",
@@ -829,6 +1070,7 @@ const toolDefinitions = [
829
1070
  },
830
1071
  {
831
1072
  name: "test_oracle",
1073
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
832
1074
  description: "Execute unit tests locally and output ONLY the failing tests and compressed stack traces, keeping context clean.",
833
1075
  inputSchema: {
834
1076
  type: "object",
@@ -839,6 +1081,7 @@ const toolDefinitions = [
839
1081
  },
840
1082
  {
841
1083
  name: "schema_extract",
1084
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
842
1085
  description: "Extract high-level schemas, Zod types, interfaces, or database models from a file using the TypeScript AST, removing all implementation code.",
843
1086
  inputSchema: {
844
1087
  type: "object",
@@ -850,6 +1093,7 @@ const toolDefinitions = [
850
1093
  },
851
1094
  {
852
1095
  name: "changelog_auto",
1096
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
853
1097
  description: "Generate a clean, token-efficient changelog summary from git logs based on Conventional Commits.",
854
1098
  inputSchema: {
855
1099
  type: "object",
@@ -860,6 +1104,7 @@ const toolDefinitions = [
860
1104
  },
861
1105
  {
862
1106
  name: "spec_to_stub",
1107
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
863
1108
  description: "Parse a TypeScript interface/type definition from a file and output a template mock/stub object configuration using pure AST analysis.",
864
1109
  inputSchema: {
865
1110
  type: "object",
@@ -872,6 +1117,7 @@ const toolDefinitions = [
872
1117
  },
873
1118
  {
874
1119
  name: "env_snapshot",
1120
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
875
1121
  description: "Capture environmental state like Node version, dependencies in package.json, and variables declared in .env files (hiding actual values/credentials).",
876
1122
  inputSchema: {
877
1123
  type: "object",
@@ -880,6 +1126,7 @@ const toolDefinitions = [
880
1126
  },
881
1127
  {
882
1128
  name: "migration_tracer",
1129
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
883
1130
  description: "Inspect changes in database schemas (tables, columns) across local migrations (e.g. Prisma migration directories).",
884
1131
  inputSchema: {
885
1132
  type: "object",
@@ -890,6 +1137,7 @@ const toolDefinitions = [
890
1137
  },
891
1138
  {
892
1139
  name: "multi_file_patch",
1140
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
893
1141
  description: "Apply search-and-replace patches to multiple workspace files in a single turn. Decreases round-trips for multi-file refactoring.",
894
1142
  inputSchema: {
895
1143
  type: "object",
@@ -914,6 +1162,7 @@ const toolDefinitions = [
914
1162
  },
915
1163
  {
916
1164
  name: "file_intent_index",
1165
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
917
1166
  description: "Map and index files inside the workspace by domain intent (e.g., auth, billing, routes, config) to speed up navigation.",
918
1167
  inputSchema: {
919
1168
  type: "object",
@@ -924,6 +1173,7 @@ const toolDefinitions = [
924
1173
  },
925
1174
  {
926
1175
  name: "cognitive_map",
1176
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
927
1177
  description: "Access and interact with the Auto-Cognitive Mind Map of the workspace (persisted globally). Holds structured high-level system domains, patterns, decisions, and bugs.",
928
1178
  inputSchema: {
929
1179
  type: "object",
@@ -946,6 +1196,7 @@ const toolDefinitions = [
946
1196
  },
947
1197
  {
948
1198
  name: "semantic_grep",
1199
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
949
1200
  description: "Search the codebase for conceptual terms using natural language and BM25 token relevance instead of exact substring matching.",
950
1201
  inputSchema: {
951
1202
  type: "object",
@@ -959,6 +1210,7 @@ const toolDefinitions = [
959
1210
  },
960
1211
  {
961
1212
  name: "imports_skeleton_resolver",
1213
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
962
1214
  description: "Resolve all imported local files inside a file and print only their signatures (classes, functions, interfaces) in a consolidated view.",
963
1215
  inputSchema: {
964
1216
  type: "object",
@@ -970,6 +1222,7 @@ const toolDefinitions = [
970
1222
  },
971
1223
  {
972
1224
  name: "ast_flow_visualizer",
1225
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
973
1226
  description: "Analyze a function's control flow statements (ifs, loops, try/catch) inside a file using AST compilation and output a visual flowchart in Mermaid syntax.",
974
1227
  inputSchema: {
975
1228
  type: "object",
@@ -982,6 +1235,7 @@ const toolDefinitions = [
982
1235
  },
983
1236
  {
984
1237
  name: "file_fingerprint",
1238
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
985
1239
  description: "Compute SHA256 fingerprint of a file. Returns 'unchanged' if hash matches cache, avoiding full re-reads. Saves 60-70% tokens on repeated file access.",
986
1240
  inputSchema: {
987
1241
  type: "object",
@@ -994,6 +1248,7 @@ const toolDefinitions = [
994
1248
  },
995
1249
  {
996
1250
  name: "git_blame_heat",
1251
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
997
1252
  description: "Analyze git blame history to identify high-risk lines and authors. Zero LLM cost. Returns risk scores per line, hotspots, and top contributors by risk.",
998
1253
  inputSchema: {
999
1254
  type: "object",
@@ -1005,6 +1260,7 @@ const toolDefinitions = [
1005
1260
  },
1006
1261
  {
1007
1262
  name: "type_coverage_report",
1263
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
1008
1264
  description: "Analyze TypeScript type coverage of a file using AST. Returns declaration count, typed vs untyped ratio, any-usage count, and a safety score 0-100. Zero LLM cost.",
1009
1265
  inputSchema: {
1010
1266
  type: "object",
@@ -1016,6 +1272,7 @@ const toolDefinitions = [
1016
1272
  },
1017
1273
  {
1018
1274
  name: "import_tree_context",
1275
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
1019
1276
  description: "Build a bidirectional import graph for a file. Shows what it imports, what imports it, and external dependencies. Zero LLM cost. Essential before refactors.",
1020
1277
  inputSchema: {
1021
1278
  type: "object",
@@ -1028,6 +1285,7 @@ const toolDefinitions = [
1028
1285
  },
1029
1286
  {
1030
1287
  name: "read_token_budgeted",
1288
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
1031
1289
  description: "Read a file with a hard token budget. Returns only the most relevant sections up to maxTokens. Saves 70-90% vs full reads.",
1032
1290
  inputSchema: {
1033
1291
  type: "object",
@@ -1041,6 +1299,7 @@ const toolDefinitions = [
1041
1299
  },
1042
1300
  {
1043
1301
  name: "bulk_file_digest",
1302
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
1044
1303
  description: "Read multiple files in one call and return a compressed digest. Saves 80% round-trips vs reading files individually.",
1045
1304
  inputSchema: {
1046
1305
  type: "object",
@@ -1058,6 +1317,7 @@ const toolDefinitions = [
1058
1317
  },
1059
1318
  {
1060
1319
  name: "auto_context_bundle",
1320
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
1061
1321
  description: "Get everything about a file in one call: compressed content, import graph, schema types, and telepathic hints from The Brain. Replaces 4-5 separate tool calls.",
1062
1322
  inputSchema: {
1063
1323
  type: "object",
@@ -1069,6 +1329,7 @@ const toolDefinitions = [
1069
1329
  },
1070
1330
  {
1071
1331
  name: "repeat_guard",
1332
+ annotations: { readOnlyHint: true, openWorldHint: false },
1072
1333
  description: "Check if a draft output is repetitive compared to recent responses. Returns similarity score and suggestion. Prevents duplicate explanations.",
1073
1334
  inputSchema: {
1074
1335
  type: "object",
@@ -1081,6 +1342,7 @@ const toolDefinitions = [
1081
1342
  },
1082
1343
  {
1083
1344
  name: "conversation_checkpoint",
1345
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1084
1346
  description: "Save or load session state. Prevents re-explaining context across turns. Save current progress or load last checkpoint.",
1085
1347
  inputSchema: {
1086
1348
  type: "object",
@@ -1096,6 +1358,7 @@ const toolDefinitions = [
1096
1358
  },
1097
1359
  {
1098
1360
  name: "test_autofix_interceptor",
1361
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1099
1362
  description: "Run tests and auto-search The Brain for fixes to failures. Returns failure list with suggested fixes from past sessions. Saves 4-5 turns of debugging.",
1100
1363
  inputSchema: {
1101
1364
  type: "object",
@@ -1106,6 +1369,7 @@ const toolDefinitions = [
1106
1369
  },
1107
1370
  {
1108
1371
  name: "prompt_pattern_cache",
1372
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1109
1373
  description: "Find or store optimized prompt templates. Reuses past prompt patterns to save 20-30% tokens on repetitive operations.",
1110
1374
  inputSchema: {
1111
1375
  type: "object",
@@ -1125,6 +1389,7 @@ const _infraToolDefs = (0, mcp_tools_1.getInfraToolDefinitions)();
1125
1389
  // than in the main array because it must never itself be hidden.
1126
1390
  const TOOLBOX_TOOL_DEFINITION = {
1127
1391
  name: "lemma_toolbox",
1392
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
1128
1393
  description: "Discover and invoke Lemma tools whose schemas are not loaded this session. Use action='list' (optionally with a query) to see what exists, 'schema' to read one tool's inputs, and 'call' to run it. Every Lemma capability is reachable here.",
1129
1394
  inputSchema: {
1130
1395
  type: "object",
@@ -1186,6 +1451,7 @@ const toolHandlers = {
1186
1451
  scrub_privacy: handleScrubPrivacy,
1187
1452
  search_memory: handleSearchMemory,
1188
1453
  store_memory: handleStoreMemory,
1454
+ downvote_memory: handleDownvoteMemory,
1189
1455
  get_routing_advice: handleGetRoutingAdvice,
1190
1456
  auto_heal: handleAutoHeal,
1191
1457
  read_workspace_file: handleReadWorkspaceFile,
@@ -1330,11 +1596,12 @@ function receiptLabelMeta(name, args) {
1330
1596
  return meta;
1331
1597
  }
1332
1598
  function setupToolsHandlers(server, onToolCall) {
1599
+ mcpServerRef = server;
1333
1600
  (0, SpeculativeWarmer_1.registerSymbolExtractor)(extractSymbolsWithTsCompiler);
1334
1601
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
1335
1602
  tools: toolDefinitionsArray,
1336
1603
  }));
1337
- server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
1604
+ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request, extra) => {
1338
1605
  const { name, arguments: args } = request.params;
1339
1606
  const startTime = Date.now();
1340
1607
  // ── Pro License Gate (granular: free tools work without license) ────────
@@ -1363,6 +1630,39 @@ function setupToolsHandlers(server, onToolCall) {
1363
1630
  });
1364
1631
  throw new Error(`Unknown tool: ${name}`);
1365
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
+ }
1366
1666
  try {
1367
1667
  // Sampled around the handler so a tool that logs its own, more specific event
1368
1668
  // (a cache hit, a miss) isn't double-counted by the generic entry below.
@@ -1398,6 +1698,9 @@ function setupToolsHandlers(server, onToolCall) {
1398
1698
  throw error;
1399
1699
  }
1400
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);
1401
1704
  }
1402
1705
  // ── Tool Implementations ────────────────────────────────────────────
1403
1706
  async function handleScrubPrivacy(args) {
@@ -1421,11 +1724,12 @@ async function handleSearchMemory(args) {
1421
1724
  // formatting below then presents it as a reusable memory. Match smarter_cache's
1422
1725
  // threshold so both paths agree on what counts as a hit.
1423
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;
1424
1728
  if (!query)
1425
1729
  throw new Error("Query is required");
1426
1730
  try {
1427
1731
  const brain = (0, TheBrainV2_1.getBrain)();
1428
- 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 });
1429
1733
  if (results.length === 0) {
1430
1734
  (0, TokenReceipt_1.recordReceiptEvent)("reasoning", query.substring(0, 100), {
1431
1735
  tool: "search_memory",
@@ -1449,7 +1753,7 @@ async function handleSearchMemory(args) {
1449
1753
  const staleCount = r.claims.filter((c) => !c.fresh).length;
1450
1754
  return `\nClaims (${r.claims.length - staleCount}/${r.claims.length} still fresh — trust only the ✓ ones):\n${lines.join("\n")}`;
1451
1755
  };
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)}`;
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)}`;
1453
1757
  if (fresh.length > 0) {
1454
1758
  // Only a fresh hit (or an untracked, purely conceptual entry) counts as a real
1455
1759
  // avoided re-investigation — this is the only case worth crediting as savings.
@@ -1497,11 +1801,15 @@ async function handleStoreMemory(args) {
1497
1801
  const claims = Array.isArray(args?.claims)
1498
1802
  ? args.claims.filter((c) => c?.text)
1499
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);
1500
1808
  if (!query || !responseText)
1501
1809
  throw new Error("Query and response are required");
1502
1810
  try {
1503
1811
  const brain = (0, TheBrainV2_1.getBrain)();
1504
- 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);
1505
1813
  // Storing a memory saves nothing — it only creates the chance of a saving later.
1506
1814
  // Crediting tokens here inflated the ledger on write and then credited the same
1507
1815
  // answer again on every read. The saving is booked by search_memory on a fresh hit.
@@ -1514,19 +1822,40 @@ async function handleStoreMemory(args) {
1514
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.` : "";
1515
1823
  const outcomeNote = outcome === "failed" ? " Tagged as a FAILED attempt — future searches will surface it as a warning, not a suggestion." : "";
1516
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.` : "";
1517
- 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}` }] };
1518
1833
  }
1519
1834
  catch (e) {
1520
1835
  (0, utils_1.logError)("store_memory", e);
1521
1836
  return { content: [{ type: "text", text: `Failed to store memory in local Brain: ${e.message}` }] };
1522
1837
  }
1523
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
+ }
1524
1852
  async function handleGetRoutingAdvice(args) {
1525
1853
  const prompt = args?.prompt;
1526
1854
  const intendedModel = args?.intended_model;
1527
1855
  const router = new ComplexityRouter_1.ComplexityRouter();
1528
1856
  const decision = router.evaluate(prompt, intendedModel);
1529
- if (decision.complexity === "low" && intendedModel && decision.model !== intendedModel) {
1857
+ const switched = !!intendedModel && decision.model !== intendedModel;
1858
+ if (decision.complexity === "low" && switched) {
1530
1859
  const estimatedPromptTokens = Math.floor((prompt?.length || 0) / 4);
1531
1860
  (0, reportSavings_1.reportSavings)({
1532
1861
  source: "complexityRouting",
@@ -1535,6 +1864,12 @@ async function handleGetRoutingAdvice(args) {
1535
1864
  query: prompt?.substring(0, 100),
1536
1865
  });
1537
1866
  }
1867
+ const structured = {
1868
+ recommendedModel: decision.model,
1869
+ complexity: decision.complexity,
1870
+ intendedModel: intendedModel || null,
1871
+ switched,
1872
+ };
1538
1873
  return {
1539
1874
  content: [
1540
1875
  {
@@ -1542,6 +1877,7 @@ async function handleGetRoutingAdvice(args) {
1542
1877
  text: `Lemma Routing Advice: Use ${decision.model}. Reason: Complexity is ${decision.complexity}.`,
1543
1878
  },
1544
1879
  ],
1880
+ structuredContent: structured,
1545
1881
  };
1546
1882
  }
1547
1883
  async function handleAutoHeal(args) {
@@ -1751,6 +2087,30 @@ function stripLineNumberGutter(text) {
1751
2087
  return text;
1752
2088
  return lines.map((l) => l.replace(GUTTER, "")).join("\n");
1753
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
+ }
1754
2114
  async function handleApplyWorkspacePatch(args) {
1755
2115
  const filePath = args?.filePath;
1756
2116
  const rawSearch = args?.searchContent;
@@ -1773,6 +2133,15 @@ async function handleApplyWorkspacePatch(args) {
1773
2133
  const matchResult = (0, PatchMatcher_1.findMatch)(originalContent, searchContent);
1774
2134
  if (!Array.isArray(matchResult)) {
1775
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
+ }
1776
2145
  return {
1777
2146
  content: [
1778
2147
  {
@@ -1801,28 +2170,71 @@ async function handleApplyWorkspacePatch(args) {
1801
2170
  ],
1802
2171
  };
1803
2172
  }
1804
- const match = matchResult[0];
1805
- let updatedContent;
1806
- if (match.strategy === "exact" && match.charStart !== undefined && match.charEnd !== undefined) {
1807
- // Splice by character offset — the search block may be a mid-line fragment, and
1808
- // a whole-line splice would discard everything else sharing that line.
1809
- updatedContent = originalContent.slice(0, match.charStart) + replaceContent + originalContent.slice(match.charEnd);
1810
- }
1811
- else {
1812
- const contentLines = originalContent.split("\n");
1813
- const finalReplace = (0, PatchMatcher_1.reindentReplacement)(replaceContent, searchContent, contentLines[match.startLine]);
1814
- const updatedLines = [...contentLines.slice(0, match.startLine), ...finalReplace.split("\n"), ...contentLines.slice(match.endLine + 1)];
1815
- updatedContent = updatedLines.join("\n");
1816
- }
1817
- fs_1.default.writeFileSync(resolved, updatedContent, "utf8");
1818
- const note = match.strategy === "exact" ? "" : ` (matched via ${match.strategy}, score ${match.score.toFixed(2)})`;
1819
- return { content: [{ type: "text", text: `Success: Patch successfully applied to ${filePath}${note}` }] };
2173
+ return applyMatch(matchResult[0], filePath, resolved, originalContent, searchContent, replaceContent);
1820
2174
  }
1821
2175
  catch (err) {
1822
2176
  (0, utils_1.logError)("apply_workspace_patch", err);
1823
2177
  return { content: [{ type: "text", text: `Error applying patch: ${err.message}` }] };
1824
2178
  }
1825
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
+ }
1826
2238
  async function handleRunWorkspaceCommand(args) {
1827
2239
  const command = args?.command;
1828
2240
  if (!command)
@@ -1852,10 +2264,10 @@ async function handleRunWorkspaceCommand(args) {
1852
2264
  // ── End allowlist check ────────────────────────────────────────────────
1853
2265
  const workspaceRoot = process.cwd();
1854
2266
  try {
1855
- // spawnSync rather than execSync: execSync leaves stderr attached to the parent unless
1856
- // stdio is overridden, which silently dropped the output of every tool that reports on
1857
- // stderr (jest and tsc both do) on a successful run. It also gives us stdout, stderr and
1858
- // 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.
1859
2271
  // 15s was below the runtime of the commands this tool exists for: a real `npm test` or
1860
2272
  // `tsc --noEmit` always tripped it, so the distillation pipeline never ran on the output
1861
2273
  // it was built for and the call was pure overhead before falling back to a plain shell.
@@ -1863,15 +2275,12 @@ async function handleRunWorkspaceCommand(args) {
1863
2275
  const MAX_TIMEOUT_MS = 600000;
1864
2276
  const requested = typeof args?.timeoutMs === "number" && args.timeoutMs > 0 ? args.timeoutMs : DEFAULT_TIMEOUT_MS;
1865
2277
  const timeoutMs = Math.min(Math.floor(requested), MAX_TIMEOUT_MS);
1866
- const result = (0, child_process_1.spawnSync)(command, {
1867
- cwd: workspaceRoot,
1868
- shell: true,
1869
- encoding: "utf8",
1870
- timeout: timeoutMs,
1871
- maxBuffer: 20 * 1024 * 1024,
1872
- env: { ...process.env },
1873
- });
1874
- const timedOut = !!result.error && result.error.code === "ETIMEDOUT";
2278
+ const elicited = await tryElicitConfirmation(`Run this command in the workspace root?\n\n${command}`);
2279
+ if (elicited.supported && !elicited.confirmed) {
2280
+ return { content: [{ type: "text", text: `Command was not run: declined during confirmation.\n\nCommand: ${command}` }] };
2281
+ }
2282
+ const result = await spawnCollect(command, workspaceRoot, timeoutMs);
2283
+ const timedOut = result.timedOut;
1875
2284
  if (result.error && !timedOut) {
1876
2285
  return {
1877
2286
  content: [{ type: "text", text: `Command could not be executed: ${result.error.message}` }],
@@ -2885,47 +3294,44 @@ async function handleSmarterCache(args) {
2885
3294
  reportSavings({ source: "cache", tokens: tokensSaved, toolName: "smarter_cache", query: query.substring(0, 100) });
2886
3295
  }
2887
3296
  catch { }
3297
+ const structured = {
3298
+ hit: true,
3299
+ similarity: topHit.similarity,
3300
+ threshold,
3301
+ answer: responseText,
3302
+ source: "lemma-brain",
3303
+ tokensSaved,
3304
+ tokensSavedFormatted: `~${tokensSaved.toLocaleString()} tokens`,
3305
+ };
2888
3306
  return {
2889
- content: [{
2890
- type: "text",
2891
- text: JSON.stringify({
2892
- hit: true,
2893
- similarity: topHit.similarity,
2894
- threshold,
2895
- answer: responseText,
2896
- source: "lemma-brain",
2897
- tokensSaved,
2898
- tokensSavedFormatted: `~${tokensSaved.toLocaleString()} tokens`,
2899
- }, null, 2),
2900
- }],
3307
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
3308
+ structuredContent: structured,
2901
3309
  };
2902
3310
  }
3311
+ const structured = {
3312
+ hit: false,
3313
+ similarity: topHit?.similarity || 0,
3314
+ threshold,
3315
+ answer: null,
3316
+ source: "llm-call-required",
3317
+ hint: "Después de resolver, llama store_memory para cachear y no gastar tokens en esto otra vez.",
3318
+ };
2903
3319
  return {
2904
- content: [{
2905
- type: "text",
2906
- text: JSON.stringify({
2907
- hit: false,
2908
- similarity: topHit?.similarity || 0,
2909
- threshold,
2910
- answer: null,
2911
- source: "llm-call-required",
2912
- hint: "Después de resolver, llama store_memory para cachear y no gastar tokens en esto otra vez.",
2913
- }, null, 2),
2914
- }],
3320
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
3321
+ structuredContent: structured,
2915
3322
  };
2916
3323
  }
2917
3324
  catch (e) {
2918
3325
  (0, utils_1.logError)("smarter_cache", e);
3326
+ const structured = {
3327
+ hit: false,
3328
+ error: e.message,
3329
+ source: "cache-unavailable",
3330
+ hint: "El cache local del brain falló. El LLM funcionará normalmente sin cache.",
3331
+ };
2919
3332
  return {
2920
- content: [{
2921
- type: "text",
2922
- text: JSON.stringify({
2923
- hit: false,
2924
- error: e.message,
2925
- source: "cache-unavailable",
2926
- hint: "El cache local del brain falló. El LLM funcionará normalmente sin cache.",
2927
- }, null, 2),
2928
- }],
3333
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
3334
+ structuredContent: structured,
2929
3335
  };
2930
3336
  }
2931
3337
  }
@@ -2946,23 +3352,21 @@ async function handleStateHashCache(args) {
2946
3352
  (0, reportSavings_1.reportSavings)({ source: "cache", tokens: tokensSaved, toolName: "state_hash_cache", query: query.substring(0, 100) });
2947
3353
  }
2948
3354
  catch { }
3355
+ const structured = {
3356
+ status: "hit",
3357
+ answer: result.entry.answer,
3358
+ note: "Respuesta desde caché exacto (hash de archivos sin cambios) — no necesita re-razonar.",
3359
+ tokensSaved,
3360
+ };
2949
3361
  return {
2950
- content: [{
2951
- type: "text",
2952
- text: JSON.stringify({
2953
- status: "hit",
2954
- answer: result.entry.answer,
2955
- note: "Respuesta desde caché exacto (hash de archivos sin cambios) — no necesita re-razonar.",
2956
- tokensSaved,
2957
- }, null, 2),
2958
- }],
3362
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
3363
+ structuredContent: structured,
2959
3364
  };
2960
3365
  }
3366
+ const structured = { status: "miss", reason: result.reason, hint: "Razona normalmente y luego llama action='store' con la respuesta." };
2961
3367
  return {
2962
- content: [{
2963
- type: "text",
2964
- text: JSON.stringify({ status: "miss", reason: result.reason, hint: "Razona normalmente y luego llama action='store' con la respuesta." }, null, 2),
2965
- }],
3368
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
3369
+ structuredContent: structured,
2966
3370
  };
2967
3371
  }
2968
3372
  if (action === "store") {
@@ -2970,8 +3374,10 @@ async function handleStateHashCache(args) {
2970
3374
  if (!answer)
2971
3375
  throw new Error("answer is required for action='store'");
2972
3376
  const entry = (0, StateHashCache_1.storeStateHash)(workspaceRoot, query, answer, filePaths);
3377
+ const structured = { status: "stored", id: entry.id, filesTracked: Object.keys(entry.fileHashes) };
2973
3378
  return {
2974
- content: [{ type: "text", text: JSON.stringify({ status: "stored", id: entry.id, filesTracked: Object.keys(entry.fileHashes) }, null, 2) }],
3379
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
3380
+ structuredContent: structured,
2975
3381
  };
2976
3382
  }
2977
3383
  throw new Error(`Unknown action: ${action}. Use 'lookup' or 'store'.`);
@@ -2980,16 +3386,15 @@ async function handleStateHashCache(args) {
2980
3386
  async function handleTokenReceipt(args) {
2981
3387
  const limit = typeof args?.limit === "number" ? args.limit : 20;
2982
3388
  const summary = (0, TokenReceipt_1.getReceiptSummary)(limit);
3389
+ const structured = {
3390
+ totalEvents: summary.totalEvents,
3391
+ byType: summary.byType,
3392
+ recentEvents: summary.recent,
3393
+ note: "Esto es una bitácora real de esta sesión, no una proyección de ahorro. Si byType.reasoning es alto, no hubo mucho cache — y está bien, es honesto.",
3394
+ };
2983
3395
  return {
2984
- content: [{
2985
- type: "text",
2986
- text: JSON.stringify({
2987
- totalEvents: summary.totalEvents,
2988
- byType: summary.byType,
2989
- recentEvents: summary.recent,
2990
- note: "Esto es una bitácora real de esta sesión, no una proyección de ahorro. Si byType.reasoning es alto, no hubo mucho cache — y está bien, es honesto.",
2991
- }, null, 2),
2992
- }],
3396
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
3397
+ structuredContent: structured,
2993
3398
  };
2994
3399
  }
2995
3400
  // ── Token Budget ─────────────────────────────────────────────────
@@ -4273,7 +4678,17 @@ async function handleAffectedTests(args) {
4273
4678
  totalTests: result.totalTests,
4274
4679
  narrowed: result.command !== null,
4275
4680
  });
4276
- return { content: [{ type: "text", text }] };
4681
+ const structured = {
4682
+ changed: result.changed,
4683
+ directTests: result.directTests,
4684
+ affected: result.affected,
4685
+ totalTests: result.totalTests,
4686
+ runner: result.runner,
4687
+ command: result.command,
4688
+ fullSuiteReason: result.fullSuiteReason,
4689
+ ungraphed: result.ungraphed,
4690
+ };
4691
+ return { content: [{ type: "text", text }], structuredContent: structured };
4277
4692
  }
4278
4693
  catch (err) {
4279
4694
  (0, utils_1.logError)("affected_tests", err);
@@ -4778,6 +5193,37 @@ async function handleSmartFileSlice(args) {
4778
5193
  }
4779
5194
  }
4780
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
+ }
4781
5227
  async function handleTestOracle(args) {
4782
5228
  const command = args.command || "npm test";
4783
5229
  const workspaceRoot = process.cwd();
@@ -4788,6 +5234,7 @@ async function handleTestOracle(args) {
4788
5234
  try {
4789
5235
  const out = (0, child_process_1.execSync)(command, { cwd: workspaceRoot, encoding: "utf8", timeout: 30000 });
4790
5236
  resultText = `All tests passed!\n\nOutput:\n${out.substring(0, 1000)}`;
5237
+ resultText += autoStoreGreenTests(command, workspaceRoot);
4791
5238
  }
4792
5239
  catch (e) {
4793
5240
  const stdout = e.stdout || "";
@@ -5103,6 +5550,41 @@ async function handleFileIntentIndex(args) {
5103
5550
  }
5104
5551
  }
5105
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
+ }
5106
5588
  async function handleCognitiveMap(args) {
5107
5589
  const action = args.action;
5108
5590
  const domain = args.domain;