@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +28 -10
  3. package/harness/packages/cli/src/commands/server.js +10 -10
  4. package/harness/packages/cli/src/commands/update.js +1 -1
  5. package/harness/packages/core/src/agent/modes.js +2 -0
  6. package/harness/packages/core/src/index.js +4 -0
  7. package/harness/packages/core/src/mcp/registry.js +43 -2
  8. package/harness/packages/core/src/providers/capabilities.js +67 -0
  9. package/harness/packages/core/src/providers/index.js +25 -7
  10. package/harness/packages/core/src/providers/ollama.js +3 -2
  11. package/harness/packages/core/src/providers/utils.js +16 -6
  12. package/harness/packages/core/src/skills/remote.js +45 -12
  13. package/harness/packages/core/src/tools/executors.js +84 -0
  14. package/harness/packages/core/src/tools/labels.js +7 -0
  15. package/harness/packages/core/src/tools/permissions.js +3 -0
  16. package/harness/packages/core/src/tools/registry.js +3 -2
  17. package/harness/packages/core/src/tools/schema.js +53 -0
  18. package/harness/packages/core/test/capabilities.test.js +51 -0
  19. package/harness/packages/core/test/mcp-registry.test.js +63 -1
  20. package/harness/packages/core/test/providers.test.js +53 -1
  21. package/harness/packages/core/test/skills.test.js +66 -1
  22. package/harness/packages/core/test/tools.test.js +63 -0
  23. package/harness/packages/server/public/app.js +2 -0
  24. package/harness/packages/server/public/cards.js +22 -6
  25. package/harness/packages/server/public/chart.js +237 -0
  26. package/harness/packages/server/public/chat.js +7 -1
  27. package/harness/packages/server/public/chatFormat.js +15 -4
  28. package/harness/packages/server/public/chrome.js +10 -4
  29. package/harness/packages/server/public/icons/icon-128x128.png +0 -0
  30. package/harness/packages/server/public/icons/icon-144x144.png +0 -0
  31. package/harness/packages/server/public/icons/icon-152x152.png +0 -0
  32. package/harness/packages/server/public/icons/icon-192x192.png +0 -0
  33. package/harness/packages/server/public/icons/icon-384x384.png +0 -0
  34. package/harness/packages/server/public/icons/icon-512x512.png +0 -0
  35. package/harness/packages/server/public/icons/icon-72x72.png +0 -0
  36. package/harness/packages/server/public/icons/icon-96x96.png +0 -0
  37. package/harness/packages/server/public/icons/logo.png +0 -0
  38. package/harness/packages/server/public/icons/logo_light.png +0 -0
  39. package/harness/packages/server/public/index.html +9 -7
  40. package/harness/packages/server/public/logo.png +0 -0
  41. package/harness/packages/server/public/markdown.js +22 -0
  42. package/harness/packages/server/public/models.js +38 -7
  43. package/harness/packages/server/public/modelsQuery.js +44 -4
  44. package/harness/packages/server/public/settings.js +39 -1
  45. package/harness/packages/server/public/styles.css +33 -0
  46. package/harness/packages/server/src/lib/channels/telegram/media.js +11 -8
  47. package/harness/packages/server/src/lib/channels/telegram/run.js +5 -0
  48. package/harness/packages/server/src/lib/imagegen.js +201 -0
  49. package/harness/packages/server/src/lib/scheduler.js +2 -0
  50. package/harness/packages/server/src/lib/securityHeaders.js +1 -1
  51. package/harness/packages/server/src/lib/tasks.js +2 -0
  52. package/harness/packages/server/src/lib/transcribe.js +3 -1
  53. package/harness/packages/server/src/lib/ttsEngine.js +69 -0
  54. package/harness/packages/server/src/routes/chat.js +2 -0
  55. package/harness/packages/server/src/routes/files.js +8 -3
  56. package/harness/packages/server/src/routes/imagegen.js +35 -0
  57. package/harness/packages/server/src/routes/models.js +2 -1
  58. package/harness/packages/server/src/routes/tts.js +4 -2
  59. package/harness/packages/server/src/server.js +2 -0
  60. package/harness/packages/server/test/chart.test.js +70 -0
  61. package/harness/packages/server/test/chat.test.js +1 -0
  62. package/harness/packages/server/test/chatFormat.test.js +9 -5
  63. package/harness/packages/server/test/imagegen.test.js +215 -0
  64. package/harness/packages/server/test/markdown.test.js +20 -0
  65. package/harness/packages/server/test/markdownSecurity.test.js +25 -0
  66. package/harness/packages/server/test/modelsQuery.test.js +25 -1
  67. package/harness/packages/server/test/securityHeaders.test.js +1 -0
  68. package/harness/packages/server/test/ttsEngine.test.js +58 -0
  69. package/harness/scripts/self-update.mjs +1 -1
  70. package/package.json +1 -1
  71. 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 { registryServerToConfig, registryServerToCandidate, normalizeMcpConfig } from '../src/mcp/registry.js';
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 { isFailedToolText, isReadOnlyTool, screenshotName, toolSummary } from './chatFormat.js';
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 captura del tool `screenshot` en su tarjeta (clic para ampliar). */
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, screenshotName(data, preview));
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