@danieltmn/tamnora 0.26.0 → 0.27.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 +43 -0
- package/README.md +28 -10
- package/harness/packages/cli/src/commands/server.js +10 -10
- package/harness/packages/cli/src/commands/update.js +1 -1
- package/harness/packages/core/src/agent/modes.js +2 -0
- package/harness/packages/core/src/index.js +4 -0
- package/harness/packages/core/src/mcp/registry.js +43 -2
- package/harness/packages/core/src/providers/capabilities.js +67 -0
- package/harness/packages/core/src/providers/index.js +25 -7
- package/harness/packages/core/src/providers/ollama.js +3 -2
- package/harness/packages/core/src/providers/utils.js +16 -6
- package/harness/packages/core/src/skills/remote.js +45 -12
- package/harness/packages/core/src/tools/executors.js +84 -0
- package/harness/packages/core/src/tools/labels.js +7 -0
- package/harness/packages/core/src/tools/permissions.js +3 -0
- package/harness/packages/core/src/tools/registry.js +3 -2
- package/harness/packages/core/src/tools/schema.js +53 -0
- package/harness/packages/core/test/capabilities.test.js +51 -0
- package/harness/packages/core/test/mcp-registry.test.js +63 -1
- package/harness/packages/core/test/providers.test.js +53 -1
- package/harness/packages/core/test/skills.test.js +66 -1
- package/harness/packages/core/test/tools.test.js +63 -0
- package/harness/packages/server/public/app.js +2 -0
- package/harness/packages/server/public/cards.js +22 -6
- package/harness/packages/server/public/chart.js +237 -0
- package/harness/packages/server/public/chat.js +7 -1
- package/harness/packages/server/public/chatFormat.js +15 -4
- package/harness/packages/server/public/chrome.js +10 -4
- package/harness/packages/server/public/icons/icon-128x128.png +0 -0
- package/harness/packages/server/public/icons/icon-144x144.png +0 -0
- package/harness/packages/server/public/icons/icon-152x152.png +0 -0
- package/harness/packages/server/public/icons/icon-192x192.png +0 -0
- package/harness/packages/server/public/icons/icon-384x384.png +0 -0
- package/harness/packages/server/public/icons/icon-512x512.png +0 -0
- package/harness/packages/server/public/icons/icon-72x72.png +0 -0
- package/harness/packages/server/public/icons/icon-96x96.png +0 -0
- package/harness/packages/server/public/icons/logo.png +0 -0
- package/harness/packages/server/public/icons/logo_light.png +0 -0
- package/harness/packages/server/public/index.html +9 -7
- package/harness/packages/server/public/logo.png +0 -0
- package/harness/packages/server/public/markdown.js +22 -0
- package/harness/packages/server/public/models.js +38 -7
- package/harness/packages/server/public/modelsQuery.js +44 -4
- package/harness/packages/server/public/settings.js +39 -1
- package/harness/packages/server/public/styles.css +33 -0
- package/harness/packages/server/src/lib/channels/telegram/media.js +11 -8
- package/harness/packages/server/src/lib/channels/telegram/run.js +5 -0
- package/harness/packages/server/src/lib/imagegen.js +201 -0
- package/harness/packages/server/src/lib/scheduler.js +2 -0
- package/harness/packages/server/src/lib/securityHeaders.js +1 -1
- package/harness/packages/server/src/lib/tasks.js +2 -0
- package/harness/packages/server/src/lib/transcribe.js +3 -1
- package/harness/packages/server/src/lib/ttsEngine.js +69 -0
- package/harness/packages/server/src/routes/chat.js +2 -0
- package/harness/packages/server/src/routes/files.js +8 -3
- package/harness/packages/server/src/routes/imagegen.js +35 -0
- package/harness/packages/server/src/routes/models.js +2 -1
- package/harness/packages/server/src/routes/tts.js +4 -2
- package/harness/packages/server/src/server.js +2 -0
- package/harness/packages/server/test/chart.test.js +70 -0
- package/harness/packages/server/test/chat.test.js +1 -0
- package/harness/packages/server/test/chatFormat.test.js +9 -5
- package/harness/packages/server/test/imagegen.test.js +215 -0
- package/harness/packages/server/test/markdown.test.js +20 -0
- package/harness/packages/server/test/markdownSecurity.test.js +25 -0
- package/harness/packages/server/test/modelsQuery.test.js +25 -1
- package/harness/packages/server/test/securityHeaders.test.js +1 -0
- package/harness/packages/server/test/ttsEngine.test.js +58 -0
- package/harness/scripts/self-update.mjs +1 -1
- package/package.json +1 -1
- package/harness/packages/server/public/icons (1).zip +0 -0
|
@@ -547,6 +547,88 @@ export function showPlanTool(_root, { title, steps } = {}) {
|
|
|
547
547
|
return `Plan mostrado al usuario${head ? ` ("${head}")` : ''}:\n${lines.join('\n')}`;
|
|
548
548
|
}
|
|
549
549
|
|
|
550
|
+
const CHART_TYPES = new Set(['bar', 'line', 'pie']);
|
|
551
|
+
const MAX_CHART_LABELS = 24;
|
|
552
|
+
const MAX_CHART_SERIES = 6;
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Normaliza los datos de un grafico: labels y series alineados y numericos.
|
|
556
|
+
* Devuelve null si no queda nada para dibujar.
|
|
557
|
+
*/
|
|
558
|
+
export function normalizeChartSpec({ title, type, labels, series, unit } = {}) {
|
|
559
|
+
const clean = Array.isArray(labels) ? labels.map((l) => String(l ?? '').trim()).slice(0, MAX_CHART_LABELS) : [];
|
|
560
|
+
const items = clean.map((l, idx) => l || `#${idx + 1}`);
|
|
561
|
+
if (!items.length) return null;
|
|
562
|
+
const n = items.length;
|
|
563
|
+
const data = (Array.isArray(series) ? series : [])
|
|
564
|
+
.filter((s) => s && typeof s === 'object' && Array.isArray(s.values))
|
|
565
|
+
.slice(0, MAX_CHART_SERIES)
|
|
566
|
+
.map((s, idx) => ({
|
|
567
|
+
name: String(s.name ?? `Serie ${idx + 1}`).trim().slice(0, 40) || `Serie ${idx + 1}`,
|
|
568
|
+
values: Array.from({ length: n }, (_, i) => {
|
|
569
|
+
const v = Number(s.values[i]);
|
|
570
|
+
return Number.isFinite(v) ? v : 0;
|
|
571
|
+
}),
|
|
572
|
+
}));
|
|
573
|
+
if (!data.length) return null;
|
|
574
|
+
return {
|
|
575
|
+
type: CHART_TYPES.has(type) ? type : 'bar',
|
|
576
|
+
title: String(title ?? '').trim().slice(0, 120),
|
|
577
|
+
unit: String(unit ?? '').trim().slice(0, 20),
|
|
578
|
+
labels: items,
|
|
579
|
+
series: data,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Muestra un grafico de datos. Solo lectura: el cliente lo dibuja como SVG; el
|
|
585
|
+
* modelo lo reemite para actualizar los datos.
|
|
586
|
+
*/
|
|
587
|
+
export function showChartTool(_root, args = {}) {
|
|
588
|
+
const spec = normalizeChartSpec(args);
|
|
589
|
+
if (!spec) return 'Error: pasá "labels" (categorías) y "series" (con "values" numéricos).';
|
|
590
|
+
const head = `${spec.type === 'pie' ? 'Torta' : spec.type === 'line' ? 'Líneas' : 'Barras'}${
|
|
591
|
+
spec.title ? ` ("${spec.title}")` : ''
|
|
592
|
+
}`;
|
|
593
|
+
const detail = spec.series.map((s) => `- ${s.name}: ${s.values.join(', ')}`).join('\n');
|
|
594
|
+
return `Gráfico mostrado al usuario — ${head} · ${spec.labels.join(' | ')}${spec.unit ? ` (${spec.unit})` : ''}:\n${detail}`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const IMAGE_SIZE = /^\d{2,4}x\d{2,4}$/;
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Genera una imagen a partir de un prompt. La generacion es del servidor (el
|
|
601
|
+
* executor la pide via `onImage`, como `ask_user` espera su formulario): la
|
|
602
|
+
* imagen se guarda en la casa y la consola la muestra inline.
|
|
603
|
+
*/
|
|
604
|
+
export async function generateImageTool(_root, { prompt, size, model } = {}, { onImage, signal } = {}) {
|
|
605
|
+
const texto = String(prompt ?? '').replace(/\s+/g, ' ').trim();
|
|
606
|
+
if (!texto) return 'Error: falta "prompt".';
|
|
607
|
+
if (typeof onImage !== 'function') {
|
|
608
|
+
return 'No hay generador de imágenes en este canal: describí la imagen en el texto de la respuesta.';
|
|
609
|
+
}
|
|
610
|
+
const opts = {
|
|
611
|
+
prompt: texto.length > 2000 ? texto.slice(0, 2000).trimEnd() : texto,
|
|
612
|
+
size: IMAGE_SIZE.test(String(size ?? '')) ? String(size) : undefined,
|
|
613
|
+
model: String(model ?? '').trim() || undefined,
|
|
614
|
+
signal,
|
|
615
|
+
};
|
|
616
|
+
let image;
|
|
617
|
+
try {
|
|
618
|
+
image = await onImage(opts);
|
|
619
|
+
} catch (error) {
|
|
620
|
+
return `Error generando la imagen: ${error?.message ?? error}`;
|
|
621
|
+
}
|
|
622
|
+
if (!image?.name) return 'Error generando la imagen: el proveedor no devolvió una imagen.';
|
|
623
|
+
const dims = image.width && image.height ? `${image.width}x${image.height}` : '';
|
|
624
|
+
return {
|
|
625
|
+
content: `Imagen generada → capturas/${image.name} (${[dims, image.bytes ? `${Math.round(image.bytes / 1024)} KB` : '']
|
|
626
|
+
.filter(Boolean)
|
|
627
|
+
.join(', ')}): "${opts.prompt}"`,
|
|
628
|
+
meta: { image: { name: image.name, width: image.width, height: image.height, prompt: opts.prompt } },
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
550
632
|
/**
|
|
551
633
|
* Captura de pantalla con el navegador headless del sistema. La guarda en la
|
|
552
634
|
* casa (`screenshots/`) y devuelve un `meta.screenshot` para que la consola la
|
|
@@ -627,6 +709,8 @@ export const EXECUTORS = {
|
|
|
627
709
|
ask_user: askUserTool,
|
|
628
710
|
offer_choices: offerChoicesTool,
|
|
629
711
|
show_plan: showPlanTool,
|
|
712
|
+
show_chart: showChartTool,
|
|
713
|
+
generate_image: generateImageTool,
|
|
630
714
|
screenshot: screenshotTool,
|
|
631
715
|
console_errors: consoleTool,
|
|
632
716
|
};
|
|
@@ -56,6 +56,13 @@ export function summarizeToolCall(name, args = {}) {
|
|
|
56
56
|
return shorten(a.url, 90);
|
|
57
57
|
case 'speak':
|
|
58
58
|
return shorten(a.texto, 90);
|
|
59
|
+
case 'show_chart': {
|
|
60
|
+
const tipo = a.type === 'pie' ? 'torta' : a.type === 'line' ? 'líneas' : 'barras';
|
|
61
|
+
const labels = Array.isArray(a.labels) ? a.labels.length : 0;
|
|
62
|
+
return [a.title ? shorten(a.title, 40) : '', labels ? `${tipo} · ${labels} datos` : tipo].filter(Boolean).join(' · ');
|
|
63
|
+
}
|
|
64
|
+
case 'generate_image':
|
|
65
|
+
return shorten(a.prompt, 90);
|
|
59
66
|
case 'git_diff':
|
|
60
67
|
return [a.ref ? `vs ${shorten(a.ref, 30)}` : a.staged ? 'staged' : 'unstaged', a.path ? path(a.path) : '']
|
|
61
68
|
.filter(Boolean)
|
|
@@ -20,10 +20,12 @@ export const DEFAULT_PERMISSIONS = {
|
|
|
20
20
|
ask_user: 'allow',
|
|
21
21
|
offer_choices: 'allow',
|
|
22
22
|
show_plan: 'allow',
|
|
23
|
+
show_chart: 'allow',
|
|
23
24
|
write: 'ask',
|
|
24
25
|
edit: 'ask',
|
|
25
26
|
bash: 'ask',
|
|
26
27
|
git_commit: 'ask',
|
|
28
|
+
generate_image: 'ask',
|
|
27
29
|
};
|
|
28
30
|
|
|
29
31
|
function patternToRegExp(pattern) {
|
|
@@ -49,6 +51,7 @@ function subjectFor(name, args) {
|
|
|
49
51
|
if (name === 'bash') return args?.command ?? '';
|
|
50
52
|
if (name === 'git_commit') return args?.message ?? '';
|
|
51
53
|
if (name === 'fetch_url') return args?.url ?? '';
|
|
54
|
+
if (name === 'generate_image') return args?.prompt ?? '';
|
|
52
55
|
if (name === 'screenshot' || name === 'console_errors' || name === 'preview_url') return args?.url ?? '';
|
|
53
56
|
return args?.path ?? '';
|
|
54
57
|
}
|
|
@@ -20,10 +20,11 @@ function extensionOf(p) {
|
|
|
20
20
|
* @param {string[]} [opts.writeExtensions] - si se define, `write`/`edit` solo aceptan estas extensiones (p.ej. ['.md', '.json'])
|
|
21
21
|
* @param {(name:string, args:object)=>Promise<boolean>} [opts.onAsk] - interactive approval
|
|
22
22
|
* @param {(payload:{title:string, questions:object[]})=>Promise<{answers:object,cancelled?:boolean}>} [opts.onQuestion] - interactive form
|
|
23
|
+
* @param {(payload:{prompt:string,size?:string,model?:string})=>Promise<{name:string,width?:number,height?:number,bytes?:number}>} [opts.onImage] - image generation (server-side)
|
|
23
24
|
* @param {boolean} [opts.yes] - auto-approve 'ask' (non-interactive)
|
|
24
25
|
* @param {AbortSignal} [opts.signal] - cancela las tools de larga duracion (bash, git)
|
|
25
26
|
*/
|
|
26
|
-
export function createToolRegistry({ root, enabled, permissions = {}, onAsk, onQuestion, yes = false, signal, writeExtensions } = {}) {
|
|
27
|
+
export function createToolRegistry({ root, enabled, permissions = {}, onAsk, onQuestion, onImage, yes = false, signal, writeExtensions } = {}) {
|
|
27
28
|
// `enabled` undefined = todas; una lista se respeta tal cual (vacía = ninguna,
|
|
28
29
|
// no "todas": un modo restringido no puede quedar sin tools por un filtro).
|
|
29
30
|
const names = new Set(Array.isArray(enabled) ? enabled : TOOL_NAMES);
|
|
@@ -63,7 +64,7 @@ export function createToolRegistry({ root, enabled, permissions = {}, onAsk, onQ
|
|
|
63
64
|
return `Permiso denegado para "${name}"${detail ? ` (${detail})` : ''}.`;
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
return EXECUTORS[name](root, args, { signal, onQuestion });
|
|
67
|
+
return EXECUTORS[name](root, args, { signal, onQuestion, onImage });
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
return {
|
|
@@ -169,6 +169,25 @@ export const TOOL_DEFINITIONS = [
|
|
|
169
169
|
},
|
|
170
170
|
},
|
|
171
171
|
},
|
|
172
|
+
{
|
|
173
|
+
type: 'function',
|
|
174
|
+
function: {
|
|
175
|
+
name: 'generate_image',
|
|
176
|
+
description:
|
|
177
|
+
'Generate an image from a text prompt with the configured image model and show it to the user in the chat. Use it when the user asks for an image, an illustration, a logo mock or similar. Describe the image in detail in the prompt.',
|
|
178
|
+
parameters: {
|
|
179
|
+
type: 'object',
|
|
180
|
+
properties: {
|
|
181
|
+
prompt: { type: 'string', description: 'What the image should depict (be detailed)' },
|
|
182
|
+
size: {
|
|
183
|
+
type: 'string',
|
|
184
|
+
description: 'Size like "1024x1024" (default 1024x1024). Common: 1536x1024 (landscape), 1024x1536 (portrait).',
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
required: ['prompt'],
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
172
191
|
{
|
|
173
192
|
type: 'function',
|
|
174
193
|
function: {
|
|
@@ -284,6 +303,40 @@ export const TOOL_DEFINITIONS = [
|
|
|
284
303
|
},
|
|
285
304
|
},
|
|
286
305
|
},
|
|
306
|
+
{
|
|
307
|
+
type: 'function',
|
|
308
|
+
function: {
|
|
309
|
+
name: 'show_chart',
|
|
310
|
+
description:
|
|
311
|
+
'Show the user a data chart (bar, line or pie) as a visualization in the chat, instead of writing a table or ASCII art. Pass labels (categories) and one or more series of numeric values; for pie, pass a single series of non-negative values. Re-emit it with new data to update the chart. Read-only: it only displays.',
|
|
312
|
+
parameters: {
|
|
313
|
+
type: 'object',
|
|
314
|
+
properties: {
|
|
315
|
+
title: { type: 'string', description: 'Short chart heading (optional)' },
|
|
316
|
+
type: { type: 'string', enum: ['bar', 'line', 'pie'], description: 'Chart type (default bar)' },
|
|
317
|
+
labels: {
|
|
318
|
+
type: 'array',
|
|
319
|
+
items: { type: 'string' },
|
|
320
|
+
description: 'Category labels (x axis / pie slices), 1-24',
|
|
321
|
+
},
|
|
322
|
+
series: {
|
|
323
|
+
type: 'array',
|
|
324
|
+
description: 'Data series (1-6). For pie only the first series is used.',
|
|
325
|
+
items: {
|
|
326
|
+
type: 'object',
|
|
327
|
+
properties: {
|
|
328
|
+
name: { type: 'string', description: 'Series name (legend)' },
|
|
329
|
+
values: { type: 'array', items: { type: 'number' }, description: 'One value per label' },
|
|
330
|
+
},
|
|
331
|
+
required: ['values'],
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
unit: { type: 'string', description: 'Optional unit for values, e.g. "USD", "%"' },
|
|
335
|
+
},
|
|
336
|
+
required: ['labels', 'series'],
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
},
|
|
287
340
|
{
|
|
288
341
|
type: 'function',
|
|
289
342
|
function: {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Capacidades por modelo: heuristica por familia + lo que reporta el proveedor.
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { modelCapabilities, capabilityText } from '../src/providers/capabilities.js';
|
|
5
|
+
import { normalizeModel } from '../src/providers/utils.js';
|
|
6
|
+
|
|
7
|
+
test('la heuristica reconoce las familias conocidas', () => {
|
|
8
|
+
const caps = (id) => modelCapabilities(id);
|
|
9
|
+
assert.equal(caps('dall-e-3').image, true);
|
|
10
|
+
assert.equal(caps('gpt-image-1').image, true);
|
|
11
|
+
assert.equal(caps('gemini-2.5-flash-image-preview').image, true);
|
|
12
|
+
assert.equal(caps('deepseek-chat').image, false);
|
|
13
|
+
assert.equal(caps('whisper-1').stt, true);
|
|
14
|
+
assert.equal(caps('whisper-1').tools, false);
|
|
15
|
+
assert.equal(caps('tts-1-hd').tts, true);
|
|
16
|
+
assert.equal(caps('gpt-4o-mini-tts').tts, true);
|
|
17
|
+
assert.equal(caps('veo-3.0-generate-001').video, true);
|
|
18
|
+
assert.equal(caps('sora-2').video, true);
|
|
19
|
+
assert.equal(caps('gpt-4o').vision, true);
|
|
20
|
+
assert.equal(caps('deepseek-reasoner').reasoning, true);
|
|
21
|
+
assert.equal(caps('deepseek-chat').tools, true);
|
|
22
|
+
assert.equal(caps('text-embedding-3-small').tools, false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('lo que reporta el proveedor manda sobre la heuristica', () => {
|
|
26
|
+
// Sin reportar, 'flux-pro' pareceria imagen; reportado sin salida de imagen, no.
|
|
27
|
+
const reported = modelCapabilities('flux-pro', {
|
|
28
|
+
architecture: { input_modalities: ['text'], output_modalities: ['text'], modality: 'text->text' },
|
|
29
|
+
supported_parameters: ['tools', 'reasoning'],
|
|
30
|
+
});
|
|
31
|
+
assert.equal(reported.image, false);
|
|
32
|
+
assert.equal(reported.vision, false);
|
|
33
|
+
assert.equal(reported.tools, true);
|
|
34
|
+
assert.equal(reported.reasoning, true);
|
|
35
|
+
|
|
36
|
+
assert.equal(modelCapabilities('otro', { output_modalities: ['text', 'image'] }).image, true);
|
|
37
|
+
assert.equal(modelCapabilities('otro', { output_modalities: ['text', 'video'] }).video, true);
|
|
38
|
+
assert.equal(modelCapabilities('otro', { output_modalities: ['text', 'audio'] }).tts, true);
|
|
39
|
+
assert.equal(modelCapabilities('otro', { input_modalities: ['text', 'audio'] }).stt, true);
|
|
40
|
+
// modality string tipo OpenRouter: text+image->text = ve imagenes
|
|
41
|
+
assert.equal(modelCapabilities('x', { architecture: { modality: 'text+image->text' } }).vision, true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('normalizeModel expone capabilities sin perder el shape de siempre', () => {
|
|
45
|
+
const model = normalizeModel({ id: 'gpt-4o', context_window: 128000 });
|
|
46
|
+
assert.equal(model.vision, true);
|
|
47
|
+
assert.equal(model.context, 128000);
|
|
48
|
+
assert.equal(model.capabilities.vision, true);
|
|
49
|
+
assert.equal(model.capabilities.image, false);
|
|
50
|
+
assert.equal(capabilityText(model.capabilities), 'visión · tools');
|
|
51
|
+
});
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
registryServerToConfig,
|
|
5
|
+
registryServerToCandidate,
|
|
6
|
+
normalizeMcpConfig,
|
|
7
|
+
dedupeRegistryServers,
|
|
8
|
+
publisherOf,
|
|
9
|
+
} from '../src/mcp/registry.js';
|
|
4
10
|
import { MCP_CATALOG, mcpCatalogEntry } from '../src/mcp/catalog.js';
|
|
5
11
|
|
|
6
12
|
test('registryServerToConfig maps a remote server to http', () => {
|
|
@@ -60,6 +66,62 @@ test('registryServerToCandidate arma una vista liviana sin instalar', () => {
|
|
|
60
66
|
assert.deepEqual(candidate.args, ['-y', 'remote-filesystem-mcp-server@0.1.2']);
|
|
61
67
|
});
|
|
62
68
|
|
|
69
|
+
test('registryServerToCandidate expone autor, version, repo y estado oficial', () => {
|
|
70
|
+
const candidate = registryServerToCandidate({
|
|
71
|
+
name: 'io.github.firecrawl/firecrawl-mcp-server',
|
|
72
|
+
title: 'Firecrawl MCP Server',
|
|
73
|
+
description: 'Scrape',
|
|
74
|
+
version: '3.21.3',
|
|
75
|
+
repository: { url: 'https://github.com/firecrawl/firecrawl-mcp-server.git', source: 'github' },
|
|
76
|
+
packages: [{ registryType: 'npm', identifier: 'firecrawl-mcp', version: '3.21.3' }],
|
|
77
|
+
_meta: { 'io.modelcontextprotocol.registry/official': { status: 'active', isLatest: true } },
|
|
78
|
+
});
|
|
79
|
+
assert.equal(candidate.publisher, 'github.com/firecrawl');
|
|
80
|
+
assert.equal(candidate.version, '3.21.3');
|
|
81
|
+
assert.equal(candidate.repository, 'https://github.com/firecrawl/firecrawl-mcp-server.git');
|
|
82
|
+
assert.equal(candidate.official, true);
|
|
83
|
+
assert.equal(candidate.status, 'active');
|
|
84
|
+
assert.equal(candidate.isLatest, true);
|
|
85
|
+
|
|
86
|
+
// Sin meta oficial no se muestra como oficial.
|
|
87
|
+
const plain = registryServerToCandidate({ name: 'com.acme/thing', packages: [] });
|
|
88
|
+
assert.equal(plain.publisher, 'com.acme');
|
|
89
|
+
assert.equal(plain.official, false);
|
|
90
|
+
assert.equal(plain.status, null);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('publisherOf deriva el autor del namespace del id', () => {
|
|
94
|
+
assert.equal(publisherOf('io.github.firecrawl/firecrawl-mcp-server'), 'github.com/firecrawl');
|
|
95
|
+
assert.equal(publisherOf('com.mcparmory/firecrawl'), 'com.mcparmory');
|
|
96
|
+
assert.equal(publisherOf(''), '');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('dedupeRegistryServers una entrada por nombre, quedandose con la version vigente', () => {
|
|
100
|
+
const entry = (name, version, isLatest) => ({
|
|
101
|
+
server: { name, version },
|
|
102
|
+
_meta: { 'io.modelcontextprotocol.registry/official': { status: 'active', isLatest } },
|
|
103
|
+
});
|
|
104
|
+
const entries = [
|
|
105
|
+
entry('io.github.firecrawl/firecrawl-mcp-server', '3.20.0', false),
|
|
106
|
+
entry('com.mcparmory/firecrawl', '1.0.2', false),
|
|
107
|
+
entry('io.github.firecrawl/firecrawl-mcp-server', '3.21.3', true),
|
|
108
|
+
entry('com.mcparmory/firecrawl', '1.0.3', true),
|
|
109
|
+
{ server: { name: '' }, _meta: {} },
|
|
110
|
+
{ title: 'sin nombre' },
|
|
111
|
+
];
|
|
112
|
+
const out = dedupeRegistryServers(entries);
|
|
113
|
+
assert.equal(out.length, 2);
|
|
114
|
+
// Conserva el orden de la busqueda y la version vigente de cada nombre.
|
|
115
|
+
assert.equal(out[0].name, 'io.github.firecrawl/firecrawl-mcp-server');
|
|
116
|
+
assert.equal(out[0].version, '3.21.3');
|
|
117
|
+
assert.equal(out[1].version, '1.0.3');
|
|
118
|
+
|
|
119
|
+
// Sin ninguna marcada isLatest se queda con la primera.
|
|
120
|
+
const first = dedupeRegistryServers([entry('a/b', '1', false), entry('a/b', '2', false)]);
|
|
121
|
+
assert.equal(first.length, 1);
|
|
122
|
+
assert.equal(first[0].version, '1');
|
|
123
|
+
});
|
|
124
|
+
|
|
63
125
|
test('el catálogo de MCP trae entradas deshabilitadas y copias', () => {
|
|
64
126
|
assert.ok(MCP_CATALOG.length > 0);
|
|
65
127
|
assert.ok(MCP_CATALOG.every((entry) => entry.enabled === false));
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
+
import http from 'node:http';
|
|
3
4
|
import { Store } from '../src/storage/sqlite.js';
|
|
4
5
|
import { composeProviderSettings } from '../src/config/providers.js';
|
|
5
6
|
import { toOllamaMessages } from '../src/providers/ollama.js';
|
|
6
|
-
import { providerFetch, normalizeModel } from '../src/providers/utils.js';
|
|
7
|
+
import { providerFetch, normalizeModel, apiRoot, joinUrl } from '../src/providers/utils.js';
|
|
7
8
|
import { openaiCompatibleProvider } from '../src/providers/openaiCompatible.js';
|
|
9
|
+
import { listProviderModels } from '../src/providers/index.js';
|
|
8
10
|
import { applyPromptCache } from '../src/providers/cache.js';
|
|
9
11
|
import { isRetryableError } from '../src/providers/resilience.js';
|
|
10
12
|
|
|
@@ -15,6 +17,56 @@ test('normalizeModel reads the context window across provider shapes', () => {
|
|
|
15
17
|
assert.equal(normalizeModel({ id: 'd' }).context, null);
|
|
16
18
|
});
|
|
17
19
|
|
|
20
|
+
test('apiRoot recorta el sufijo de endpoint pegado como baseUrl', () => {
|
|
21
|
+
assert.equal(apiRoot('https://openrouter.ai/api/v1/chat/completions'), 'https://openrouter.ai/api/v1');
|
|
22
|
+
assert.equal(apiRoot('https://x/v1/images/generations/'), 'https://x/v1');
|
|
23
|
+
assert.equal(apiRoot('https://x/v1/audio/speech'), 'https://x/v1');
|
|
24
|
+
assert.equal(apiRoot('https://x/v1/audio/transcriptions'), 'https://x/v1');
|
|
25
|
+
assert.equal(apiRoot('https://x/v1'), 'https://x/v1');
|
|
26
|
+
assert.equal(apiRoot(''), '');
|
|
27
|
+
assert.equal(joinUrl('https://x/v1/chat/completions', '/images/generations'), 'https://x/v1/images/generations');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('listProviderModels suma la lista manual al catalogo del API y cae a ella si falla', async () => {
|
|
31
|
+
const server = http.createServer((req, res) => {
|
|
32
|
+
if (req.url === '/models') {
|
|
33
|
+
res.end(JSON.stringify({ data: [{ id: 'api-1' }, { id: 'shared' }] }));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
res.statusCode = 500;
|
|
37
|
+
res.end('nope');
|
|
38
|
+
});
|
|
39
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
40
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
41
|
+
try {
|
|
42
|
+
// La lista manual ("modelos") completa al catalogo, sin duplicar ids.
|
|
43
|
+
const ok = await listProviderModels({
|
|
44
|
+
providerId: 'stubprov',
|
|
45
|
+
customProviders: [{ id: 'stubprov', label: 'Stub', baseUrl: base, models: ['shared', 'manual-1'] }],
|
|
46
|
+
});
|
|
47
|
+
assert.deepEqual(ok.map((m) => m.id).sort(), ['api-1', 'manual-1', 'shared']);
|
|
48
|
+
|
|
49
|
+
// Si el API no lista (500), la lista manual es el catalogo entero.
|
|
50
|
+
const fallback = await listProviderModels({
|
|
51
|
+
providerId: 'broken',
|
|
52
|
+
customProviders: [{ id: 'broken', label: 'Broken', baseUrl: `${base}/broken`, models: ['m1', 'm2'] }],
|
|
53
|
+
});
|
|
54
|
+
assert.deepEqual(fallback.map((m) => m.id), ['m1', 'm2']);
|
|
55
|
+
|
|
56
|
+
// Sin lista manual, un API caido sigue siendo un error visible.
|
|
57
|
+
await assert.rejects(
|
|
58
|
+
() =>
|
|
59
|
+
listProviderModels({
|
|
60
|
+
providerId: 'broken2',
|
|
61
|
+
customProviders: [{ id: 'broken2', label: 'Broken2', baseUrl: `${base}/broken2`, models: [] }],
|
|
62
|
+
}),
|
|
63
|
+
/No se pudo listar modelos/
|
|
64
|
+
);
|
|
65
|
+
} finally {
|
|
66
|
+
server.close();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
18
70
|
test('Store persists providers and settings', () => {
|
|
19
71
|
const store = new Store(':memory:');
|
|
20
72
|
try {
|
|
@@ -4,7 +4,7 @@ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { parseSkillMarkdown, loadSkills, mergeSkills, buildSkillsPrompt, buildSkillsIndex } from '../src/skills/index.js';
|
|
7
|
-
import { parseSkillRef } from '../src/skills/remote.js';
|
|
7
|
+
import { parseSkillRef, resolveSkillDir, fetchSkillFromGithub } from '../src/skills/remote.js';
|
|
8
8
|
import { SKILLS_CATALOG, skillCatalogEntry } from '../src/skills/catalog.js';
|
|
9
9
|
|
|
10
10
|
test('parseSkillMarkdown reads frontmatter and body', () => {
|
|
@@ -62,6 +62,71 @@ test('parseSkillRef accepts skills.sh URLs, GitHub URLs and ids', () => {
|
|
|
62
62
|
assert.equal(parseSkillRef('solo-un-slug'), null);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
test('resolveSkillDir encuentra la skill en las estructuras reales de los repos', () => {
|
|
66
|
+
const tree = (paths) => paths.map((p) => ({ type: 'blob', path: p }));
|
|
67
|
+
// Skill en la raiz.
|
|
68
|
+
assert.equal(resolveSkillDir(tree(['SKILL.md', 'README.md']), { repo: 'ponytail' }), '');
|
|
69
|
+
// Skill en una subcarpeta (cloudflare/security-audit-skill): es la unica.
|
|
70
|
+
assert.equal(
|
|
71
|
+
resolveSkillDir(tree(['skills/security-audit/SKILL.md', 'skills/security-audit/EXTRA.md']), {
|
|
72
|
+
repo: 'security-audit-skill',
|
|
73
|
+
}),
|
|
74
|
+
'skills/security-audit'
|
|
75
|
+
);
|
|
76
|
+
// Slug pedido coincide con la carpeta, aunque este anidada.
|
|
77
|
+
assert.equal(
|
|
78
|
+
resolveSkillDir(tree(['skills/frontend-design/SKILL.md', 'skills/api-design/SKILL.md']), {
|
|
79
|
+
slug: 'frontend-design',
|
|
80
|
+
repo: 'skills',
|
|
81
|
+
}),
|
|
82
|
+
'skills/frontend-design'
|
|
83
|
+
);
|
|
84
|
+
// Varias skills y una carpeta se llama como el repo.
|
|
85
|
+
assert.equal(
|
|
86
|
+
resolveSkillDir(tree(['skills/ponytail/SKILL.md', 'skills/otra/SKILL.md']), { repo: 'ponytail' }),
|
|
87
|
+
'skills/ponytail'
|
|
88
|
+
);
|
|
89
|
+
// Varias skills y ninguna coincide -> null (el caller lista las opciones).
|
|
90
|
+
assert.equal(resolveSkillDir(tree(['a/SKILL.md', 'b/SKILL.md']), { repo: 'claude-skills' }), null);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('fetchSkillFromGithub instala una skill que no esta en la raiz del repo', async () => {
|
|
94
|
+
const realFetch = globalThis.fetch;
|
|
95
|
+
const trees = {
|
|
96
|
+
'cloudflare/security-audit-skill': [
|
|
97
|
+
{ type: 'blob', path: 'README.md' },
|
|
98
|
+
{ type: 'blob', path: 'skills/security-audit/SKILL.md' },
|
|
99
|
+
{ type: 'blob', path: 'skills/security-audit/EXTRA.md' },
|
|
100
|
+
],
|
|
101
|
+
'alirezarezvani/claude-skills': [
|
|
102
|
+
{ type: 'blob', path: 'skills/uno/SKILL.md' },
|
|
103
|
+
{ type: 'blob', path: 'skills/dos/SKILL.md' },
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
globalThis.fetch = async (url) => {
|
|
107
|
+
const u = String(url);
|
|
108
|
+
const tree = /\/repos\/([^/]+\/[^/]+)\/git\/trees\//.exec(u);
|
|
109
|
+
if (tree) return Response.json({ tree: trees[tree[1]] ?? [] });
|
|
110
|
+
const meta = /\/repos\/([^/]+\/[^/]+)$/.exec(u);
|
|
111
|
+
if (meta) return Response.json({ default_branch: 'main' });
|
|
112
|
+
if (u.includes('raw.githubusercontent.com')) return new Response(u.endsWith('SKILL.md') ? '# skill' : 'extra');
|
|
113
|
+
throw new Error(`url inesperada: ${u}`);
|
|
114
|
+
};
|
|
115
|
+
try {
|
|
116
|
+
const skill = await fetchSkillFromGithub({ owner: 'cloudflare', repo: 'security-audit-skill', slug: null });
|
|
117
|
+
assert.equal(skill.id, 'security-audit-skill');
|
|
118
|
+
assert.deepEqual(skill.files.map((f) => f.path).sort(), ['EXTRA.md', 'SKILL.md']);
|
|
119
|
+
|
|
120
|
+
// Repo con varias skills y sin slug: el error lista las opciones.
|
|
121
|
+
await assert.rejects(
|
|
122
|
+
() => fetchSkillFromGithub({ owner: 'alirezarezvani', repo: 'claude-skills', slug: null }),
|
|
123
|
+
/El repo tiene 2: uno, dos.*claude-skills\/<slug>/
|
|
124
|
+
);
|
|
125
|
+
} finally {
|
|
126
|
+
globalThis.fetch = realFetch;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
65
130
|
test('buildSkillsPrompt lists enabled skills and is empty without skills', () => {
|
|
66
131
|
assert.equal(buildSkillsPrompt([]), '');
|
|
67
132
|
const text = buildSkillsPrompt([{ id: 'demo', name: 'Demo', description: 'desc', body: 'Hacé X.' }]);
|
|
@@ -15,6 +15,9 @@ import {
|
|
|
15
15
|
askUserTool,
|
|
16
16
|
offerChoicesTool,
|
|
17
17
|
showPlanTool,
|
|
18
|
+
showChartTool,
|
|
19
|
+
normalizeChartSpec,
|
|
20
|
+
generateImageTool,
|
|
18
21
|
} from '../src/tools/executors.js';
|
|
19
22
|
|
|
20
23
|
async function makeWorkspace() {
|
|
@@ -422,3 +425,63 @@ test('show_plan valida steps y marca el estado', () => {
|
|
|
422
425
|
assert.match(out, /\[ \] Tres/);
|
|
423
426
|
assert.equal(evaluatePermission('show_plan', {}, {}), 'allow');
|
|
424
427
|
});
|
|
428
|
+
|
|
429
|
+
test('show_chart valida labels y series numericas', () => {
|
|
430
|
+
assert.match(showChartTool(null, {}), /Error/);
|
|
431
|
+
assert.match(showChartTool(null, { labels: ['a'], series: [] }), /Error/);
|
|
432
|
+
|
|
433
|
+
const spec = normalizeChartSpec({
|
|
434
|
+
type: 'line',
|
|
435
|
+
title: 'Ventas',
|
|
436
|
+
labels: ['Ene', 'Feb', 'Mar'],
|
|
437
|
+
series: [{ name: '2025', values: [10, 22, null] }],
|
|
438
|
+
});
|
|
439
|
+
assert.equal(spec.type, 'line');
|
|
440
|
+
assert.deepEqual(spec.series[0].values, [10, 22, 0]);
|
|
441
|
+
assert.equal(spec.labels.length, 3);
|
|
442
|
+
|
|
443
|
+
const out = showChartTool(null, {
|
|
444
|
+
type: 'pie',
|
|
445
|
+
labels: ['a', 'b'],
|
|
446
|
+
series: [{ name: 'S', values: [1, 2] }],
|
|
447
|
+
unit: 'USD',
|
|
448
|
+
});
|
|
449
|
+
assert.match(out, /Torta/);
|
|
450
|
+
assert.match(out, /1, 2/);
|
|
451
|
+
assert.match(out, /USD/);
|
|
452
|
+
assert.equal(evaluatePermission('show_chart', {}, {}), 'allow');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('generate_image delega en onImage y devuelve meta.image', async () => {
|
|
456
|
+
assert.match(await generateImageTool(null, { prompt: 'un gato' }), /No hay generador/);
|
|
457
|
+
assert.match(await generateImageTool(null, {}, { onImage: async () => ({}) }), /Error: falta "prompt"/);
|
|
458
|
+
|
|
459
|
+
let pedido = null;
|
|
460
|
+
const out = await generateImageTool(
|
|
461
|
+
null,
|
|
462
|
+
{ prompt: ' un gato astronauta ', size: '500x500' },
|
|
463
|
+
{
|
|
464
|
+
onImage: async (req) => {
|
|
465
|
+
pedido = req;
|
|
466
|
+
return { name: 'gen-1-abc.png', width: 1024, height: 1024, bytes: 2048 };
|
|
467
|
+
},
|
|
468
|
+
}
|
|
469
|
+
);
|
|
470
|
+
assert.equal(pedido.prompt, 'un gato astronauta');
|
|
471
|
+
assert.equal(pedido.size, '500x500');
|
|
472
|
+
assert.equal(out.meta.image.name, 'gen-1-abc.png');
|
|
473
|
+
assert.match(out.content, /capturas\/gen-1-abc\.png/);
|
|
474
|
+
|
|
475
|
+
// Size con formato raro se descarta (usa la default del generador).
|
|
476
|
+
const sinSize = await generateImageTool(
|
|
477
|
+
null,
|
|
478
|
+
{ prompt: 'x', size: 'grande' },
|
|
479
|
+
{ onImage: async (req) => ({ name: 'g.png', width: req.size }) }
|
|
480
|
+
);
|
|
481
|
+
assert.equal(sinSize.meta.image.width, undefined);
|
|
482
|
+
|
|
483
|
+
const failed = await generateImageTool(null, { prompt: 'x' }, { onImage: async () => { throw new Error('402 pago'); } });
|
|
484
|
+
assert.match(failed, /^Error generando la imagen: 402 pago/);
|
|
485
|
+
|
|
486
|
+
assert.equal(evaluatePermission('generate_image', {}, {}), 'ask');
|
|
487
|
+
});
|
|
@@ -170,6 +170,7 @@ import {
|
|
|
170
170
|
submitMemory,
|
|
171
171
|
submitProfile,
|
|
172
172
|
submitProvider,
|
|
173
|
+
syncProviderDefaults,
|
|
173
174
|
submitSchedule,
|
|
174
175
|
submitSkill,
|
|
175
176
|
syncMcpFormFields,
|
|
@@ -549,6 +550,7 @@ $('#pf-cancel').addEventListener('click', closeProviderForm);
|
|
|
549
550
|
$('#pf-close').addEventListener('click', closeProviderForm);
|
|
550
551
|
$('#pf-test').addEventListener('click', testProvider);
|
|
551
552
|
providerForm.addEventListener('submit', submitProvider);
|
|
553
|
+
$('#pf-id')?.addEventListener('input', syncProviderDefaults);
|
|
552
554
|
providerModal.addEventListener('click', (event) => {
|
|
553
555
|
if (event.target === providerModal) closeProviderForm();
|
|
554
556
|
});
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import { api, button, el, icon, state } from './core.js';
|
|
5
5
|
import { toast } from './commands.js';
|
|
6
6
|
import { addMessage, chatInput, paneOf, scrollMessages, sendMessage } from './chat.js';
|
|
7
|
-
import {
|
|
7
|
+
import { renderChart } from './chart.js';
|
|
8
|
+
import { imageName, isFailedToolText, isReadOnlyTool, toolSummary } from './chatFormat.js';
|
|
8
9
|
import { renderMarkdown } from './markdown.js';
|
|
9
10
|
import { summarizeArgs } from './reconcile.js';
|
|
10
11
|
import { yesInput } from './chrome.js';
|
|
@@ -34,13 +35,13 @@ export function renderToolOut(node, text) {
|
|
|
34
35
|
|
|
35
36
|
export const toolCardIndex = new Map();
|
|
36
37
|
|
|
37
|
-
/** Muestra la
|
|
38
|
-
export function showScreenshot(entry, name) {
|
|
38
|
+
/** Muestra la imagen del tool (`screenshot` o `generate_image`) en su tarjeta (clic para ampliar). */
|
|
39
|
+
export function showScreenshot(entry, name, toolName) {
|
|
39
40
|
if (!entry || !name || entry.shot) return;
|
|
40
41
|
entry.shot = true;
|
|
41
42
|
const img = el('img', 'tool-shot');
|
|
42
43
|
img.loading = 'lazy';
|
|
43
|
-
img.alt = 'captura de pantalla';
|
|
44
|
+
img.alt = toolName === 'generate_image' ? 'imagen generada' : 'captura de pantalla';
|
|
44
45
|
img.src = `/api/screenshots/${encodeURIComponent(name)}`;
|
|
45
46
|
img.addEventListener('click', () => window.open(img.src, '_blank'));
|
|
46
47
|
entry.card.appendChild(img);
|
|
@@ -60,7 +61,7 @@ export function finishToolCard(entry, data) {
|
|
|
60
61
|
entry.truncated = true;
|
|
61
62
|
entry.more.hidden = false;
|
|
62
63
|
}
|
|
63
|
-
if (!failed) showScreenshot(entry,
|
|
64
|
+
if (!failed) showScreenshot(entry, imageName(data, preview), entry.name);
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
export function ensureToolsToggle(message) {
|
|
@@ -134,7 +135,7 @@ export function addToolCard(message, name, args, { callId = null, open = null, r
|
|
|
134
135
|
ensureToolsToggle(message);
|
|
135
136
|
scrollMessages(paneOf(message));
|
|
136
137
|
|
|
137
|
-
const entry = { card, out, glyph, argsEl, more, callId, full: false, truncated: false };
|
|
138
|
+
const entry = { card, out, glyph, argsEl, more, callId, name, full: false, truncated: false };
|
|
138
139
|
card.addEventListener('toggle', () => {
|
|
139
140
|
if (card.open && entry.truncated && !entry.full) loadFullToolResult(entry, message);
|
|
140
141
|
});
|
|
@@ -409,6 +410,21 @@ export function addPlanCard(container, { title, steps } = {}) {
|
|
|
409
410
|
return card;
|
|
410
411
|
}
|
|
411
412
|
|
|
413
|
+
/* --- graficos de datos (show_chart) --------------------------------------- */
|
|
414
|
+
|
|
415
|
+
/** Grafico SVG: el modelo lo reemite para actualizar los datos. */
|
|
416
|
+
export function addChartCard(container, args = {}) {
|
|
417
|
+
const svg = renderChart(args);
|
|
418
|
+
if (!svg || !container) return null;
|
|
419
|
+
const card = el('div', 'chart-card');
|
|
420
|
+
const figure = el('figure', 'chart-figure');
|
|
421
|
+
figure.innerHTML = svg;
|
|
422
|
+
card.appendChild(figure);
|
|
423
|
+
(container.tools ?? container).appendChild(card);
|
|
424
|
+
scrollMessages(paneOf(container));
|
|
425
|
+
return card;
|
|
426
|
+
}
|
|
427
|
+
|
|
412
428
|
/**
|
|
413
429
|
* Marca del punto de compactación: una línea que corta el historial y, debajo,
|
|
414
430
|
* el resumen que pasa a ser el contexto del modelo. Lo de arriba sigue visible
|