@danieltmn/tamnora 0.16.1 → 0.18.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 +58 -4
- 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 +17 -3
- 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/procs/detect.js +59 -8
- package/harness/packages/core/src/procs/kill.js +26 -5
- 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 +93 -2
- 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 +86 -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/procs.test.js +70 -2
- 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 +629 -28
- package/harness/packages/server/public/index.html +44 -2
- package/harness/packages/server/public/styles.css +106 -7
- package/harness/packages/server/src/lib/agentTools.js +45 -3
- package/harness/packages/server/src/lib/channels/telegram.js +2 -0
- package/harness/packages/server/src/lib/procs.js +9 -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/runManager.js +29 -2
- 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/routes/chat.js +40 -3
- 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/procs.js +20 -3
- 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/server.js +37 -1
- package/harness/packages/server/test/preview.test.js +111 -0
- package/harness/packages/server/test/server.test.js +285 -34
- 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/tamnora.example.json +3 -1
- package/package.json +1 -1
|
@@ -54,7 +54,9 @@ export function registryServerToConfig(server) {
|
|
|
54
54
|
if (runtime === 'npx' && !args.includes('-y')) args.unshift('-y');
|
|
55
55
|
args.push(`${pkg.identifier}${pkg.version ? `@${pkg.version}` : ''}`);
|
|
56
56
|
const env = {};
|
|
57
|
-
for (const variable of pkg.environmentVariables ?? [])
|
|
57
|
+
for (const variable of pkg.environmentVariables ?? []) {
|
|
58
|
+
env[variable.name] = variable.isRequired ? `env:${variable.name}` : '';
|
|
59
|
+
}
|
|
58
60
|
const requiredEnv = (pkg.environmentVariables ?? []).filter((v) => v.isRequired).map((v) => v.name);
|
|
59
61
|
return {
|
|
60
62
|
id,
|
|
@@ -70,6 +72,32 @@ export function registryServerToConfig(server) {
|
|
|
70
72
|
throw new Error('El servidor del registro no tiene remotes ni packages compatibles.');
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
/** Vista liviana de un server del registro, para mostrar candidatos sin instalar. */
|
|
76
|
+
export function registryServerToCandidate(server) {
|
|
77
|
+
const id = slugifyId(server?.name) || 'mcp';
|
|
78
|
+
const remote = (server?.remotes ?? [])[0];
|
|
79
|
+
const pkg = (server?.packages ?? [])[0];
|
|
80
|
+
const runtime = pkg ? pkg.runtimeHint || (pkg.registryType === 'npm' ? 'npx' : 'uvx') : '';
|
|
81
|
+
const requiredEnv = (pkg?.environmentVariables ?? []).filter((v) => v.isRequired).map((v) => v.name);
|
|
82
|
+
const args = pkg
|
|
83
|
+
? [
|
|
84
|
+
...(pkg.runtimeArguments ?? []).filter((a) => a.type === 'positional' && a.value).map((a) => a.value),
|
|
85
|
+
`${pkg.identifier}${pkg.version ? `@${pkg.version}` : ''}`,
|
|
86
|
+
]
|
|
87
|
+
: [];
|
|
88
|
+
return {
|
|
89
|
+
id,
|
|
90
|
+
name: server?.title || server?.name || id,
|
|
91
|
+
description: server?.description ?? '',
|
|
92
|
+
registry: server?.name ?? null,
|
|
93
|
+
transport: remote?.url ? 'http' : 'stdio',
|
|
94
|
+
url: remote?.url ?? null,
|
|
95
|
+
command: runtime || null,
|
|
96
|
+
args,
|
|
97
|
+
requiredEnv,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
73
101
|
/**
|
|
74
102
|
* Normaliza un snippet JSON a nuestra config. Acepta:
|
|
75
103
|
* - `{ command, args, env }` o `{ url }`
|
|
@@ -24,12 +24,53 @@ const SCRIPT_ORDER = ['dev', 'start', 'serve', 'preview'];
|
|
|
24
24
|
const STATIC_SERVER = fileURLToPath(new URL('../../../../scripts/static-server.mjs', import.meta.url));
|
|
25
25
|
const STATIC_PORT = 8080;
|
|
26
26
|
|
|
27
|
+
/** Archivo por proyecto (relativo a su raiz) que define sus procesos de dev. */
|
|
28
|
+
export const PROCS_FILE = '.tamnora/procs.json';
|
|
29
|
+
|
|
27
30
|
function findStaticRoot(dir) {
|
|
28
31
|
if (existsSync(path.join(dir, 'index.html'))) return '.';
|
|
29
32
|
if (existsSync(path.join(dir, 'public', 'index.html'))) return 'public';
|
|
30
33
|
return null;
|
|
31
34
|
}
|
|
32
35
|
|
|
36
|
+
/** True si `target` es `root` o vive dentro de el (sin salirse por `..`). */
|
|
37
|
+
function isInside(root, target) {
|
|
38
|
+
const rel = path.relative(root, target);
|
|
39
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Procesos definidos a mano por el proyecto en `<root>/.tamnora/procs.json`.
|
|
44
|
+
* Formato: `{ "processes": [{ label?, dir?, command, args?, port? }] }` o un array
|
|
45
|
+
* directo. `dir` es relativo a la raiz y debe quedar dentro de ella. Cada chip
|
|
46
|
+
* sigue pasando por la whitelist (`processes.allow`) al arrancar.
|
|
47
|
+
*/
|
|
48
|
+
export function loadProjectProcs(root) {
|
|
49
|
+
const file = path.join(root, PROCS_FILE);
|
|
50
|
+
if (!existsSync(file)) return [];
|
|
51
|
+
const raw = readJson(file);
|
|
52
|
+
const list = Array.isArray(raw) ? raw : Array.isArray(raw?.processes) ? raw.processes : [];
|
|
53
|
+
const chips = [];
|
|
54
|
+
for (const entry of list) {
|
|
55
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
56
|
+
const command = String(entry.command ?? '').trim();
|
|
57
|
+
if (!command) continue;
|
|
58
|
+
const rel = String(entry.dir ?? '').trim().replace(/^[/\\]+/, '');
|
|
59
|
+
const cwd = path.resolve(root, rel || '.');
|
|
60
|
+
if (!isInside(root, cwd) || !existsSync(cwd)) continue;
|
|
61
|
+
const port = entry.port ? Number(entry.port) : undefined;
|
|
62
|
+
chips.push({
|
|
63
|
+
label: String(entry.label ?? command).trim() || command,
|
|
64
|
+
command,
|
|
65
|
+
args: Array.isArray(entry.args) ? entry.args.map(String) : [],
|
|
66
|
+
cwd,
|
|
67
|
+
port: Number.isInteger(port) && port > 0 ? port : undefined,
|
|
68
|
+
source: 'project',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return chips;
|
|
72
|
+
}
|
|
73
|
+
|
|
33
74
|
/** Chip para servir `dir` como sitio estático con el servidor sin dependencias. */
|
|
34
75
|
export function staticServerChip(dir, { port = STATIC_PORT, label } = {}) {
|
|
35
76
|
return {
|
|
@@ -59,9 +100,9 @@ export function packageManager(dir) {
|
|
|
59
100
|
return 'npm';
|
|
60
101
|
}
|
|
61
102
|
|
|
62
|
-
/** Devuelve chips `{ label, command, args, cwd }` para arrancar el proyecto. */
|
|
103
|
+
/** Devuelve chips `{ label, command, args, cwd, source }` para arrancar el proyecto. */
|
|
63
104
|
export function detectProcs(dir) {
|
|
64
|
-
const
|
|
105
|
+
const auto = [];
|
|
65
106
|
const pkg = readJson(path.join(dir, 'package.json'));
|
|
66
107
|
if (pkg?.scripts && typeof pkg.scripts === 'object') {
|
|
67
108
|
const pm = packageManager(dir);
|
|
@@ -70,29 +111,39 @@ export function detectProcs(dir) {
|
|
|
70
111
|
const first = Object.keys(pkg.scripts)[0];
|
|
71
112
|
if (first) names.push(first);
|
|
72
113
|
}
|
|
73
|
-
for (const name of names)
|
|
114
|
+
for (const name of names) auto.push({ label: `${name} (${pm})`, command: pm, args: ['run', name], cwd: dir });
|
|
74
115
|
}
|
|
75
116
|
|
|
76
117
|
if (existsSync(path.join(dir, 'artisan'))) {
|
|
77
|
-
|
|
118
|
+
auto.push({ label: 'artisan serve', command: 'php', args: ['artisan', 'serve'], cwd: dir });
|
|
78
119
|
} else if (readJson(path.join(dir, 'composer.json'))?.scripts?.dev) {
|
|
79
|
-
|
|
120
|
+
auto.push({ label: 'composer dev', command: 'composer', args: ['run', 'dev'], cwd: dir });
|
|
80
121
|
}
|
|
81
122
|
|
|
82
123
|
for (const rel of ['public/index.php', 'backend/public/index.php', 'index.php']) {
|
|
83
124
|
if (!existsSync(path.join(dir, rel))) continue;
|
|
84
125
|
const docroot = path.dirname(rel) === '.' ? '.' : path.dirname(rel);
|
|
85
|
-
|
|
126
|
+
auto.push({ label: `php -S (${rel})`, command: 'php', args: ['-S', '127.0.0.1:8000', '-t', docroot], cwd: dir });
|
|
86
127
|
break;
|
|
87
128
|
}
|
|
88
129
|
|
|
89
|
-
if (!
|
|
130
|
+
if (!auto.length) {
|
|
90
131
|
const staticRoot = findStaticRoot(dir);
|
|
91
132
|
if (staticRoot) {
|
|
92
133
|
const target = staticRoot === '.' ? dir : path.join(dir, staticRoot);
|
|
93
|
-
|
|
134
|
+
auto.push(staticServerChip(target));
|
|
94
135
|
}
|
|
95
136
|
}
|
|
96
137
|
|
|
138
|
+
const chips = [];
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
const add = (chip) => {
|
|
141
|
+
const key = `${chip.command}|${(chip.args ?? []).join(' ')}|${chip.cwd ?? ''}`;
|
|
142
|
+
if (seen.has(key)) return;
|
|
143
|
+
seen.add(key);
|
|
144
|
+
chips.push(chip);
|
|
145
|
+
};
|
|
146
|
+
for (const chip of loadProjectProcs(dir)) add(chip);
|
|
147
|
+
for (const chip of auto) add({ ...chip, source: 'auto' });
|
|
97
148
|
return chips;
|
|
98
149
|
}
|
|
@@ -71,16 +71,37 @@ async function pidsOnUnix(port) {
|
|
|
71
71
|
return [];
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
function validPort(port) {
|
|
75
|
+
const value = Number(port);
|
|
76
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
77
|
+
throw new Error('Puerto inválido (usá 1-65535).');
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** PIDs escuchando en `port` (Windows `netstat` o Unix lsof/fuser/ss). */
|
|
83
|
+
export async function pidsOnPort(port) {
|
|
84
|
+
const value = validPort(port);
|
|
85
|
+
return process.platform === 'win32' ? pidsOnWindows(value) : pidsOnUnix(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Estado de un puerto.
|
|
90
|
+
* @returns {Promise<{port:number,listening:boolean,pids:number[]}>}
|
|
91
|
+
*/
|
|
92
|
+
export async function portInfo(port) {
|
|
93
|
+
const value = validPort(port);
|
|
94
|
+
const pids = await pidsOnPort(value);
|
|
95
|
+
return { port: value, listening: pids.length > 0, pids };
|
|
96
|
+
}
|
|
97
|
+
|
|
74
98
|
/**
|
|
75
99
|
* Kill every process listening on `port`.
|
|
76
100
|
* @returns {Promise<{port:number,pids:number[],killed:number[],errors:string[]}>}
|
|
77
101
|
*/
|
|
78
102
|
export async function killPort(port) {
|
|
79
|
-
const value =
|
|
80
|
-
|
|
81
|
-
throw new Error('Puerto inválido (usá 1-65535).');
|
|
82
|
-
}
|
|
83
|
-
const pids = process.platform === 'win32' ? await pidsOnWindows(value) : await pidsOnUnix(value);
|
|
103
|
+
const value = validPort(port);
|
|
104
|
+
const pids = await pidsOnPort(value);
|
|
84
105
|
const killed = [];
|
|
85
106
|
const errors = [];
|
|
86
107
|
for (const pid of pids) {
|
|
@@ -27,6 +27,31 @@ export function isSecretName(name) {
|
|
|
27
27
|
return SECRET_NAME.test(String(name ?? ''));
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Nombre sensible con criterio mas estricto (para el scrub de entorno): solo
|
|
32
|
+
* credenciales reales, para no romper variables legitimas como `SSH_AUTH_SOCK`
|
|
33
|
+
* o `DBUS_SESSION_BUS_ADDRESS` que matchean el patron amplio de `isSecretName`.
|
|
34
|
+
*/
|
|
35
|
+
const SENSITIVE_ENV_NAME = /(API[_-]?KEY|ACCESS[_-]?KEY|SECRET|PASSWORD|PASSWD|TOKEN|CREDENTIAL|PRIVATE[_-]?KEY|OAUTH)/i;
|
|
36
|
+
|
|
37
|
+
export function isSensitiveEnvName(name) {
|
|
38
|
+
return SENSITIVE_ENV_NAME.test(String(name ?? ''));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Copia el entorno sin las variables con nombre de credencial, para no filtrar
|
|
43
|
+
* secretos a un proceso hijo (`bash`). Conserva PATH/HOME/etc. Defensa en
|
|
44
|
+
* profundidad: el comando del modelo no puede leer `env` y ver las claves.
|
|
45
|
+
*/
|
|
46
|
+
export function scrubEnv(env = getEnv()) {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const [name, value] of Object.entries(env ?? {})) {
|
|
49
|
+
if (isSensitiveEnvName(name)) continue;
|
|
50
|
+
out[name] = value;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
30
55
|
/**
|
|
31
56
|
* Valores de entorno que hay que redactar: los de variables con nombre
|
|
32
57
|
* sensible y al menos 8 caracteres (evita tapar valores triviales).
|
|
@@ -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;
|
|
@@ -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
|
+
}
|