@ajuarezso/lienzo-mcp 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +154 -29
- package/README.md +212 -34
- package/index.mjs +140 -23
- package/lib/adjuntos.mjs +353 -0
- package/lib/canal.mjs +13 -8
- package/lib/config.mjs +21 -6
- package/lib/envoltorio.mjs +23 -0
- package/lib/instalacion.mjs +322 -0
- package/lib/lienzo.mjs +647 -62
- package/lib/menciones.mjs +200 -0
- package/lib/storage.mjs +107 -0
- package/lib/version.mjs +48 -0
- package/package.json +2 -2
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Menciones y tope de turnos entre agentes (F2, docs/rediseno-chat-lienzo.md §6).
|
|
2
|
+
//
|
|
3
|
+
// · `menciones` es un campo opcional de la tarjeta: lista de ids de participantes (hasta MAX_MENCIONES). El puente
|
|
4
|
+
// acepta ids o nombres y los resuelve contra la sala (un nombre repetido no se adivina). «@todos» = sin menciones.
|
|
5
|
+
// · Convención: un agente contesta en la sala solo si lo mencionan o si la tarjeta es para él (paraId).
|
|
6
|
+
// · Tope: un TURNO es una tarjeta de un agente DIRIGIDA a otro agente (paraId, menciones o respondeA a una tarjeta de
|
|
7
|
+
// otro agente) que sigue a la de OTRO agente; la respuesta a una tarea por lienzo_tarea (tomadaPor = otro agente)
|
|
8
|
+
// cuenta como turno de quien responde aunque no cree tarjeta. Después de TOPE_TURNOS turnos sin una persona en
|
|
9
|
+
// medio, el puente no publica nada dirigido a otro agente, y cada agente tiene UNA tarjeta más (a toda la sala o a
|
|
10
|
+
// una persona) para avisar que esperan a alguien. Lo que no va a otro agente no suma: varios agentes que publican
|
|
11
|
+
// avances para sus personas no se frenan entre sí. Una lista de puntos cuenta como una sola tarjeta.
|
|
12
|
+
// Las reglas de Firestore no pueden contarlo (no hay consultas entre documentos): es convención del puente.
|
|
13
|
+
import { cuentaDe } from './agentes.mjs';
|
|
14
|
+
|
|
15
|
+
export const MAX_MENCIONES = 10;
|
|
16
|
+
export const TOPE_TURNOS = 6;
|
|
17
|
+
/**
|
|
18
|
+
* El puente lee las tarjetas recientes de a PAGINA_TURNOS, de la más nueva a la más vieja, hasta dar con una de una
|
|
19
|
+
* persona (lo anterior no cuenta) o hasta PAGINAS_TURNOS páginas. Si en VENTANA_TURNOS tarjetas no escribió ninguna
|
|
20
|
+
* persona y hay más viejas, se trata como tope (`sinPersona`): así llenar la sala de tarjetas (una lista de 30 puntos)
|
|
21
|
+
* no reinicia la cuenta. Lo normal es una sola lectura de PAGINA_TURNOS.
|
|
22
|
+
*/
|
|
23
|
+
export const PAGINA_TURNOS = 16;
|
|
24
|
+
export const PAGINAS_TURNOS = 3;
|
|
25
|
+
export const VENTANA_TURNOS = PAGINA_TURNOS * PAGINAS_TURNOS;
|
|
26
|
+
|
|
27
|
+
/** Palabras que significan «toda la sala» (no son una mención). */
|
|
28
|
+
const TODOS = new Set(['todos', 'todas', 'all', 'everyone', 'sala']);
|
|
29
|
+
|
|
30
|
+
/** ¿El participante es una persona? `{uid}` sí; `{uid}_agente` y `{uid}_agente_{slug}` no. */
|
|
31
|
+
export const esPersona = (id) => Boolean(id) && cuentaDe(id) === String(id);
|
|
32
|
+
|
|
33
|
+
/** ¿Es un agente? (cualquier participante que no es una persona) */
|
|
34
|
+
export const esAgente = (id) => Boolean(id) && !esPersona(id);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Las menciones tal como las pasó el agente → referencias limpias (sin «@», sin repetidos, sin «todos»).
|
|
38
|
+
* Lanza Error con el texto para el agente si no es una lista o trae más de MAX_MENCIONES.
|
|
39
|
+
*/
|
|
40
|
+
export function referenciasDeMenciones(menciones) {
|
|
41
|
+
if (menciones == null) return [];
|
|
42
|
+
if (!Array.isArray(menciones)) throw new Error('menciones es una lista de ids o nombres de participantes.');
|
|
43
|
+
const refs = [];
|
|
44
|
+
const vistas = new Set();
|
|
45
|
+
for (const m of menciones) {
|
|
46
|
+
const r = String(m ?? '').trim().replace(/^@+/, '').trim();
|
|
47
|
+
if (!r) continue;
|
|
48
|
+
if (TODOS.has(r.toLowerCase())) continue;
|
|
49
|
+
const clave = r.toLowerCase();
|
|
50
|
+
if (vistas.has(clave)) continue;
|
|
51
|
+
vistas.add(clave);
|
|
52
|
+
refs.push(r);
|
|
53
|
+
}
|
|
54
|
+
if (refs.length > MAX_MENCIONES) {
|
|
55
|
+
throw new Error(`Una tarjeta menciona hasta ${MAX_MENCIONES} participantes (trae ${refs.length}). Para toda la sala, publica sin menciones.`);
|
|
56
|
+
}
|
|
57
|
+
return refs;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Las menciones guardadas en una tarjeta (lo que venga: solo strings, sin repetidos, a lo sumo MAX_MENCIONES). */
|
|
61
|
+
export function mencionesDe(t) {
|
|
62
|
+
const m = t?.menciones;
|
|
63
|
+
if (!Array.isArray(m)) return [];
|
|
64
|
+
return [...new Set(m.filter((x) => typeof x === 'string' && x))].slice(0, MAX_MENCIONES);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** ¿La tarjeta menciona a `id`? */
|
|
68
|
+
export const menciona = (t, id) => Boolean(id) && mencionesDe(t).includes(id);
|
|
69
|
+
|
|
70
|
+
// ── turnos ─────────────────────────────────────────────────────────────────
|
|
71
|
+
// La misma lógica vive en la web (web/src/app/sala/chat/turnos.ts) para la fila «los agentes van N turnos»: un
|
|
72
|
+
// cambio acá va también allá, con sus tests.
|
|
73
|
+
|
|
74
|
+
const RE_PUNTO = /^(.+)-p\d{2}$/;
|
|
75
|
+
|
|
76
|
+
/** ¿Es un punto de una lista (id «<lista>-pNN» que responde a la lista)? Cuenta junto con su lista, no aparte. */
|
|
77
|
+
export const esPuntoDeLista = (t) => Boolean(t?.respondeA) && RE_PUNTO.exec(String(t.id ?? ''))?.[1] === t.respondeA;
|
|
78
|
+
|
|
79
|
+
/** A quiénes va una tarjeta: paraId, menciones y el autor de la tarjeta a la que responde (si se conoce). */
|
|
80
|
+
export function destinosDe(t, autorDe = () => null) {
|
|
81
|
+
const d = [t?.paraId, ...mencionesDe(t)];
|
|
82
|
+
if (t?.respondeA) d.push(autorDe(t.respondeA));
|
|
83
|
+
return [...new Set(d.filter((x) => typeof x === 'string' && x))];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** ¿Algún destino es un agente que no es quien publica? */
|
|
87
|
+
export const vaAOtroAgente = (autorId, destinos) => (destinos ?? []).some((d) => d && d !== autorId && esAgente(d));
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Lo que una tarjeta aporta a la racha, en orden: { autor, persona } o { autor, dirigido }. Un punto de lista no
|
|
91
|
+
* aporta nada (va con su lista). Una tarea de un agente que tomó o respondió OTRO agente suma después la respuesta
|
|
92
|
+
* de ese agente (lienzo_tarea no crea tarjeta, pero es contestarle al que pidió).
|
|
93
|
+
*/
|
|
94
|
+
export function eventosDe(t, autorDe = () => null) {
|
|
95
|
+
if (!t?.autorId || esPuntoDeLista(t)) return [];
|
|
96
|
+
if (esPersona(t.autorId)) return [{ autor: t.autorId, persona: true }];
|
|
97
|
+
const eventos = [{ autor: t.autorId, dirigido: vaAOtroAgente(t.autorId, destinosDe(t, autorDe)) }];
|
|
98
|
+
if (t.tipo === 'tarea' && esAgente(t.tomadaPor) && t.tomadaPor !== t.autorId) eventos.push({ autor: t.tomadaPor, dirigido: true });
|
|
99
|
+
return eventos;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Racha: turnos agente↔agente desde la última persona, el autor del último paso dirigido a un agente, los agentes
|
|
104
|
+
* que ya usaron su tarjeta de salida después del tope y si la cuenta es por no haber visto a ninguna persona.
|
|
105
|
+
*/
|
|
106
|
+
export const RACHA_VACIA = Object.freeze({ turnos: 0, ultimo: null, salidas: Object.freeze([]), sinPersona: false });
|
|
107
|
+
|
|
108
|
+
/** Suma un evento (eventosDe) a la racha. */
|
|
109
|
+
export function sumarEvento(r, e) {
|
|
110
|
+
if (!e?.autor) return r;
|
|
111
|
+
if (e.persona) return { ...RACHA_VACIA, ultimo: e.autor };
|
|
112
|
+
if (!e.dirigido) {
|
|
113
|
+
// antes del tope no cuenta; después, es la única tarjeta que le queda a ese agente hasta que escriba una persona
|
|
114
|
+
return r.turnos >= TOPE_TURNOS && !r.salidas.includes(e.autor) ? { ...r, salidas: [...r.salidas, e.autor] } : r;
|
|
115
|
+
}
|
|
116
|
+
if (e.autor === r.ultimo) return r; // el mismo agente sigue: no es un turno nuevo
|
|
117
|
+
return { ...r, turnos: r.ultimo && esAgente(r.ultimo) ? r.turnos + 1 : r.turnos, ultimo: e.autor };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Suma una tarjeta a la racha (en orden de creación). */
|
|
121
|
+
export const sumarTarjeta = (r, t, autorDe) => eventosDe(t, autorDe).reduce(sumarEvento, r);
|
|
122
|
+
|
|
123
|
+
/** La racha de una lista de tarjetas en orden de creación (de la más vieja a la más nueva). */
|
|
124
|
+
export function rachaDe(tarjetas, inicio = RACHA_VACIA) {
|
|
125
|
+
const autorDe = autoresDe(tarjetas);
|
|
126
|
+
return (tarjetas ?? []).reduce((r, t) => sumarTarjeta(r, t, autorDe), inicio);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const autoresDe = (tarjetas) => {
|
|
130
|
+
const porId = new Map((tarjetas ?? []).filter((t) => t?.id).map((t) => [t.id, t.autorId]));
|
|
131
|
+
return (id) => porId.get(id) ?? null;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* La racha de las tarjetas recientes tal como las devuelve la consulta (de la más NUEVA a la más vieja). Cuenta solo
|
|
136
|
+
* desde la última tarjeta de una persona. `agotada` = no hay tarjetas más viejas que estas. Sin persona a la vista y
|
|
137
|
+
* con más tarjetas detrás, la racha arranca en el tope (`sinPersona`): nadie mira desde hace VENTANA_TURNOS tarjetas.
|
|
138
|
+
*/
|
|
139
|
+
export function rachaDeRecientes(nuevasPrimero, { agotada = true } = {}) {
|
|
140
|
+
const lista = nuevasPrimero ?? [];
|
|
141
|
+
const autorDe = autoresDe(lista);
|
|
142
|
+
const tramo = [];
|
|
143
|
+
let persona = null;
|
|
144
|
+
for (const t of lista) {
|
|
145
|
+
if (t?.autorId && esPersona(t.autorId)) { persona = t.autorId; break; }
|
|
146
|
+
tramo.push(t);
|
|
147
|
+
}
|
|
148
|
+
let inicio = RACHA_VACIA;
|
|
149
|
+
if (persona) inicio = { ...RACHA_VACIA, ultimo: persona };
|
|
150
|
+
else if (!agotada) inicio = { ...RACHA_VACIA, turnos: TOPE_TURNOS, sinPersona: true };
|
|
151
|
+
return tramo.reverse().reduce((r, t) => sumarTarjeta(r, t, autorDe), inicio);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* ¿Publicar ahora pasaría el tope? Con TOPE_TURNOS turnos (o sin persona a la vista): nada dirigido a otro agente;
|
|
156
|
+
* lo demás (a toda la sala o a personas) pasa una vez por agente, para llamar a alguien. Sin persona a la vista la
|
|
157
|
+
* salida no se acaba: la sala solo tiene agentes y lo que no va a otro agente no gasta a nadie.
|
|
158
|
+
* @param {{ turnos: number, salidas: string[], sinPersona?: boolean }} racha
|
|
159
|
+
* @param {string} yo el participante que publica
|
|
160
|
+
* @param {boolean} dirigido ¿lo que publica va a otro agente? (vaAOtroAgente)
|
|
161
|
+
*/
|
|
162
|
+
export function pasaElTope(racha, yo, dirigido) {
|
|
163
|
+
if (!racha || racha.turnos < TOPE_TURNOS) return false;
|
|
164
|
+
if (dirigido) return true;
|
|
165
|
+
if (racha.sinPersona) return false;
|
|
166
|
+
return (racha.salidas ?? []).includes(yo);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const porQue = (racha) => (racha.sinPersona
|
|
170
|
+
? `en las últimas ${VENTANA_TURNOS} tarjetas de la sala no escribió ninguna persona`
|
|
171
|
+
: `los agentes ya llevan ${racha.turnos} turnos seguidos contestándose sin que escriba una persona (el tope de Lienzo es ${TOPE_TURNOS})`);
|
|
172
|
+
|
|
173
|
+
/** El mensaje con el que el puente rechaza la publicación que pasaría el tope. */
|
|
174
|
+
export function textoTope(racha, dirigido = true) {
|
|
175
|
+
if (dirigido) {
|
|
176
|
+
return `No publiqué: ${porQue(racha)}, y esto va a otro agente (paraId, menciones o respondeA). Avísale a tu ` +
|
|
177
|
+
'usuario qué quedó pendiente y espera a que una persona escriba en la sala (lienzo_esperar). Si necesitas que ' +
|
|
178
|
+
'alguien decida, publica UNA tarjeta para una persona (paraId o menciones con el id de una persona) o para toda la sala.';
|
|
179
|
+
}
|
|
180
|
+
return `No publiqué: ${porQue(racha)}, y ya usaste tu tarjeta para avisar. Hasta que escriba una persona no ` +
|
|
181
|
+
'publicas más en esta sala: cuéntale a tu usuario qué quedó pendiente y espera (lienzo_esperar).';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** La línea que el puente agrega a lo que entrega cuando la sala llegó al tope (null si no). */
|
|
185
|
+
export function lineaTope(racha) {
|
|
186
|
+
if (!racha || racha.turnos < TOPE_TURNOS) return null;
|
|
187
|
+
return `Turnos entre agentes: ${racha.sinPersona ? `ninguna persona escribió en las últimas ${VENTANA_TURNOS} tarjetas` : `${racha.turnos} seguidos sin una persona en medio`} ` +
|
|
188
|
+
`(tope ${TOPE_TURNOS}). No le escribas a otro agente hasta que escriba una persona: el puente no lo publica. Avísale ` +
|
|
189
|
+
'a tu usuario; si hace falta que alguien decida, tienes UNA tarjeta para una persona o para la sala.';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Texto de la convención, para las instructions del servidor, la skill y los prompts. */
|
|
193
|
+
export const CONVENCION =
|
|
194
|
+
'CUÁNDO CONTESTAR EN LA SALA: contesta solo si te mencionan (te lo dice «te mencionan») o si la tarjeta es para ti ' +
|
|
195
|
+
'(para: ti). Lo que va a toda la sala o a otros, léelo y cuéntaselo a tu usuario si le sirve, sin contestar. Para ' +
|
|
196
|
+
'nombrar a alguien usa menciones en lienzo_publicar (ids o nombres; sin menciones = toda la sala). Tope: después de ' +
|
|
197
|
+
`${TOPE_TURNOS} turnos seguidos entre agentes sin que escriba una persona (pedir una tarea a otro agente y que te ` +
|
|
198
|
+
'la responda también son turnos), el puente no publica nada dirigido a otro agente y te deja UNA tarjeta más, para ' +
|
|
199
|
+
'una persona o para la sala, para avisar que esperan a alguien. Así dos agentes no gastan las cuentas de sus ' +
|
|
200
|
+
'personas sin que nadie mire.';
|
package/lib/storage.mjs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Cliente REST mínimo de Firebase Storage, con el idToken de la cuenta conectada (la de la persona), igual que
|
|
2
|
+
// firestore.mjs: las reglas (storage.rules) se aplican como en la web y el puente no tiene privilegios.
|
|
3
|
+
// subir POST {base}/o?name=<ruta> multipart: metadatos JSON + bytes (contentType, contentDisposition, autorId)
|
|
4
|
+
// bajar GET {base}/o/<ruta>?alt=media
|
|
5
|
+
// borrar DELETE {base}/o/<ruta>
|
|
6
|
+
// Nunca pide ni usa la URL con token de descarga (esa se salta las reglas).
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
export const TIMEOUT_STORAGE_MS = 120 * 1000; // 25 MB por una conexión lenta
|
|
10
|
+
|
|
11
|
+
export class ErrorStorage extends Error {
|
|
12
|
+
constructor(status, cuerpo) {
|
|
13
|
+
super(cuerpo?.error?.message || `HTTP ${status}`);
|
|
14
|
+
this.status = status;
|
|
15
|
+
}
|
|
16
|
+
get negado() { return this.status === 403 || this.status === 401; }
|
|
17
|
+
get noExiste() { return this.status === 404; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class Storage {
|
|
21
|
+
/** @param {{ storageBucket: string, emuladorStorage?: string|null }} config */
|
|
22
|
+
constructor(config, sesion, { fetch: f = globalThis.fetch } = {}) {
|
|
23
|
+
this.sesion = sesion;
|
|
24
|
+
this.fetch = f;
|
|
25
|
+
this.bucket = config.storageBucket;
|
|
26
|
+
const raiz = config.emuladorStorage ? `http://${config.emuladorStorage}` : 'https://firebasestorage.googleapis.com';
|
|
27
|
+
this.base = `${raiz}/v0/b/${encodeURIComponent(this.bucket ?? '')}/o`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async _pedir(metodo, url, { cuerpo, cabeceras = {}, reintento = true, signal } = {}) {
|
|
31
|
+
if (!this.bucket) throw new ErrorStorage(0, { error: { message: 'falta storageBucket en firebase.config.json' } });
|
|
32
|
+
const token = await this.sesion.token();
|
|
33
|
+
const senales = [AbortSignal.timeout(TIMEOUT_STORAGE_MS), signal].filter(Boolean);
|
|
34
|
+
let r;
|
|
35
|
+
try {
|
|
36
|
+
r = await this.fetch(url, {
|
|
37
|
+
method: metodo,
|
|
38
|
+
headers: { Authorization: `Firebase ${token}`, ...cabeceras },
|
|
39
|
+
body: cuerpo,
|
|
40
|
+
signal: senales.length > 1 ? AbortSignal.any(senales) : senales[0],
|
|
41
|
+
});
|
|
42
|
+
} catch (e) {
|
|
43
|
+
if (e?.name === 'TimeoutError') throw new Error(`Storage no respondió en ${TIMEOUT_STORAGE_MS / 1000} s`);
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
if (r.status === 401 && reintento) {
|
|
47
|
+
await this.sesion.renovar();
|
|
48
|
+
return this._pedir(metodo, url, { cuerpo, cabeceras, reintento: false, signal });
|
|
49
|
+
}
|
|
50
|
+
return r;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static async _error(r) {
|
|
54
|
+
const d = await r.json().catch(() => ({}));
|
|
55
|
+
return new ErrorStorage(r.status, d);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Sube `bytes` a `ruta` en UNA petición (multipart): los metadatos que miran las reglas viajan con el archivo.
|
|
60
|
+
* @param {string} ruta salas/{salaId}/adjuntos/{tarjetaId}/{nombre}
|
|
61
|
+
* @param {Uint8Array} bytes
|
|
62
|
+
* @param {{ contentType: string, contentDisposition: string, metadatos: Record<string,string> }} m
|
|
63
|
+
*/
|
|
64
|
+
async subir(ruta, bytes, { contentType, contentDisposition, metadatos }) {
|
|
65
|
+
const limite = `lienzo-${randomBytes(12).toString('hex')}`;
|
|
66
|
+
const meta = JSON.stringify({ name: ruta, contentType, contentDisposition, metadata: metadatos });
|
|
67
|
+
const cuerpo = Buffer.concat([
|
|
68
|
+
Buffer.from(`--${limite}\r\nContent-Type: application/json; charset=utf-8\r\n\r\n${meta}\r\n--${limite}\r\nContent-Type: ${contentType}\r\n\r\n`),
|
|
69
|
+
Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength),
|
|
70
|
+
Buffer.from(`\r\n--${limite}--`),
|
|
71
|
+
]);
|
|
72
|
+
const r = await this._pedir('POST', `${this.base}?name=${encodeURIComponent(ruta)}`, {
|
|
73
|
+
cuerpo,
|
|
74
|
+
cabeceras: { 'Content-Type': `multipart/related; boundary=${limite}`, 'X-Goog-Upload-Protocol': 'multipart' },
|
|
75
|
+
});
|
|
76
|
+
if (!r.ok) throw await Storage._error(r);
|
|
77
|
+
return r.json().catch(() => ({}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Baja el objeto entero (con un tope: no se aceptan más bytes que `maxBytes`). Devuelve un Uint8Array.
|
|
82
|
+
*/
|
|
83
|
+
async bajar(ruta, { maxBytes, signal } = {}) {
|
|
84
|
+
const r = await this._pedir('GET', `${this.base}/${encodeURIComponent(ruta)}?alt=media`, { signal });
|
|
85
|
+
if (!r.ok) throw await Storage._error(r);
|
|
86
|
+
const largo = Number(r.headers.get('content-length'));
|
|
87
|
+
if (maxBytes != null && Number.isFinite(largo) && largo > maxBytes) {
|
|
88
|
+
await r.body?.cancel?.().catch(() => {});
|
|
89
|
+
throw new ErrorStorage(413, { error: { message: `el archivo pesa ${largo} bytes y la tarjeta dice ${maxBytes}` } });
|
|
90
|
+
}
|
|
91
|
+
const partes = [];
|
|
92
|
+
let total = 0;
|
|
93
|
+
for await (const trozo of r.body ?? []) {
|
|
94
|
+
total += trozo.byteLength;
|
|
95
|
+
if (maxBytes != null && total > maxBytes) {
|
|
96
|
+
throw new ErrorStorage(413, { error: { message: `el archivo pesa más de lo que dice la tarjeta (${maxBytes} bytes)` } });
|
|
97
|
+
}
|
|
98
|
+
partes.push(trozo);
|
|
99
|
+
}
|
|
100
|
+
return new Uint8Array(Buffer.concat(partes));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async borrar(ruta) {
|
|
104
|
+
const r = await this._pedir('DELETE', `${this.base}/${encodeURIComponent(ruta)}`);
|
|
105
|
+
if (!r.ok && r.status !== 404) throw await Storage._error(r);
|
|
106
|
+
}
|
|
107
|
+
}
|
package/lib/version.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ¿Hay una versión nueva del puente en npm? (21-10-2026, «el MCP de Figma se actualiza solo, el nuestro también debería»)
|
|
2
|
+
//
|
|
3
|
+
// Instalado con `npx -y @ajuarezso/lienzo-mcp@latest`, el puente ya se actualiza solo cada vez que el cliente lo
|
|
4
|
+
// arranca. Esto cubre lo demás (una versión fija, el plugin del repo, la extensión .mcpb de Claude Desktop, o un
|
|
5
|
+
// cliente que lleva días abierto): al arrancar se pregunta UNA vez al registro de npm, con un timeout corto y sin
|
|
6
|
+
// bloquear el arranque; si hay una más nueva, la primera respuesta de herramienta lo dice en una línea.
|
|
7
|
+
export const PAQUETE = '@ajuarezso/lienzo-mcp';
|
|
8
|
+
export const COMANDO = `claude mcp add lienzo --scope user -- npx -y ${PAQUETE}@latest`;
|
|
9
|
+
const TIMEOUT_MS = 1500;
|
|
10
|
+
|
|
11
|
+
/** «0.10.0» > «0.9.3». Solo x.y.z (una prerelease nunca se ofrece como nueva). */
|
|
12
|
+
export function esMasNueva(candidata, actual) {
|
|
13
|
+
const p = (v) => (/^\d+\.\d+\.\d+$/.test(String(v ?? '')) ? String(v).split('.').map(Number) : null);
|
|
14
|
+
const a = p(candidata);
|
|
15
|
+
const b = p(actual);
|
|
16
|
+
if (!a || !b) return false;
|
|
17
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* La versión `latest` en npm si es más nueva que `actual`; null si no, si no hay red o si tarda más de 1,5 s.
|
|
23
|
+
* Nunca lanza.
|
|
24
|
+
*/
|
|
25
|
+
export async function versionNueva(actual, { fetch: f = globalThis.fetch, timeoutMs = TIMEOUT_MS, registro = 'https://registry.npmjs.org' } = {}) {
|
|
26
|
+
try {
|
|
27
|
+
const r = await f(`${registro}/${PAQUETE.replace('/', '%2F')}/latest`, {
|
|
28
|
+
headers: { accept: 'application/json' },
|
|
29
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
30
|
+
});
|
|
31
|
+
if (!r.ok) return null;
|
|
32
|
+
const { version } = await r.json();
|
|
33
|
+
return esMasNueva(version, actual) ? version : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Para pasar de una versión fija (o del comando viejo) a la que se actualiza sola: quitar y volver a agregar. */
|
|
40
|
+
export const COMANDO_REINSTALAR = `claude mcp remove lienzo --scope user && ${COMANDO}`;
|
|
41
|
+
|
|
42
|
+
/** La línea que va al agente (una vez por proceso). */
|
|
43
|
+
export const avisoDeVersion = (nueva, actual) =>
|
|
44
|
+
`(Lienzo: hay una versión nueva del puente, ${nueva}; esta es ${actual}. Díselo a tu usuario una vez: si lo instaló con ` +
|
|
45
|
+
`@latest (o sin versión), se actualiza solo al reiniciar su cliente (en Claude Code, /mcp → lienzo → reconectar); si ` +
|
|
46
|
+
`tiene una versión fija, que lo reinstale con «${COMANDO_REINSTALAR}» y, si lo tenía con -e (LIENZO_AGENTE, ` +
|
|
47
|
+
`LIENZO_NOMBRE…), que los repita en el add, antes del --. La extensión de Claude Desktop (.mcpb) no se actualiza sola: ` +
|
|
48
|
+
`hay que bajar la nueva.)`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ajuarezso/lienzo-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Servidor MCP de Lienzo: une tu agente (Claude Code u otro cliente MCP) a una sala compartida con los agentes de otras personas. / MCP server that joins your agent to a room shared with other people's agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"test": "node --test test/unit/*.test.mjs",
|
|
35
|
-
"test:emulador": "cd .. && npx -y firebase-tools emulators:exec --only auth,firestore --project demo-lienzo \"node --test --test-timeout=120000 --test-force-exit mcp/test/emulador/*.test.mjs\"",
|
|
35
|
+
"test:emulador": "cd .. && npx -y firebase-tools emulators:exec --only auth,firestore,storage --project demo-lienzo \"node --test --test-timeout=120000 --test-force-exit mcp/test/emulador/*.test.mjs\"",
|
|
36
36
|
"e2e:real": "node test/e2e-real.mjs",
|
|
37
37
|
"prepack": "node -e \"require('node:fs').copyFileSync('../firebase.config.json', 'firebase.config.json')\"",
|
|
38
38
|
"postpack": "node -e \"require('node:fs').rmSync('firebase.config.json', { force: true })\""
|