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.
- package/CREDITS.md +80 -0
- package/LICENSE +21 -0
- package/README.md +289 -0
- package/apreton/README.md +54 -0
- package/apreton/handshake.md +33 -0
- package/apreton/headless.md +58 -0
- package/apreton/mcp.md +221 -0
- package/apreton/navegador.md +58 -0
- package/bin/instalacion.js +148 -0
- package/bin/ratacode.js +1251 -0
- package/fabrica/settings.yaml +247 -0
- package/mcp/README.md +253 -0
- package/mcp/bin/ratacode-mcp.js +215 -0
- package/mcp/lib/actividad.js +49 -0
- package/mcp/lib/casa.js +126 -0
- package/mcp/lib/claves.js +148 -0
- package/mcp/lib/espacios.js +117 -0
- package/mcp/lib/http.js +184 -0
- package/mcp/lib/lectura.js +210 -0
- package/mcp/lib/modelos.js +235 -0
- package/mcp/lib/nucleo.js +332 -0
- package/mcp/lib/registro.js +21 -0
- package/mcp/lib/seguridad.js +283 -0
- package/mcp/lib/servidor.js +397 -0
- package/mcp/lib/tareas.js +406 -0
- package/mcp/package.json +22 -0
- package/mcp/tunel.mjs +209 -0
- package/modos/arquitecto/agent.cordis.yml +120 -0
- package/modos/arquitecto/preset.yml +3 -0
- package/modos/capataz/agent.cordis.yml +120 -0
- package/modos/capataz/preset.yml +3 -0
- package/modos/faro/agent.cordis.yml +120 -0
- package/modos/faro/preset.yml +3 -0
- package/modos/gepeto/agent.cordis.yml +191 -0
- package/modos/gepeto/preset.yml +3 -0
- package/modos/hero/agent.cordis.yml +198 -0
- package/modos/hero/preset.yml +3 -0
- package/modos/modo-rata/agent.cordis.yml +198 -0
- package/modos/modo-rata/preset.yml +3 -0
- package/modos/nex/agent.cordis.yml +213 -0
- package/modos/nex/preset.yml +3 -0
- package/modos/nex/skills/cordis-plugin-development/SKILL.md +420 -0
- package/modos/nex/skills/editing-cordis-compositions/SKILL.md +165 -0
- package/modos/pix/agent.cordis.yml +207 -0
- package/modos/pix/preset.yml +3 -0
- package/modos/tirita/agent.cordis.yml +130 -0
- package/modos/tirita/preset.yml +3 -0
- package/package.json +49 -0
- package/piel/activos/ratacode-emblema.svg +14 -0
- package/piel/activos/ratacode-es.js +1331 -0
- package/piel/activos/ratacode-identidad.css +50 -0
- package/piel/activos/ratacode-piel.css +217 -0
- package/piel/activos/ratacode-piel.js +487 -0
- package/piel/activos/ratacode-vida.js +424 -0
- package/piel/cordis.patch.yml +6 -0
- package/piel/lib/cliente.js +1020 -0
- package/piel/lib/index.js +1230 -0
- package/piel/package.json +36 -0
|
@@ -0,0 +1,1230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ratacode-piel — LA PIEL DE RATACODE SOBRE DSH WEB.
|
|
3
|
+
*
|
|
4
|
+
* ── CÓMO ENGANCHA (medido, con el código delante) ───────────────────────────
|
|
5
|
+
* `@deepseek-ai/dsh-host-webserver` expone el servicio `webServer`
|
|
6
|
+
* (`lib/index.js:138,157`) con `tapIndex(transform)` (`lib/index.js:219`), que
|
|
7
|
+
* apunta una transformación pura `html -> html`. El dueño del asiento de
|
|
8
|
+
* respaldo, `@deepseek-ai/dsh-host-frontend-static`, sirve el `index.html` del
|
|
9
|
+
* frontend pasándolo por `ctx.webServer.renderIndex(...)`
|
|
10
|
+
* (`dsh-host-frontend-static/lib/index.js:85`), y `renderIndex` aplica las filas
|
|
11
|
+
* estructuradas y DESPUÉS los taps (`dsh-host-webserver/lib/index.js:360-362`).
|
|
12
|
+
*
|
|
13
|
+
* O sea: no se toca ni un fichero del motor ni del frontend. Se engancha una
|
|
14
|
+
* función al index que se sirve, y ahí dentro van el CSS y los guiones.
|
|
15
|
+
*
|
|
16
|
+
* ── LAS CLAVES: DONDE LAS PONE DSH (R12) ────────────────────────────────────
|
|
17
|
+
* Aquí hubo una ventana propia con las 3 claves. Ya no: las claves se ponen
|
|
18
|
+
* donde DSH las pone SIEMPRE, en **Ajustes > Models** (cada proveedor con su
|
|
19
|
+
* campo «API key»), y el valor lo escribe el motor en `<casa>\.credentials.yaml`
|
|
20
|
+
* con `ctx.remote.credentials.set` → `dsh-api-settings-controller` →
|
|
21
|
+
* `@deepseek-ai/dsh-credentials-local`. Esta piel ya no lee, no pide ni guarda
|
|
22
|
+
* ninguna clave: no queda ni una ruta de claves.
|
|
23
|
+
*
|
|
24
|
+
* ── LO QUE SÍ SIRVE ESTA PIEL (R12) ────────────────────────────────────────
|
|
25
|
+
* · el CSS y los guiones de la cara (identidad, piel, vida, y el ESPAÑOL de
|
|
26
|
+
* RATACODE como paquete de idioma oficial; ver `activos/ratacode-es.js`), y
|
|
27
|
+
* · el TEXTO DE LA CONEXIÓN de ESTA casa, que vive en Ajustes > Conexiones:
|
|
28
|
+
* GET /ratacode/handshake → el texto corto, con la URL de esta casa.
|
|
29
|
+
* POST /ratacode/handshake → además lo deja en `<casa>\handshake.md`.
|
|
30
|
+
* GET /ratacode/conexion → (R23) el estado de la conexión de los chats
|
|
31
|
+
* web: si está encendida (el túnel abierto),
|
|
32
|
+
* la dirección que se pega y si la encendió
|
|
33
|
+
* RATACODE (entonces el botón puede apagarla).
|
|
34
|
+
* POST /ratacode/conexion/encender y /apagar → los botones del usuario.
|
|
35
|
+
* GET /ratacode/mcp → el MCP para chats web: estado del HTTP y
|
|
36
|
+
* del túnel, la carpeta autorizada (que es la
|
|
37
|
+
* línea que enseña la tarjeta), los dos
|
|
38
|
+
* comandos y el texto para pegar en el chat.
|
|
39
|
+
* GET /ratacode/clave → (R17) si al modelo por DEFECTO de la casa le
|
|
40
|
+
* falta la clave, con el `describe` del
|
|
41
|
+
* servicio de credenciales del motor (nunca
|
|
42
|
+
* se lee un fichero de claves) y el nombre
|
|
43
|
+
* visible de cada proveedor. Desde R18 dice
|
|
44
|
+
* también si ese modelo es LOCAL y, si lo es,
|
|
45
|
+
* si su runtime está encendido.
|
|
46
|
+
* GET /ratacode/runtimes → (R18) los dos runtimes locales (Ollama y LM
|
|
47
|
+
* Studio): si están encendidos (sondeo corto
|
|
48
|
+
* de `/v1/models`; en Ollama también
|
|
49
|
+
* `/api/tags`), sus modelos y cuáles valen
|
|
50
|
+
* como agente, su `baseURL`, cómo encenderlos
|
|
51
|
+
* en una línea y la tabla del README (qué
|
|
52
|
+
* modelo según tu tarjeta).
|
|
53
|
+
* Las cuatro van con el cerco del motor (`connection.requestRejection`:
|
|
54
|
+
* Host/Origin + cookie de sesión de navegador, `dsh-client-connection`
|
|
55
|
+
* `lib/index.js:552-556`), igual que los canales del propio DSH.
|
|
56
|
+
*
|
|
57
|
+
* La SECCIÓN «Conexiones» del menú de Ajustes NO la pinta este fichero: la
|
|
58
|
+
* registra el plugin de cliente `lib/cliente.js` por la vía OFICIAL de DSH
|
|
59
|
+
* (`ctx.slots.register({name:'settings.section', …}, Component)`, la misma que
|
|
60
|
+
* usa `dsh-client-ui-agent-preset/lib/client.js:1519`). Ver `package.json`
|
|
61
|
+
* (`exports["./client"]` + `dsh.client.platform = "web"`).
|
|
62
|
+
*
|
|
63
|
+
* Si algún día el motor cambia de nombre el servicio o el tap, esta piel no
|
|
64
|
+
* engancha: se calla y lo dice por consola, en vez de romper el arranque.
|
|
65
|
+
*/
|
|
66
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
67
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
68
|
+
import { homedir } from 'node:os';
|
|
69
|
+
import { dirname, join } from 'node:path';
|
|
70
|
+
import { fileURLToPath } from 'node:url';
|
|
71
|
+
|
|
72
|
+
/** Nombre estable del plugin de cordis. */
|
|
73
|
+
export const name = 'ratacode-piel';
|
|
74
|
+
/** El tap vive en el servidor web: hasta que no está, no hay nada que enganchar. */
|
|
75
|
+
export const inject = ['webServer'];
|
|
76
|
+
|
|
77
|
+
const AQUI = dirname(fileURLToPath(import.meta.url));
|
|
78
|
+
const ACTIVOS = join(AQUI, '..', 'activos');
|
|
79
|
+
|
|
80
|
+
function leer(nombre) {
|
|
81
|
+
return readFileSync(join(ACTIVOS, nombre), 'utf8');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** El emblema de la rata, en data-URI, para la variable `--mr-emblema`. */
|
|
85
|
+
function emblemaCss() {
|
|
86
|
+
const svg = leer('ratacode-emblema.svg');
|
|
87
|
+
return ':root{--mr-emblema:url("data:image/svg+xml,' + encodeURIComponent(svg) + '")}';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Todo el CSS de la piel, en el orden en que se aplica (identidad, emblema, piel). */
|
|
91
|
+
function cssDeLaPiel() {
|
|
92
|
+
return [leer('ratacode-identidad.css'), emblemaCss(), leer('ratacode-piel.css')].join('\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Un guion dentro de un `<script>`: sólo hay que romper el `</script` literal. */
|
|
96
|
+
function dentroDeScript(js) {
|
|
97
|
+
return js.replace(/<\/script/gi, '<\\/script');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const MARCA = 'id="ratacode-piel"';
|
|
101
|
+
/** El título que trae el frontend de DSH y que NUNCA debe verse. */
|
|
102
|
+
const TITULO_AJENO = /<title[^>]*>[\s\S]*?<\/title>/i;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* La transformación: mete el `<style>` en la cabeza y los guiones al final
|
|
106
|
+
* del cuerpo, y cambia el `<title>` por «RATACODE». Idempotente: si la marca
|
|
107
|
+
* ya está, devuelve el html tal cual.
|
|
108
|
+
*
|
|
109
|
+
* El título completo («<sesión> — RATACODE») lo pone el guion de cliente, que
|
|
110
|
+
* intercepta `document.title`: el frontend escribe
|
|
111
|
+
* `${sesión} — DeepSeek Harness` (`dsh-client-ui-layout/lib/client.js:62`), así
|
|
112
|
+
* que se cambia en el único sitio por el que pasan todos los cambios.
|
|
113
|
+
* @param html - el index.html tal y como lo sirve el motor.
|
|
114
|
+
* @returns el index con la cara de RATACODE dentro.
|
|
115
|
+
*/
|
|
116
|
+
export function vestir(html) {
|
|
117
|
+
if (html.includes(MARCA)) return html;
|
|
118
|
+
const estilo = '<style ' + MARCA + '>' + cssDeLaPiel() + '</style>';
|
|
119
|
+
// El español va PRIMERO: deja los diccionarios en `window.__RATACODE_ES` antes
|
|
120
|
+
// de que arranque el módulo de la piel, que es quien los registra por la vía
|
|
121
|
+
// oficial de idiomas de DSH (`ctx.locale`). Así no se ve ni un parpadeo en
|
|
122
|
+
// inglés al abrir el panel.
|
|
123
|
+
const guiones = '<script>' + dentroDeScript(leer('ratacode-es.js')) + '</script>'
|
|
124
|
+
+ '<script>' + dentroDeScript(leer('ratacode-piel.js')) + '</script>'
|
|
125
|
+
+ '<script>' + dentroDeScript(leer('ratacode-vida.js')) + '</script>';
|
|
126
|
+
let salida = html;
|
|
127
|
+
if (TITULO_AJENO.test(salida)) salida = salida.replace(TITULO_AJENO, '<title>RATACODE</title>');
|
|
128
|
+
const cabeza = /<head(?:\s[^>]*)?>/i.exec(salida);
|
|
129
|
+
salida = cabeza === null ? estilo + salida : salida.slice(0, cabeza.index + cabeza[0].length) + estilo + salida.slice(cabeza.index + cabeza[0].length);
|
|
130
|
+
const cuerpo = /<\/body>/i.exec(salida);
|
|
131
|
+
salida = cuerpo === null ? salida + guiones : salida.slice(0, cuerpo.index) + guiones + salida.slice(cuerpo.index);
|
|
132
|
+
return salida;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── el texto de la conexión (Ajustes > Conexiones) ─────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Dónde está `apreton\handshake.md`. Se busca en dos sitios porque el plugin
|
|
139
|
+
* viaja COPIADO dentro del perfil de la casa: ahí `bin\ratacode.js` deja una
|
|
140
|
+
* copia del texto junto al plugin; en el repositorio está dos carpetas más
|
|
141
|
+
* arriba. Si no aparece en ninguno, la ruta lo dice en vez de inventarse nada.
|
|
142
|
+
*/
|
|
143
|
+
const HANDSHAKE_CANDIDATOS = [
|
|
144
|
+
join(AQUI, '..', 'apreton', 'handshake.md'),
|
|
145
|
+
join(AQUI, '..', '..', 'apreton', 'handshake.md'),
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* La instalación de RATACODE (la carpeta del paquete, con `mcp\tunel.mjs`
|
|
150
|
+
* dentro). `bin\ratacode.js` deja la ruta en `instalacion.txt`, junto a este
|
|
151
|
+
* plugin; así los comandos del MCP llevan la ruta de verdad y no un `<ruta>`
|
|
152
|
+
* que el usuario tenga que adivinar. Si no está, se dice `cd <ruta de
|
|
153
|
+
* RATACODE>` y que lo mire en el README.
|
|
154
|
+
*/
|
|
155
|
+
function instalacionDeEstaCasa() {
|
|
156
|
+
try {
|
|
157
|
+
const leida = readFileSync(join(AQUI, '..', 'instalacion.txt'), 'utf8').trim();
|
|
158
|
+
if (leida !== '') return leida;
|
|
159
|
+
} catch { /* instalación antigua: no hay pista */ }
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** La casa de ESTE motor, la misma que usa `bin/ratacode.js`. */
|
|
164
|
+
function casaDeEstaCasa() {
|
|
165
|
+
return process.env.DSH_HOME ?? process.env.RATACODE_HOME ?? join(homedir(), '.ratacode');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* La URL real de ESTA casa: la que `ratacode` dejó en `<casa>\url.txt` (con su
|
|
170
|
+
* token). Si no está, se compone con el `Host` del pedido — que el cerco ya ha
|
|
171
|
+
* dado por bueno — y sin token.
|
|
172
|
+
*/
|
|
173
|
+
function urlDeEstaCasa(req) {
|
|
174
|
+
try {
|
|
175
|
+
const leida = readFileSync(join(casaDeEstaCasa(), 'url.txt'), 'utf8').trim();
|
|
176
|
+
if (leida !== '') return leida;
|
|
177
|
+
} catch { /* sin url.txt: se compone */ }
|
|
178
|
+
const host = req.headers.host;
|
|
179
|
+
return typeof host === 'string' && host !== '' ? 'http://' + host + '/' : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* El texto de la conexión para ESTA casa: el `handshake.md` corto, con la casa y
|
|
184
|
+
* la URL ya puestas arriba, para que quien lo reciba no tenga que buscar nada.
|
|
185
|
+
* No lleva ninguna clave: el token de la URL es el de la sesión del navegador,
|
|
186
|
+
* que sólo se sirve por esta ruta protegida con el mismo cerco que el resto.
|
|
187
|
+
* @param req - el pedido, para poder componer la URL si no hay `url.txt`.
|
|
188
|
+
* @returns {{texto: string, url: string|null}|null} null si no hay handshake.md.
|
|
189
|
+
*/
|
|
190
|
+
function textoDelHandshake(req) {
|
|
191
|
+
let crudo = null;
|
|
192
|
+
for (const candidato of HANDSHAKE_CANDIDATOS) {
|
|
193
|
+
try {
|
|
194
|
+
crudo = readFileSync(candidato, 'utf8');
|
|
195
|
+
break;
|
|
196
|
+
} catch { /* se prueba el siguiente */ }
|
|
197
|
+
}
|
|
198
|
+
if (crudo === null) return null;
|
|
199
|
+
const casa = casaDeEstaCasa();
|
|
200
|
+
const url = urlDeEstaCasa(req);
|
|
201
|
+
const cabecera = [
|
|
202
|
+
'> **Esta casa, ya puesta.** Carpeta: `' + casa + '` · Panel: '
|
|
203
|
+
+ (url === null ? '(mira `' + join(casa, 'url.txt') + '`)' : url),
|
|
204
|
+
'> Pégalo en tu chat tal cual: con esto, el chat sabe trabajar con RATACODE.',
|
|
205
|
+
'',
|
|
206
|
+
'',
|
|
207
|
+
].join('\n');
|
|
208
|
+
return { texto: cabecera + crudo.split('<casa>').join(casa), url, casa };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── el MCP para chats web ──────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/** Lee un fichero de la casa sin reventar si no está; '' si no se puede. */
|
|
214
|
+
function leerDeLaCasa(...trozos) {
|
|
215
|
+
try { return readFileSync(join(casaDeEstaCasa(), ...trozos), 'utf8').trim(); } catch { return ''; }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** El puerto del MCP por HTTP, tal y como quedó en su URL local. */
|
|
219
|
+
function puertoDeLaUrlMCP(url) {
|
|
220
|
+
const m = /^https?:\/\/[^/]+:(\d+)\//.exec(url);
|
|
221
|
+
return m === null ? null : Number(m[1]);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* El texto que se pega en un chat web (ChatGPT, Claude…) para que sepa qué es
|
|
226
|
+
* RATACODE, qué herramientas tiene y cómo se espera a que acabe una tarea.
|
|
227
|
+
* @param url - la URL del conector MCP que va a usar ese chat.
|
|
228
|
+
* @returns el texto, en español, listo para pegar.
|
|
229
|
+
*/
|
|
230
|
+
function textoParaPegar(url) {
|
|
231
|
+
return [
|
|
232
|
+
'Trabajo con RATACODE por MCP. RATACODE es mi terminal de trabajo: un motor',
|
|
233
|
+
'que corre en MI ordenador, con los modelos que yo',
|
|
234
|
+
'tengo configurados (baratos) y herramientas de verdad: leer y escribir',
|
|
235
|
+
'ficheros, shell, búsqueda, subagentes. Tú planificas y revisas; el trabajo',
|
|
236
|
+
'pesado se descarga en RATACODE.',
|
|
237
|
+
'',
|
|
238
|
+
'URL del conector MCP: ' + (url ?? '(el MCP todavía no está arrancado)'),
|
|
239
|
+
'',
|
|
240
|
+
'Herramientas:',
|
|
241
|
+
'- list_models: lista los modelos disponibles (proveedor, id, contexto).',
|
|
242
|
+
' Llámala primero para saber con qué cuenta RATACODE.',
|
|
243
|
+
'- run_task: lanza un encargo. Con esperar_segundos (1-600) la llamada ESPERA',
|
|
244
|
+
' y devuelve el resultado completo en esa misma respuesta (usa 300 para un',
|
|
245
|
+
' encargo normal). Sin esperar_segundos devuelve un task_id al momento y el',
|
|
246
|
+
' trabajo sigue en segundo plano.',
|
|
247
|
+
'- get_task_status: estado de una tarea (queued, running, completed, failed,',
|
|
248
|
+
' cancelled).',
|
|
249
|
+
'- get_task_result: la respuesta, el modelo, los tokens, el coste y la',
|
|
250
|
+
' duración.',
|
|
251
|
+
'',
|
|
252
|
+
'Si yo he elegido modelo, no lo cambies. Si no, usa el de por defecto de la',
|
|
253
|
+
'casa y dime cuál es. Con un encargo largo, usa esperar_segundos y no cierres',
|
|
254
|
+
'tu turno hasta que get_task_status diga completed o failed.',
|
|
255
|
+
'',
|
|
256
|
+
'AVISO: cada tarea trabaja ENCERRADA en las carpetas autorizadas de mi casa',
|
|
257
|
+
'(mcp.workspaces): lee y escribe solo ahí, sin terminal, sin red y sin',
|
|
258
|
+
'subagentes. Fuera de ahí la herramienta se para y lo dice. No la compartas',
|
|
259
|
+
'y no la dejes abierta más de lo necesario.',
|
|
260
|
+
].join('\n');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Las carpetas que el MCP tiene autorizadas en ESTA casa (`mcp.workspaces` de
|
|
265
|
+
* `settings.yaml`). Se lee el texto a mano, y a propósito: esta piel viaja
|
|
266
|
+
* copiada dentro del perfil del motor y no lleva dependencias (no tiene
|
|
267
|
+
* js-yaml), y lo único que hace falta es la lista que va debajo de `mcp:`.
|
|
268
|
+
* @returns {string[]} las carpetas, en el orden en que están declaradas.
|
|
269
|
+
*/
|
|
270
|
+
function carpetasAutorizadas() {
|
|
271
|
+
const casa = casaDeEstaCasa();
|
|
272
|
+
let texto;
|
|
273
|
+
try { texto = readFileSync(join(casa, 'settings.yaml'), 'utf8'); } catch { return []; }
|
|
274
|
+
const salida = [];
|
|
275
|
+
let enMcp = false;
|
|
276
|
+
let enLista = false;
|
|
277
|
+
for (const linea of texto.split(/\r?\n/)) {
|
|
278
|
+
if (/^\S/.test(linea)) { // una clave de primer nivel: empieza (o acaba) `mcp:`
|
|
279
|
+
enMcp = /^mcp:/.test(linea);
|
|
280
|
+
enLista = false;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (!enMcp) continue;
|
|
284
|
+
const clave = /^\s+([A-Za-z_][\w-]*):/.exec(linea);
|
|
285
|
+
if (clave !== null) {
|
|
286
|
+
enLista = clave[1] === 'workspaces';
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (!enLista) continue;
|
|
290
|
+
const punto = /^\s*-\s+(.+?)\s*$/.exec(linea);
|
|
291
|
+
if (punto !== null) salida.push(punto[1].replace(/^['"]|['"]$/g, ''));
|
|
292
|
+
}
|
|
293
|
+
return salida;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* El estado del MCP de esta casa y lo que hay que pegar/ejecutar.
|
|
298
|
+
* - El MCP por HTTP está ARRANCADO si existe `<casa>\mcp\http-url.txt` (lo
|
|
299
|
+
* escribe el propio servidor al escuchar, `mcp/bin/ratacode-mcp.js:179-183`).
|
|
300
|
+
* - El túnel está ABIERTO si existe `<casa>\mcp\tunel-url.txt` (lo escribe
|
|
301
|
+
* `mcp/tunel.mjs` cuando cloudflared da la URL pública, y lo borra al cerrar).
|
|
302
|
+
* @param req - el pedido, para el caso de que falte el `url.txt` del panel.
|
|
303
|
+
* @returns el estado, los dos comandos y el texto para el chat.
|
|
304
|
+
*/
|
|
305
|
+
function estadoDelMcp(req) {
|
|
306
|
+
const casa = casaDeEstaCasa();
|
|
307
|
+
const urlLocal = leerDeLaCasa('mcp', 'http-url.txt');
|
|
308
|
+
const urlTunel = leerDeLaCasa('mcp', 'tunel-url.txt');
|
|
309
|
+
const instalacion = instalacionDeEstaCasa();
|
|
310
|
+
const donde = instalacion === null ? '<ruta de RATACODE>' : instalacion;
|
|
311
|
+
const conecta = urlTunel !== '' ? urlTunel : (urlLocal !== '' ? urlLocal : null);
|
|
312
|
+
const comandos = [
|
|
313
|
+
'cd ' + donde + '; ratacode mcp --http',
|
|
314
|
+
'cd ' + donde + '; node mcp/tunel.mjs --home ' + casa,
|
|
315
|
+
].join('\n');
|
|
316
|
+
const carpetas = carpetasAutorizadas();
|
|
317
|
+
return {
|
|
318
|
+
ok: true,
|
|
319
|
+
casa,
|
|
320
|
+
instalacion,
|
|
321
|
+
http: {
|
|
322
|
+
abierto: urlLocal !== '',
|
|
323
|
+
url: urlLocal === '' ? null : urlLocal,
|
|
324
|
+
puerto: puertoDeLaUrlMCP(urlLocal),
|
|
325
|
+
},
|
|
326
|
+
tunel: {
|
|
327
|
+
abierto: urlTunel !== '',
|
|
328
|
+
url: urlTunel === '' ? null : urlTunel,
|
|
329
|
+
},
|
|
330
|
+
panel: urlDeEstaCasa(req),
|
|
331
|
+
// R25 · las carpetas donde ESTE chat puede leer y escribir, tal y como las
|
|
332
|
+
// declara la casa. Sin lista no hay conexión posible (el MCP no arranca por
|
|
333
|
+
// HTTP sin `mcp.workspaces`), así que aquí siempre hay al menos una.
|
|
334
|
+
carpetas,
|
|
335
|
+
carpeta: carpetas[0] ?? null,
|
|
336
|
+
comandos,
|
|
337
|
+
pegar: textoParaPegar(conecta),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── R17 · la clave que le falta al modelo por defecto ──────────────────────
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Un valor dentro de un objeto, por su camino de claves (`['providers','b-ai']`).
|
|
345
|
+
* El `settingsPath` que declara cada proveedor configurable es justo eso.
|
|
346
|
+
*/
|
|
347
|
+
function porCamino(objeto, camino) {
|
|
348
|
+
let actual = objeto;
|
|
349
|
+
for (const trozo of camino ?? []) {
|
|
350
|
+
if (actual === null || typeof actual !== 'object') return undefined;
|
|
351
|
+
actual = actual[trozo];
|
|
352
|
+
}
|
|
353
|
+
return actual;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* ¿Al modelo por DEFECTO de esta casa le falta la clave? Se responde con la vía
|
|
358
|
+
* OFICIAL del motor y sin leer un solo fichero de claves:
|
|
359
|
+
* · el proveedor y el modelo, por `ctx.agentDefaultModel.currentSelection()`;
|
|
360
|
+
* · la credencial que esa ruta nombra (`apiKeyEnv`), por el `settingsPath` que
|
|
361
|
+
* declara el directorio de proveedores (`ctx.llm.listConfigurableProviders`)
|
|
362
|
+
* y el valor resuelto de su namespace (`ctx.settings.get`);
|
|
363
|
+
* · si está puesta o no, por `ctx.credentials.describe` (`configured`).
|
|
364
|
+
* Una ruta que NO nombra credencial (Ollama y LM Studio de fábrica) no pide
|
|
365
|
+
* clave: esa es la regla del propio motor (`dsh-llm-pi-ai`: `namesCredential`).
|
|
366
|
+
* @param c - contexto de cordis, ya con `connection`.
|
|
367
|
+
* @returns el estado; `ok:false` cuando a este motor le falta alguna pieza.
|
|
368
|
+
*/
|
|
369
|
+
async function estadoDeLaClave(c) {
|
|
370
|
+
const llm = c.get('llm');
|
|
371
|
+
const settings = c.get('settings');
|
|
372
|
+
if (llm === undefined || settings === undefined) {
|
|
373
|
+
return { ok: false, falta: false, motivo: 'este motor no expone llm/settings' };
|
|
374
|
+
}
|
|
375
|
+
const entradas = llm.listConfigurableProviders();
|
|
376
|
+
const proveedores = {};
|
|
377
|
+
for (const entrada of entradas) proveedores[entrada.provider] = entrada.displayName;
|
|
378
|
+
const seleccion = c.get('agentDefaultModel')?.currentSelection?.() ?? null;
|
|
379
|
+
const proveedor = seleccion?.provider ?? null;
|
|
380
|
+
const entrada = entradas.find((e) => e.provider === proveedor);
|
|
381
|
+
const perfil = entrada === undefined ? undefined : porCamino(settings.get(entrada.settingsNs), entrada.settingsPath);
|
|
382
|
+
const variable = typeof perfil?.apiKeyEnv === 'string' && perfil.apiKeyEnv !== '' ? perfil.apiKeyEnv : null;
|
|
383
|
+
// Un nombre que no es un identificador de shell no puede ser una referencia:
|
|
384
|
+
// `describe` lo rechazaría, así que se trata como «esta ruta no pide clave».
|
|
385
|
+
const conForma = variable !== null && /^[A-Za-z_][A-Za-z0-9_]*$/.test(variable);
|
|
386
|
+
let tieneClave = null;
|
|
387
|
+
if (conForma) {
|
|
388
|
+
const credentials = c.get('credentials');
|
|
389
|
+
if (credentials === undefined) return { ok: false, falta: false, motivo: 'este motor no monta el servicio de credenciales' };
|
|
390
|
+
try { tieneClave = (await credentials.describe(variable)).configured === true; }
|
|
391
|
+
catch (e) { return { ok: false, falta: false, motivo: 'no pude preguntar por la credencial: ' + (e?.message ?? e) }; }
|
|
392
|
+
}
|
|
393
|
+
// R18 · si el modelo elegido es LOCAL, no le falta ninguna clave: lo que puede
|
|
394
|
+
// pasar es que su runtime esté APAGADO. Se sondea aquí (1,5 s de tope) y el
|
|
395
|
+
// aviso de la piel dice eso otro: «Ollama no está encendido: …».
|
|
396
|
+
const runtime = RUNTIMES_LOCALES.find((r) => r.id === proveedor);
|
|
397
|
+
let local = null;
|
|
398
|
+
if (runtime !== undefined) {
|
|
399
|
+
const sondeado = await sondearRuntime(c, runtime);
|
|
400
|
+
local = {
|
|
401
|
+
id: sondeado.id,
|
|
402
|
+
nombre: sondeado.nombre,
|
|
403
|
+
encendido: sondeado.encendido,
|
|
404
|
+
arranque: sondeado.arranque,
|
|
405
|
+
enlace: sondeado.enlace,
|
|
406
|
+
baseURL: sondeado.baseURL,
|
|
407
|
+
modelos: sondeado.modelos.map((m) => m.id),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
ok: true,
|
|
412
|
+
falta: conForma && tieneClave === false,
|
|
413
|
+
necesitaClave: conForma,
|
|
414
|
+
tieneClave,
|
|
415
|
+
proveedor,
|
|
416
|
+
nombre: (proveedor !== null ? proveedores[proveedor] : null) ?? proveedor,
|
|
417
|
+
variable: conForma ? variable : null,
|
|
418
|
+
modelo: seleccion?.model ?? null,
|
|
419
|
+
proveedores,
|
|
420
|
+
local,
|
|
421
|
+
encendido: local === null ? null : local.encendido,
|
|
422
|
+
arranque: local === null ? null : local.arranque,
|
|
423
|
+
enlace: local === null ? null : local.enlace,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── R18 · los DOS runtimes locales (Ajustes › Modelos locales) ─────────────
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Los dos runtimes locales que RATACODE declara de fábrica (`fabrica/settings.yaml`,
|
|
431
|
+
* proveedores `ollama` y `lmstudio`): su arranque en UNA línea pegable y su
|
|
432
|
+
* enlace de descarga. `baseURL` y `models` salen de los ajustes vivos de la casa
|
|
433
|
+
* (por eso, si el usuario cambia el puerto, aquí se ve el puerto de verdad); los
|
|
434
|
+
* valores de `defecto`/`puerto` son sólo el respaldo cuando no hay ajustes.
|
|
435
|
+
*/
|
|
436
|
+
const RUNTIMES_LOCALES = [
|
|
437
|
+
{
|
|
438
|
+
id: 'ollama',
|
|
439
|
+
nombre: 'Ollama',
|
|
440
|
+
defecto: 'http://127.0.0.1:11434/v1',
|
|
441
|
+
puerto: 11434,
|
|
442
|
+
arranque: 'ollama serve',
|
|
443
|
+
enlace: 'https://ollama.com/download',
|
|
444
|
+
descarga: 'Descargar Ollama',
|
|
445
|
+
pull: (modelo) => 'ollama pull ' + modelo,
|
|
446
|
+
/** Busca su programa en el PC: primero en el PATH, luego donde se instala. */
|
|
447
|
+
ejecutable: 'ollama',
|
|
448
|
+
rutas: ['%LOCALAPPDATA%/Programs/Ollama/ollama.exe'],
|
|
449
|
+
argsArranque: ['serve'],
|
|
450
|
+
argsParada: null,
|
|
451
|
+
/** Cómo se apaga sin tocar nada que no hayamos arrancado nosotros. */
|
|
452
|
+
apagadoAMano: 'Se apaga desde el icono de Ollama en la bandeja: botón derecho → Quit.',
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
id: 'lmstudio',
|
|
456
|
+
nombre: 'LM Studio',
|
|
457
|
+
defecto: 'http://127.0.0.1:1234/v1',
|
|
458
|
+
puerto: 1234,
|
|
459
|
+
arranque: 'lms server start',
|
|
460
|
+
enlace: 'https://lmstudio.ai/download',
|
|
461
|
+
descarga: 'Descargar LM Studio',
|
|
462
|
+
pull: null,
|
|
463
|
+
ejecutable: 'lms',
|
|
464
|
+
rutas: ['%USERPROFILE%/.lmstudio/bin/lms.exe', '%LOCALAPPDATA%/LM-Studio/lms.exe', '%LOCALAPPDATA%/Programs/LM Studio/lms.exe'],
|
|
465
|
+
argsArranque: ['server', 'start'],
|
|
466
|
+
argsParada: ['server', 'stop'],
|
|
467
|
+
apagadoAMano: null,
|
|
468
|
+
},
|
|
469
|
+
];
|
|
470
|
+
|
|
471
|
+
/** Los runtimes que ha arrancado ESTA piel (y por tanto puede parar ella). */
|
|
472
|
+
const ARRANCADOS = new Map();
|
|
473
|
+
|
|
474
|
+
/** Expande `%VARIABLE%` y `~` en una ruta de Windows. */
|
|
475
|
+
function expandir(ruta) {
|
|
476
|
+
return ruta.replace(/%([A-Za-z_][A-Za-z0-9_]*)%/g, (_todo, nombre) => process.env[nombre] ?? '');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** ¿Existe este fichero? Sin lanzar nada. */
|
|
480
|
+
function existe(ruta) {
|
|
481
|
+
try { return statSync(ruta).isFile(); } catch { return false; }
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Su programa en el PC: primero el PATH (que es lo que usa el usuario al
|
|
486
|
+
* escribirlo a mano), después las carpetas donde se instala. Sin bloquear: son
|
|
487
|
+
* comprobaciones de fichero, y el PATH se mira una vez por runtime.
|
|
488
|
+
* @param runtime - la ficha del runtime.
|
|
489
|
+
* @returns la ruta del ejecutable, o null si no está instalado.
|
|
490
|
+
*/
|
|
491
|
+
function ejecutableDe(runtime) {
|
|
492
|
+
const carpetas = (process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':').filter((c) => c !== '');
|
|
493
|
+
const nombres = process.platform === 'win32' ? [runtime.ejecutable + '.exe', runtime.ejecutable + '.cmd', runtime.ejecutable] : [runtime.ejecutable];
|
|
494
|
+
for (const carpeta of carpetas) {
|
|
495
|
+
for (const nombre of nombres) {
|
|
496
|
+
const ruta = join(carpeta, nombre);
|
|
497
|
+
if (existe(ruta)) return ruta;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
for (const plantilla of runtime.rutas ?? []) {
|
|
501
|
+
const ruta = expandir(plantilla);
|
|
502
|
+
if (ruta !== '' && existe(ruta)) return ruta;
|
|
503
|
+
}
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** La tarjeta del PC (VRAM), mirada UNA vez. `null` si no se puede saber. */
|
|
508
|
+
let tarjetaCache;
|
|
509
|
+
function tarjetaDelPc() {
|
|
510
|
+
if (tarjetaCache !== undefined) return tarjetaCache;
|
|
511
|
+
tarjetaCache = null;
|
|
512
|
+
try {
|
|
513
|
+
const salida = execFileSync('nvidia-smi', ['--query-gpu=name,memory.total', '--format=csv,noheader'], {
|
|
514
|
+
encoding: 'utf8', timeout: 4000, windowsHide: true,
|
|
515
|
+
});
|
|
516
|
+
const primera = salida.split(/\r?\n/).find((l) => l.trim() !== '');
|
|
517
|
+
const m = /^(.*?),\s*(\d+)\s*MiB/.exec(primera ?? '');
|
|
518
|
+
if (m !== null) {
|
|
519
|
+
tarjetaCache = { nombre: m[1].trim(), gb: Math.round(Number(m[2]) / 1024) };
|
|
520
|
+
}
|
|
521
|
+
} catch { /* sin nvidia-smi (o sin tarjeta NVIDIA): no se sabe, y se dice */ }
|
|
522
|
+
return tarjetaCache;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* El modelo recomendado para una tarjeta: el más grande de la tabla que quepa.
|
|
527
|
+
* @param gb - los GB de VRAM, o null si no se sabe.
|
|
528
|
+
* @returns el id del modelo, o null si no hay tarjeta que mirar.
|
|
529
|
+
*/
|
|
530
|
+
function modeloParaTarjeta(gb) {
|
|
531
|
+
if (gb === null) return null;
|
|
532
|
+
const caben = TARJETAS_LOCALES.filter((t) => Number.parseInt(t.tarjeta, 10) <= gb);
|
|
533
|
+
if (caben.length === 0) return TARJETAS_LOCALES[TARJETAS_LOCALES.length - 1].modelo;
|
|
534
|
+
return caben[caben.length - 1].modelo;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* La tabla del README («qué modelo local según tu tarjeta»), en una línea por
|
|
540
|
+
* tarjeta. R15 la midió: es la recomendación que enseña la pestaña nueva.
|
|
541
|
+
*/
|
|
542
|
+
const TARJETAS_LOCALES = [
|
|
543
|
+
{ tarjeta: '8 GB', modelo: 'qwen3:8b', tamano: '5,2 GB', nota: 'la mejor evidencia independiente de uso de herramientas (F1 0,919); aquí devuelve tool_calls de verdad' },
|
|
544
|
+
{ tarjeta: '12 GB', modelo: 'gemma4:12b', tamano: '7,6 GB', nota: 'cifra agéntica publicada (τ² 69,0); a ≤16K de contexto' },
|
|
545
|
+
{ tarjeta: '16 GB', modelo: 'gpt-oss:20b', tamano: '14 GB', nota: '`tools` nativo; SÚBELE el contexto (con 4K por defecto las herramientas se rompen)' },
|
|
546
|
+
{ tarjeta: '24 GB', modelo: 'muse-glimmer:30b', tamano: '18 GB', nota: 'o `qwen3.6:27b`: los dos con cifras de trabajo real (SWE-bench 76-77)' },
|
|
547
|
+
{ tarjeta: 'Solo CPU', modelo: 'granite4.1:3b', tamano: '2,1 GB', nota: 'o `lfm2.5:8b` (1B activo): caben en RAM sin tarjeta' },
|
|
548
|
+
];
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Qué modelos locales valen como AGENTE (que llamen bien a las herramientas, no
|
|
552
|
+
* que hablen de ellas). Las dos listas salen de la tabla y de los avisos de R15,
|
|
553
|
+
* que es lo que publica el README; `re` casa por delante para que valga también
|
|
554
|
+
* con etiquetas (`qwen3:8b-q4_K_M`, `gemma4:12b-instruct`…).
|
|
555
|
+
*/
|
|
556
|
+
const MODELOS_QUE_VALEN = [
|
|
557
|
+
{ re: /^qwen3:8b/i, nota: 'la mejor evidencia independiente (F1 0,919 en el banco de Docker); medido aquí: devuelve tool_calls' },
|
|
558
|
+
{ re: /^lfm2\.5:8b/i, nota: 'badge `tools thinking`, hecho para tool calling (sin cifras publicadas)' },
|
|
559
|
+
{ re: /^gemma4:12b/i, nota: 'function calling nativo con cifra agéntica publicada (τ² 69,0)' },
|
|
560
|
+
{ re: /^qwen3:14b/i, nota: 'F1 0,971, empatado con GPT-4 (no entra en 10 GB)' },
|
|
561
|
+
{ re: /^gpt-oss:20b/i, nota: '`tools` nativo y Apache-2.0; SÚBELE el contexto y no hace llamadas en paralelo' },
|
|
562
|
+
{ re: /^mistral-small3\.2:24b/i, nota: 'el mejor «agentic» de BFCL v4 entre los de su talla (31,0)' },
|
|
563
|
+
{ re: /^muse-glimmer:30b/i, nota: 'entrenado para recuperarse de fallos (MCP Atlas 75,5)' },
|
|
564
|
+
{ re: /^qwen3\.6:27b/i, nota: '`vision tools thinking`; SWE-bench Verified 77,2' },
|
|
565
|
+
{ re: /^gemma4:26b/i, nota: 'τ²-retail 85,5' },
|
|
566
|
+
{ re: /^granite4\.1:3b/i, nota: '`tools` + JSON estructurado, Apache-2.0' },
|
|
567
|
+
{ re: /^nemotron-3\.5-lightning:30b/i, nota: '3B activos y 1M de contexto: cabe en 32 GB de RAM' },
|
|
568
|
+
];
|
|
569
|
+
|
|
570
|
+
/** Los que NO valen como agente, y por qué (medido en R15). */
|
|
571
|
+
const MODELOS_QUE_NO = [
|
|
572
|
+
{ re: /^qwen2\.5-coder/i, nota: 'devuelve las herramientas como TEXTO dentro del mensaje: el agente se queda mirando (medido en R15)' },
|
|
573
|
+
{ re: /^qwen3\.5:9b/i, nota: 'con el *thinking* activado imprime el tool call en XML y no llega a ejecutarlo' },
|
|
574
|
+
];
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* ¿Este modelo local vale como agente? `clase` es `agente` (sí), `no` (medido
|
|
578
|
+
* que no) o `sin-datos` (no hay ninguna cifra publicada: no se promete nada).
|
|
579
|
+
* @param id - el id que devuelve el runtime (`qwen3:8b`, `gemma4:12b`…).
|
|
580
|
+
* @returns la clase y la nota, en español.
|
|
581
|
+
*/
|
|
582
|
+
function clasificarModeloLocal(id) {
|
|
583
|
+
for (const regla of MODELOS_QUE_NO) {
|
|
584
|
+
if (regla.re.test(id)) return { clase: 'no', nota: regla.nota };
|
|
585
|
+
}
|
|
586
|
+
for (const regla of MODELOS_QUE_VALEN) {
|
|
587
|
+
if (regla.re.test(id)) return { clase: 'agente', nota: regla.nota };
|
|
588
|
+
}
|
|
589
|
+
return { clase: 'sin-datos', nota: 'sin cifras publicadas de uso de herramientas: pruébalo antes de fiarte' };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Una petición corta que NUNCA revienta: `null` si no contesta a tiempo. */
|
|
593
|
+
async function pedirCorto(url, ms) {
|
|
594
|
+
try {
|
|
595
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(ms) });
|
|
596
|
+
if (!res.ok) return null;
|
|
597
|
+
return await res.json();
|
|
598
|
+
} catch { return null; }
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* La `baseURL` viva de un runtime local: la que la casa tiene puesta en sus
|
|
603
|
+
* ajustes (así se ve el puerto de verdad si el usuario lo cambió) o la de
|
|
604
|
+
* fábrica si no hay ajustes que la declaren.
|
|
605
|
+
* @param c - contexto de cordis (para `settings` y `llm`, los dos opcionales).
|
|
606
|
+
* @param runtime - la ficha del runtime (`RUNTIMES_LOCALES`).
|
|
607
|
+
* @returns la URL base, sin barra final.
|
|
608
|
+
*/
|
|
609
|
+
function baseURLDelRuntime(c, runtime) {
|
|
610
|
+
const llm = c.get?.('llm');
|
|
611
|
+
const settings = c.get?.('settings');
|
|
612
|
+
if (llm !== undefined && settings !== undefined) {
|
|
613
|
+
try {
|
|
614
|
+
const entrada = llm.listConfigurableProviders().find((e) => e.provider === runtime.id);
|
|
615
|
+
if (entrada !== undefined) {
|
|
616
|
+
const perfil = porCamino(settings.get(entrada.settingsNs), entrada.settingsPath);
|
|
617
|
+
if (typeof perfil?.baseURL === 'string' && perfil.baseURL !== '') return perfil.baseURL.replace(/\/+$/, '');
|
|
618
|
+
}
|
|
619
|
+
} catch { /* ajustes raros: se usa la de fábrica */ }
|
|
620
|
+
}
|
|
621
|
+
return runtime.defecto;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Cómo se cambia la dirección de un runtime local. La pestaña «Modelos locales»
|
|
626
|
+
* lo dice con la ruta de ESTA casa, porque en Ajustes › Models ya no está (R18):
|
|
627
|
+
* se toca el `baseURL` de su bloque en `settings.yaml` (o se levanta el runtime
|
|
628
|
+
* escuchando en otro puerto).
|
|
629
|
+
* @param c - contexto de cordis.
|
|
630
|
+
* @param runtime - la ficha del runtime.
|
|
631
|
+
* @param baseURL - la baseURL viva.
|
|
632
|
+
* @returns el texto, en español, con la ruta real del fichero.
|
|
633
|
+
*/
|
|
634
|
+
function comoCambiarLaDireccion(c, runtime, baseURL) {
|
|
635
|
+
const casa = casaDeEstaCasa();
|
|
636
|
+
const puerto = /:(\d+)\//.exec(baseURL + '/');
|
|
637
|
+
return 'Se cambia en ' + join(casa, 'settings.yaml') + ' → llm-pi-ai.providers.'
|
|
638
|
+
+ runtime.id + '.baseURL (hoy ' + baseURL + (puerto === null ? '' : ', puerto ' + puerto[1]) + '). '
|
|
639
|
+
+ 'Para otro puerto, cambia las dos cosas: esa línea y el arranque del runtime (por ejemplo '
|
|
640
|
+
+ '`' + (runtime.id === 'ollama' ? 'OLLAMA_HOST=127.0.0.1:11435 ollama serve' : 'lms server start --port 1235') + '`).';
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Sondea un runtime local: ¿está encendido?, ¿qué modelos tiene?, ¿cuáles valen
|
|
645
|
+
* como agente? Se mira `/v1/models` (la API de OpenAI que usan los dos) y, en
|
|
646
|
+
* Ollama, también `/api/tags` (su API propia). Si no contesta en 1,5 s, se dice
|
|
647
|
+
* que está apagado y NO se espera más: la página no se bloquea.
|
|
648
|
+
* @param c - contexto de cordis.
|
|
649
|
+
* @param runtime - la ficha del runtime.
|
|
650
|
+
* @returns la ficha con `baseURL`, `encendido`, `instalado` y la lista de modelos.
|
|
651
|
+
*/
|
|
652
|
+
async function sondearRuntime(c, runtime) {
|
|
653
|
+
const baseURL = baseURLDelRuntime(c, runtime);
|
|
654
|
+
const raiz = baseURL.replace(/\/v1$/, '');
|
|
655
|
+
const [v1, etiquetas] = await Promise.all([
|
|
656
|
+
pedirCorto(baseURL + '/models', 1500),
|
|
657
|
+
runtime.id === 'ollama' ? pedirCorto(raiz + '/api/tags', 1500) : Promise.resolve(null),
|
|
658
|
+
]);
|
|
659
|
+
let ids = Array.isArray(v1?.data) ? v1.data.map((m) => m?.id).filter((x) => typeof x === 'string' && x !== '') : [];
|
|
660
|
+
if (ids.length === 0 && Array.isArray(etiquetas?.models)) {
|
|
661
|
+
ids = etiquetas.models.map((m) => m?.name).filter((x) => typeof x === 'string' && x !== '');
|
|
662
|
+
}
|
|
663
|
+
const modelos = [...new Set(ids)].sort((a, b) => a.localeCompare(b)).map((id) => ({ id, ...clasificarModeloLocal(id) }));
|
|
664
|
+
const ejecutable = ejecutableDe(runtime);
|
|
665
|
+
const tarjeta = tarjetaDelPc();
|
|
666
|
+
const encendido = v1 !== null || etiquetas !== null;
|
|
667
|
+
return {
|
|
668
|
+
id: runtime.id,
|
|
669
|
+
nombre: runtime.nombre,
|
|
670
|
+
baseURL,
|
|
671
|
+
puerto: Number((/:(\d+)(?:\/|$)/.exec(baseURL) ?? [null, null])[1]) || runtime.puerto,
|
|
672
|
+
encendido,
|
|
673
|
+
respondeV1: v1 !== null,
|
|
674
|
+
respondeApi: etiquetas !== null,
|
|
675
|
+
/** ¿Su programa está en el PC? Se mira el PC, no la red: sin bloquear. */
|
|
676
|
+
instalado: ejecutable !== null || encendido,
|
|
677
|
+
ejecutable,
|
|
678
|
+
/** Se puede encender desde aquí si sabemos dónde está su programa. */
|
|
679
|
+
puedeEncender: ejecutable !== null && !encendido,
|
|
680
|
+
/** Se puede apagar sin matar nada ajeno: lo arrancamos nosotros, o tiene orden propia. */
|
|
681
|
+
puedeApagar: encendido && (ARRANCADOS.has(runtime.id) || runtime.argsParada !== null),
|
|
682
|
+
loArrancamos: ARRANCADOS.has(runtime.id),
|
|
683
|
+
apagadoAMano: runtime.apagadoAMano,
|
|
684
|
+
modelos,
|
|
685
|
+
arranque: runtime.arranque,
|
|
686
|
+
enlace: runtime.enlace,
|
|
687
|
+
descarga: runtime.descarga,
|
|
688
|
+
pull: runtime.pull === null ? null : runtime.pull(modeloParaTarjeta(tarjeta === null ? null : tarjeta.gb) ?? 'qwen3:8b'),
|
|
689
|
+
recomendado: modeloParaTarjeta(tarjeta === null ? null : tarjeta.gb) ?? 'qwen3:8b',
|
|
690
|
+
cambiar: comoCambiarLaDireccion(c, runtime, baseURL),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Enciende un runtime local a petición del usuario (el botón «Encender»): se
|
|
696
|
+
* lanza SU programa, con sus argumentos, sin shell. Si no sabemos dónde está, no
|
|
697
|
+
* se lanza nada y se dice. Sólo se arranca lo que se pueda parar después.
|
|
698
|
+
* @param runtime - la ficha del runtime.
|
|
699
|
+
* @returns `{ok, motivo}`.
|
|
700
|
+
*/
|
|
701
|
+
function encenderRuntime(runtime) {
|
|
702
|
+
const ejecutable = ejecutableDe(runtime);
|
|
703
|
+
if (ejecutable === null) return { ok: false, motivo: 'no encuentro el programa de ' + runtime.nombre + ' en este PC' };
|
|
704
|
+
if (ARRANCADOS.has(runtime.id)) return { ok: true, motivo: 'ya lo había encendido RATACODE' };
|
|
705
|
+
try {
|
|
706
|
+
const hijo = spawn(ejecutable, runtime.argsArranque, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
707
|
+
hijo.on('error', () => { ARRANCADOS.delete(runtime.id); });
|
|
708
|
+
hijo.unref();
|
|
709
|
+
ARRANCADOS.set(runtime.id, { hijo, cuando: Date.now() });
|
|
710
|
+
return { ok: true, motivo: 'encendido desde RATACODE' };
|
|
711
|
+
} catch (e) {
|
|
712
|
+
return { ok: false, motivo: 'no pude encenderlo: ' + (e?.message ?? e) };
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Apaga un runtime local a petición del usuario (el botón «Apagar»). Regla de la
|
|
718
|
+
* casa: NUNCA se mata un proceso que no haya arrancado RATACODE. Si lo arrancó
|
|
719
|
+
* RATACODE, se para ese proceso (y su árbol). Si no, sólo se para si el propio
|
|
720
|
+
* runtime trae su orden (`lms server stop`); si no, se dice cómo se apaga a mano.
|
|
721
|
+
* @param runtime - la ficha del runtime.
|
|
722
|
+
* @returns `{ok, motivo, aMano}`.
|
|
723
|
+
*/
|
|
724
|
+
function apagarRuntime(runtime) {
|
|
725
|
+
const nuestro = ARRANCADOS.get(runtime.id);
|
|
726
|
+
if (nuestro !== undefined) {
|
|
727
|
+
ARRANCADOS.delete(runtime.id);
|
|
728
|
+
try {
|
|
729
|
+
if (process.platform === 'win32' && nuestro.hijo.pid !== undefined) {
|
|
730
|
+
spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 'taskkill /pid ' + nuestro.hijo.pid + ' /T /F'], { windowsHide: true, stdio: 'ignore' });
|
|
731
|
+
} else {
|
|
732
|
+
nuestro.hijo.kill('SIGTERM');
|
|
733
|
+
}
|
|
734
|
+
return { ok: true, motivo: 'parado (lo había encendido RATACODE)' };
|
|
735
|
+
} catch (e) {
|
|
736
|
+
return { ok: false, motivo: 'no pude pararlo: ' + (e?.message ?? e) };
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
const ejecutable = ejecutableDe(runtime);
|
|
740
|
+
if (runtime.argsParada !== null && ejecutable !== null) {
|
|
741
|
+
try {
|
|
742
|
+
execFileSync(ejecutable, runtime.argsParada, { timeout: 15000, windowsHide: true, stdio: 'ignore' });
|
|
743
|
+
return { ok: true, motivo: runtime.nombre + ' ha parado su servidor' };
|
|
744
|
+
} catch (e) {
|
|
745
|
+
return { ok: false, motivo: 'no pude pararlo: ' + (e?.message ?? e) };
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return { ok: false, aMano: true, motivo: runtime.apagadoAMano ?? ('Para apagar ' + runtime.nombre + ', ciérralo desde su propia ventana.') };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** El runtime por su id, o null. */
|
|
752
|
+
function runtimePorId(id) {
|
|
753
|
+
return RUNTIMES_LOCALES.find((r) => r.id === id) ?? null;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** Un runtime por su id, esperando un poco a que arranque o se apague. */
|
|
757
|
+
async function esperarCambio(c, id, queremosEncendido, plazoMs) {
|
|
758
|
+
const limite = Date.now() + plazoMs;
|
|
759
|
+
let ultimo = null;
|
|
760
|
+
for (;;) {
|
|
761
|
+
ultimo = await sondearRuntime(c, runtimePorId(id));
|
|
762
|
+
if (ultimo.encendido === queremosEncendido || Date.now() >= limite) return ultimo;
|
|
763
|
+
await new Promise((listo) => setTimeout(listo, 400));
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** ¿Este proveedor es uno de los dos runtimes locales? */
|
|
768
|
+
function esProveedorLocal(id) {
|
|
769
|
+
return RUNTIMES_LOCALES.some((r) => r.id === id);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* El estado de los dos runtimes locales, tal y como lo pinta Ajustes › Modelos
|
|
774
|
+
* locales: si están instalados, encendidos o apagados, sus modelos (y cuáles
|
|
775
|
+
* valen como agente), su dirección, cómo encenderlos y la recomendación según la
|
|
776
|
+
* tarjeta de ESTE PC.
|
|
777
|
+
* @param c - contexto de cordis.
|
|
778
|
+
* @returns el estado completo; nunca lanza (si un sondeo falla, sale apagado).
|
|
779
|
+
*/
|
|
780
|
+
async function estadoDeLosRuntimes(c) {
|
|
781
|
+
const runtimes = await Promise.all(RUNTIMES_LOCALES.map((r) => sondearRuntime(c, r)));
|
|
782
|
+
const tarjeta = tarjetaDelPc();
|
|
783
|
+
return {
|
|
784
|
+
ok: true,
|
|
785
|
+
casa: casaDeEstaCasa(),
|
|
786
|
+
runtimes,
|
|
787
|
+
tarjeta,
|
|
788
|
+
recomendado: modeloParaTarjeta(tarjeta === null ? null : tarjeta.gb),
|
|
789
|
+
tarjetas: TARJETAS_LOCALES,
|
|
790
|
+
aviso: 'Una ruta sin `apiKeyEnv` (Ollama y LM Studio) NO pide clave: nada de lo que hables con ellos '
|
|
791
|
+
+ 'sale de tu ordenador, y por eso no están en Ajustes › Models, donde sólo se ponen claves.',
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// ── R22 · el aviso de migración de claves (una línea, y no vuelve) ─────────
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* El aviso de migración: la PRIMERA vez que una casa arranca con la versión que
|
|
799
|
+
* ya no lee claves del entorno, `bin\ratacode.js` deja los NOMBRES de las
|
|
800
|
+
* variables de claves que hay en Windows en `<casa>\aviso-claves.txt` (nunca sus
|
|
801
|
+
* valores). Aquí se mira si esa clave **no** está ya puesta en la casa —con el
|
|
802
|
+
* `describe` del servicio de credenciales del motor, sin leer ningún fichero— y,
|
|
803
|
+
* si no lo está, se devuelve el aviso en una línea. Cuando el usuario lo cierra,
|
|
804
|
+
* se deja `<casa>\aviso-claves-visto` y no vuelve más.
|
|
805
|
+
* @param c - contexto de cordis, ya con `credentials`.
|
|
806
|
+
* @returns `{ok, aviso: {variable, texto}|null}`.
|
|
807
|
+
*/
|
|
808
|
+
async function estadoDeLaMigracion(c) {
|
|
809
|
+
const casa = casaDeEstaCasa();
|
|
810
|
+
let nombres = [];
|
|
811
|
+
try {
|
|
812
|
+
nombres = readFileSync(join(casa, 'aviso-claves.txt'), 'utf8').split('\n').map((l) => l.trim()).filter((l) => l !== '');
|
|
813
|
+
} catch {
|
|
814
|
+
return { ok: true, aviso: null };
|
|
815
|
+
}
|
|
816
|
+
if (nombres.length === 0) return { ok: true, aviso: null };
|
|
817
|
+
if (existsSync(join(casa, 'aviso-claves-visto'))) return { ok: true, aviso: null };
|
|
818
|
+
const credentials = c.get('credentials');
|
|
819
|
+
for (const nombre of nombres) {
|
|
820
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(nombre)) continue;
|
|
821
|
+
let puesta = null;
|
|
822
|
+
if (credentials !== undefined) {
|
|
823
|
+
try { puesta = (await credentials.describe(nombre)).configured === true; } catch { puesta = null; }
|
|
824
|
+
}
|
|
825
|
+
// Si la clave YA está en la casa, no hay nada que avisar: el usuario ya la pegó.
|
|
826
|
+
if (puesta !== false) continue;
|
|
827
|
+
return {
|
|
828
|
+
ok: true,
|
|
829
|
+
aviso: {
|
|
830
|
+
variable: nombre,
|
|
831
|
+
texto: 'Tienes ' + nombre + ' en Windows. RATACODE ya no la usa: pega tu clave en Ajustes › Models.',
|
|
832
|
+
},
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
return { ok: true, aviso: null };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ── R23 · la conexión de los chats web, con botón (Encender / Apagar) ───────
|
|
839
|
+
|
|
840
|
+
/** Los dos hijos que ha arrancado RATACODE para abrir la conexión. */
|
|
841
|
+
const CONEXION = { mcp: null, tunel: null, ultimoError: null };
|
|
842
|
+
|
|
843
|
+
/** ¿Ese hijo sigue vivo? */
|
|
844
|
+
function vivo(hijo) {
|
|
845
|
+
return hijo !== null && hijo.exitCode === null && hijo.killed !== true;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** La raíz de la instalación de RATACODE (donde vive `mcp\`). */
|
|
849
|
+
function raizDeLaInstalacion() {
|
|
850
|
+
return instalacionDeEstaCasa() ?? join(AQUI, '..', '..');
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Espera a que aparezca un fichero con algo dentro. `false` si no llega a tiempo. */
|
|
854
|
+
async function esperarFichero(ruta, plazoMs) {
|
|
855
|
+
const limite = Date.now() + plazoMs;
|
|
856
|
+
for (;;) {
|
|
857
|
+
try {
|
|
858
|
+
if (readFileSync(ruta, 'utf8').trim() !== '') return true;
|
|
859
|
+
} catch { /* todavía no está */ }
|
|
860
|
+
if (Date.now() >= limite) return false;
|
|
861
|
+
await new Promise((listo) => setTimeout(listo, 400));
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** La primera línea con algo de la salida de un hijo, para poder decir qué falló. */
|
|
866
|
+
function primeraLineaUtil(texto) {
|
|
867
|
+
const lineas = String(texto).split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== '');
|
|
868
|
+
return lineas.length === 0 ? '' : lineas[0];
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Enciende la conexión de los chats web: el MCP por HTTP (local) y el túnel, que
|
|
873
|
+
* es lo que deja entrar a ChatGPT. Lo hace A PEDIDO DEL USUARIO —su botón— y ya
|
|
874
|
+
* no hay nada que aceptar al pulsarlo: desde R25 cada tarea del MCP va encerrada
|
|
875
|
+
* en las carpetas de `mcp.workspaces` (lee y escribe sólo ahí, sin terminal, sin
|
|
876
|
+
* red, sin subagentes y sin guiones). Aquí no hay ningún reloj que abra esto
|
|
877
|
+
* solo.
|
|
878
|
+
* @param casa - la casa de RATACODE.
|
|
879
|
+
* @returns `{ok, motivo}`.
|
|
880
|
+
*/
|
|
881
|
+
async function encenderConexion(casa) {
|
|
882
|
+
if (vivo(CONEXION.mcp) || vivo(CONEXION.tunel)) return { ok: true, motivo: 'ya estaba encendida' };
|
|
883
|
+
const raiz = raizDeLaInstalacion();
|
|
884
|
+
const guionMcp = join(raiz, 'mcp', 'bin', 'ratacode-mcp.js');
|
|
885
|
+
const guionTunel = join(raiz, 'mcp', 'tunel.mjs');
|
|
886
|
+
if (!existsSync(guionMcp) || !existsSync(guionTunel)) {
|
|
887
|
+
return { ok: false, motivo: 'no encuentro el MCP en esta instalación (' + raiz + ')' };
|
|
888
|
+
}
|
|
889
|
+
const salida = { mcp: '', tunel: '' };
|
|
890
|
+
const arrancar = (cual, guion, args) => {
|
|
891
|
+
const hijo = spawn(process.execPath, [guion, ...args], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
892
|
+
hijo.stdout.setEncoding('utf8');
|
|
893
|
+
hijo.stderr.setEncoding('utf8');
|
|
894
|
+
const guardar = (t) => { salida[cual] = (salida[cual] + t).slice(-4000); };
|
|
895
|
+
hijo.stdout.on('data', guardar);
|
|
896
|
+
hijo.stderr.on('data', guardar);
|
|
897
|
+
return hijo;
|
|
898
|
+
};
|
|
899
|
+
CONEXION.ultimoError = null;
|
|
900
|
+
CONEXION.mcp = arrancar('mcp', guionMcp, ['--home', casa, '--http']);
|
|
901
|
+
const rutaHttp = join(casa, 'mcp', 'http-url.txt');
|
|
902
|
+
const rutaTunel = join(casa, 'mcp', 'tunel-url.txt');
|
|
903
|
+
if (!await esperarFichero(rutaHttp, 20000) || !vivo(CONEXION.mcp)) {
|
|
904
|
+
const motivo = primeraLineaUtil(salida.mcp) || 'el MCP por HTTP no llegó a escuchar';
|
|
905
|
+
apagarConexion();
|
|
906
|
+
return { ok: false, motivo };
|
|
907
|
+
}
|
|
908
|
+
CONEXION.tunel = arrancar('tunel', guionTunel, ['--home', casa]);
|
|
909
|
+
if (!await esperarFichero(rutaTunel, 30000) || !vivo(CONEXION.tunel)) {
|
|
910
|
+
const motivo = primeraLineaUtil(salida.tunel) || primeraLineaUtil(salida.mcp) || 'el túnel no llegó a abrirse';
|
|
911
|
+
apagarConexion();
|
|
912
|
+
return { ok: false, motivo };
|
|
913
|
+
}
|
|
914
|
+
return { ok: true, motivo: 'encendida desde RATACODE' };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Apaga la conexión. Sólo se para lo que arrancó RATACODE: un MCP o un túnel
|
|
919
|
+
* abiertos a mano (en su ventana) NO se tocan — eso lo dice la tarjeta.
|
|
920
|
+
* @returns `{ok, motivo, nuestro}`.
|
|
921
|
+
*/
|
|
922
|
+
function apagarConexion() {
|
|
923
|
+
const nuestros = [CONEXION.mcp, CONEXION.tunel].filter((h) => vivo(h));
|
|
924
|
+
CONEXION.mcp = null;
|
|
925
|
+
CONEXION.tunel = null;
|
|
926
|
+
if (nuestros.length === 0) {
|
|
927
|
+
return { ok: false, nuestro: false, motivo: 'la conexión no la encendió RATACODE: ciérrala donde la lanzaste (Ctrl+C)' };
|
|
928
|
+
}
|
|
929
|
+
for (const hijo of nuestros) {
|
|
930
|
+
try {
|
|
931
|
+
if (process.platform === 'win32' && hijo.pid !== undefined) {
|
|
932
|
+
spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 'taskkill /pid ' + hijo.pid + '/T /F'], { windowsHide: true, stdio: 'ignore' });
|
|
933
|
+
} else {
|
|
934
|
+
hijo.kill('SIGTERM');
|
|
935
|
+
}
|
|
936
|
+
} catch { /* ya se fue */ }
|
|
937
|
+
}
|
|
938
|
+
return { ok: true, nuestro: true, motivo: 'apagada' };
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* El estado de la conexión, para la tarjeta «ChatGPT y Claude web»: si hay una
|
|
943
|
+
* dirección pública que pegar (el túnel abierto), cuál es, y si quien la abrió
|
|
944
|
+
* fue RATACODE (entonces el botón «Apagar» puede cerrarla).
|
|
945
|
+
* @param req - el pedido, para componer la URL del panel si falta.
|
|
946
|
+
* @returns el estado, con `conectado`, `direccion` y `nuestro`.
|
|
947
|
+
*/
|
|
948
|
+
function estadoDeLaConexion(req) {
|
|
949
|
+
const base = estadoDelMcp(req);
|
|
950
|
+
const conectado = base.tunel.abierto;
|
|
951
|
+
return {
|
|
952
|
+
ok: true,
|
|
953
|
+
conectado,
|
|
954
|
+
direccion: conectado ? base.tunel.url : null,
|
|
955
|
+
nuestro: vivo(CONEXION.mcp) || vivo(CONEXION.tunel),
|
|
956
|
+
mcpLocal: base.http.abierto,
|
|
957
|
+
carpeta: base.carpeta,
|
|
958
|
+
carpetas: base.carpetas,
|
|
959
|
+
comandos: base.comandos,
|
|
960
|
+
panel: base.panel,
|
|
961
|
+
pegar: base.pegar,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// ── R21 · EL ASPECTO QUE LA CASA RECUERDA ──────────────────────────────────
|
|
966
|
+
/**
|
|
967
|
+
* Los TRES temas de RATACODE, en su orden, con el nombre que se ve y el color
|
|
968
|
+
* que los identifica. Los aplica la vía OFICIAL de temas de DSH
|
|
969
|
+
* (`piel\lib\cliente.js`: `ctx.theme.register` con su ficha de tokens); aquí
|
|
970
|
+
* sólo se guarda CUÁL, porque el motor no admite un id de tema de fuera en su
|
|
971
|
+
* esquema de ajustes (`light`/`dark`/`system`).
|
|
972
|
+
*/
|
|
973
|
+
const TEMAS_DE_LA_CASA = [
|
|
974
|
+
{ id: 'ratacode-pink', nombre: 'RATACODE PINK', principal: '#ff268e', detalle: '#e4f226' },
|
|
975
|
+
{ id: 'ratacode-yellow', nombre: 'RATACODE YELLOW', principal: '#e4f226', detalle: '#ff268e' },
|
|
976
|
+
{ id: 'minimal', nombre: 'MINIMAL', principal: '#e8e6df', detalle: '#8c9396' },
|
|
977
|
+
];
|
|
978
|
+
/** El tema de fábrica: el primero de la lista. */
|
|
979
|
+
const TEMA_POR_DEFECTO = TEMAS_DE_LA_CASA[0].id;
|
|
980
|
+
/** Los tres temas, tal cual (para la ruta). */
|
|
981
|
+
function sistemaDeTemas() {
|
|
982
|
+
return TEMAS_DE_LA_CASA;
|
|
983
|
+
}
|
|
984
|
+
/** Dónde se apunta el tema elegido: `<casa>\tema.txt`, una palabra. */
|
|
985
|
+
function rutaDelTema() {
|
|
986
|
+
return join(casaDeEstaCasa(), 'tema.txt');
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* El tema que la casa recuerda. Si no hay nada apuntado —o lo apuntado no es
|
|
990
|
+
* uno de los tres— se responde el de fábrica; NUNCA se inventa otro.
|
|
991
|
+
* @returns el id del tema.
|
|
992
|
+
*/
|
|
993
|
+
function leerTema() {
|
|
994
|
+
try {
|
|
995
|
+
const leido = readFileSync(rutaDelTema(), 'utf8').trim();
|
|
996
|
+
if (TEMAS_DE_LA_CASA.some((t) => t.id === leido)) return leido;
|
|
997
|
+
} catch { /* sin fichero: el de fábrica */ }
|
|
998
|
+
return TEMA_POR_DEFECTO;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// ── las rutas del servidor de la piel ──────────────────────────────────────
|
|
1002
|
+
|
|
1003
|
+
function json(res, codigo, objeto) {
|
|
1004
|
+
res.writeHead(codigo, {
|
|
1005
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1006
|
+
'cache-control': 'no-store',
|
|
1007
|
+
});
|
|
1008
|
+
res.end(JSON.stringify(objeto));
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Monta las rutas del texto de la conexión, del MCP, de la conexión con botón,
|
|
1013
|
+
* de la clave que falta y de los runtimes locales sobre el `webServer`,
|
|
1014
|
+
* autenticadas con el mismo cerco que el motor aplica a sus canales
|
|
1015
|
+
* (`connection.requestRejection`).
|
|
1016
|
+
* @param c - contexto de cordis con `webServer` y `connection`.
|
|
1017
|
+
*/
|
|
1018
|
+
function montarRutas(c) {
|
|
1019
|
+
const servidor = c.webServer;
|
|
1020
|
+
|
|
1021
|
+
const autorizada = (req, res) => {
|
|
1022
|
+
let rechazo;
|
|
1023
|
+
try { rechazo = c.connection.requestRejection(req); }
|
|
1024
|
+
catch { rechazo = 500; }
|
|
1025
|
+
if (rechazo === undefined) return true;
|
|
1026
|
+
res.writeHead(rechazo, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
|
1027
|
+
res.end(rechazo === 401 ? 'sin autorización' : 'prohibido');
|
|
1028
|
+
return false;
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
/** Escribe `<casa>\handshake.md` (0600) y devuelve dónde quedó. */
|
|
1032
|
+
const escribirHandshake = (texto) => {
|
|
1033
|
+
const ruta = join(casaDeEstaCasa(), 'handshake.md');
|
|
1034
|
+
mkdirSync(dirname(ruta), { recursive: true });
|
|
1035
|
+
writeFileSync(ruta, texto, { mode: 0o600 });
|
|
1036
|
+
return ruta;
|
|
1037
|
+
};
|
|
1038
|
+
|
|
1039
|
+
// GET /ratacode/handshake → el texto corto de ESTA casa, sin escribir nada.
|
|
1040
|
+
// POST /ratacode/handshake → lo mismo, y además lo deja en `<casa>\handshake.md`.
|
|
1041
|
+
const handshake = (req, res) => {
|
|
1042
|
+
if (!autorizada(req, res)) return;
|
|
1043
|
+
if (req.method !== 'GET' && req.method !== 'POST') { json(res, 405, { ok: false, error: 'Usa GET o POST.' }); return; }
|
|
1044
|
+
const hecha = textoDelHandshake(req);
|
|
1045
|
+
if (hecha === null) {
|
|
1046
|
+
json(res, 404, { ok: false, error: 'no encuentro apreton/handshake.md en esta instalación' });
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
let ruta = null;
|
|
1050
|
+
if (req.method === 'POST') {
|
|
1051
|
+
try { ruta = escribirHandshake(hecha.texto); }
|
|
1052
|
+
catch (e) { json(res, 500, { ok: false, error: 'no pude escribir handshake.md: ' + (e?.message ?? e) }); return; }
|
|
1053
|
+
c.logger?.info?.('ratacode-piel: el texto de la conexión dejado en ' + ruta);
|
|
1054
|
+
}
|
|
1055
|
+
json(res, 200, { ok: true, url: hecha.url, casa: hecha.casa, ruta, texto: hecha.texto });
|
|
1056
|
+
};
|
|
1057
|
+
c.effect(() => servidor.register({ kind: 'exact', path: '/ratacode/handshake', handler: handshake }), 'ratacode-piel.handshake');
|
|
1058
|
+
|
|
1059
|
+
// GET /ratacode/mcp → estado del MCP + los dos comandos + el texto del chat.
|
|
1060
|
+
const mcp = (req, res) => {
|
|
1061
|
+
if (!autorizada(req, res)) return;
|
|
1062
|
+
if (req.method !== 'GET') { json(res, 405, { ok: false, error: 'Usa GET.' }); return; }
|
|
1063
|
+
json(res, 200, estadoDelMcp(req));
|
|
1064
|
+
};
|
|
1065
|
+
c.effect(() => servidor.register({ kind: 'exact', path: '/ratacode/mcp', handler: mcp }), 'ratacode-piel.mcp');
|
|
1066
|
+
|
|
1067
|
+
// GET /ratacode/clave → ¿le falta la clave al modelo por defecto de la casa?
|
|
1068
|
+
// Lo pregunta el guion de la piel para avisar en español encima de la caja.
|
|
1069
|
+
const clave = (req, res) => {
|
|
1070
|
+
if (!autorizada(req, res)) return;
|
|
1071
|
+
if (req.method !== 'GET') { json(res, 405, { ok: false, error: 'Usa GET.' }); return; }
|
|
1072
|
+
estadoDeLaClave(c).then((estado) => json(res, 200, estado), (e) => json(res, 500, { ok: false, falta: false, error: String(e?.message ?? e) }));
|
|
1073
|
+
};
|
|
1074
|
+
c.effect(() => servidor.register({ kind: 'exact', path: '/ratacode/clave', handler: clave }), 'ratacode-piel.clave');
|
|
1075
|
+
|
|
1076
|
+
// GET /ratacode/runtimes → (R18) Ollama y LM Studio: encendido/apagado, sus
|
|
1077
|
+
// modelos y cuáles valen como agente, su dirección, cómo encenderlos y la
|
|
1078
|
+
// recomendación por tarjeta. Lo pinta la sección Ajustes › Modelos locales.
|
|
1079
|
+
const runtimes = (req, res) => {
|
|
1080
|
+
if (!autorizada(req, res)) return;
|
|
1081
|
+
if (req.method !== 'GET') { json(res, 405, { ok: false, error: 'Usa GET.' }); return; }
|
|
1082
|
+
estadoDeLosRuntimes(c).then((estado) => json(res, 200, estado),
|
|
1083
|
+
(e) => json(res, 500, { ok: false, error: String(e?.message ?? e) }));
|
|
1084
|
+
};
|
|
1085
|
+
// GET /ratacode/conexion → (R23) el estado de la conexión de los chats web.
|
|
1086
|
+
// POST /ratacode/conexion/encender y /apagar → los botones del usuario.
|
|
1087
|
+
const conexion = (req, res) => {
|
|
1088
|
+
if (!autorizada(req, res)) return;
|
|
1089
|
+
if (req.method !== 'GET') { json(res, 405, { ok: false, error: 'Usa GET.' }); return; }
|
|
1090
|
+
json(res, 200, estadoDeLaConexion(req));
|
|
1091
|
+
};
|
|
1092
|
+
c.effect(() => servidor.register({ kind: 'exact', path: '/ratacode/conexion', handler: conexion }), 'ratacode-piel.conexion');
|
|
1093
|
+
|
|
1094
|
+
const botonConexion = (cual) => (req, res) => {
|
|
1095
|
+
if (!autorizada(req, res)) return;
|
|
1096
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: 'Usa POST.' }); return; }
|
|
1097
|
+
const casa = casaDeEstaCasa();
|
|
1098
|
+
if (cual === 'apagar') {
|
|
1099
|
+
const dicho = apagarConexion();
|
|
1100
|
+
json(res, 200, { ...dicho, estado: estadoDeLaConexion(req) });
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
encenderConexion(casa).then((dicho) => {
|
|
1104
|
+
json(res, 200, { ...dicho, estado: estadoDeLaConexion(req) });
|
|
1105
|
+
}, (e) => json(res, 500, { ok: false, motivo: String(e?.message ?? e) }));
|
|
1106
|
+
};
|
|
1107
|
+
c.effect(() => servidor.register({ kind: 'exact', path: '/ratacode/conexion/encender', handler: botonConexion('encender') }), 'ratacode-piel.conexion-encender');
|
|
1108
|
+
c.effect(() => servidor.register({ kind: 'exact', path: '/ratacode/conexion/apagar', handler: botonConexion('apagar') }), 'ratacode-piel.conexion-apagar');
|
|
1109
|
+
// Si la piel se va (el panel se cierra), no se dejan el MCP ni el túnel
|
|
1110
|
+
// abiertos por detrás: se paran los dos, que los arrancó RATACODE.
|
|
1111
|
+
c.effect(() => () => { apagarConexion(); }, 'ratacode-piel.conexion-cierre');
|
|
1112
|
+
// POST /ratacode/migracion → el usuario lo ha cerrado: no vuelve.
|
|
1113
|
+
const migracion = (req, res) => {
|
|
1114
|
+
if (!autorizada(req, res)) return;
|
|
1115
|
+
if (req.method === 'POST') {
|
|
1116
|
+
try {
|
|
1117
|
+
writeFileSync(join(casaDeEstaCasa(), 'aviso-claves-visto'), new Date().toISOString() + '\n', { mode: 0o600 });
|
|
1118
|
+
} catch (e) {
|
|
1119
|
+
json(res, 500, { ok: false, error: 'no pude apuntar que el aviso está visto: ' + (e?.message ?? e) });
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
json(res, 200, { ok: true, aviso: null });
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
if (req.method !== 'GET') { json(res, 405, { ok: false, error: 'Usa GET o POST.' }); return; }
|
|
1126
|
+
estadoDeLaMigracion(c).then((estado) => json(res, 200, estado),
|
|
1127
|
+
(e) => json(res, 500, { ok: false, aviso: null, error: String(e?.message ?? e) }));
|
|
1128
|
+
};
|
|
1129
|
+
c.effect(() => servidor.register({ kind: 'exact', path: '/ratacode/migracion', handler: migracion }), 'ratacode-piel.migracion');
|
|
1130
|
+
|
|
1131
|
+
// GET /ratacode/tema → (R21) el aspecto que la casa recuerda (los tres temas
|
|
1132
|
+
// de RATACODE; `ratacode-pink` si no hay nada apuntado).
|
|
1133
|
+
// POST /ratacode/tema → los botones de Ajustes › General › Aspecto.
|
|
1134
|
+
// El motor NO guarda un id de tema de fuera (su esquema sólo admite
|
|
1135
|
+
// `light`/`dark`/`system`: `THEME_PREFERENCES`), así que el recuerdo lo pone
|
|
1136
|
+
// la casa, en UN fichero con una palabra dentro. El TEMA en sí lo aplica la
|
|
1137
|
+
// vía oficial (`ctx.theme.register` + `setTheme`), no esta ruta.
|
|
1138
|
+
const temas = sistemaDeTemas();
|
|
1139
|
+
const tema = (req, res) => {
|
|
1140
|
+
if (!autorizada(req, res)) return;
|
|
1141
|
+
if (req.method === 'GET') { json(res, 200, { ok: true, tema: leerTema(), temas: temas.map((t) => t.id) }); return; }
|
|
1142
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: 'Usa GET o POST.' }); return; }
|
|
1143
|
+
let cuerpo = '';
|
|
1144
|
+
req.on('data', (trozo) => { cuerpo += trozo; if (cuerpo.length > 1024) req.destroy(); });
|
|
1145
|
+
req.on('end', () => {
|
|
1146
|
+
let pedido = {};
|
|
1147
|
+
try { pedido = JSON.parse(cuerpo === '' ? '{}' : cuerpo); } catch { /* sin cuerpo: se dice */ }
|
|
1148
|
+
const cual = typeof pedido.tema === 'string' ? pedido.tema : '';
|
|
1149
|
+
if (!temas.some((t) => t.id === cual)) {
|
|
1150
|
+
json(res, 400, { ok: false, error: 'no conozco el tema «' + cual + '»' });
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
try {
|
|
1154
|
+
writeFileSync(rutaDelTema(), cual + '\n', { mode: 0o600 });
|
|
1155
|
+
} catch (e) {
|
|
1156
|
+
json(res, 500, { ok: false, error: 'no pude apuntar el tema: ' + (e?.message ?? e) });
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
c.logger?.info?.('ratacode-piel: el aspecto de la casa queda en ' + cual);
|
|
1160
|
+
json(res, 200, { ok: true, tema: cual });
|
|
1161
|
+
});
|
|
1162
|
+
};
|
|
1163
|
+
c.effect(() => servidor.register({ kind: 'exact', path: '/ratacode/tema', handler: tema }), 'ratacode-piel.tema');
|
|
1164
|
+
|
|
1165
|
+
c.effect(() => servidor.register({ kind: 'exact', path: '/ratacode/runtimes', handler: runtimes }), 'ratacode-piel.runtimes');
|
|
1166
|
+
|
|
1167
|
+
// POST /ratacode/runtimes/encender y /apagar → (R23) el botón del usuario, y
|
|
1168
|
+
// NADA MÁS: aquí no hay ningún reloj que encienda ni apague por su cuenta. Al
|
|
1169
|
+
// apagar sólo se para lo que arrancó RATACODE (o lo que el runtime sabe parar
|
|
1170
|
+
// por su cuenta: `lms server stop`); un proceso ajeno no se toca jamás.
|
|
1171
|
+
const accionRuntime = (accion) => (req, res) => {
|
|
1172
|
+
if (!autorizada(req, res)) return;
|
|
1173
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: 'Usa POST.' }); return; }
|
|
1174
|
+
let cuerpo = '';
|
|
1175
|
+
req.on('data', (trozo) => { cuerpo += trozo; if (cuerpo.length > 4096) req.destroy(); });
|
|
1176
|
+
req.on('end', () => {
|
|
1177
|
+
let pedido = {};
|
|
1178
|
+
try { pedido = JSON.parse(cuerpo === '' ? '{}' : cuerpo); } catch { /* sin cuerpo: se dice */ }
|
|
1179
|
+
const runtime = runtimePorId(pedido.id);
|
|
1180
|
+
if (runtime === null) { json(res, 400, { ok: false, error: 'no conozco el runtime «' + String(pedido.id) + '»' }); return; }
|
|
1181
|
+
const dicho = accion === 'encender' ? encenderRuntime(runtime) : apagarRuntime(runtime);
|
|
1182
|
+
if (!dicho.ok) {
|
|
1183
|
+
// No se ha podido encender desde aquí (su programa no arrancó): se dice en
|
|
1184
|
+
// UNA línea, CON el comando, y la tarjeta deja ese comando para copiar —
|
|
1185
|
+
// que es la otra vía del encargo («si no, botón Copiar comando»).
|
|
1186
|
+
json(res, 200, accion === 'encender'
|
|
1187
|
+
? { ok: false, aMano: true, motivo: 'no llegó a encenderse: ' + dicho.motivo + ' — a mano, «' + runtime.arranque + '»' }
|
|
1188
|
+
: { ok: false, motivo: dicho.motivo, aMano: dicho.aMano === true });
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
esperarCambio(c, runtime.id, accion === 'encender', 12000).then((estado) => {
|
|
1192
|
+
// El botón no da por bueno lo que no ha pasado: si le hemos pedido que se
|
|
1193
|
+
// encienda y sigue apagado (su programa no arrancó, o tardó más de la
|
|
1194
|
+
// cuenta), se dice —y la tarjeta deja el COMANDO a mano, que es la otra
|
|
1195
|
+
// vía del encargo— en vez de dejar al usuario mirando una pantalla igual.
|
|
1196
|
+
if (estado.encendido !== (accion === 'encender')) {
|
|
1197
|
+
json(res, 200, {
|
|
1198
|
+
ok: false,
|
|
1199
|
+
aMano: true,
|
|
1200
|
+
motivo: accion === 'encender'
|
|
1201
|
+
? 'no llegó a encenderse: pruébalo a mano con «' + runtime.arranque + '»'
|
|
1202
|
+
: 'sigue encendido: páralo a mano' + (runtime.apagadoAMano === null ? '' : ' (' + runtime.apagadoAMano + ')'),
|
|
1203
|
+
runtime: estado,
|
|
1204
|
+
});
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
json(res, 200, { ok: true, motivo: dicho.motivo, runtime: estado });
|
|
1208
|
+
}, (e) => json(res, 500, { ok: false, error: String(e?.message ?? e) }));
|
|
1209
|
+
});
|
|
1210
|
+
};
|
|
1211
|
+
c.effect(() => servidor.register({ kind: 'exact', path: '/ratacode/runtimes/encender', handler: accionRuntime('encender') }), 'ratacode-piel.runtimes-encender');
|
|
1212
|
+
c.effect(() => servidor.register({ kind: 'exact', path: '/ratacode/runtimes/apagar', handler: accionRuntime('apagar') }), 'ratacode-piel.runtimes-apagar');
|
|
1213
|
+
c.logger?.info?.('ratacode-piel: el texto de la conexión se sirve en /ratacode/handshake, el MCP en /ratacode/mcp, la conexión con botón en /ratacode/conexion, la clave que falta en /ratacode/clave, los runtimes locales en /ratacode/runtimes y el aspecto en /ratacode/tema');}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Monta la piel sobre el servidor web del motor.
|
|
1217
|
+
* @param ctx - contexto de cordis con el servicio `webServer`.
|
|
1218
|
+
*/
|
|
1219
|
+
export function apply(ctx) {
|
|
1220
|
+
const servidor = ctx.webServer;
|
|
1221
|
+
if (servidor === undefined || typeof servidor.tapIndex !== 'function') {
|
|
1222
|
+
ctx.logger?.warn?.('ratacode-piel: este motor no tiene webServer.tapIndex; la piel no se pone');
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
ctx.effect(() => servidor.tapIndex(vestir), 'ratacode-piel.tapIndex');
|
|
1226
|
+
// Las rutas necesitan el cerco del canal del navegador. Si faltara, la cara se
|
|
1227
|
+
// pone igual y las rutas no salen: la sección de Ajustes lo dirá al pedirlas.
|
|
1228
|
+
ctx.inject(['connection'], (c) => montarRutas(c));
|
|
1229
|
+
ctx.logger?.info?.('ratacode-piel: enganchada a la web del panel');
|
|
1230
|
+
}
|