ratacode 0.2.5

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 (58) hide show
  1. package/CREDITS.md +80 -0
  2. package/LICENSE +21 -0
  3. package/README.md +289 -0
  4. package/apreton/README.md +54 -0
  5. package/apreton/handshake.md +33 -0
  6. package/apreton/headless.md +58 -0
  7. package/apreton/mcp.md +221 -0
  8. package/apreton/navegador.md +58 -0
  9. package/bin/instalacion.js +148 -0
  10. package/bin/ratacode.js +1251 -0
  11. package/fabrica/settings.yaml +247 -0
  12. package/mcp/README.md +253 -0
  13. package/mcp/bin/ratacode-mcp.js +215 -0
  14. package/mcp/lib/actividad.js +49 -0
  15. package/mcp/lib/casa.js +126 -0
  16. package/mcp/lib/claves.js +148 -0
  17. package/mcp/lib/espacios.js +117 -0
  18. package/mcp/lib/http.js +184 -0
  19. package/mcp/lib/lectura.js +210 -0
  20. package/mcp/lib/modelos.js +235 -0
  21. package/mcp/lib/nucleo.js +332 -0
  22. package/mcp/lib/registro.js +21 -0
  23. package/mcp/lib/seguridad.js +283 -0
  24. package/mcp/lib/servidor.js +397 -0
  25. package/mcp/lib/tareas.js +406 -0
  26. package/mcp/package.json +22 -0
  27. package/mcp/tunel.mjs +209 -0
  28. package/modos/arquitecto/agent.cordis.yml +120 -0
  29. package/modos/arquitecto/preset.yml +3 -0
  30. package/modos/capataz/agent.cordis.yml +120 -0
  31. package/modos/capataz/preset.yml +3 -0
  32. package/modos/faro/agent.cordis.yml +120 -0
  33. package/modos/faro/preset.yml +3 -0
  34. package/modos/gepeto/agent.cordis.yml +191 -0
  35. package/modos/gepeto/preset.yml +3 -0
  36. package/modos/hero/agent.cordis.yml +198 -0
  37. package/modos/hero/preset.yml +3 -0
  38. package/modos/modo-rata/agent.cordis.yml +198 -0
  39. package/modos/modo-rata/preset.yml +3 -0
  40. package/modos/nex/agent.cordis.yml +213 -0
  41. package/modos/nex/preset.yml +3 -0
  42. package/modos/nex/skills/cordis-plugin-development/SKILL.md +420 -0
  43. package/modos/nex/skills/editing-cordis-compositions/SKILL.md +165 -0
  44. package/modos/pix/agent.cordis.yml +207 -0
  45. package/modos/pix/preset.yml +3 -0
  46. package/modos/tirita/agent.cordis.yml +130 -0
  47. package/modos/tirita/preset.yml +3 -0
  48. package/package.json +49 -0
  49. package/piel/activos/ratacode-emblema.svg +14 -0
  50. package/piel/activos/ratacode-es.js +1331 -0
  51. package/piel/activos/ratacode-identidad.css +50 -0
  52. package/piel/activos/ratacode-piel.css +217 -0
  53. package/piel/activos/ratacode-piel.js +487 -0
  54. package/piel/activos/ratacode-vida.js +424 -0
  55. package/piel/cordis.patch.yml +6 -0
  56. package/piel/lib/cliente.js +1020 -0
  57. package/piel/lib/index.js +1230 -0
  58. package/piel/package.json +36 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * http — transporte Streamable HTTP para RATACODE-MCP.
3
+ *
4
+ * El MISMO servidor de herramientas que habla por stdio, ahora también por
5
+ * HTTP en `127.0.0.1:<puerto>/mcp/<clave>`. La clave va en la propia URL
6
+ * (nunca en el repositorio: se genera y se guarda en la casa). Cada petición
7
+ * se atiende con un `McpServer` nuevo que COMPARTE el registro de tareas, así
8
+ * `get_task_result` ve las tareas de otras sesiones y el tope por hora es
9
+ * global. Modo sin estado (stateless): cada POST es independiente, que es justo
10
+ * lo que necesitan estas siete herramientas.
11
+ *
12
+ * Sólo escucha en loopback. La exposición a Internet es cosa del túnel
13
+ * (tunel.mjs), que lo decide el usuario. Y lo que se expone va encerrado: cada
14
+ * tarea lee y escribe sólo dentro de las carpetas autorizadas, sin terminal y
15
+ * sin red (mira `lib/lectura.js`), así que aquí no hay nada que aceptar.
16
+ *
17
+ * Cuatro reglas que se cumplen aquí, y todas se prueban:
18
+ * 1 · LA CLAVE NO SE REGISTRA. Ni en los avisos ni en los errores: en el
19
+ * registro la ruta sale como `/mcp/<oculta>`. (Antes, un error escribía
20
+ * `req.url` con la clave dentro, y los clientes MCP guardan ese stderr.)
21
+ * 2 · LA CLAVE SE COMPARA EN TIEMPO CONSTANTE (`crypto.timingSafeEqual`).
22
+ * 3 · EL CUERPO TIENE TOPE, Y AL PASARSE SE CONTESTA 413 (no se mata el
23
+ * socket antes de contestar: el cliente se merece una respuesta).
24
+ * 4 · HAY TOPE DE PETICIONES A LA VEZ: cada POST autenticado crea un servidor
25
+ * MCP y un transporte; sin tope, la URL abierta es una fábrica de ellos.
26
+ *
27
+ * Y una quinta, para poder rotar la clave SIN reiniciar: si se le da la ruta
28
+ * del fichero de la clave, este módulo la relee cada pocos segundos y adopta la
29
+ * nueva. Así `tunel.mjs` puede estrenar clave al abrir el túnel y el servidor
30
+ * que ya está en marcha la acepta.
31
+ */
32
+ import { timingSafeEqual } from 'node:crypto';
33
+ import { readFileSync } from 'node:fs';
34
+ import { createServer } from 'node:http';
35
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
36
+ import { aviso } from './registro.js';
37
+
38
+ /** Tope del cuerpo de una petición (5 MB: ningún prompt legítimo se acerca). */
39
+ const TOPE_CUERPO = 5_000_000;
40
+ /** Tope por defecto de peticiones atendidas a la vez. */
41
+ const SIMULTANEAS_DEFECTO = 8;
42
+ /** Cada cuánto se mira si la clave del fichero ha cambiado. */
43
+ const MS_REVISION_CLAVE = 2000;
44
+
45
+ /** Leer el cuerpo JSON de una petición, con tope. */
46
+ function leerCuerpo(req) {
47
+ return new Promise((listo, rechaza) => {
48
+ const trozos = [];
49
+ let total = 0;
50
+ let pasado = false;
51
+ req.on('data', (t) => {
52
+ if (pasado) return;
53
+ total += t.length;
54
+ if (total > TOPE_CUERPO) {
55
+ pasado = true;
56
+ trozos.length = 0;
57
+ const error = new Error('cuerpo demasiado grande');
58
+ error.codigo = 413;
59
+ rechaza(error);
60
+ return;
61
+ }
62
+ trozos.push(t);
63
+ });
64
+ req.on('end', () => {
65
+ if (pasado) return;
66
+ const texto = Buffer.concat(trozos).toString('utf8').trim();
67
+ if (texto === '') return listo(undefined);
68
+ try { listo(JSON.parse(texto)); } catch { rechaza(new Error('cuerpo JSON inválido')); }
69
+ });
70
+ req.on('error', rechaza);
71
+ });
72
+ }
73
+
74
+ /** ¿Es esta la clave buena? En tiempo constante, y sin decir por qué falla. */
75
+ function claveValida(recibida, buena) {
76
+ if (typeof recibida !== 'string' || typeof buena !== 'string' || buena === '') return false;
77
+ const a = Buffer.from(recibida, 'utf8');
78
+ const b = Buffer.from(buena, 'utf8');
79
+ // Longitudes distintas: no se puede comparar en tiempo constante, y una clave
80
+ // con otra longitud no es la clave. (La nuestra es hex de 64: no filtra nada.)
81
+ if (a.length !== b.length) return false;
82
+ return timingSafeEqual(a, b);
83
+ }
84
+
85
+ /**
86
+ * Arrancar el servidor HTTP.
87
+ * @param {{fabricaServidor: () => import('@modelcontextprotocol/sdk/server/mcp.js').McpServer, puerto: number, clave: string, rutaClave?: string, alRotar?: (nueva: string) => void, host?: string, simultaneas?: number}} opciones
88
+ * @returns {Promise<{servidor: import('node:http').Server, claveActual: () => string, parar: () => void}>} ya escuchando.
89
+ */
90
+ export function iniciarServidorHttp({ fabricaServidor, puerto, clave, rutaClave, alRotar, host = '127.0.0.1', simultaneas = SIMULTANEAS_DEFECTO }) {
91
+ /** La clave viva: puede cambiar si `tunel.mjs` estrena una. */
92
+ let claveViva = clave;
93
+ let atendiendose = 0;
94
+ let temporizadorClave;
95
+
96
+ if (typeof rutaClave === 'string' && rutaClave !== '') {
97
+ temporizadorClave = setInterval(() => {
98
+ try {
99
+ const leida = readFileSync(rutaClave, 'utf8').trim();
100
+ if (leida !== '' && leida !== claveViva) {
101
+ claveViva = leida;
102
+ aviso('http: clave rotada (la nueva está en ' + rutaClave + '); la URL ha cambiado');
103
+ if (typeof alRotar === 'function') alRotar(leida);
104
+ }
105
+ } catch { /* sin fichero legible se sigue con la que había */ }
106
+ }, MS_REVISION_CLAVE);
107
+ if (typeof temporizadorClave.unref === 'function') temporizadorClave.unref();
108
+ }
109
+
110
+ const server = createServer((req, res) => {
111
+ manejar(req, res).catch((e) => {
112
+ // NUNCA `req.url`: lleva la clave. Y nunca el valor de la clave.
113
+ aviso('http: error manejando ' + req.method + ' /mcp/<oculta>: ' + (e?.message ?? e));
114
+ if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }).end('Error interno');
115
+ else res.end();
116
+ });
117
+ });
118
+
119
+ async function manejar(req, res) {
120
+ const url = new URL(req.url ?? '/', 'http://' + (req.headers.host ?? 'localhost'));
121
+ const partes = url.pathname.split('/').filter(Boolean); // ['mcp', '<clave>']
122
+ if (partes[0] !== 'mcp' || !claveValida(partes[1], claveViva)) {
123
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('Not found');
124
+ return;
125
+ }
126
+
127
+ if (req.method === 'POST') {
128
+ if (atendiendose >= simultaneas) {
129
+ res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8', 'retry-after': '5' })
130
+ .end('Demasiadas peticiones a la vez (máximo ' + simultaneas + '). Prueba dentro de un momento.');
131
+ return;
132
+ }
133
+ let cuerpo;
134
+ try {
135
+ cuerpo = await leerCuerpo(req);
136
+ } catch (e) {
137
+ const codigo = e?.codigo === 413 ? 413 : 400;
138
+ res.writeHead(codigo, { 'content-type': 'text/plain; charset=utf-8', connection: 'close' })
139
+ .end(codigo === 413 ? 'Cuerpo demasiado grande (máximo 5 MB)' : 'Bad request');
140
+ // Se contesta PRIMERO y se cierra DESPUÉS: matar el socket antes deja al
141
+ // cliente sin respuesta (medido: `curl` terminaba en exit 56 sin HTTP).
142
+ res.on('finish', () => req.destroy());
143
+ return;
144
+ }
145
+ atendiendose += 1;
146
+ try {
147
+ const transporte = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
148
+ const servidor = fabricaServidor();
149
+ await servidor.connect(transporte);
150
+ res.on('close', () => { servidor.close().catch(() => {}); transporte.close().catch(() => {}); });
151
+ await transporte.handleRequest(req, res, cuerpo);
152
+ } finally {
153
+ atendiendose -= 1;
154
+ }
155
+ return;
156
+ }
157
+
158
+ if (req.method === 'GET' || req.method === 'DELETE') {
159
+ // Sin estado: no hay sesiones que mantener ni stream SSE persistente.
160
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' })
161
+ .end('Método no permitido en modo sin estado (usa POST)');
162
+ return;
163
+ }
164
+
165
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' }).end('Método no permitido');
166
+ }
167
+
168
+ const escuchando = new Promise((listo, rechaza) => {
169
+ server.once('error', rechaza);
170
+ server.listen(puerto, host, () => {
171
+ server.removeListener('error', rechaza);
172
+ listo(server);
173
+ });
174
+ });
175
+
176
+ return escuchando.then(() => ({
177
+ servidor: server,
178
+ claveActual: () => claveViva,
179
+ parar: () => {
180
+ if (temporizadorClave !== undefined) clearInterval(temporizadorClave);
181
+ server.close();
182
+ },
183
+ }));
184
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * lectura — LA LECTURA, ENCERRADA DE VERDAD (R25).
3
+ *
4
+ * ── LO QUE DICE EL MOTOR (medido, con el código delante) ───────────────────
5
+ * · `dsh-fs-sandbox/lib/types/index.d.ts:7-8` — «Reads pass through
6
+ * untouched: every mode permits reading.»
7
+ * · `dsh-sandbox/lib/types/roots.d.ts:28-36` — la ÚNICA lista de raíces que
8
+ * el motor sabe derivar es la de ESCRITURA (`writableRoots`).
9
+ * · `dsh-sandbox-windows-acl/lib/types/index.d.ts:24-25` — «writes are
10
+ * restricted; reads, network, and process visibility are NOT».
11
+ * · El vocabulario de modos (`read-only | workspace-write |
12
+ * danger-full-access`) es un eje de EFECTOS SOBRE FICHEROS: `read-only`
13
+ * deniega toda MUTACIÓN, no toda lectura.
14
+ *
15
+ * O sea: NO hay modo, ni ajuste, ni plugin del motor que encierre la LECTURA.
16
+ * Lo que SÍ hay es un gancho de permiso POR HERRAMIENTA: `tools/pre-execute`
17
+ * (`dsh-tools/lib/types/index.d.ts:29-38`), que ve cada llamada antes de
18
+ * ejecutarse y puede contestar `{kind:'deny', reason}` (`dsh-tools/lib/index.js:3116-3135`).
19
+ *
20
+ * ── ESTE FICHERO ES DOS COSAS A LA VEZ ─────────────────────────────────────
21
+ * 1 · las funciones PURAS que deciden si una ruta está dentro de las
22
+ * carpetas autorizadas, normalizando de verdad: rutas relativas, `..`,
23
+ * mayúsculas/minúsculas de Windows, enlaces y uniones (realpath del trozo
24
+ * que existe), UNC (`\\servidor\recurso`) y el prefijo de rutas largas
25
+ * (`\\?\C:\…`, `\\?\UNC\…`);
26
+ * 2 · el PLUGIN DE CORDIS que el motor monta en cada tarea del MCP: se
27
+ * inserta por parche y engancha `tools/pre-execute`. Deniega con UNA
28
+ * línea: «Fuera de la carpeta autorizada: <ruta>».
29
+ *
30
+ * El MCP copia este fichero junto al parche (`<casa>\mcp\tmp\lectura.js`) y el
31
+ * parche lo inserta por su nombre relativo: el motor resuelve `./lectura.js`
32
+ * desde la CARPETA DEL PARCHE (`cordis-plugin-loader/lib/index.js:273-278`),
33
+ * medido. Por eso el fichero es autosuficiente: sólo usa módulos de Node.
34
+ */
35
+ import { realpathSync } from 'node:fs';
36
+ import { basename, dirname, join, resolve, sep } from 'node:path';
37
+
38
+ /** ¿Es Windows? (allí las rutas no distinguen mayúsculas) */
39
+ const ES_WINDOWS = process.platform === 'win32';
40
+
41
+ /** La línea que ve el agente cuando la herramienta se para. Una, y con la ruta. */
42
+ export function motivoFuera(ruta) {
43
+ return 'Fuera de la carpeta autorizada: ' + ruta;
44
+ }
45
+
46
+ /**
47
+ * Las claves de los argumentos que llevan una ruta, medidas en las herramientas
48
+ * del motor: `file_path` (dsh-tool-fs: read, read_image, write, edit), `path`
49
+ * (dsh-tool-fs-search: glob, grep) y `files[].path` (dsh-tool-present).
50
+ * Un nombre que NO esté aquí no se mira: el cuerpo de un texto (`old_string`,
51
+ * `content`, un encargo) no es una ruta y no se toca.
52
+ */
53
+ export const CLAVES_DE_RUTA = [
54
+ 'file_path', 'path', 'dir', 'directory', 'cwd', 'working_directory',
55
+ 'root', 'workspace', 'files', 'paths',
56
+ ];
57
+
58
+ /**
59
+ * La forma canónica de una ruta:
60
+ * · quita el prefijo de rutas largas de Windows (`\\?\`, `\\?\UNC\`);
61
+ * · resuelve lo relativo contra `cwd` (el espacio de la tarea);
62
+ * · resuelve `..`, barras repetidas y barras al revés (lo hace `resolve`);
63
+ * · sigue los enlaces/uniones del trozo que EXISTE (realpath), y deja el
64
+ * resto tal cual: una ruta que aún no existe (un `write`) también se juzga,
65
+ * y se juzga por dónde va a caer de verdad.
66
+ * @param {string} ruta - lo que pidió la herramienta.
67
+ * @param {string} [cwd] - desde dónde se resuelve lo relativo.
68
+ * @returns {string} la ruta canónica.
69
+ */
70
+ export function canonica(ruta, cwd = process.cwd()) {
71
+ let texto = String(ruta ?? '').trim();
72
+ if (texto === '') return '';
73
+ // \\?\UNC\servidor\recurso\… → \\servidor\recurso\…
74
+ if (/^\\\\\?\\UNC\\/i.test(texto)) texto = '\\\\' + texto.slice(8);
75
+ // \\?\C:\… → C:\…
76
+ else if (/^\\\\\?\\/.test(texto)) texto = texto.slice(4);
77
+ // Y lo mismo con barras normales (una ruta copiada de un chat).
78
+ if (/^\/\/\?\/UNC\//i.test(texto)) texto = '\\\\' + texto.slice(8);
79
+ else if (/^\/\/\?\//.test(texto)) texto = texto.slice(4);
80
+ return realDe(resolve(cwd, texto));
81
+ }
82
+
83
+ /** Un alias del nombre que usa `seguridad.js` para lo mismo. */
84
+ export const normalizarRuta = canonica;
85
+
86
+ /**
87
+ * `realpath` del trozo que existe. Si `C:\a\enlace\no-existe.txt` tiene el
88
+ * enlace en medio, lo que importa es dónde acaba el enlace, no el nombre.
89
+ * @param {string} absoluta - ruta absoluta ya resuelta.
90
+ * @returns {string} la ruta con los enlaces resueltos.
91
+ */
92
+ function realDe(absoluta) {
93
+ let actual = absoluta;
94
+ const cola = [];
95
+ for (;;) {
96
+ try {
97
+ const real = realpathSync.native(actual);
98
+ return cola.length === 0 ? real : join(real, ...cola.reverse());
99
+ } catch {
100
+ const padre = dirname(actual);
101
+ // Se llegó a la raíz (o a algo que no se puede mirar): se devuelve la ruta
102
+ // resuelta tal cual, que ya no tiene `..` ni prefijos raros.
103
+ if (padre === actual) return absoluta;
104
+ cola.push(basename(actual));
105
+ actual = padre;
106
+ }
107
+ }
108
+ }
109
+
110
+ /** La forma comparable de una ruta (en Windows, sin distinguir mayúsculas). */
111
+ export function comparable(ruta) {
112
+ const limpia = ruta.endsWith(sep) && ruta.length > 1 ? ruta.slice(0, -1) : ruta;
113
+ return ES_WINDOWS ? limpia.toLowerCase() : limpia;
114
+ }
115
+
116
+ /**
117
+ * ¿`hijo` está dentro de `raiz` (o es `raiz`)? Las dos, ya normalizadas.
118
+ * @param {string} hijo - ruta normalizada.
119
+ * @param {string} raiz - ruta normalizada.
120
+ * @returns {boolean}
121
+ */
122
+ export function estaDentro(hijo, raiz) {
123
+ const a = comparable(hijo);
124
+ const b = comparable(raiz);
125
+ if (a === b) return true;
126
+ return a.startsWith(b.endsWith(sep) ? b : b + sep);
127
+ }
128
+
129
+ /**
130
+ * ¿La ruta cae dentro de ALGUNA de las raíces autorizadas?
131
+ * @param {string} ruta - la ruta pedida (cruda).
132
+ * @param {string[]} raices - las carpetas autorizadas (crudas).
133
+ * @param {string} [cwd] - desde dónde se resuelve lo relativo.
134
+ * @returns {{dentro: boolean, canonica: string}}
135
+ */
136
+ export function dentroDeAlguna(ruta, raices, cwd) {
137
+ const suya = canonica(ruta, cwd);
138
+ if (suya === '') return { dentro: true, canonica: suya };
139
+ for (const raiz of raices ?? []) {
140
+ const canonRaiz = canonica(raiz, cwd);
141
+ if (canonRaiz === '') continue;
142
+ if (estaDentro(suya, canonRaiz)) return { dentro: true, canonica: suya };
143
+ }
144
+ return { dentro: false, canonica: suya };
145
+ }
146
+
147
+ /**
148
+ * Las rutas que lleva una llamada a herramienta. Se miran las claves conocidas
149
+ * ({@link CLAVES_DE_RUTA}) en el primer nivel y en los objetos de dentro
150
+ * (`files: [{path: …}]`), nunca el texto libre.
151
+ * @param {object} entrada - los argumentos de la herramienta.
152
+ * @param {number} [profundidad] - cuántos niveles de objeto se miran.
153
+ * @returns {string[]} las rutas encontradas, tal cual venían.
154
+ */
155
+ export function rutasDe(entrada, profundidad = 2) {
156
+ const salida = [];
157
+ if (entrada === null || typeof entrada !== 'object' || profundidad < 0) return salida;
158
+ for (const [clave, valor] of Object.entries(entrada)) {
159
+ if (CLAVES_DE_RUTA.includes(clave)) {
160
+ if (typeof valor === 'string') {
161
+ if (valor.trim() !== '') salida.push(valor);
162
+ } else if (Array.isArray(valor)) {
163
+ for (const uno of valor) {
164
+ if (typeof uno === 'string' && uno.trim() !== '') salida.push(uno);
165
+ else if (uno !== null && typeof uno === 'object' && typeof uno.path === 'string' && uno.path.trim() !== '') salida.push(uno.path);
166
+ }
167
+ }
168
+ continue;
169
+ }
170
+ if (valor !== null && typeof valor === 'object') salida.push(...rutasDe(valor, profundidad - 1));
171
+ }
172
+ return salida;
173
+ }
174
+
175
+ /**
176
+ * La decisión del cerco para UNA llamada.
177
+ * @param {{entrada: object, raices: string[], cwd?: string}} opciones
178
+ * @returns {{fuera: string}|null} la ruta que se sale, o null si todo está dentro.
179
+ */
180
+ export function decidir({ entrada, raices, cwd }) {
181
+ if (!Array.isArray(raices) || raices.length === 0) return { fuera: '(sin carpetas autorizadas)' };
182
+ for (const ruta of rutasDe(entrada)) {
183
+ const juicio = dentroDeAlguna(ruta, raices, cwd);
184
+ if (!juicio.dentro) return { fuera: ruta };
185
+ }
186
+ return null;
187
+ }
188
+
189
+ // ── EL PLUGIN ──────────────────────────────────────────────────────────────
190
+ // Esto es lo que el motor monta: una fila insertada por el parche del MCP.
191
+
192
+ /** El nombre del plugin en la composición del motor. */
193
+ export const name = 'ratacode-cerco';
194
+
195
+ /**
196
+ * Engancha `tools/pre-execute` y deniega la herramienta que lleve una ruta
197
+ * fuera de las carpetas autorizadas. Sin raíces NO deja pasar nada (falla
198
+ * cerrado): una tarea del MCP siempre las trae.
199
+ * @param {object} ctx - el contexto de cordis.
200
+ * @param {{raices?: string[]}} [config] - las carpetas autorizadas, del parche.
201
+ */
202
+ export function apply(ctx, config) {
203
+ const raices = Array.isArray(config?.raices) ? config.raices.filter((r) => typeof r === 'string' && r.trim() !== '') : [];
204
+ ctx.on('tools/pre-execute', async (exec, next) => {
205
+ const cwd = exec?.agent?.session?.header?.cwd ?? process.cwd();
206
+ const juicio = decidir({ entrada: exec?.arguments ?? {}, raices, cwd });
207
+ if (juicio === null) return next();
208
+ return { kind: 'deny', reason: motivoFuera(juicio.fuera) };
209
+ });
210
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * modelos — el catálogo de proveedores y modelos de la casa.
3
+ *
4
+ * De dónde sale, y por qué así: el core monta el adaptador `llm-pi-ai` DORMIDO
5
+ * (cero rutas) hasta que `settings.yaml` trae una sección `llm-pi-ai:`
6
+ * (`dsh-base/cordis.patch.yml:100-108`). O sea: el catálogo de proveedores y
7
+ * modelos del usuario ES ese documento. Leerlo aquí no duplica la lógica del
8
+ * core — es leer la misma configuración que el core lee, igual que hace la web.
9
+ *
10
+ * Lo que este módulo NO hace: llamar a la red, adivinar modelos, ni inventarse
11
+ * precios. Si un dato no está, se devuelve `null` y se dice.
12
+ */
13
+ import { ajustesMcp, leerAjustes } from './casa.js';
14
+ import { describeEnLaCasa, faltaLaClave } from './claves.js';
15
+
16
+ /** El id de la ruta nativa de DeepSeek en el core (medido en `dsh-sdk-jsonrpc-server/lib/index.js:118`). */
17
+ export const PROVEEDOR_NATIVO = 'deepseek-official';
18
+ /** Lo que la ruta nativa resuelve por defecto si el usuario no dice otra cosa. */
19
+ const CLAVE_NATIVA_POR_DEFECTO = 'DEEPSEEK_API_KEY';
20
+
21
+ /** Un objeto-mapa, o {}. */
22
+ function mapa(valor) {
23
+ return valor !== null && typeof valor === 'object' && !Array.isArray(valor) ? valor : {};
24
+ }
25
+
26
+ /** Número finito, o null. */
27
+ function numero(valor) {
28
+ return typeof valor === 'number' && Number.isFinite(valor) ? valor : null;
29
+ }
30
+
31
+ /** Texto no vacío, o null. */
32
+ function texto(valor) {
33
+ return typeof valor === 'string' && valor.trim() !== '' ? valor : null;
34
+ }
35
+
36
+ /**
37
+ * El catálogo completo de la casa. Las claves NO se miran en el entorno: se le
38
+ * pregunta al almacén de la casa (Ajustes › Models) por la vía del motor, que
39
+ * contesta configurada sí/no y nunca un valor.
40
+ * @param {string} casa - la casa de RATACODE.
41
+ * @returns {Promise<{proveedores: object[], modelos: object[], porDefecto: {provider: string|null, model: string|null}, avisos: string[]}>}
42
+ */
43
+ export async function catalogo(casa) {
44
+ const { documento, error } = leerAjustes(casa);
45
+ const ajustes = ajustesMcp(casa);
46
+ const avisos = [];
47
+ if (error !== null) avisos.push(error);
48
+ avisos.push(...ajustes.avisos);
49
+
50
+ // Una sola pregunta por variable, aunque la nombren varias rutas.
51
+ const preguntas = new Map();
52
+ const describe = async (nombre) => {
53
+ if (nombre === null) return null;
54
+ if (!preguntas.has(nombre)) preguntas.set(nombre, await describeEnLaCasa(casa, nombre));
55
+ return preguntas.get(nombre);
56
+ };
57
+
58
+ const proveedores = [];
59
+ const modelos = [];
60
+
61
+ // ── las rutas que declara el usuario (llm-pi-ai.providers) ────────────────
62
+ const piAi = mapa(documento['llm-pi-ai']);
63
+ const declarados = mapa(piAi.providers);
64
+ for (const [id, perfilBruto] of Object.entries(declarados)) {
65
+ const perfil = mapa(perfilBruto);
66
+ const apiKeyEnv = texto(perfil.apiKeyEnv);
67
+ const nombre = texto(perfil.displayName) ?? id;
68
+ const cred = await describe(apiKeyEnv);
69
+ const tiene = cred !== null && cred.configurada === true;
70
+ proveedores.push({
71
+ id,
72
+ nombre,
73
+ api: texto(perfil.api),
74
+ base_url: texto(perfil.baseURL),
75
+ credencial: apiKeyEnv,
76
+ tiene_clave: tiene,
77
+ falta: apiKeyEnv === null || cred === null || cred.motivo !== null || tiene ? null : faltaLaClave(nombre),
78
+ declarado_por_el_usuario: true,
79
+ });
80
+ const lista = Array.isArray(perfil.models) ? perfil.models : [];
81
+ for (const modeloBruto of lista) {
82
+ const modelo = mapa(modeloBruto);
83
+ const modelId = texto(modelo.id);
84
+ if (modelId === null) {
85
+ avisos.push('el proveedor «' + id + '» tiene un modelo sin id; lo salto');
86
+ continue;
87
+ }
88
+ modelos.push(fichaModelo({ casa, ajustes, provider: id, modelo, modelId, tieneClave: tiene }));
89
+ }
90
+ }
91
+
92
+ // ── la ruta nativa de DeepSeek (siempre montada en el core) ───────────────
93
+ const nativo = mapa(documento['llm-deepseek']);
94
+ const claveNativa = texto(nativo.apiKeyEnv) ?? CLAVE_NATIVA_POR_DEFECTO;
95
+ const credNativa = await describe(claveNativa);
96
+ const estaNativa = credNativa !== null && credNativa.configurada === true;
97
+ const nombreNativo = texto(nativo.displayName) ?? 'DeepSeek (nativo)';
98
+ proveedores.push({
99
+ id: PROVEEDOR_NATIVO,
100
+ nombre: nombreNativo,
101
+ api: texto(nativo.api),
102
+ base_url: texto(nativo.baseURL),
103
+ credencial: claveNativa,
104
+ tiene_clave: estaNativa,
105
+ falta: estaNativa || credNativa === null || credNativa.motivo !== null ? null : faltaLaClave(nombreNativo),
106
+ declarado_por_el_usuario: Object.keys(nativo).length > 0,
107
+ });
108
+ const modelosNativos = Array.isArray(nativo.models) ? nativo.models : [];
109
+ for (const modeloBruto of modelosNativos) {
110
+ const modelo = mapa(modeloBruto);
111
+ const modelId = texto(modelo.id);
112
+ if (modelId === null) continue;
113
+ modelos.push(fichaModelo({ casa, ajustes, provider: PROVEEDOR_NATIVO, modelo, modelId, tieneClave: estaNativa }));
114
+ }
115
+
116
+ // ── el modelo por defecto de la casa ─────────────────────────────────────
117
+ const porDefecto = modeloPorDefecto(casa);
118
+ if (porDefecto.provider !== null && porDefecto.model !== null) {
119
+ for (const ficha of modelos) {
120
+ ficha.es_por_defecto = ficha.provider === porDefecto.provider && ficha.model_id === porDefecto.model;
121
+ }
122
+ } else {
123
+ avisos.push('la casa no tiene `agent-default-model`: habrá que decir proveedor y modelo en cada tarea');
124
+ }
125
+
126
+ return { proveedores, modelos, porDefecto, avisos };
127
+ }
128
+
129
+ /**
130
+ * El modelo por defecto de la casa (`agent-default-model`). Sólo lee los
131
+ * ajustes: aquí no hay ninguna credencial de por medio.
132
+ * @param {string} casa - la casa de RATACODE.
133
+ * @returns {{provider: string|null, model: string|null}}
134
+ */
135
+ export function modeloPorDefecto(casa) {
136
+ const { documento } = leerAjustes(casa);
137
+ const bruto = mapa(documento['agent-default-model']);
138
+ return { provider: texto(bruto.provider), model: texto(bruto.model) };
139
+ }
140
+
141
+ /** Una ficha de modelo, con lo que se sabe y con null en lo que no. */
142
+ function fichaModelo({ casa, ajustes, provider, modelo, modelId, tieneClave }) {
143
+ const capacidades = Array.isArray(modelo.input) ? modelo.input.filter((m) => typeof m === 'string') : null;
144
+ return {
145
+ name: texto(modelo.name) ?? modelId,
146
+ provider,
147
+ model_id: modelId,
148
+ contexto: numero(modelo.contextWindow),
149
+ max_tokens: numero(modelo.maxTokens),
150
+ capacidades,
151
+ coste: precioDe(ajustes.precios, provider, modelId),
152
+ // `disponible` = la clave está guardada en la casa (Ajustes › Models); si
153
+ // no, el motor no tendrá con qué y la tarea no llega a salir.
154
+ estado: tieneClave ? 'disponible' : 'sin_clave',
155
+ es_por_defecto: false,
156
+ descripcion: texto(modelo.description),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * El precio declarado a mano en `mcp.precios`, si el humano lo puso.
162
+ * El core no trae precios de texto (medido: `dsh-llm` sólo tiene precios de
163
+ * imagen), así que o está aquí o se devuelve null. No se inventa nada.
164
+ * @param {object} precios - la sección `mcp.precios`.
165
+ * @param {string} provider - ruta del proveedor.
166
+ * @param {string} modelId - id del modelo.
167
+ * @returns {object|null} el precio tal cual lo declaró el humano, o null.
168
+ */
169
+ function precioDe(precios, provider, modelId) {
170
+ const porProveedor = precios[provider];
171
+ if (porProveedor === null || typeof porProveedor !== 'object') return null;
172
+ const entrada = porProveedor[modelId];
173
+ if (entrada === null || typeof entrada !== 'object') return null;
174
+ return { ...entrada, moneda: typeof entrada.moneda === 'string' ? entrada.moneda : 'EUR', origen: 'mcp.precios' };
175
+ }
176
+
177
+ /**
178
+ * La credencial que necesita una ruta, y si la casa la tiene guardada. Sirve
179
+ * para PARAR ANTES de arrancar nada cuando no hay clave por ningún lado, en vez
180
+ * de dejar que el motor falle nueve segundos después con un error más oscuro.
181
+ * La respuesta la da el almacén de la casa (Ajustes › Models) por la vía del
182
+ * motor: aquí no se lee ningún fichero de claves ni se mira el entorno.
183
+ * @param {string} casa - la casa de RATACODE.
184
+ * @param {string} provider - la ruta del proveedor.
185
+ * @returns {Promise<{nombre: string, nombreVisible: string, configurada: boolean, motivo: string|null}|null>}
186
+ * null si la ruta no declara credencial (Ollama y LM Studio: no piden clave).
187
+ */
188
+ export async function credencialDeProveedor(casa, provider) {
189
+ const { documento } = leerAjustes(casa);
190
+ if (provider === PROVEEDOR_NATIVO) {
191
+ const nativo = mapa(documento['llm-deepseek']);
192
+ const nombre = texto(nativo.apiKeyEnv) ?? CLAVE_NATIVA_POR_DEFECTO;
193
+ const dicho = await describeEnLaCasa(casa, nombre);
194
+ return { nombre, nombreVisible: texto(nativo.displayName) ?? 'DeepSeek', ...dicho };
195
+ }
196
+ const perfil = mapa(mapa(documento['llm-pi-ai']).providers)[provider];
197
+ if (perfil === null || typeof perfil !== 'object') return null;
198
+ const nombre = texto(mapa(perfil).apiKeyEnv);
199
+ if (nombre === null) return null;
200
+ const dicho = await describeEnLaCasa(casa, nombre);
201
+ return { nombre, nombreVisible: texto(mapa(perfil).displayName) ?? provider, ...dicho };
202
+ }
203
+
204
+ /**
205
+ * La ruta que se usará si el cliente no dice nada, y si se puede usar.
206
+ * Sin routing oculto: esto sólo lee el modelo por defecto de la casa.
207
+ * @param {string} casa - la casa de RATACODE.
208
+ * @param {string|undefined} provider - lo que pidió el cliente.
209
+ * @param {string|undefined} model - lo que pidió el cliente.
210
+ * @returns {{provider: string, model: string, origen: 'peticion'|'por_defecto'}}
211
+ */
212
+ export function resolverRuta(casa, provider, model) {
213
+ const pedidoProvider = texto(provider);
214
+ const pedidoModel = texto(model);
215
+ if (pedidoProvider !== null && pedidoModel !== null) {
216
+ return { provider: pedidoProvider, model: pedidoModel, origen: 'peticion' };
217
+ }
218
+ const porDefecto = modeloPorDefecto(casa);
219
+ if (pedidoProvider !== null && pedidoModel === null) {
220
+ if (porDefecto.provider === pedidoProvider && porDefecto.model !== null) {
221
+ return { provider: pedidoProvider, model: porDefecto.model, origen: 'por_defecto' };
222
+ }
223
+ throw new Error('me diste el proveedor «' + pedidoProvider + '» pero no el modelo, y el modelo por defecto de la casa es de otra ruta. Dime el modelo (mira list_models).');
224
+ }
225
+ if (pedidoProvider === null && pedidoModel !== null) {
226
+ if (porDefecto.model === pedidoModel && porDefecto.provider !== null) {
227
+ return { provider: porDefecto.provider, model: pedidoModel, origen: 'por_defecto' };
228
+ }
229
+ throw new Error('me diste el modelo «' + pedidoModel + '» pero no el proveedor, y el modelo por defecto de la casa es otro. Dime el proveedor (mira list_models).');
230
+ }
231
+ if (porDefecto.provider === null || porDefecto.model === null) {
232
+ throw new Error('no me has dicho proveedor ni modelo, y la casa no tiene `agent-default-model`. Dime los dos (mira list_models).');
233
+ }
234
+ return { provider: porDefecto.provider, model: porDefecto.model, origen: 'por_defecto' };
235
+ }