@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.
- package/CHANGELOG.md +37 -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/index.html +9 -7
- 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/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 (``, 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`).
|
|
16
|
-
|
|
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 (``, 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`).
|
|
83
|
-
|
|
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
|
|
114
|
-
y celdas saneadas)
|
|
124
|
+
- **Markdown con tablas e imágenes**: las respuestas renderizan tablas (`|`, con
|
|
125
|
+
alineacion y celdas saneadas) e imágenes inline (``, 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`
|
|
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
|
|
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
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
117
|
-
if (
|
|
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
|
|
129
|
-
// tiene que viajar con el o `--
|
|
130
|
-
if (values
|
|
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
|
|
195
|
-
//
|
|
196
|
-
if (
|
|
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
|
-
--
|
|
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'
|
|
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
|
-
|
|
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 ?? []).
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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 `${
|
|
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
|
|
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`,
|
|
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
|
-
|
|
106
|
-
if (
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|