@danieltmn/tamnora 0.17.0 → 0.19.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 (61) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +37 -3
  3. package/SECURITY.md +8 -0
  4. package/harness/packages/cli/src/commands/mcp.js +31 -4
  5. package/harness/packages/cli/src/commands/repl.js +7 -2
  6. package/harness/packages/cli/src/commands/run.js +5 -2
  7. package/harness/packages/cli/src/commands/tui.js +9 -2
  8. package/harness/packages/cli/src/lib/runtime.js +23 -1
  9. package/harness/packages/cli/test/tunnel.test.js +13 -1
  10. package/harness/packages/core/src/agent/modes.js +3 -0
  11. package/harness/packages/core/src/config/index.js +3 -1
  12. package/harness/packages/core/src/index.js +15 -1
  13. package/harness/packages/core/src/mcp/catalog.js +79 -0
  14. package/harness/packages/core/src/mcp/index.js +14 -2
  15. package/harness/packages/core/src/mcp/registry.js +29 -1
  16. package/harness/packages/core/src/redact.js +25 -0
  17. package/harness/packages/core/src/skills/catalog.js +78 -0
  18. package/harness/packages/core/src/skills/remote.js +51 -0
  19. package/harness/packages/core/src/tools/executors.js +95 -4
  20. package/harness/packages/core/src/tools/index.js +27 -6
  21. package/harness/packages/core/src/tools/labels.js +2 -0
  22. package/harness/packages/core/src/tools/mcp.js +91 -0
  23. package/harness/packages/core/src/tools/permissions.js +53 -8
  24. package/harness/packages/core/src/tools/registry.js +14 -7
  25. package/harness/packages/core/src/tools/schema.js +87 -0
  26. package/harness/packages/core/src/tts/edge.js +28 -7
  27. package/harness/packages/core/src/tunnel.js +51 -5
  28. package/harness/packages/core/test/compact.test.js +2 -0
  29. package/harness/packages/core/test/labels.test.js +1 -0
  30. package/harness/packages/core/test/mcp-registry.test.js +37 -1
  31. package/harness/packages/core/test/mcp-routing.test.js +115 -0
  32. package/harness/packages/core/test/redact.test.js +22 -0
  33. package/harness/packages/core/test/skills.test.js +11 -0
  34. package/harness/packages/core/test/tools.test.js +97 -3
  35. package/harness/packages/server/public/app.js +567 -33
  36. package/harness/packages/server/public/index.html +51 -7
  37. package/harness/packages/server/public/styles.css +124 -4
  38. package/harness/packages/server/src/lib/agentTools.js +45 -3
  39. package/harness/packages/server/src/lib/channels/telegram.js +10 -3
  40. package/harness/packages/server/src/lib/prompt.js +4 -1
  41. package/harness/packages/server/src/lib/questions.js +72 -0
  42. package/harness/packages/server/src/lib/scheduler.js +12 -1
  43. package/harness/packages/server/src/lib/tasks.js +3 -1
  44. package/harness/packages/server/src/lib/ttsSettings.js +11 -0
  45. package/harness/packages/server/src/routes/chat.js +31 -2
  46. package/harness/packages/server/src/routes/mcp.js +54 -2
  47. package/harness/packages/server/src/routes/preview.js +49 -28
  48. package/harness/packages/server/src/routes/providers.js +12 -0
  49. package/harness/packages/server/src/routes/sessions.js +3 -1
  50. package/harness/packages/server/src/routes/skills.js +48 -1
  51. package/harness/packages/server/src/routes/state.js +8 -1
  52. package/harness/packages/server/src/routes/tts.js +4 -2
  53. package/harness/packages/server/src/server.js +37 -1
  54. package/harness/packages/server/test/preview.test.js +111 -0
  55. package/harness/packages/server/test/server.test.js +186 -19
  56. package/harness/packages/server/test/telegram-features.test.js +2 -0
  57. package/harness/packages/server/test/telegram-voz.test.js +2 -0
  58. package/harness/packages/server/test/telegram.test.js +2 -0
  59. package/harness/packages/server/test/tts.test.js +5 -1
  60. package/harness/tamnora.example.json +3 -1
  61. package/package.json +1 -1
@@ -0,0 +1,78 @@
1
+ // Catalogo curado de skills sugeridas. Se instalan (copian su SKILL.md a la
2
+ // casa) deshabilitadas; el usuario las habilita desde la consola.
3
+
4
+ export const SKILLS_CATALOG = [
5
+ {
6
+ id: 'browser-qa',
7
+ name: 'QA de webs',
8
+ description: 'Verifica una web en el navegador headless antes de darla por buena.',
9
+ when: 'cuando toques HTML/CSS/JS de una web o necesites confirmar que renderiza',
10
+ body: `# QA de webs
11
+
12
+ Cuando trabajes en una web, verificala en el navegador antes de dar el cambio por terminado.
13
+
14
+ 1. Levantá el dev server (pestaña procs o \`bash\`) y anotá el puerto.
15
+ 2. Sacá una captura con la tool \`screenshot\` sobre la URL local.
16
+ 3. Revisá el resultado y los errores de consola con \`console_errors\`.
17
+ 4. Si hay errores, corregí y repetí. No des por bueno un cambio sin una captura limpia.
18
+ 5. Para webs con JavaScript o Cloudflare que \`fetch_url\` no puede leer, usá \`screenshot\`/\`console_errors\`.`,
19
+ },
20
+ {
21
+ id: 'git-commits',
22
+ name: 'Commits del repo',
23
+ description: 'Convenciones de mensajes de commit y qué no commitear.',
24
+ when: 'cuando vayas a proponer o preparar un commit',
25
+ body: `# Commits
26
+
27
+ - Mensajes en español, con prefijos tipo \`feat:\`, \`fix:\`, \`docs:\`, \`chore(release):\`.
28
+ - Una unidad lógica por commit; no mezcles refactors con features.
29
+ - Antes de commitear, revisá \`git status\` y \`git diff\`; commiteá solo lo que corresponde.
30
+ - Nunca agregues \`.tamnora/\`, \`tamnora.json\`, \`.env*\`, \`.npmrc\` ni \`*.db\`.
31
+ - No commitees, hagas push ni abras PRs si el usuario no lo pidió.`,
32
+ },
33
+ {
34
+ id: 'code-review',
35
+ name: 'Revisión de cambios',
36
+ description: 'Revisa el diff antes de commitear buscando riesgos y secretos.',
37
+ when: 'antes de commitear o cuando el usuario pida una revisión',
38
+ body: `# Revisión de cambios
39
+
40
+ - Leé el diff completo con \`git_diff\`.
41
+ - Buscá: secretos o claves hardcodeadas, rutas que se salen del workspace, cambios no pedidos, logs de datos sensibles.
42
+ - Confirmá que cada cambio responde al pedido; si hay refactors de más, señalalo.
43
+ - Verificá que el código nuevo tenga su test si el repo usa tests.
44
+ - Devolvé un resumen breve con hallazgos y, si está todo bien, decilo explícitamente.`,
45
+ },
46
+ {
47
+ id: 'static-landing',
48
+ name: 'Landing estática',
49
+ description: 'Arma una página estática sin dependencias ni build.',
50
+ when: 'cuando pidan una landing o página simple',
51
+ body: `# Landing estática
52
+
53
+ - \`index.html\` + \`styles.css\` (y \`script.js\` solo si hace falta). Sin frameworks ni build.
54
+ - CSS moderno: variables, grid/flex, responsive con una columna en mobile.
55
+ - Accesibilidad: \`lang\`, \`alt\` en imágenes, contraste y foco visible.
56
+ - Sin errores de consola: verificalo con \`screenshot\`/\`console_errors\`.
57
+
58
+ ## Cierre
59
+ Al terminar, levantá el sitio y confirmá con una captura que se ve bien en adaptable y en móvil.`,
60
+ },
61
+ {
62
+ id: 'changelog',
63
+ name: 'Changelog',
64
+ description: 'Mantiene CHANGELOG.md con entradas claras.',
65
+ when: 'cuando haya cambios que el usuario quiera registrar en el changelog',
66
+ body: `# Changelog
67
+
68
+ - Mantené una sección \`## [No publicado]\` (o \`Unreleased\`) arriba con \`### Agregado\`, \`### Cambiado\`, \`### Corregido\`.
69
+ - Entradas en imperativo y orientadas al usuario, no al archivo tocado.
70
+ - Al publicar, mové lo de "No publicado" a la versión con fecha \`YYYY-MM-DD\`.
71
+ - No dupliques entradas ni describas detalles internos irrelevantes.`,
72
+ },
73
+ ];
74
+
75
+ /** Entrada del catalogo de skills por id. */
76
+ export function skillCatalogEntry(id) {
77
+ return SKILLS_CATALOG.find((item) => item.id === id) ?? null;
78
+ }
@@ -41,6 +41,57 @@ async function ghJson(url) {
41
41
  return res.json();
42
42
  }
43
43
 
44
+ /**
45
+ * Busca skills en GitHub y devuelve refs (`owner/repo[/slug]`) instalables.
46
+ * Sin token usa la busqueda de repositorios (limitada); con token usa la
47
+ * busqueda de codigo por `filename:SKILL.md`, que es mucho mas precisa.
48
+ */
49
+ export async function searchSkillsGithub(query, { token, limit = 10 } = {}) {
50
+ const q = String(query ?? '').trim();
51
+ if (!q) return [];
52
+ const headers = { 'User-Agent': 'tamnora-harness', Accept: 'application/vnd.github+json' };
53
+ if (token) headers.Authorization = `Bearer ${token}`;
54
+ const only = Math.max(1, Math.min(30, Number(limit) || 10));
55
+
56
+ if (token) {
57
+ const url = new URL(`${GITHUB_API}/search/code`);
58
+ url.searchParams.set('q', `${q} filename:SKILL.md`);
59
+ url.searchParams.set('per_page', String(only));
60
+ const res = await fetch(url.toString(), { headers });
61
+ if (!res.ok) throw new Error(`GitHub respondio HTTP ${res.status}`);
62
+ const data = await res.json();
63
+ return (data.items ?? [])
64
+ .map((item) => {
65
+ const [owner, repo] = String(item.repository?.full_name ?? '').split('/');
66
+ const parts = String(item.path ?? '').split('/');
67
+ const idx = parts.lastIndexOf('SKILL.md');
68
+ const slug = idx > 0 ? parts[idx - 1] : null;
69
+ return { ref: slug ? `${owner}/${repo}/${slug}` : `${owner}/${repo}`, owner, repo, slug, source: `${owner}/${repo}` };
70
+ })
71
+ .filter((r) => r.owner && r.repo);
72
+ }
73
+
74
+ const url = new URL(`${GITHUB_API}/search/repositories`);
75
+ url.searchParams.set('q', `${q} SKILL.md in:readme`);
76
+ url.searchParams.set('per_page', String(only));
77
+ const res = await fetch(url.toString(), { headers });
78
+ if (!res.ok) throw new Error(`GitHub respondio HTTP ${res.status}`);
79
+ const data = await res.json();
80
+ return (data.items ?? [])
81
+ .map((item) => {
82
+ const [owner, repo] = String(item.full_name ?? '').split('/');
83
+ return {
84
+ ref: `${owner}/${repo}`,
85
+ owner,
86
+ repo,
87
+ slug: null,
88
+ source: `${owner}/${repo}`,
89
+ description: item.description ?? '',
90
+ };
91
+ })
92
+ .filter((r) => r.owner && r.repo);
93
+ }
94
+
44
95
  /**
45
96
  * Baja los archivos de una skill (`SKILL.md` y los del mismo directorio) desde
46
97
  * el repositorio de GitHub. Sin `slug`, toma el `SKILL.md` de la raiz.
@@ -6,6 +6,7 @@ import { globToRegExp, walkFiles } from './glob.js';
6
6
  import { diffLines } from './diff.js';
7
7
  import { captureScreenshot, readConsole } from '../browser/index.js';
8
8
  import { screenshotsDir } from '../config/home.js';
9
+ import { scrubEnv } from '../redact.js';
9
10
 
10
11
  const MAX_READ_LINES = 2000;
11
12
  const MAX_LIST = 500;
@@ -314,7 +315,7 @@ export function bashTool(root, { command, timeout } = {}, { signal } = {}) {
314
315
  const seconds = Math.min(Math.max(Number(timeout) || 30, 1), 300);
315
316
  let child;
316
317
  try {
317
- child = spawn(command, { cwd: root, shell: true, windowsHide: true });
318
+ child = spawn(command, { cwd: root, shell: true, windowsHide: true, env: scrubEnv(process.env) });
318
319
  } catch (err) {
319
320
  resolve(`Error ejecutando el comando: ${err.message}`);
320
321
  return;
@@ -359,7 +360,7 @@ function runGit(root, args) {
359
360
  return new Promise((resolve) => {
360
361
  let child;
361
362
  try {
362
- child = spawn('git', args, { cwd: root, windowsHide: true });
363
+ child = spawn('git', args, { cwd: root, windowsHide: true, env: scrubEnv(process.env) });
363
364
  } catch (err) {
364
365
  resolve(`Error ejecutando git: ${err.message}`);
365
366
  return;
@@ -421,11 +422,11 @@ const MAX_SPEAK_TEXT = 1200;
421
422
  * son del cliente (la consola lo reproduce y Telegram lo manda como nota de
422
423
  * voz), asi que aca solo se normaliza el pedido y se acota el largo.
423
424
  */
424
- export function speakTool(_root, { texto, voice, rate } = {}) {
425
+ export function speakTool(_root, { texto, voice, rate, pitch } = {}) {
425
426
  const value = String(texto ?? '').replace(/\s+/g, ' ').trim();
426
427
  if (!value) return 'Error: falta "texto".';
427
428
  const recortado = value.length > MAX_SPEAK_TEXT ? `${value.slice(0, MAX_SPEAK_TEXT - 1).trimEnd()}…` : value;
428
- const opciones = [voice, rate].filter(Boolean).join(', ');
429
+ const opciones = [voice, rate, pitch].filter(Boolean).join(', ');
429
430
  return `Audio pedido${opciones ? ` (${opciones})` : ''}: "${recortado}". El cliente lo reproduce o lo envía como nota de voz.`;
430
431
  }
431
432
 
@@ -458,6 +459,93 @@ export function previewUrlTool(_root, { url } = {}) {
458
459
  return `Vista previa pedida: ${parsed.url}. La consola la abre en el panel de vista previa.`;
459
460
  }
460
461
 
462
+ const QUESTION_TYPES = new Set(['text', 'textarea', 'select', 'confirm']);
463
+
464
+ function normalizeQuestions(raw) {
465
+ if (!Array.isArray(raw)) return [];
466
+ const out = [];
467
+ for (const item of raw) {
468
+ if (!item || typeof item !== 'object') continue;
469
+ const id = String(item.id ?? '').trim();
470
+ const label = String(item.label ?? '').trim();
471
+ if (!id || !label) continue;
472
+ let type = QUESTION_TYPES.has(item.type) ? item.type : 'text';
473
+ const options = Array.isArray(item.options) ? item.options.map((o) => String(o)) : [];
474
+ if (type === 'select' && !options.length) type = 'text';
475
+ out.push({
476
+ id,
477
+ label,
478
+ type,
479
+ options,
480
+ placeholder: String(item.placeholder ?? ''),
481
+ required: item.required === true,
482
+ });
483
+ if (out.length >= 12) break;
484
+ }
485
+ return out;
486
+ }
487
+
488
+ function formatAnswer(value) {
489
+ if (value === undefined || value === null || value === '') return '(sin respuesta)';
490
+ if (Array.isArray(value)) return value.join(', ');
491
+ if (typeof value === 'boolean') return value ? 'sí' : 'no';
492
+ return String(value);
493
+ }
494
+
495
+ /**
496
+ * Pregunta al usuario con un formulario interactivo. La entrega es del cliente
497
+ * (como `speak`/`preview_url`): aca se normaliza el pedido y se espera la
498
+ * respuesta via `onQuestion`. Sin canal interactivo, se le pide al modelo que
499
+ * pregunte en el texto.
500
+ */
501
+ export async function askUserTool(_root, args = {}, { onQuestion } = {}) {
502
+ const questions = normalizeQuestions(args.questions);
503
+ if (!questions.length) return 'Error: falta "questions" (una lista con id y label).';
504
+ if (typeof onQuestion !== 'function') {
505
+ return 'No hay formulario interactivo en este canal: hacé las preguntas en el texto de la respuesta.';
506
+ }
507
+ const result = await onQuestion({ title: String(args.title ?? '').trim(), questions });
508
+ if (!result || result.ok === false || result.cancelled) return 'El usuario no respondió el formulario.';
509
+ const answers = result.answers ?? {};
510
+ const lines = questions.map((q) => `- ${q.label}: ${formatAnswer(answers[q.id])}`);
511
+ return `Respuestas del usuario:\n${lines.join('\n')}`;
512
+ }
513
+
514
+ /**
515
+ * Ofrece opciones como botones tappables. La entrega es del cliente (como
516
+ * `speak`/`preview_url`): aca solo se valida; al tocar una, la consola manda la
517
+ * opcion como mensaje del usuario.
518
+ */
519
+ export function offerChoicesTool(_root, { question, options } = {}) {
520
+ const prompt = String(question ?? '').trim();
521
+ const items = Array.isArray(options) ? options.map((o) => String(o).trim()).filter(Boolean).slice(0, 6) : [];
522
+ if (!prompt || !items.length) return 'Error: pasá "question" y "options" (2 a 6 opciones).';
523
+ return `Opciones mostradas al usuario: ${items.join(' | ')}. Al tocar una, la consola la manda como su mensaje.`;
524
+ }
525
+
526
+ const PLAN_STATUSES = new Set(['pending', 'active', 'done']);
527
+
528
+ /**
529
+ * Muestra un plan/checklist interactivo. Solo lectura: el cliente lo dibuja; el
530
+ * modelo lo reemite para actualizar el estado de los pasos.
531
+ */
532
+ export function showPlanTool(_root, { title, steps } = {}) {
533
+ const clean = Array.isArray(steps)
534
+ ? steps
535
+ .filter((step) => step && typeof step === 'object' && String(step.text ?? '').trim())
536
+ .slice(0, 20)
537
+ .map((step) => ({
538
+ text: String(step.text).trim(),
539
+ status: PLAN_STATUSES.has(step.status) ? step.status : 'pending',
540
+ }))
541
+ : [];
542
+ if (!clean.length) return 'Error: pasá "steps" (una lista con "text").';
543
+ const marks = { done: '[x]', active: '[~]', pending: '[ ]' };
544
+ const head = String(title ?? '').trim();
545
+ const lines = clean.map((step) => `- ${marks[step.status]} ${step.text}`);
546
+ return `Plan mostrado al usuario${head ? ` ("${head}")` : ''}:\n${lines.join('\n')}`;
547
+ }
548
+
461
549
  /**
462
550
  * Captura de pantalla con el navegador headless del sistema. La guarda en la
463
551
  * casa (`screenshots/`) y devuelve un `meta.screenshot` para que la consola la
@@ -535,6 +623,9 @@ export const EXECUTORS = {
535
623
  git_commit: gitCommitTool,
536
624
  speak: speakTool,
537
625
  preview_url: previewUrlTool,
626
+ ask_user: askUserTool,
627
+ offer_choices: offerChoicesTool,
628
+ show_plan: showPlanTool,
538
629
  screenshot: screenshotTool,
539
630
  console_errors: consoleTool,
540
631
  };
@@ -1,28 +1,49 @@
1
1
  import { createToolRegistry } from './registry.js';
2
+ import { MCP_ROUTER_NAME, planMcpTools } from './mcp.js';
2
3
 
3
4
  export { createToolRegistry } from './registry.js';
4
5
  export { TOOL_DEFINITIONS, TOOL_NAMES } from './schema.js';
5
- export { DEFAULT_PERMISSIONS, evaluatePermission } from './permissions.js';
6
+ export { DEFAULT_PERMISSIONS, evaluatePermission, bashGuard } from './permissions.js';
6
7
  export { resolveInRoot, toRel } from './paths.js';
7
8
  export { globToRegExp, walkFiles, DEFAULT_IGNORES } from './glob.js';
9
+ export { MCP_ROUTER_NAME, MCP_AUTO_THRESHOLD, useLazyMcp, buildMcpIndex, mcpRouterDefinition, planMcpTools } from './mcp.js';
8
10
  export * as executors from './executors.js';
9
11
 
10
12
  /**
11
13
  * Combine local tools with an optional MCP registry into a single
12
14
  * `{ definitions, execute }` pair for the agent loop.
13
15
  *
16
+ * When the MCP registry advertises many tools (`mcpMode: 'auto'`/`'lazy'`),
17
+ * only the pinned tools keep their full schema and the rest are reached through
18
+ * the `mcp_call` router; the caller injects `buildMcpIndex(mcp.tools)` into the
19
+ * prompt so the model knows what exists.
20
+ *
14
21
  * @param {object} opts - same options as createToolRegistry, plus:
15
- * @param {{definitions:object[], handles:(n:string)=>boolean, call:Function}} [opts.mcp]
22
+ * @param {{definitions:object[], tools?:object[], handles:(n:string)=>boolean, call:Function}} [opts.mcp]
23
+ * @param {'auto'|'eager'|'lazy'} [opts.mcpMode]
24
+ * @param {string[]} [opts.mcpPinned] - tool/server names always exposed in full
25
+ * @param {number} [opts.mcpThreshold] - auto cut-over (default 12)
16
26
  */
17
- export function createTools({ mcp, ...registryOptions } = {}) {
27
+ export function createTools({ mcp, mcpMode = 'auto', mcpPinned = [], mcpThreshold, ...registryOptions } = {}) {
18
28
  const registry = createToolRegistry(registryOptions);
19
- const definitions = [...registry.definitions, ...(mcp?.definitions ?? [])];
29
+ const planned = planMcpTools(mcp, { mode: mcpMode, pinned: mcpPinned, threshold: mcpThreshold });
30
+ const definitions = [...registry.definitions, ...planned.definitions];
20
31
 
21
32
  async function execute(name, args, context) {
22
33
  if (registry.handles(name)) return registry.execute(name, args, context);
23
- if (mcp?.handles?.(name)) return mcp.call(name, args);
34
+ // Las tools MCP y el router pasan por el mismo control de permisos que las
35
+ // locales (default `ask`): se evalua/consulta con el nombre real de la tool.
36
+ const routed = planned.routed && name === MCP_ROUTER_NAME;
37
+ const toolName = routed ? String(args?.tool ?? '').trim() : name;
38
+ if (mcp?.handles?.(toolName)) {
39
+ const toolArgs = routed ? args?.args ?? {} : args;
40
+ const decision = await registry.authorize(toolName, toolArgs);
41
+ if (decision === 'deny') return `Permiso denegado para "${toolName}".`;
42
+ return mcp.call(toolName, toolArgs);
43
+ }
44
+ if (routed) return `Herramienta MCP desconocida: "${toolName}". Mirá el índice de MCP.`;
24
45
  return `Error: herramienta desconocida "${name}".`;
25
46
  }
26
47
 
27
- return { definitions, execute, registry };
48
+ return { definitions, execute, registry, mcp: { routed: planned.routed } };
28
49
  }
@@ -67,6 +67,8 @@ export function summarizeToolCall(name, args = {}) {
67
67
  case 'task':
68
68
  case 'spawn_agent':
69
69
  return shorten(a.description || a.prompt, 80);
70
+ case 'mcp_call':
71
+ return shorten(a.tool, 70);
70
72
  default:
71
73
  return '';
72
74
  }
@@ -0,0 +1,91 @@
1
+ // Enrutamiento de tools MCP (progressive disclosure). En vez de inyectar el
2
+ // esquema de TODAS las tools MCP en cada turno (ruido de contexto y "fatiga de
3
+ // atención"), cuando hay muchas se expone un indice compacto y una unica tool
4
+ // router `mcp_call({ tool, args })`; las tools "fijadas" (hot) siguen cargadas
5
+ // con su esquema completo.
6
+
7
+ export const MCP_ROUTER_NAME = 'mcp_call';
8
+
9
+ /** Corte de `auto`: por debajo de esto se cargan completas, por encima se difiere. */
10
+ export const MCP_AUTO_THRESHOLD = 12;
11
+
12
+ const INDEX_DESC_CHARS = 140;
13
+
14
+ /** Decide si conviene diferir las tools MCP segun el modo y el total. */
15
+ export function useLazyMcp(mode = 'auto', total = 0, threshold = MCP_AUTO_THRESHOLD) {
16
+ if (mode === 'eager') return false;
17
+ if (mode === 'lazy') return total > 0;
18
+ return total > threshold;
19
+ }
20
+
21
+ /**
22
+ * Indice compacto agrupado por servidor (nombre + descripcion recortada), sin
23
+ * los esquemas. El modelo lo lee y llama `mcp_call` con el nombre exacto.
24
+ */
25
+ export function buildMcpIndex(tools = []) {
26
+ if (!Array.isArray(tools) || !tools.length) return '';
27
+ const groups = new Map();
28
+ for (const tool of tools) {
29
+ const key = tool.server ?? 'mcp';
30
+ if (!groups.has(key)) groups.set(key, { name: tool.serverName ?? key, tools: [] });
31
+ groups.get(key).tools.push(tool);
32
+ }
33
+ const blocks = [
34
+ [
35
+ 'Tenés herramientas MCP disponibles, pero son demasiadas para cargarlas todas en contexto.',
36
+ 'Según la tarea, usá `mcp_call({ tool, args })` con el nombre exacto de la herramienta de este índice:',
37
+ ].join(' '),
38
+ ];
39
+ for (const group of groups.values()) {
40
+ const lines = [`\n## ${group.name}`];
41
+ for (const tool of group.tools) {
42
+ const desc = String(tool.description ?? '').replace(/\s+/g, ' ').trim();
43
+ const hint = desc.length > INDEX_DESC_CHARS ? `${desc.slice(0, INDEX_DESC_CHARS)}…` : desc;
44
+ lines.push(`- ${tool.name}${hint ? ` — ${hint}` : ''}`);
45
+ }
46
+ blocks.push(lines.join('\n'));
47
+ }
48
+ return blocks.join('\n');
49
+ }
50
+
51
+ /** Definicion (OpenAI-schema) del router que ejecuta cualquier tool MCP. */
52
+ export function mcpRouterDefinition() {
53
+ return {
54
+ type: 'function',
55
+ function: {
56
+ name: MCP_ROUTER_NAME,
57
+ description:
58
+ 'Ejecuta una herramienta MCP por su nombre completo. Usala para las herramientas del índice de MCP que no estén cargadas directamente (hay demasiadas para exponerlas todas).',
59
+ parameters: {
60
+ type: 'object',
61
+ properties: {
62
+ tool: { type: 'string', description: 'Nombre completo, ej. "mcp__postgres__query".' },
63
+ args: { type: 'object', description: 'Argumentos de la herramienta (según su esquema).' },
64
+ },
65
+ required: ['tool'],
66
+ },
67
+ },
68
+ };
69
+ }
70
+
71
+ function isPinned(name, pinned) {
72
+ for (const entry of pinned) {
73
+ const text = String(entry ?? '').trim();
74
+ if (!text) continue;
75
+ if (name === text || name.startsWith(`mcp__${text}__`)) return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ /**
81
+ * Elige que definiciones de MCP exponer este turno.
82
+ * @returns {{definitions:object[], routed:boolean}}
83
+ */
84
+ export function planMcpTools(mcp, { mode = 'auto', pinned = [], threshold = MCP_AUTO_THRESHOLD } = {}) {
85
+ const all = Array.isArray(mcp?.definitions) ? mcp.definitions : [];
86
+ if (!all.length) return { definitions: [], routed: false };
87
+ if (!useLazyMcp(mode, all.length, threshold)) return { definitions: all, routed: false };
88
+ const hot = Array.isArray(pinned) ? pinned : [];
89
+ const eager = all.filter((d) => isPinned(d.function?.name, hot));
90
+ return { definitions: [...eager, mcpRouterDefinition()], routed: true };
91
+ }
@@ -17,6 +17,9 @@ export const DEFAULT_PERMISSIONS = {
17
17
  screenshot: 'allow',
18
18
  console_errors: 'allow',
19
19
  preview_url: 'allow',
20
+ ask_user: 'allow',
21
+ offer_choices: 'allow',
22
+ show_plan: 'allow',
20
23
  write: 'ask',
21
24
  edit: 'ask',
22
25
  bash: 'ask',
@@ -50,27 +53,69 @@ function subjectFor(name, args) {
50
53
  return args?.path ?? '';
51
54
  }
52
55
 
56
+ // Guardas de `bash`: comandos catastroficos o irreversibles que no deberian
57
+ // colarse aunque la policy sea `allow` (o `--yes`). `deny` = nunca legitimo;
58
+ // `ask` = nunca se autoaprueba (siempre consulta, aunque bash sea `allow`).
59
+ const BASH_DENY = [
60
+ /:\s*\(\s*\)\s*\{[\s\S]*\}\s*;?\s*:/, // fork bomb :(){ :|:& };:
61
+ /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)(\s+--?[\w-]+)*\s+(\/|~|\$HOME|\/\*)(\s|$)/i,
62
+ /\bmkfs(\.|\s|$)/i,
63
+ /\bdd\b[^\n]*\bof=\/dev\//i,
64
+ />\s*\/dev\/(sd|nvme|disk)/i,
65
+ /\bchmod\s+-R\s+777\s+\/(\s|$)/i,
66
+ /\b(?:format|del|erase|rd|rmdir)\b[^\n]*\b[a-z]:[\\/]\*/i, // Windows: borrar/formatear unidad
67
+ /\b(?:remove-item|del|rd|rmdir|format)\b[^\n]*(?:-recurse|-r\b)[^\n]*\b[a-z]:\\?\s*$/i,
68
+ ];
69
+ const BASH_ASK = [
70
+ /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)(\s+--?[\w-]+)*\s+(\*|\.|\.\/)(\s|$)/i, // rm -rf * | . (workspace)
71
+ /\bgit\s+push\b[^\n]*(\s-f\b|\s--force\b)/i,
72
+ /\bgit\s+reset\s+--hard\b/i,
73
+ /\bgit\s+clean\s+-[a-z]*f/i,
74
+ ];
75
+
76
+ /** Devuelve `'deny'`/`'ask'` si el comando es peligroso, o `null`. */
77
+ export function bashGuard(command) {
78
+ const text = String(command ?? '');
79
+ if (!text.trim()) return null;
80
+ for (const re of BASH_DENY) if (re.test(text)) return 'deny';
81
+ for (const re of BASH_ASK) if (re.test(text)) return 'ask';
82
+ return null;
83
+ }
84
+
85
+ const RANK = { allow: 0, ask: 1, deny: 2 };
86
+
53
87
  /**
54
88
  * Decide the permission for a tool call.
55
89
  * @returns {'allow'|'ask'|'deny'}
56
90
  */
57
91
  export function evaluatePermission(name, args = {}, permissions = {}) {
58
92
  const value = permissions[name] ?? DEFAULT_PERMISSIONS[name] ?? 'ask';
93
+ let decision = 'ask';
59
94
 
60
- if (typeof value === 'string') return value;
61
-
62
- if (value && typeof value === 'object') {
95
+ if (typeof value === 'string') {
96
+ decision = value;
97
+ } else if (value && typeof value === 'object') {
63
98
  const subject = subjectFor(name, args);
64
99
  let fallback = 'ask';
65
- for (const [pattern, decision] of Object.entries(value)) {
100
+ let matched = false;
101
+ for (const [pattern, d] of Object.entries(value)) {
66
102
  if (pattern === '*') {
67
- fallback = decision;
103
+ fallback = d;
68
104
  continue;
69
105
  }
70
- if (matchPattern(pattern, subject)) return decision;
106
+ if (matchPattern(pattern, subject)) {
107
+ decision = d;
108
+ matched = true;
109
+ break;
110
+ }
71
111
  }
72
- return fallback;
112
+ if (!matched) decision = fallback;
73
113
  }
74
114
 
75
- return 'ask';
115
+ // Piso de seguridad: un comando peligroso no se puede autoaprobar por policy.
116
+ if (name === 'bash') {
117
+ const guard = bashGuard(args?.command);
118
+ if (guard && RANK[guard] > RANK[decision]) decision = guard;
119
+ }
120
+ return decision;
76
121
  }
@@ -10,29 +10,35 @@ import { evaluatePermission } from './permissions.js';
10
10
  * @param {string[]} [opts.enabled] - tool names to expose (default: all)
11
11
  * @param {object} [opts.permissions] - policy per tool ('allow'|'ask'|'deny' or pattern map)
12
12
  * @param {(name:string, args:object)=>Promise<boolean>} [opts.onAsk] - interactive approval
13
+ * @param {(payload:{title:string, questions:object[]})=>Promise<{answers:object,cancelled?:boolean}>} [opts.onQuestion] - interactive form
13
14
  * @param {boolean} [opts.yes] - auto-approve 'ask' (non-interactive)
14
15
  * @param {AbortSignal} [opts.signal] - cancela las tools de larga duracion (bash, git)
15
16
  */
16
- export function createToolRegistry({ root, enabled, permissions = {}, onAsk, yes = false, signal } = {}) {
17
+ export function createToolRegistry({ root, enabled, permissions = {}, onAsk, onQuestion, yes = false, signal } = {}) {
17
18
  const names = new Set(enabled?.length ? enabled : TOOL_NAMES);
18
19
  const definitions = TOOL_DEFINITIONS.filter((d) => names.has(d.function.name));
19
20
 
20
- async function execute(name, args = {}) {
21
- if (!names.has(name) || !(name in EXECUTORS)) {
22
- return `Error: herramienta desconocida "${name}".`;
23
- }
24
-
21
+ async function authorize(name, args = {}) {
25
22
  let decision = evaluatePermission(name, args, permissions);
26
23
  if (decision === 'ask') {
27
24
  if (onAsk) decision = (await onAsk(name, args)) ? 'allow' : 'deny';
28
25
  else decision = yes ? 'allow' : 'deny';
29
26
  }
27
+ return decision;
28
+ }
29
+
30
+ async function execute(name, args = {}) {
31
+ if (!names.has(name) || !(name in EXECUTORS)) {
32
+ return `Error: herramienta desconocida "${name}".`;
33
+ }
34
+
35
+ const decision = await authorize(name, args);
30
36
  if (decision === 'deny') {
31
37
  const detail = args?.command ?? args?.path ?? '';
32
38
  return `Permiso denegado para "${name}"${detail ? ` (${detail})` : ''}.`;
33
39
  }
34
40
 
35
- return EXECUTORS[name](root, args, { signal });
41
+ return EXECUTORS[name](root, args, { signal, onQuestion });
36
42
  }
37
43
 
38
44
  return {
@@ -40,6 +46,7 @@ export function createToolRegistry({ root, enabled, permissions = {}, onAsk, yes
40
46
  definitions,
41
47
  names: [...names],
42
48
  handles: (name) => names.has(name) && name in EXECUTORS,
49
+ authorize,
43
50
  execute,
44
51
  };
45
52
  }