@danieltmn/tamnora 0.26.1 → 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 (59) hide show
  1. package/CHANGELOG.md +37 -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/index.html +9 -7
  30. package/harness/packages/server/public/markdown.js +22 -0
  31. package/harness/packages/server/public/models.js +38 -7
  32. package/harness/packages/server/public/modelsQuery.js +44 -4
  33. package/harness/packages/server/public/settings.js +39 -1
  34. package/harness/packages/server/public/styles.css +33 -0
  35. package/harness/packages/server/src/lib/channels/telegram/media.js +11 -8
  36. package/harness/packages/server/src/lib/channels/telegram/run.js +5 -0
  37. package/harness/packages/server/src/lib/imagegen.js +201 -0
  38. package/harness/packages/server/src/lib/scheduler.js +2 -0
  39. package/harness/packages/server/src/lib/securityHeaders.js +1 -1
  40. package/harness/packages/server/src/lib/tasks.js +2 -0
  41. package/harness/packages/server/src/lib/transcribe.js +3 -1
  42. package/harness/packages/server/src/lib/ttsEngine.js +69 -0
  43. package/harness/packages/server/src/routes/chat.js +2 -0
  44. package/harness/packages/server/src/routes/files.js +8 -3
  45. package/harness/packages/server/src/routes/imagegen.js +35 -0
  46. package/harness/packages/server/src/routes/models.js +2 -1
  47. package/harness/packages/server/src/routes/tts.js +4 -2
  48. package/harness/packages/server/src/server.js +2 -0
  49. package/harness/packages/server/test/chart.test.js +70 -0
  50. package/harness/packages/server/test/chat.test.js +1 -0
  51. package/harness/packages/server/test/chatFormat.test.js +9 -5
  52. package/harness/packages/server/test/imagegen.test.js +215 -0
  53. package/harness/packages/server/test/markdown.test.js +20 -0
  54. package/harness/packages/server/test/markdownSecurity.test.js +25 -0
  55. package/harness/packages/server/test/modelsQuery.test.js +25 -1
  56. package/harness/packages/server/test/securityHeaders.test.js +1 -0
  57. package/harness/packages/server/test/ttsEngine.test.js +58 -0
  58. package/harness/scripts/self-update.mjs +1 -1
  59. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.27.0] - 2026-11-11
9
+
10
+ ### Cambios
11
+
12
+ - feat(server): abrir el navegador es opt-in — `tamnora server --open`; el
13
+ comando ya no abre la consola solo (se quita `--no-open`)
14
+ - feat(visualizaciones): imágenes inline en el markdown (`![alt](url)`, solo de
15
+ orígenes permitidos), tool `show_chart` (gráficos de barras/líneas/torta como
16
+ SVG en el chat) y tool `generate_image` (genera con el mismo modelo del chat
17
+ si tiene salida de imagen; si no, `app.json` → `imageGen` y default por
18
+ proveedor). La consola las muestra inline y Telegram manda la imagen como foto
19
+ - feat(capacidades): capacidades por modelo (visión, imagen, video, voz,
20
+ escucha, tools, razonamiento) híbridas entre lo que reporta el proveedor y una
21
+ heurística por familia de id: chips en el picker de modelos, tooltip en el
22
+ pill y búsqueda por capacidad. La voz se sintetiza con el proveedor cuando el
23
+ modelo habla (`lib/ttsEngine.js`, con fallback a Edge); nuevos ajustes de
24
+ generación de imágenes (`GET/PUT /api/imagegen/settings`) y de modelo TTS
25
+ (`tts.model`)
26
+ - feat(consola): picker de modelos con vistas `todos | gratis | seleccionados`
27
+ más las acciones `marcar todo`/`vaciar`; el modal de proveedores
28
+ autocompleta el base URL desde el preset conocido
29
+ - feat(mcp): la búsqueda del registro oficial muestra autor, versión, repo,
30
+ transporte y chip `oficial` por resultado
31
+ - fix(proveedores): el campo `modelos` (lista manual) ahora **suma** al catálogo
32
+ del API en vez de reemplazarlo, así el picker muestra todos los modelos del
33
+ proveedor (antes solo los de la lista)
34
+ - fix(skills): la importación desde GitHub resuelve el `SKILL.md` en la raíz, en
35
+ la única skill del repo (p. ej. `cloudflare/security-audit-skill`) o en la
36
+ carpeta homónima al repo; con varias skills y sin coincidencia, el error lista
37
+ los slugs para instalar con `owner/repo/<slug>`
38
+ - fix(imagen): `apiRoot` normaliza el `baseUrl` cuando se pega la URL completa
39
+ del endpoint como base (evita el 404 de
40
+ `.../chat/completions/images/generations`); aplica a providers, imagen, TTS y
41
+ transcripción. Los errores del proveedor muestran la URL intentada
42
+ - fix(mcp): la búsqueda ya no repite el mismo servidor por cada versión
43
+ publicada en el registro (se queda con la vigente, `isLatest`)
44
+
8
45
  ## [0.26.1] - 2026-11-04
9
46
 
10
47
  ### Cambios
package/README.md CHANGED
@@ -12,8 +12,10 @@ con TUI, consola web, tools locales y MCP.
12
12
  saca una captura PNG o lista los errores de consola) y `preview_url`.
13
13
  - **Voz**: el agente narra su progreso, puede **leer la respuesta final** (sin
14
14
  bloques de codigo ni tablas) y mandar audio bajo pedido (`speak`,
15
- `/audioresumen`). El **modo Voz** habilita **manos libres**: hablás y responde en
16
- audio, como una conversacion (y solo escribe notas `.md`/`.json`).
15
+ `/audioresumen`). La síntesis usa el **proveedor** cuando el modelo habla y el
16
+ servicio Edge como respaldo. El **modo Voz** habilita **manos libres**: hablás
17
+ y responde en audio, como una conversacion (y solo escribe notas
18
+ `.md`/`.json`).
17
19
  - **Actualizar desde la consola**: la consola avisa cuando hay una version nueva
18
20
  y la instala con un click (el server se reinicia solo).
19
21
  - **Memoria y reglas**: memoria durable por proyecto (`memory`) y reglas por path
@@ -25,6 +27,13 @@ con TUI, consola web, tools locales y MCP.
25
27
  - **UI interactiva**: el agente pide un **formulario** (`ask_user`), ofrece
26
28
  **botones** (`offer_choices`) o muestra un **checklist** (`show_plan`); la
27
29
  consola los renderiza.
30
+ - **Visualizaciones**: imágenes inline en el markdown (`![alt](url)`, solo de
31
+ orígenes permitidos), gráficos de datos (`show_chart`: barras, líneas o torta
32
+ como SVG) y generación de imágenes (`generate_image`) con el mismo modelo del
33
+ chat cuando sabe generar; se ven en la consola y en Telegram llegan como foto.
34
+ - **Capacidades por modelo**: cada modelo declara qué sabe (visión, imagen,
35
+ video, voz, escucha, tools, razonamiento) y las tools resuelven con el mismo
36
+ modelo del chat cuando puede (imagen y voz).
28
37
  - **Plataforma**: hooks y plugins locales declarados en `tamnora.json`
29
38
  (`.tamnora/hooks`, `.tamnora/plugins`; nunca se cargan por presencia).
30
39
 
@@ -69,6 +78,7 @@ exactamente lo que pedis, sin refactors ni cambios fuera del pedido.
69
78
  ```bash
70
79
  tamnora server --init # crea la casa y arranca (segundo plano)
71
80
  tamnora server --start # arranca (default)
81
+ tamnora server --open # abre la consola en el navegador (opt-in)
72
82
  tamnora server --reload # reinicia aplicando config/codigo
73
83
  tamnora server --stop
74
84
  tamnora server --status
@@ -79,8 +89,9 @@ tamnora autostart install # arranque automatico del server
79
89
  ```
80
90
 
81
91
  `tamnora server` corre en segundo plano y **sobrevive al cierre de la consola**
82
- (puerto default `4517`). La configuracion de la app vive en la **casa** de
83
- Tamnora (override con `--home` o `TAMNORA_HOME`):
92
+ (puerto default `4517`). El navegador **no** se abre solo: `--open` lo hace
93
+ (también si el server ya está corriendo). La configuracion de la app vive en la
94
+ **casa** de Tamnora (override con `--home` o `TAMNORA_HOME`):
84
95
 
85
96
  - Windows: `%APPDATA%\tamnora`
86
97
  - macOS: `~/Library/Application Support/tamnora`
@@ -110,8 +121,10 @@ principales son `chat`, `proveedores` y `ajustes`. En el chat:
110
121
  - **Actividad colapsable**: las tools de lectura (`read`, `list`, `glob`, `grep`,
111
122
  `fetch_url`, `git_*`) salen **plegadas** y al hacer click se ve el detalle
112
123
  (args + resultado); el volcado completo se pide bajo demanda.
113
- - **Markdown con tablas**: las respuestas renderizan tablas (`|`, con alineacion
114
- y celdas saneadas) y botones flotantes para saltar al inicio/final del turno.
124
+ - **Markdown con tablas e imágenes**: las respuestas renderizan tablas (`|`, con
125
+ alineacion y celdas saneadas) e imágenes inline (`![alt](url)`, solo de
126
+ orígenes permitidos), mas botones flotantes para saltar al inicio/final del
127
+ turno.
115
128
  - **Acciones por mensaje**: copiar, editar, regenerar y borrar se agrupan en un
116
129
  menu `⋮`; el indicador de "pensando/trabajando" vive en el pie (`#sb-activity`).
117
130
  - **Runs durables**: cerrar el celular o cortar la red **no cancela** el turno; al
@@ -133,7 +146,9 @@ principales son `chat`, `proveedores` y `ajustes`. En el chat:
133
146
  - **Comandos**: el input muestra un menu al escribir `/` (`/new`, `/init`, `/goal`,
134
147
  `/resumen`, `/audioresumen`, `/compact`, `/clear`, `/cwd`, `/port`, `/portkill`,
135
148
  `/help`).
136
- - **Capturas**: el tool `screenshot` muestra la imagen inline en su tarjeta.
149
+ - **Capturas e imágenes**: el tool `screenshot` y el de generación de imágenes
150
+ (`generate_image`) muestran la imagen inline en su tarjeta, y `show_chart`
151
+ dibuja gráficos de datos (barras, líneas o torta) como SVG.
137
152
 
138
153
  ### Memoria, reglas y automatizacion
139
154
 
@@ -291,15 +306,18 @@ solo las "calientes" con esquema completo (`mcpMode`: `auto` | `eager` | `lazy`;
291
306
  `permissions: { "mcp__postgres__query": "allow" }`. Se ajustan en Ajustes → MCP.
292
307
 
293
308
  Desde Ajustes → MCP tambien hay un **catalogo de sugeridos** y busqueda en el
294
- registro oficial; se agregan deshabilitados y los prendes cuando quieras.
309
+ registro oficial (con autor, version, repo y estado oficial por resultado, y sin
310
+ repetir el mismo servidor por cada version publicada); se agregan deshabilitados
311
+ y los prendes cuando quieras.
295
312
 
296
313
  ## Tools
297
314
 
298
315
  Tools locales confinadas al workspace (`read`, `list`, `glob`, `grep`, `write`,
299
316
  `edit`, `bash`, `fetch_url`), de navegador (`screenshot`, `console_errors`,
300
317
  `preview_url`), de voz (`speak`), de interfaz interactiva (`ask_user`,
301
- `offer_choices`, `show_plan`), de Git (`git_status`, `git_diff`, `git_log`,
302
- `git_commit`) y de conocimiento (`memory`, `skill`), con permisos
318
+ `offer_choices`, `show_plan`), de visualizacion (`show_chart`, `generate_image`),
319
+ de Git (`git_status`, `git_diff`, `git_log`, `git_commit`) y de conocimiento
320
+ (`memory`, `skill`), con permisos
303
321
  `allow`/`ask`/`deny` por tool o por patron.
304
322
 
305
323
  `bash` esta **endurecido**: un piso de seguridad **deniega** los comandos
@@ -45,7 +45,7 @@ const OPTIONS = {
45
45
  dir: { type: 'string' },
46
46
  home: { type: 'string' },
47
47
  force: { type: 'boolean' },
48
- 'no-open': { type: 'boolean' },
48
+ open: { type: 'boolean' },
49
49
  yes: { type: 'boolean', short: 'y' },
50
50
  help: { type: 'boolean', short: 'h' },
51
51
  };
@@ -113,8 +113,8 @@ if (!isChild) {
113
113
  if (cur && pidAlive(cur.pid)) {
114
114
  console.log(`Tamnora ya esta corriendo (pid ${cur.pid}).`);
115
115
  printStatus(cur);
116
- // Aprovechamos que ya esta sirviendo para abrir la consola.
117
- if (!values['no-open']) await openBrowser(cur.url, { log: (line) => console.warn(line) });
116
+ // Solo con --open: abrir el navegador es una decision explicita.
117
+ if (values.open) await openBrowser(cur.url, { log: (line) => console.warn(line) });
118
118
  return 0;
119
119
  }
120
120
  }
@@ -125,9 +125,9 @@ if (!foreground) {
125
125
  if (values.dir) args.push('--dir', cwd);
126
126
  if (process.env.TAMNORA_HOME) args.push('--home', process.env.TAMNORA_HOME);
127
127
  if (values.yes) args.push('--yes');
128
- // El hijo es quien.listen() y por lo tanto quien abre el navegador: el flag
129
- // tiene que viajar con el o `--no-open` no serviria de nada.
130
- if (values['no-open']) args.push('--no-open');
128
+ // El hijo es quien.listen() y por lo tanto quien abriria el navegador: el
129
+ // flag tiene que viajar con el o `--open` no serviria de nada.
130
+ if (values.open) args.push('--open');
131
131
  const { child, isDead } = spawnDetached(args, { envFlag: 'TAMNORA_DETACHED' });
132
132
  console.log(`Tamnora arrancando en segundo plano (pid ${child.pid})...`);
133
133
  const rt = await waitForState(() => readRuntime(), { childPid: child.pid, isDead, timeoutMs: 30000, tickMs: 400 });
@@ -191,9 +191,9 @@ log(`Tamnora escuchando en ${info.url} (workspace ${cwd})`);
191
191
  if (!instance.ctx.authEnabled) {
192
192
  log('aviso: sin usuario configurado; el server no pide login. Crea uno: tamnora user --name <n> --password <p>');
193
193
  }
194
- // Abrir la consola es lo esperable al arrancar. Solo cuando queda escuchando:
195
- // si hubo EADDRINUSE ya hay una consola abierta sirviendo.
196
- if (!values['no-open']) await openBrowser(info.url, { log: (line) => log(line) });
194
+ // Abrir la consola es opt-in (`--open`). Solo cuando queda escuchando: si hubo
195
+ // EADDRINUSE ya hay una consola abierta sirviendo.
196
+ if (values.open) await openBrowser(info.url, { log: (line) => log(line) });
197
197
 
198
198
  return new Promise((resolve) => {
199
199
  let closing = false;
@@ -316,6 +316,6 @@ Opciones:
316
316
  --host <addr> Interfaz (default: 127.0.0.1)
317
317
  --dir <path> Workspace del server
318
318
  --home <path> Casa de Tamnora (default: TAMNORA_HOME o datos del sistema)
319
- --no-open No abrir la consola en el navegador al arrancar
319
+ --open Abrir la consola en el navegador al arrancar (default: no abrir)
320
320
  -y, --yes Auto-aprobar tools con permiso "ask"`);
321
321
  }
@@ -61,7 +61,7 @@ async function stopServerIfRunning() {
61
61
  function restartServer(rt) {
62
62
  try {
63
63
  const bin = process.argv[1];
64
- const args = [bin, 'server', '--stream', '--no-open'];
64
+ const args = [bin, 'server', '--stream'];
65
65
  if (rt?.port) args.push('--port', String(rt.port));
66
66
  if (rt?.host) args.push('--host', rt.host);
67
67
  if (rt?.cwd) args.push('--dir', rt.cwd);
@@ -26,6 +26,7 @@ export const MODES = {
26
26
  'ask_user',
27
27
  'offer_choices',
28
28
  'show_plan',
29
+ 'show_chart',
29
30
  'git_status',
30
31
  'git_diff',
31
32
  'git_log',
@@ -50,6 +51,7 @@ export const MODES = {
50
51
  'ask_user',
51
52
  'offer_choices',
52
53
  'show_plan',
54
+ 'show_chart',
53
55
  'git_status',
54
56
  'git_diff',
55
57
  'git_log',
@@ -33,6 +33,8 @@ export {
33
33
  streamChat,
34
34
  } from './providers/index.js';
35
35
  export { EFFORT_LEVELS, EFFORT_LABELS, normalizeEffort, applyReasoningEffort } from './providers/reasoning.js';
36
+ export { apiRoot, joinUrl } from './providers/utils.js';
37
+ export { CAPABILITIES, CAPABILITY_LABELS, modelCapabilities, capabilityText } from './providers/capabilities.js';
36
38
  export {
37
39
  connectMcpServer,
38
40
  connectMcpHttpServer,
@@ -146,6 +148,8 @@ export {
146
148
  registryServerToConfig,
147
149
  registryServerToCandidate,
148
150
  normalizeMcpConfig,
151
+ dedupeRegistryServers,
152
+ publisherOf,
149
153
  } from './mcp/registry.js';
150
154
  export { MCP_CATALOG, mcpCatalogEntry } from './mcp/catalog.js';
151
155
  export {
@@ -4,6 +4,7 @@
4
4
  import { providerFetch } from '../providers/utils.js';
5
5
 
6
6
  const REGISTRY = 'https://registry.modelcontextprotocol.io';
7
+ const OFFICIAL_META = 'io.modelcontextprotocol.registry/official';
7
8
 
8
9
  function slugifyId(name) {
9
10
  return String(name ?? '')
@@ -15,17 +16,50 @@ function slugifyId(name) {
15
16
  .slice(0, 40);
16
17
  }
17
18
 
19
+ /**
20
+ * Autor/editor del servidor a partir del namespace del id: `io.github.firecrawl/x`
21
+ * -> `github.com/firecrawl`.
22
+ */
23
+ export function publisherOf(name) {
24
+ const ns = String(name ?? '').split('/')[0];
25
+ const gh = /^io\.github\.(.+)$/i.exec(ns);
26
+ return gh ? `github.com/${gh[1]}` : ns;
27
+ }
28
+
29
+ /**
30
+ * El registro devuelve una entrada por cada version publicada del servidor. Se
31
+ * queda con la vigente (`isLatest`) por nombre y preserva el orden del registro
32
+ * (que es el orden de relevancia de la busqueda).
33
+ */
34
+ export function dedupeRegistryServers(entries = []) {
35
+ const best = new Map();
36
+ for (const entry of entries) {
37
+ const raw = entry?.server ?? entry;
38
+ const name = raw?.name;
39
+ if (!name) continue;
40
+ const meta = entry?._meta ?? raw?._meta ?? {};
41
+ const prev = best.get(name);
42
+ const official = meta[OFFICIAL_META] ?? null;
43
+ if (!prev || (official?.isLatest && !prev._meta[OFFICIAL_META]?.isLatest)) {
44
+ best.set(name, { ...raw, _meta: meta });
45
+ }
46
+ }
47
+ return [...best.values()];
48
+ }
49
+
18
50
  /** Busca servidores en el registro oficial. Devuelve la lista de `server`. */
19
51
  export async function searchMcpRegistry(query, { limit = 10 } = {}) {
20
52
  const url = new URL(`${REGISTRY}/v0/servers`);
21
53
  if (query) url.searchParams.set('search', String(query));
22
- url.searchParams.set('limit', String(limit));
54
+ // Se pide el doble para que, tras deduplicar versiones por nombre, sigan
55
+ // quedando al menos `limit` servidores distintos.
56
+ url.searchParams.set('limit', String(Math.min(50, limit * 2)));
23
57
  const res = await providerFetch(url.toString(), {
24
58
  headers: { 'User-Agent': 'tamnora-harness', Accept: 'application/json' },
25
59
  });
26
60
  if (!res.ok) throw new Error(`El registro MCP respondio HTTP ${res.status}`);
27
61
  const data = await res.json();
28
- return (data.servers ?? []).map((entry) => entry.server ?? entry);
62
+ return dedupeRegistryServers(data.servers ?? []).slice(0, limit);
29
63
  }
30
64
 
31
65
  /** Busca por nombre exacto; con `strict` no cae en el primer resultado difuso. */
@@ -77,6 +111,7 @@ export function registryServerToCandidate(server) {
77
111
  const id = slugifyId(server?.name) || 'mcp';
78
112
  const remote = (server?.remotes ?? [])[0];
79
113
  const pkg = (server?.packages ?? [])[0];
114
+ const official = server?._meta?.[OFFICIAL_META] ?? null;
80
115
  const runtime = pkg ? pkg.runtimeHint || (pkg.registryType === 'npm' ? 'npx' : 'uvx') : '';
81
116
  const requiredEnv = (pkg?.environmentVariables ?? []).filter((v) => v.isRequired).map((v) => v.name);
82
117
  const args = pkg
@@ -90,6 +125,12 @@ export function registryServerToCandidate(server) {
90
125
  name: server?.title || server?.name || id,
91
126
  description: server?.description ?? '',
92
127
  registry: server?.name ?? null,
128
+ publisher: publisherOf(server?.name),
129
+ version: server?.version ?? pkg?.version ?? null,
130
+ repository: server?.repository?.url ?? null,
131
+ official: official?.status === 'active',
132
+ status: official?.status ?? null,
133
+ isLatest: Boolean(official?.isLatest),
93
134
  transport: remote?.url ? 'http' : 'stdio',
94
135
  url: remote?.url ?? null,
95
136
  command: runtime || null,
@@ -0,0 +1,67 @@
1
+ // Capacidades por modelo: hibrido entre lo que reporta el proveedor (las
2
+ // modalidades del API de OpenRouter) y un mapa heuristico por familia de id
3
+ // para los proveedores que no reportan nada (OpenAI-compatible, Ollama,
4
+ // modelos manuales). Cuando el proveedor habla, manda el; la heuristica solo
5
+ // rellena huecos.
6
+ //
7
+ // Con esto la consola puede mostrar que sabe hacer cada modelo y las tools
8
+ // pueden resolver con el mismo modelo del chat (imagen, voz, ...) en vez de
9
+ // pedirle a un proveedor aparte.
10
+
11
+ export const CAPABILITIES = ['vision', 'image', 'video', 'tts', 'stt', 'tools', 'reasoning'];
12
+
13
+ export const CAPABILITY_LABELS = {
14
+ vision: 'visión',
15
+ image: 'imagen',
16
+ video: 'video',
17
+ tts: 'voz',
18
+ stt: 'escucha',
19
+ tools: 'tools',
20
+ reasoning: 'razona',
21
+ };
22
+
23
+ const RE = {
24
+ vision: /vision|llava|moondream|bakllava|minicpm-v|pixtral|gemma-3|gpt-4o|gpt-4\.1|gpt-5|^o[134](?:-|$)|gemini|claude-(?:3|sonnet-4|opus-4|haiku-4)|qwen[23](?:\.\d+)?-vl|internvl/i,
25
+ image: /dall-e|gpt-image|imagen-|-image(?:[-/]|$)|stable-diffusion|(?:^|[-/])(?:flux|seedream|sdxl|sd3)/i,
26
+ video: /sora|veo-|kling|pika|runway|luma-|hunyuan-video|cogvideo|minimax.*video|wan2/i,
27
+ tts: /(?:^|[-/])tts|gpt-4o-mini-tts|kokoro|bark-|eleven|(?:^|[-/])(?:speech|voice)(?!-to)/i,
28
+ stt: /whisper|speech-to-text|distil-whisper|parakeet|moonshine|(?:^|[-/])stt/i,
29
+ reasoning: /deepseek-reasoner|(?:^|[-/])r1(?:[-/]|$)|^o[134](?:-|$)|-think|thinking|qwq|glm-.*think|magistral/i,
30
+ nonChat: /embedding|moderation|rerank|whisper|dall-e|gpt-image|imagen-|stable-diffusion|(?:^|[-/])(?:tts|stt|flux|sora|veo)|-image(?:[-/]|$)/i,
31
+ };
32
+
33
+ function modalities(raw, which) {
34
+ const direct = raw?.[`${which}_modalities`];
35
+ const arch = raw?.architecture?.[`${which}_modalities`];
36
+ const list = Array.isArray(direct) && direct.length ? direct : Array.isArray(arch) && arch.length ? arch : null;
37
+ return list ? list.map((m) => String(m).toLowerCase()) : null;
38
+ }
39
+
40
+ /**
41
+ * Capacidades de un modelo: `{ vision, image, video, tts, stt, tools,
42
+ * reasoning }`. `raw` es el payload del proveedor (si lo hay).
43
+ */
44
+ export function modelCapabilities(id, raw = {}) {
45
+ const name = String(id ?? raw?.id ?? '').toLowerCase();
46
+ const inputs = modalities(raw, 'input');
47
+ const outputs = modalities(raw, 'output');
48
+ const params =
49
+ Array.isArray(raw?.supported_parameters) && raw.supported_parameters.length ? raw.supported_parameters : null;
50
+ const modality = String(raw?.architecture?.modality ?? raw?.modality ?? '').split('->')[0];
51
+ return {
52
+ vision: inputs ? inputs.includes('image') : RE.vision.test(name) || modality.includes('image'),
53
+ image: outputs ? outputs.includes('image') : RE.image.test(name),
54
+ video: outputs ? outputs.includes('video') : RE.video.test(name),
55
+ tts: outputs ? outputs.includes('audio') : RE.tts.test(name),
56
+ stt: inputs ? inputs.includes('audio') : RE.stt.test(name),
57
+ tools: params ? params.includes('tools') || params.includes('tool_choice') : !RE.nonChat.test(name),
58
+ reasoning: params ? params.includes('reasoning') : RE.reasoning.test(name),
59
+ };
60
+ }
61
+
62
+ /** `'visión · imagen · voz'` para badges/tooltips; '' si no se sabe nada. */
63
+ export function capabilityText(caps = {}) {
64
+ return CAPABILITIES.filter((key) => caps[key])
65
+ .map((key) => CAPABILITY_LABELS[key])
66
+ .join(' · ');
67
+ }
@@ -2,6 +2,7 @@ import { openrouterProvider } from './openrouter.js';
2
2
  import { openaiCompatibleProvider } from './openaiCompatible.js';
3
3
  import { ollamaProvider } from './ollama.js';
4
4
  import { OPENAI_COMPATIBLE_PRESETS } from './presets.js';
5
+ import { modelCapabilities } from './capabilities.js';
5
6
  import { envValue } from '../env.js';
6
7
 
7
8
  export const PROVIDERS = [
@@ -80,9 +81,12 @@ export function resolveProviderRuntime({
80
81
  }
81
82
 
82
83
  function manualModel(model) {
83
- if (typeof model === 'string') return { id: model, label: model, vision: false };
84
+ if (typeof model === 'string') {
85
+ return { id: model, label: model, vision: false, capabilities: modelCapabilities(model) };
86
+ }
84
87
  const id = model.id ?? model.name ?? '';
85
- return { id, label: model.label ?? id, vision: Boolean(model.vision) };
88
+ const capabilities = modelCapabilities(id, model);
89
+ return { id, label: model.label ?? id, vision: capabilities.vision, capabilities };
86
90
  }
87
91
 
88
92
  /**
@@ -97,14 +101,28 @@ export function selectVisibleModels(models = [], visible = null) {
97
101
  return models.filter((m) => wanted.has(String(m?.id)));
98
102
  }
99
103
 
104
+ /**
105
+ * Modelos de un proveedor: el catalogo del API **mas** la lista manual del
106
+ * registro ("modelos (coma, opcional)"), sin duplicar ids. La lista manual no
107
+ * reemplaza al catalogo (asi el picker puede mostrar todos los modelos del
108
+ * proveedor y el usuario agregar a su seleccion); solo es el catalogo entero
109
+ * cuando el API no lista modelos (o falla).
110
+ */
100
111
  export async function listProviderModels(settings = {}) {
101
112
  const { provider, config } = resolveProviderRuntime(settings);
102
- const manual = settings.providerModels?.[provider.id];
103
- if (manual?.length) return manual.map(manualModel).filter((m) => m.id);
104
- if (provider.manualModels?.length) {
105
- return provider.manualModels.map(manualModel).filter((m) => m.id);
113
+ const manual = settings.providerModels?.[provider.id]?.length
114
+ ? settings.providerModels[provider.id]
115
+ : provider.manualModels ?? [];
116
+ const manualList = manual.map(manualModel).filter((m) => m.id);
117
+ try {
118
+ const listed = await provider.listModels(config);
119
+ if (!manualList.length) return listed;
120
+ const seen = new Set(listed.map((m) => m.id));
121
+ return [...listed, ...manualList.filter((m) => !seen.has(m.id))];
122
+ } catch (error) {
123
+ if (manualList.length) return manualList;
124
+ throw error;
106
125
  }
107
- return provider.listModels(config);
108
126
  }
109
127
 
110
128
  /**
@@ -1,5 +1,6 @@
1
1
  import { readNDJSON } from './stream.js';
2
2
  import { applyReasoningEffort } from './reasoning.js';
3
+ import { modelCapabilities } from './capabilities.js';
3
4
  import { joinUrl, providerError, providerFetch, sortModels } from './utils.js';
4
5
 
5
6
  const OLLAMA_BASE_URL = 'http://localhost:11434';
@@ -7,8 +8,8 @@ const OLLAMA_BASE_URL = 'http://localhost:11434';
7
8
  function toOllamaModel(model) {
8
9
  const id = model.name ?? model.model ?? '';
9
10
  const family = model.details?.family ?? '';
10
- const vision = /vision|llava|bakllava|moondream/i.test(`${id} ${family}`);
11
- return { id, label: id, vision, context: null };
11
+ const capabilities = modelCapabilities(id, { modality: /vision|llava|bakllava|moondream/i.test(`${id} ${family}`) ? 'text+image->text' : '' });
12
+ return { id, label: id, vision: capabilities.vision, context: null, capabilities };
12
13
  }
13
14
 
14
15
  /**
@@ -1,7 +1,20 @@
1
1
  // Shared helpers for provider adapters.
2
2
 
3
+ import { modelCapabilities } from './capabilities.js';
4
+
5
+ /**
6
+ * Raiz del API. Los usuarios pegan a veces la URL completa del endpoint como
7
+ * baseUrl (`https://.../v1/chat/completions`); se recorta el sufijo de endpoint
8
+ * conocido para no armar rutas duplicadas (`.../chat/completions/images/generations`).
9
+ */
10
+ export function apiRoot(baseUrl) {
11
+ return String(baseUrl ?? '')
12
+ .replace(/\/+$/, '')
13
+ .replace(/\/(?:chat\/completions|images\/generations|audio\/(?:speech|transcriptions|translations))$/i, '');
14
+ }
15
+
3
16
  export function joinUrl(baseUrl, path) {
4
- return `${String(baseUrl).replace(/\/+$/, '')}${path}`;
17
+ return `${apiRoot(baseUrl)}${path}`;
5
18
  }
6
19
 
7
20
  export function authHeaders({ apiKey, headers = {} }) {
@@ -100,10 +113,7 @@ const perMillion = (value) => {
100
113
  export function normalizeModel(model) {
101
114
  const id = model.id ?? model.name ?? model.model ?? '';
102
115
  const label = model.name ?? model.label ?? model.model ?? id;
103
- const modality = model.architecture?.modality ?? model.modality ?? '';
104
- const vision =
105
- (typeof modality === 'string' && modality.includes('image')) ||
106
- (Array.isArray(model.input_modalities) && model.input_modalities.includes('image'));
116
+ const capabilities = modelCapabilities(id, model);
107
117
  const context =
108
118
  Number(
109
119
  model.context_length ??
@@ -116,7 +126,7 @@ export function normalizeModel(model) {
116
126
  const prompt = perMillion(model.pricing?.prompt);
117
127
  const completion = perMillion(model.pricing?.completion);
118
128
  const free = id.endsWith(':free') || (prompt === 0 && completion === 0);
119
- return { id, label, vision, context, free, pricing: { prompt, completion } };
129
+ return { id, label, vision: capabilities.vision, context, free, pricing: { prompt, completion }, capabilities };
120
130
  }
121
131
 
122
132
  export function sortModels(models) {
@@ -92,9 +92,38 @@ export async function searchSkillsGithub(query, { token, limit = 10 } = {}) {
92
92
  .filter((r) => r.owner && r.repo);
93
93
  }
94
94
 
95
+ /**
96
+ * Directorio de la skill dentro del arbol del repo (el que contiene el
97
+ * `SKILL.md`). Orden: el `<slug>` pedido, un `SKILL.md` en la raiz, la unica
98
+ * skill del repo (muchas skills "de una" viven en `skills/<nombre>/`), o una
99
+ * carpeta que se llame como el repo. Devuelve null si hay varias y ninguna
100
+ * coincide (el caller lista las que hay).
101
+ */
102
+ export function resolveSkillDir(entries, { slug, repo } = {}) {
103
+ const dirs = [
104
+ ...new Set(
105
+ entries
106
+ .filter((e) => e.path === 'SKILL.md' || e.path.endsWith('/SKILL.md'))
107
+ .map((e) => e.path.slice(0, -'SKILL.md'.length).replace(/\/$/, ''))
108
+ ),
109
+ ].sort((a, b) => a.split('/').length - b.split('/').length || a.length - b.length);
110
+ const base = (dir) => dir.split('/').pop();
111
+
112
+ if (slug) {
113
+ const match = dirs.find((dir) => dir === slug || base(dir) === slug);
114
+ if (match !== undefined) return match;
115
+ }
116
+ if (dirs.includes('')) return '';
117
+ if (dirs.length === 1) return dirs[0];
118
+ const named = dirs.find((dir) => base(dir) === slug || base(dir) === repo);
119
+ if (named !== undefined) return named;
120
+ return null;
121
+ }
122
+
95
123
  /**
96
124
  * Baja los archivos de una skill (`SKILL.md` y los del mismo directorio) desde
97
- * el repositorio de GitHub. Sin `slug`, toma el `SKILL.md` de la raiz.
125
+ * el repositorio de GitHub. Sin `slug`, resuelve la skill del repo (raiz o la
126
+ * unica que tenga).
98
127
  */
99
128
  export async function fetchSkillFromGithub({ owner, repo, slug }) {
100
129
  const meta = await ghJson(`${GITHUB_API}/repos/${owner}/${repo}`);
@@ -102,18 +131,22 @@ export async function fetchSkillFromGithub({ owner, repo, slug }) {
102
131
  const tree = await ghJson(`${GITHUB_API}/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`);
103
132
  const entries = (tree.tree ?? []).filter((e) => e.type === 'blob');
104
133
 
105
- let dir = null;
106
- if (slug) {
107
- const matches = entries
108
- .filter((e) => e.path.endsWith(`/${slug}/SKILL.md`) || e.path === `${slug}/SKILL.md`)
109
- .sort((a, b) => a.path.length - b.path.length);
110
- if (matches.length) dir = matches[0].path.slice(0, -'SKILL.md'.length).replace(/\/$/, '');
111
- }
112
- if (dir === null && !slug) {
113
- const root = entries.find((e) => e.path === 'SKILL.md');
114
- if (root) dir = '';
134
+ const dir = resolveSkillDir(entries, { slug, repo });
135
+ if (dir === null) {
136
+ const found = [
137
+ ...new Set(
138
+ entries
139
+ .filter((e) => e.path === 'SKILL.md' || e.path.endsWith('/SKILL.md'))
140
+ .map((e) => e.path.slice(0, -'SKILL.md'.length).replace(/\/$/, ''))
141
+ .map((d) => d.split('/').pop())
142
+ ),
143
+ ];
144
+ if (!found.length) throw new Error(`No encontre la skill "${slug ?? repo}" en ${owner}/${repo}: el repo no tiene ningún SKILL.md.`);
145
+ throw new Error(
146
+ `No encontre la skill "${slug ?? repo}" en ${owner}/${repo}. El repo tiene ${found.length}: ` +
147
+ `${found.slice(0, 12).join(', ')}${found.length > 12 ? '…' : ''}. Instalá una con ${owner}/${repo}/<slug>.`
148
+ );
115
149
  }
116
- if (dir === null) throw new Error(`No encontre la skill "${slug ?? repo}" en ${owner}/${repo}.`);
117
150
 
118
151
  const prefix = dir ? `${dir}/` : '';
119
152
  const paths = entries