@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.
- package/CHANGELOG.md +45 -0
- package/README.md +37 -3
- package/SECURITY.md +8 -0
- package/harness/packages/cli/src/commands/mcp.js +31 -4
- package/harness/packages/cli/src/commands/repl.js +7 -2
- package/harness/packages/cli/src/commands/run.js +5 -2
- package/harness/packages/cli/src/commands/tui.js +9 -2
- package/harness/packages/cli/src/lib/runtime.js +23 -1
- package/harness/packages/cli/test/tunnel.test.js +13 -1
- package/harness/packages/core/src/agent/modes.js +3 -0
- package/harness/packages/core/src/config/index.js +3 -1
- package/harness/packages/core/src/index.js +15 -1
- package/harness/packages/core/src/mcp/catalog.js +79 -0
- package/harness/packages/core/src/mcp/index.js +14 -2
- package/harness/packages/core/src/mcp/registry.js +29 -1
- package/harness/packages/core/src/redact.js +25 -0
- package/harness/packages/core/src/skills/catalog.js +78 -0
- package/harness/packages/core/src/skills/remote.js +51 -0
- package/harness/packages/core/src/tools/executors.js +95 -4
- package/harness/packages/core/src/tools/index.js +27 -6
- package/harness/packages/core/src/tools/labels.js +2 -0
- package/harness/packages/core/src/tools/mcp.js +91 -0
- package/harness/packages/core/src/tools/permissions.js +53 -8
- package/harness/packages/core/src/tools/registry.js +14 -7
- package/harness/packages/core/src/tools/schema.js +87 -0
- package/harness/packages/core/src/tts/edge.js +28 -7
- package/harness/packages/core/src/tunnel.js +51 -5
- package/harness/packages/core/test/compact.test.js +2 -0
- package/harness/packages/core/test/labels.test.js +1 -0
- package/harness/packages/core/test/mcp-registry.test.js +37 -1
- package/harness/packages/core/test/mcp-routing.test.js +115 -0
- package/harness/packages/core/test/redact.test.js +22 -0
- package/harness/packages/core/test/skills.test.js +11 -0
- package/harness/packages/core/test/tools.test.js +97 -3
- package/harness/packages/server/public/app.js +567 -33
- package/harness/packages/server/public/index.html +51 -7
- package/harness/packages/server/public/styles.css +124 -4
- package/harness/packages/server/src/lib/agentTools.js +45 -3
- package/harness/packages/server/src/lib/channels/telegram.js +10 -3
- package/harness/packages/server/src/lib/prompt.js +4 -1
- package/harness/packages/server/src/lib/questions.js +72 -0
- package/harness/packages/server/src/lib/scheduler.js +12 -1
- package/harness/packages/server/src/lib/tasks.js +3 -1
- package/harness/packages/server/src/lib/ttsSettings.js +11 -0
- package/harness/packages/server/src/routes/chat.js +31 -2
- package/harness/packages/server/src/routes/mcp.js +54 -2
- package/harness/packages/server/src/routes/preview.js +49 -28
- package/harness/packages/server/src/routes/providers.js +12 -0
- package/harness/packages/server/src/routes/sessions.js +3 -1
- package/harness/packages/server/src/routes/skills.js +48 -1
- package/harness/packages/server/src/routes/state.js +8 -1
- package/harness/packages/server/src/routes/tts.js +4 -2
- package/harness/packages/server/src/server.js +37 -1
- package/harness/packages/server/test/preview.test.js +111 -0
- package/harness/packages/server/test/server.test.js +186 -19
- package/harness/packages/server/test/telegram-features.test.js +2 -0
- package/harness/packages/server/test/telegram-voz.test.js +2 -0
- package/harness/packages/server/test/telegram.test.js +2 -0
- package/harness/packages/server/test/tts.test.js +5 -1
- package/harness/tamnora.example.json +3 -1
- 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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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')
|
|
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
|
-
|
|
100
|
+
let matched = false;
|
|
101
|
+
for (const [pattern, d] of Object.entries(value)) {
|
|
66
102
|
if (pattern === '*') {
|
|
67
|
-
fallback =
|
|
103
|
+
fallback = d;
|
|
68
104
|
continue;
|
|
69
105
|
}
|
|
70
|
-
if (matchPattern(pattern, subject))
|
|
106
|
+
if (matchPattern(pattern, subject)) {
|
|
107
|
+
decision = d;
|
|
108
|
+
matched = true;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
71
111
|
}
|
|
72
|
-
|
|
112
|
+
if (!matched) decision = fallback;
|
|
73
113
|
}
|
|
74
114
|
|
|
75
|
-
|
|
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
|
|
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
|
}
|