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,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tareas — el registro de trabajos del MCP.
|
|
3
|
+
*
|
|
4
|
+
* Estados: `queued` → `running` → `completed` | `failed` | `cancelled`.
|
|
5
|
+
* Se guardan en `<casa>/mcp/tareas/<id>.json` (para poder mirarlos sin
|
|
6
|
+
* pantalla y para que el panel los lea) y se resume el conjunto en
|
|
7
|
+
* `<casa>/mcp/estado.json`.
|
|
8
|
+
*
|
|
9
|
+
* Una tarea = un hijo del motor. Cancelar = matar ese hijo. Por eso el estado
|
|
10
|
+
* nunca miente: si el hijo se fue, la tarea está terminada.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { anotar } from './actividad.js';
|
|
15
|
+
import { ajustesMcp } from './casa.js';
|
|
16
|
+
import { apuntarSesionEnEspacio } from './espacios.js';
|
|
17
|
+
import { lanzarTarea } from './nucleo.js';
|
|
18
|
+
|
|
19
|
+
/** Un id corto y ordenable. */
|
|
20
|
+
function nuevoId() {
|
|
21
|
+
const sello = Date.now().toString(36);
|
|
22
|
+
const azar = Math.random().toString(36).slice(2, 6);
|
|
23
|
+
return 't-' + sello + '-' + azar;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** El registro de tareas de una casa. */
|
|
27
|
+
export class Tareas {
|
|
28
|
+
/**
|
|
29
|
+
* @param {{casa: string, dshBin: string}} opciones - la casa y el motor.
|
|
30
|
+
*/
|
|
31
|
+
constructor({ casa, dshBin }) {
|
|
32
|
+
this.casa = casa;
|
|
33
|
+
this.dshBin = dshBin;
|
|
34
|
+
/** @type {Map<string, object>} */
|
|
35
|
+
this.mapa = new Map();
|
|
36
|
+
/** @type {Set<string>} */
|
|
37
|
+
this.clientes = new Set();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** La carpeta de tareas. */
|
|
41
|
+
get carpeta() {
|
|
42
|
+
return join(this.casa, 'mcp', 'tareas');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Anotar que un cliente se ha presentado (para el panel). */
|
|
46
|
+
verCliente(nombre) {
|
|
47
|
+
if (typeof nombre === 'string' && nombre.trim() !== '') this.clientes.add(nombre.trim());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Lanzar una tarea.
|
|
52
|
+
* @param {object} peticion - prompt, ruta de modelo, espacio, modo y límites.
|
|
53
|
+
* @returns {object} el recibo (task_id y estado).
|
|
54
|
+
*/
|
|
55
|
+
crear(peticion) {
|
|
56
|
+
const taskId = nuevoId();
|
|
57
|
+
const registro = {
|
|
58
|
+
task_id: taskId,
|
|
59
|
+
estado: 'queued',
|
|
60
|
+
prompt: peticion.prompt,
|
|
61
|
+
prompt_resumen: peticion.prompt.replace(/\s+/g, ' ').trim().slice(0, 200),
|
|
62
|
+
provider: peticion.provider,
|
|
63
|
+
model: peticion.model,
|
|
64
|
+
espacio: peticion.espacio,
|
|
65
|
+
modo: peticion.modo,
|
|
66
|
+
max_tokens: peticion.maxTokens ?? null,
|
|
67
|
+
timeout_ms: peticion.timeoutMs ?? null,
|
|
68
|
+
cliente: peticion.cliente ?? 'MCP',
|
|
69
|
+
creada: new Date().toISOString(),
|
|
70
|
+
empezada: null,
|
|
71
|
+
terminada: null,
|
|
72
|
+
duracion_ms: null,
|
|
73
|
+
respuesta: null,
|
|
74
|
+
pasos: 0,
|
|
75
|
+
tokens: null,
|
|
76
|
+
coste: null,
|
|
77
|
+
motivo: null,
|
|
78
|
+
errores: [],
|
|
79
|
+
cancelada: false,
|
|
80
|
+
session_id: null,
|
|
81
|
+
};
|
|
82
|
+
this.mapa.set(taskId, registro);
|
|
83
|
+
this.persistir(registro);
|
|
84
|
+
this.resumir();
|
|
85
|
+
|
|
86
|
+
const mando = lanzarTarea({
|
|
87
|
+
id: taskId,
|
|
88
|
+
casa: this.casa,
|
|
89
|
+
dshBin: this.dshBin,
|
|
90
|
+
espacio: peticion.espacio,
|
|
91
|
+
raices: peticion.raices,
|
|
92
|
+
provider: peticion.provider,
|
|
93
|
+
model: peticion.model,
|
|
94
|
+
prompt: peticion.prompt,
|
|
95
|
+
maxTokens: peticion.maxTokens,
|
|
96
|
+
timeoutMs: peticion.timeoutMs,
|
|
97
|
+
modo: peticion.modo,
|
|
98
|
+
});
|
|
99
|
+
registro._mando = mando;
|
|
100
|
+
registro.estado = 'running';
|
|
101
|
+
registro.empezada = new Date().toISOString();
|
|
102
|
+
this.apuntarEnElPanel(registro);
|
|
103
|
+
this.persistir(registro);
|
|
104
|
+
this.resumir();
|
|
105
|
+
|
|
106
|
+
const cierre = mando.promesa.then((salida) => {
|
|
107
|
+
registro.session_id = salida.session_id;
|
|
108
|
+
registro.duracion_ms = salida.duracion_ms;
|
|
109
|
+
registro.respuesta = salida.texto === '' ? null : salida.texto;
|
|
110
|
+
registro.pasos = salida.pasos;
|
|
111
|
+
registro.tokens = salida.tokens?.informado === true
|
|
112
|
+
? { input: salida.tokens.input, output: salida.tokens.output, total: salida.tokens.total, cache_read: salida.tokens.cache_read, cache_write: salida.tokens.cache_write, reasoning: salida.tokens.reasoning }
|
|
113
|
+
: null;
|
|
114
|
+
registro.motivo = salida.motivo;
|
|
115
|
+
registro.errores = salida.errores;
|
|
116
|
+
registro.cancelada = salida.cancelada === true;
|
|
117
|
+
registro.codigo_salida = salida.codigo_salida;
|
|
118
|
+
registro.coste = this.calcularCoste(registro);
|
|
119
|
+
registro.terminada = new Date().toISOString();
|
|
120
|
+
if (registro.cancelada) registro.estado = 'cancelled';
|
|
121
|
+
else if (salida.ok === true) registro.estado = 'completed';
|
|
122
|
+
else registro.estado = 'failed';
|
|
123
|
+
delete registro._mando;
|
|
124
|
+
// Y otra vez, ya cerrada: el panel puede haber reescrito su copia del
|
|
125
|
+
// registro mientras la tarea corría, y la sesión tiene que seguir ahí.
|
|
126
|
+
this.apuntarEnElPanel(registro);
|
|
127
|
+
this.persistir(registro);
|
|
128
|
+
this.resumir();
|
|
129
|
+
anotar(this.casa, {
|
|
130
|
+
cliente: registro.cliente,
|
|
131
|
+
modelo: registro.model,
|
|
132
|
+
proveedor: registro.provider,
|
|
133
|
+
tarea: registro.prompt,
|
|
134
|
+
duracion_ms: registro.duracion_ms,
|
|
135
|
+
tokens_input: registro.tokens?.input ?? null,
|
|
136
|
+
tokens_output: registro.tokens?.output ?? null,
|
|
137
|
+
coste: registro.coste?.total ?? null,
|
|
138
|
+
estado: registro.estado,
|
|
139
|
+
espacio: registro.espacio,
|
|
140
|
+
task_id: registro.task_id,
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
/** Se resuelve cuando la tarea ha cerrado de verdad (lo espera `cancelar`). */
|
|
144
|
+
registro._cierre = cierre;
|
|
145
|
+
|
|
146
|
+
return this.recibo(registro);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Apuntar la sesión de ESTA tarea en el registro de espacios de la casa, que
|
|
151
|
+
* es de donde el panel saca la barra lateral: así cada tarea del MCP se ve en
|
|
152
|
+
* el panel, con su conversación, y deja de ser una caja negra (R12 §3, R16 §3).
|
|
153
|
+
* Nunca puede tumbar una tarea: si el registro no se deja escribir, se apunta
|
|
154
|
+
* el motivo y se sigue.
|
|
155
|
+
* @param {object} registro - la tarea.
|
|
156
|
+
*/
|
|
157
|
+
apuntarEnElPanel(registro) {
|
|
158
|
+
try {
|
|
159
|
+
const salida = apuntarSesionEnEspacio(this.casa, registro.espacio, 'mcp-' + registro.task_id);
|
|
160
|
+
registro.en_panel = salida.apuntada === true;
|
|
161
|
+
if (salida.apuntada === true) delete registro.aviso_panel;
|
|
162
|
+
else registro.aviso_panel = salida.motivo ?? 'no se pudo apuntar la sesión en el panel';
|
|
163
|
+
} catch (e) {
|
|
164
|
+
registro.en_panel = false;
|
|
165
|
+
registro.aviso_panel = e instanceof Error ? e.message : String(e);
|
|
166
|
+
}
|
|
167
|
+
if (registro.session_id === null) registro.session_id = 'mcp-' + registro.task_id;
|
|
168
|
+
return registro.en_panel;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** El estado de una tarea, o un error claro si no existe. */
|
|
172
|
+
estado(taskId) {
|
|
173
|
+
const registro = this.exigir(taskId);
|
|
174
|
+
return {
|
|
175
|
+
task_id: registro.task_id,
|
|
176
|
+
estado: registro.estado,
|
|
177
|
+
provider: registro.provider,
|
|
178
|
+
model: registro.model,
|
|
179
|
+
espacio: registro.espacio,
|
|
180
|
+
modo: registro.modo,
|
|
181
|
+
cliente: registro.cliente,
|
|
182
|
+
creada: registro.creada,
|
|
183
|
+
empezada: registro.empezada,
|
|
184
|
+
terminada: registro.terminada,
|
|
185
|
+
duracion_ms: registro.duracion_ms,
|
|
186
|
+
pasos: registro.pasos,
|
|
187
|
+
cancelada: registro.cancelada,
|
|
188
|
+
motivo: registro.motivo,
|
|
189
|
+
errores: registro.errores,
|
|
190
|
+
coste: registro.coste,
|
|
191
|
+
sesion_en_el_panel: registro.en_panel === true,
|
|
192
|
+
aviso_panel: registro.aviso_panel ?? null,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** El resultado de una tarea. Si aún no terminó, se dice, no se inventa. */
|
|
197
|
+
resultado(taskId) {
|
|
198
|
+
const registro = this.exigir(taskId);
|
|
199
|
+
if (registro.estado === 'queued' || registro.estado === 'running') {
|
|
200
|
+
throw new Error('la tarea ' + taskId + ' sigue en «' + registro.estado + '». Espera y vuelve a preguntar (get_task_status).');
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
task_id: registro.task_id,
|
|
204
|
+
estado: registro.estado,
|
|
205
|
+
respuesta: registro.respuesta,
|
|
206
|
+
modelo: registro.model,
|
|
207
|
+
proveedor: registro.provider,
|
|
208
|
+
espacio: registro.espacio,
|
|
209
|
+
tokens: registro.tokens,
|
|
210
|
+
coste: registro.coste,
|
|
211
|
+
duracion_ms: registro.duracion_ms,
|
|
212
|
+
pasos: registro.pasos,
|
|
213
|
+
motivo: registro.motivo,
|
|
214
|
+
errores: registro.errores,
|
|
215
|
+
cancelada: registro.cancelada,
|
|
216
|
+
session_id: registro.session_id,
|
|
217
|
+
sesion_en_el_panel: registro.en_panel === true,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Esperar a que una tarea cierre, como mucho `ms`. Si ya había cerrado, se
|
|
223
|
+
* resuelve al momento: no se duerme de más. Nunca lanza por tardar — devuelve
|
|
224
|
+
* si terminó o no, y quien llama decide qué contar.
|
|
225
|
+
* @param {string} taskId - el id que devolvió `crear`.
|
|
226
|
+
* @param {number} ms - tope de espera, en milisegundos.
|
|
227
|
+
* @returns {Promise<{terminada: boolean, esperada_ms: number, estado: string}>}
|
|
228
|
+
*/
|
|
229
|
+
async esperar(taskId, ms) {
|
|
230
|
+
const registro = this.exigir(taskId);
|
|
231
|
+
const empezado = Date.now();
|
|
232
|
+
const viva = registro.estado === 'queued' || registro.estado === 'running';
|
|
233
|
+
if (viva && registro._cierre !== undefined) {
|
|
234
|
+
let temporizador;
|
|
235
|
+
try {
|
|
236
|
+
await Promise.race([
|
|
237
|
+
registro._cierre,
|
|
238
|
+
new Promise((listo) => { temporizador = setTimeout(listo, ms); }),
|
|
239
|
+
]);
|
|
240
|
+
} finally {
|
|
241
|
+
if (temporizador !== undefined) clearTimeout(temporizador);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
terminada: registro.estado !== 'queued' && registro.estado !== 'running',
|
|
246
|
+
esperada_ms: Date.now() - empezado,
|
|
247
|
+
estado: registro.estado,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Cancelar: se mata el hijo de esa tarea. El recibo no miente: se espera a que la tarea cierre. */
|
|
252
|
+
async cancelar(taskId) {
|
|
253
|
+
const registro = this.exigir(taskId);
|
|
254
|
+
if (registro.estado === 'queued' || registro.estado === 'running') {
|
|
255
|
+
if (registro._mando !== undefined) {
|
|
256
|
+
// `cancelar` mata el árbol y termina la tarea; el estado se escribe cuando
|
|
257
|
+
// la promesa se resuelve, así que aquí se espera antes de contestar.
|
|
258
|
+
registro._mando.cancelar('cancelada por el cliente');
|
|
259
|
+
await registro._cierre;
|
|
260
|
+
} else {
|
|
261
|
+
registro.cancelada = true;
|
|
262
|
+
registro.estado = 'cancelled';
|
|
263
|
+
this.persistir(registro);
|
|
264
|
+
this.resumir();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return this.estado(taskId);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Todas las tareas de esta sesión del servidor (las últimas primero). */
|
|
271
|
+
listar() {
|
|
272
|
+
return [...this.mapa.values()]
|
|
273
|
+
.sort((a, b) => (a.creada < b.creada ? 1 : -1))
|
|
274
|
+
.map((registro) => this.recibo(registro));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** El recibo corto de una tarea. */
|
|
278
|
+
recibo(registro) {
|
|
279
|
+
return {
|
|
280
|
+
task_id: registro.task_id,
|
|
281
|
+
estado: registro.estado,
|
|
282
|
+
provider: registro.provider,
|
|
283
|
+
model: registro.model,
|
|
284
|
+
espacio: registro.espacio,
|
|
285
|
+
modo: registro.modo,
|
|
286
|
+
cliente: registro.cliente,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Una tarea del registro, o un error que dice qué ids hay. */
|
|
291
|
+
exigir(taskId) {
|
|
292
|
+
const registro = this.mapa.get(String(taskId));
|
|
293
|
+
if (registro === undefined) {
|
|
294
|
+
const conocidas = [...this.mapa.keys()].slice(-10);
|
|
295
|
+
throw new Error('no conozco la tarea «' + taskId + '»'
|
|
296
|
+
+ (conocidas.length === 0 ? ' (todavía no hay ninguna)' : '. Las últimas que he visto: ' + conocidas.join(', ')));
|
|
297
|
+
}
|
|
298
|
+
return registro;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* El coste, sólo si el humano declaró precios en `mcp.precios`.
|
|
303
|
+
* Si no hay precios o no hay tokens, se devuelve null: no se inventa.
|
|
304
|
+
*/
|
|
305
|
+
calcularCoste(registro) {
|
|
306
|
+
if (registro.tokens === null) return null;
|
|
307
|
+
const precios = ajustesMcp(this.casa).precios;
|
|
308
|
+
const porProveedor = precios[registro.provider];
|
|
309
|
+
if (porProveedor === null || typeof porProveedor !== 'object') return null;
|
|
310
|
+
const entrada = porProveedor[registro.model];
|
|
311
|
+
if (entrada === null || typeof entrada !== 'object') return null;
|
|
312
|
+
const porEntrada = typeof entrada.entrada_por_millon === 'number' ? entrada.entrada_por_millon : null;
|
|
313
|
+
const porSalida = typeof entrada.salida_por_millon === 'number' ? entrada.salida_por_millon : null;
|
|
314
|
+
if (porEntrada === null || porSalida === null) return null;
|
|
315
|
+
const deEntrada = (registro.tokens.input / 1_000_000) * porEntrada;
|
|
316
|
+
const deSalida = (registro.tokens.output / 1_000_000) * porSalida;
|
|
317
|
+
return {
|
|
318
|
+
moneda: typeof entrada.moneda === 'string' ? entrada.moneda : 'EUR',
|
|
319
|
+
entrada: Number(deEntrada.toFixed(6)),
|
|
320
|
+
salida: Number(deSalida.toFixed(6)),
|
|
321
|
+
total: Number((deEntrada + deSalida).toFixed(6)),
|
|
322
|
+
origen: 'mcp.precios (declarado a mano en la casa)',
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Guardar una tarea en disco (sin el mando ni la promesa de cierre, que son de memoria). */
|
|
327
|
+
persistir(registro) {
|
|
328
|
+
try {
|
|
329
|
+
mkdirSync(this.carpeta, { recursive: true });
|
|
330
|
+
const { _mando, _cierre, ...guardable } = registro;
|
|
331
|
+
writeFileSync(join(this.carpeta, registro.task_id + '.json'), JSON.stringify(guardable, null, 2) + '\n');
|
|
332
|
+
} catch {
|
|
333
|
+
// Que no poder escribir el cuaderno no tumbe la tarea.
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** El estado del servidor, para el panel y para `--status`. */
|
|
338
|
+
resumir() {
|
|
339
|
+
const tareas = [...this.mapa.values()];
|
|
340
|
+
const activas = tareas.filter((t) => t.estado === 'queued' || t.estado === 'running').length;
|
|
341
|
+
const estado = {
|
|
342
|
+
mcp: 'on',
|
|
343
|
+
casa: this.casa,
|
|
344
|
+
motor: this.dshBin,
|
|
345
|
+
actualizado: new Date().toISOString(),
|
|
346
|
+
tareas_totales: tareas.length,
|
|
347
|
+
tareas_activas: activas,
|
|
348
|
+
clientes: [...this.clientes],
|
|
349
|
+
ultima: tareas.length === 0 ? null : {
|
|
350
|
+
task_id: tareas[tareas.length - 1].task_id,
|
|
351
|
+
estado: tareas[tareas.length - 1].estado,
|
|
352
|
+
cliente: tareas[tareas.length - 1].cliente,
|
|
353
|
+
model: tareas[tareas.length - 1].model,
|
|
354
|
+
terminada: tareas[tareas.length - 1].terminada,
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
try {
|
|
358
|
+
mkdirSync(join(this.casa, 'mcp'), { recursive: true });
|
|
359
|
+
writeFileSync(join(this.casa, 'mcp', 'estado.json'), JSON.stringify(estado, null, 2) + '\n');
|
|
360
|
+
} catch {
|
|
361
|
+
// Igual que arriba: el cuaderno no puede tumbar el servicio.
|
|
362
|
+
}
|
|
363
|
+
return estado;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Leer el cuaderno de actividad de la casa (lo que ya pasó, aunque el servidor
|
|
369
|
+
* se haya reiniciado). Sólo lectura.
|
|
370
|
+
* @param {string} casa - la casa de RATACODE.
|
|
371
|
+
* @param {number} cuantas - cuántas líneas devolver (las últimas).
|
|
372
|
+
* @returns {object[]} las líneas más recientes primero.
|
|
373
|
+
*/
|
|
374
|
+
export function leerActividad(casa, cuantas = 20) {
|
|
375
|
+
const ruta = join(casa, 'mcp', 'actividad.jsonl');
|
|
376
|
+
if (!existsSync(ruta)) return [];
|
|
377
|
+
const lineas = readFileSync(ruta, 'utf8').split(/\r?\n/).filter((l) => l.trim() !== '');
|
|
378
|
+
const salida = [];
|
|
379
|
+
for (const linea of lineas.slice(-cuantas).reverse()) {
|
|
380
|
+
try {
|
|
381
|
+
salida.push(JSON.parse(linea));
|
|
382
|
+
} catch {
|
|
383
|
+
// Una línea rota no invalida el cuaderno.
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return salida;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Un resumen de la casa para `ratacode-mcp --status`, sin arrancar el motor.
|
|
391
|
+
* @param {string} casa - la casa de RATACODE.
|
|
392
|
+
* @returns {object} estado del MCP y del cuaderno.
|
|
393
|
+
*/
|
|
394
|
+
export function estadoDeLaCasa(casa) {
|
|
395
|
+
const ruta = join(casa, 'mcp', 'estado.json');
|
|
396
|
+
const guardado = existsSync(ruta) ? JSON.parse(readFileSync(ruta, 'utf8')) : null;
|
|
397
|
+
const carpetaTareas = join(casa, 'mcp', 'tareas');
|
|
398
|
+
const cuantas = existsSync(carpetaTareas) ? readdirSync(carpetaTareas).filter((n) => n.endsWith('.json')).length : 0;
|
|
399
|
+
return {
|
|
400
|
+
mcp: guardado === null ? 'sin_arrancar' : 'on',
|
|
401
|
+
casa,
|
|
402
|
+
estado_guardado: guardado,
|
|
403
|
+
tareas_en_disco: cuantas,
|
|
404
|
+
ultimas: leerActividad(casa, 5),
|
|
405
|
+
};
|
|
406
|
+
}
|
package/mcp/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ratacode-mcp",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Servidor MCP de RATACODE: deja que ChatGPT, Codex, Claude Code, Rowboat u OpenClaw manden trabajo a los modelos que ya tienes configurados en RATACODE.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Patxi"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"private": true,
|
|
11
|
+
"bin": {
|
|
12
|
+
"ratacode-mcp": "bin/ratacode-mcp.js"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=24"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
19
|
+
"js-yaml": "^4.3.2",
|
|
20
|
+
"zod": "^4.6.5"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/mcp/tunel.mjs
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* tunel.mjs — el túnel de Cloudflare para ChatGPT web.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ NO lo ejecutes tú solo: lo arranca el usuario cuando quiera abrir RATACODE a
|
|
6
|
+
* ChatGPT. Este script SÓLO es transporte: el servidor MCP (con su clave en la
|
|
7
|
+
* URL y su tope de tareas) ya funciona sin esto; el túnel sólo lo expone a
|
|
8
|
+
* Internet mientras corre.
|
|
9
|
+
*
|
|
10
|
+
* Y exponerlo ya NO expone nada de más: cada tarea va encerrada en las carpetas
|
|
11
|
+
* de `mcp.workspaces` (lee y escribe sólo ahí, sin terminal ni red ni
|
|
12
|
+
* subagentes), así que quien tenga la URL puede mandar trabajo, pero no sacar
|
|
13
|
+
* ficheros de fuera de esas carpetas. Antes hacía falta `--acepto-lectura-total`
|
|
14
|
+
* porque el motor no encerraba la LECTURA; desde R25 sí está encerrada (mira
|
|
15
|
+
* `lib/lectura.js`) y la bandera es un no-op que se acepta por no romper nada.
|
|
16
|
+
*
|
|
17
|
+
* Qué hace al ejecutarlo:
|
|
18
|
+
* 1. Lee la URL local del MCP (http://127.0.0.1:<puerto>/mcp/<clave>) de la
|
|
19
|
+
* casa (<casa>/mcp/http-url.txt, o la reconstruye desde http-secret.txt).
|
|
20
|
+
* 2. Comprueba que `cloudflared` está instalado.
|
|
21
|
+
* 3. Si existe el túnel nombrado `mcp.mod-rat.com` (lo da de alta el usuario en
|
|
22
|
+
* su Cloudflare), lo usa con ese hostname fijo (por fichero de config, no
|
|
23
|
+
* por argumentos).
|
|
24
|
+
* 4. Si no, usa un quick tunnel sin cuenta (URL efímera).
|
|
25
|
+
* 5. Imprime la URL pública COMPLETA para pegar en ChatGPT (dominio + /mcp/<clave>).
|
|
26
|
+
*
|
|
27
|
+
* Uso:
|
|
28
|
+
* node mcp/tunel.mjs --home <casa> [--port <puerto>] [--host 127.0.0.1] [--misma-clave]
|
|
29
|
+
*
|
|
30
|
+
* Al abrirlo estrena clave (el servidor que ya corre la adopta sin reiniciar);
|
|
31
|
+
* con `--misma-clave` reutiliza la que había.
|
|
32
|
+
*
|
|
33
|
+
* Para pararlo: Ctrl+C (mata cloudflared y deja de exponer el puerto).
|
|
34
|
+
*/
|
|
35
|
+
import { randomBytes } from 'node:crypto';
|
|
36
|
+
import { spawnSync, spawn } from 'node:child_process';
|
|
37
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
38
|
+
import { dirname, join } from 'node:path';
|
|
39
|
+
import { resolverCasa } from './lib/casa.js';
|
|
40
|
+
|
|
41
|
+
const TUNEL_NOMBRADO = 'mcp.mod-rat.com';
|
|
42
|
+
|
|
43
|
+
/** Escribir un secreto con permisos de sólo-dueño (en POSIX el `mode` no se
|
|
44
|
+
* aplica si el fichero ya existía: por eso el `chmod` detrás). */
|
|
45
|
+
function escribirSoloDueno(ruta, texto) {
|
|
46
|
+
mkdirSync(dirname(ruta), { recursive: true });
|
|
47
|
+
writeFileSync(ruta, texto, { mode: 0o600 });
|
|
48
|
+
try { chmodSync(ruta, 0o600); } catch { /* Windows: el modo es decorativo */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function leerOrdenes(argv) {
|
|
52
|
+
const o = { casa: undefined, port: 3778, aceptoLecturaTotal: false, mismaClave: false };
|
|
53
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
54
|
+
const a = argv[i];
|
|
55
|
+
if (a === '--acepto-lectura-total') { o.aceptoLecturaTotal = true; continue; }
|
|
56
|
+
if (a === '--misma-clave') { o.mismaClave = true; continue; }
|
|
57
|
+
const v = a.includes('=') ? a.slice(a.indexOf('=') + 1) : argv[++i];
|
|
58
|
+
if (a === '--home' || a.startsWith('--home=')) o.casa = v;
|
|
59
|
+
else if (a === '--port' || a.startsWith('--port=')) o.port = Number(v);
|
|
60
|
+
else if (a === '--host' || a.startsWith('--host=')) o.host = v;
|
|
61
|
+
else throw new Error('no entiendo ' + a);
|
|
62
|
+
}
|
|
63
|
+
return o;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const o = leerOrdenes(process.argv.slice(2));
|
|
67
|
+
|
|
68
|
+
// R25 · `--acepto-lectura-total` ya no hace falta (y no hace nada): la lectura
|
|
69
|
+
// va encerrada por el mismo cerco que en el MCP local. Se sigue aceptando para
|
|
70
|
+
// no romper los comandos antiguos.
|
|
71
|
+
|
|
72
|
+
const casa = resolverCasa(o.casa);
|
|
73
|
+
const host = o.host ?? '127.0.0.1';
|
|
74
|
+
const puerto = o.port;
|
|
75
|
+
|
|
76
|
+
/** Dónde queda constancia de que el túnel está ABIERTO: `<casa>\mcp\tunel-url.txt`.
|
|
77
|
+
* Lo lee Ajustes > Conexiones (la piel, ruta /ratacode/conexion) para decir si el
|
|
78
|
+
* túnel está abierto y enseñar la URL pública. Se borra al cerrar el túnel, así
|
|
79
|
+
* que su presencia es señal de que hay alguien exponiendo el puerto. */
|
|
80
|
+
const RUTA_TUNEL_ABIERTO = join(casa, 'mcp', 'tunel-url.txt');
|
|
81
|
+
/** La de la vez anterior no vale: hasta que cloudflared no dé la URL, no hay túnel. */
|
|
82
|
+
try { rmSync(RUTA_TUNEL_ABIERTO, { force: true }); } catch { /* no había nada */ }
|
|
83
|
+
|
|
84
|
+
/** El origen y la clave del MCP, tal como los dejó `ratacode mcp --http`. */
|
|
85
|
+
function urlLocal() {
|
|
86
|
+
const rutaUrl = join(casa, 'mcp', 'http-url.txt');
|
|
87
|
+
let deFichero = null;
|
|
88
|
+
if (existsSync(rutaUrl)) {
|
|
89
|
+
const u = readFileSync(rutaUrl, 'utf8').trim();
|
|
90
|
+
if (u.startsWith('http') && u.includes('/mcp/')) deFichero = u;
|
|
91
|
+
}
|
|
92
|
+
if (deFichero === null) {
|
|
93
|
+
const rutaClave = join(casa, 'mcp', 'http-secret.txt');
|
|
94
|
+
if (!existsSync(rutaClave)) {
|
|
95
|
+
throw new Error('no encuentro la clave del MCP en ' + rutaClave + '. Arranca primero: ratacode mcp --http --port ' + puerto + ' --home ' + casa);
|
|
96
|
+
}
|
|
97
|
+
const leida = readFileSync(rutaClave, 'utf8').trim();
|
|
98
|
+
return { origen: 'http://' + host + ':' + puerto, clave: leida };
|
|
99
|
+
}
|
|
100
|
+
const corte = deFichero.indexOf('/mcp/');
|
|
101
|
+
return { origen: deFichero.slice(0, corte), clave: deFichero.slice(corte + '/mcp/'.length) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let { origen, clave } = urlLocal();
|
|
105
|
+
|
|
106
|
+
// CLAVE NUEVA AL ABRIR EL TÚNEL (esto es lo que se expone a Internet). El
|
|
107
|
+
// servidor que ya está en marcha la adopta solo (mira `lib/http.js`): relee
|
|
108
|
+
// `<casa>\mcp\http-secret.txt` cada dos segundos. Con `--misma-clave` se
|
|
109
|
+
// reutiliza la que había (útil si el cliente ya la tiene pegada).
|
|
110
|
+
if (o.mismaClave !== true) {
|
|
111
|
+
const nueva = randomBytes(32).toString('hex');
|
|
112
|
+
escribirSoloDueno(join(casa, 'mcp', 'http-secret.txt'), nueva + '\n');
|
|
113
|
+
clave = nueva;
|
|
114
|
+
console.log('Clave NUEVA para esta sesión del túnel (la anterior ya no vale).');
|
|
115
|
+
} else {
|
|
116
|
+
console.log('Reutilizando la clave que ya había (--misma-clave).');
|
|
117
|
+
}
|
|
118
|
+
escribirSoloDueno(join(casa, 'mcp', 'http-url.txt'), origen + '/mcp/' + clave + '\n');
|
|
119
|
+
|
|
120
|
+
console.log('URL local del MCP: ' + origen + '/mcp/<oculta> (la clave está en ' + join(casa, 'mcp', 'http-url.txt') + ')');
|
|
121
|
+
|
|
122
|
+
// 1) ¿Está cloudflared?
|
|
123
|
+
const donde = spawnSync('where', ['cloudflared'], { encoding: 'utf8' });
|
|
124
|
+
if (donde.status !== 0) {
|
|
125
|
+
console.log('\n✋ cloudflared NO está instalado en este equipo.');
|
|
126
|
+
console.log(' Instálalo (Windows): winget install Cloudflare.cloudflared');
|
|
127
|
+
console.log(' o descárgalo de https://github.com/cloudflare/cloudflared/releases');
|
|
128
|
+
console.log(' Luego vuelve a ejecutar este script.');
|
|
129
|
+
process.exit(2);
|
|
130
|
+
}
|
|
131
|
+
console.log('cloudflared encontrado: ' + donde.stdout.trim().split('\n')[0]);
|
|
132
|
+
|
|
133
|
+
// 2) ¿Existe el túnel nombrado del usuario?
|
|
134
|
+
let modo = 'quick';
|
|
135
|
+
let args;
|
|
136
|
+
const lista = spawnSync('cloudflared', ['tunnel', 'list'], { encoding: 'utf8' });
|
|
137
|
+
if (lista.status === 0 && lista.stdout.includes(TUNEL_NOMBRADO)) {
|
|
138
|
+
modo = 'nombrado';
|
|
139
|
+
// Un túnel nombrado necesita una regla de entrada (hostname → servicio) y su
|
|
140
|
+
// credencial; se pasa por FICHERO de configuración, no por argumentos (los
|
|
141
|
+
// argumentos de cualquier proceso los ve cualquier proceso local).
|
|
142
|
+
const rutaConfig = join(casa, 'mcp', 'cloudflared.yml');
|
|
143
|
+
mkdirSync(join(casa, 'mcp'), { recursive: true });
|
|
144
|
+
writeFileSync(rutaConfig, [
|
|
145
|
+
'# RATACODE · config del túnel nombrado. Lo escribe mcp/tunel.mjs.',
|
|
146
|
+
'tunnel: ' + TUNEL_NOMBRADO,
|
|
147
|
+
'ingress:',
|
|
148
|
+
' - hostname: ' + TUNEL_NOMBRADO,
|
|
149
|
+
' service: ' + origen,
|
|
150
|
+
' - service: http_status:404',
|
|
151
|
+
'',
|
|
152
|
+
].join('\n'), { mode: 0o600 });
|
|
153
|
+
args = ['tunnel', '--config', rutaConfig, 'run', TUNEL_NOMBRADO];
|
|
154
|
+
console.log('\nUsando túnel nombrado fijo: ' + TUNEL_NOMBRADO + ' (config: ' + rutaConfig + ')');
|
|
155
|
+
} else {
|
|
156
|
+
modo = 'quick';
|
|
157
|
+
// A cloudflared se le pasa SÓLO el origen (sin la clave): el prefijo /mcp/<clave>
|
|
158
|
+
// viaja en la URL pública que se imprime abajo, no en los argumentos.
|
|
159
|
+
args = ['tunnel', '--url', origen];
|
|
160
|
+
console.log('\nNo hay túnel nombrado configurado; usando quick tunnel (URL efímera).');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log('\nArrancando cloudflared ' + (modo === 'nombrado' ? '(hostname fijo ' + TUNEL_NOMBRADO + ')' : '(quick tunnel)') + '...');
|
|
164
|
+
console.log('En cuanto dé la URL pública, aquí abajo saldrá COMPLETA (dominio + /mcp/<clave>).\n');
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* De la salida de cloudflared, la URL pública que sirve. El quick tunnel la
|
|
168
|
+
* escribe como `https://<algo>.trycloudflare.com`; el nombrado es el hostname.
|
|
169
|
+
*/
|
|
170
|
+
function dominioPublico(trozo) {
|
|
171
|
+
const m = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i.exec(trozo);
|
|
172
|
+
if (m !== null) return m[0];
|
|
173
|
+
if (modo === 'nombrado' && trozo.includes(TUNEL_NOMBRADO)) return 'https://' + TUNEL_NOMBRADO;
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 3) Arrancar cloudflared y dejarlo vivo; Ctrl+C para parar.
|
|
178
|
+
// La salida va por tubería (no heredada) para poder CAZAR el dominio público y
|
|
179
|
+
// enseñar la URL completa; se reenvía tal cual a la consola.
|
|
180
|
+
let visto = '';
|
|
181
|
+
let anunciada = false;
|
|
182
|
+
const proc = spawn('cloudflared', args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: false });
|
|
183
|
+
const mirar = (trozo, destino) => {
|
|
184
|
+
destino.write(trozo);
|
|
185
|
+
if (anunciada) return;
|
|
186
|
+
visto = (visto + trozo).slice(-16384);
|
|
187
|
+
const dominio = dominioPublico(visto);
|
|
188
|
+
if (dominio === null) return;
|
|
189
|
+
anunciada = true;
|
|
190
|
+
// La URL pública COMPLETA (dominio + /mcp/<clave>) queda en la casa, para que
|
|
191
|
+
// Ajustes > Conexiones pueda decir «conectado» y enseñarla sin que nadie
|
|
192
|
+
// tenga que copiarla de la consola. Sólo-dueño, y se borra al cerrar.
|
|
193
|
+
try { escribirSoloDueno(RUTA_TUNEL_ABIERTO, dominio + '/mcp/' + clave + '\n'); } catch { /* la casa manda, pero no es imprescindible */ }
|
|
194
|
+
process.stdout.write('\n============================================================\n');
|
|
195
|
+
process.stdout.write('URL PÚBLICA DEL MCP (pégala en ChatGPT, modo desarrollador):\n');
|
|
196
|
+
process.stdout.write(' ' + dominio + '/mcp/' + clave + '\n');
|
|
197
|
+
process.stdout.write('============================================================\n\n');
|
|
198
|
+
};
|
|
199
|
+
proc.stdout.setEncoding('utf8');
|
|
200
|
+
proc.stdout.on('data', (t) => mirar(t, process.stdout));
|
|
201
|
+
proc.stderr.setEncoding('utf8');
|
|
202
|
+
proc.stderr.on('data', (t) => mirar(t, process.stderr));
|
|
203
|
+
proc.on('exit', (code) => {
|
|
204
|
+
try { rmSync(RUTA_TUNEL_ABIERTO, { force: true }); } catch { /* da igual */ }
|
|
205
|
+
console.log('\ncloudflared terminó (código ' + code + '). El túnel está cerrado; el puerto ya no se expone.');
|
|
206
|
+
process.exit(code ?? 0);
|
|
207
|
+
});
|
|
208
|
+
process.on('SIGINT', () => { proc.kill('SIGINT'); });
|
|
209
|
+
process.on('SIGTERM', () => { proc.kill('SIGTERM'); });
|