@ajuarezso/lienzo-mcp 0.2.0 → 0.3.1

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/lib/config.mjs CHANGED
@@ -3,6 +3,9 @@
3
3
  // LIENZO_HOME carpeta de estado (por defecto ~/.lienzo)
4
4
  // LIENZO_SALA sala a la que se une solo al primer uso
5
5
  // LIENZO_NOMBRE nombre visible del agente (por defecto «Agente de <tu primer nombre>», de tu cuenta)
6
+ // LIENZO_AGENTE cuál de tus agentes es este puente (personal, trabajo, pc…): con él, la misma cuenta puede
7
+ // tener varios agentes en una sala, cada uno con su id `{uid}_agente_{slug}`. Sin él, el
8
+ // agente de siempre (`{uid}_agente`)
6
9
  // LIENZO_CLIENTE 'claude-code' por defecto
7
10
  // LIENZO_FIREBASE_CONFIG ruta a otro firebase.config.json (por defecto: el que viaja en el paquete de npm,
8
11
  // y si no está, el de la raíz del repo)
@@ -14,6 +17,17 @@ import { readFileSync, existsSync } from 'node:fs';
14
17
  import { homedir } from 'node:os';
15
18
  import { join, dirname } from 'node:path';
16
19
  import { fileURLToPath } from 'node:url';
20
+ import { slugDeAgente, ErrorAgente } from './agentes.mjs';
21
+
22
+ /** El slug de LIENZO_AGENTE, o el error para decirlo en la primera herramienta (el puente arranca igual). */
23
+ function agenteDe(entrada) {
24
+ try {
25
+ return { agente: slugDeAgente(entrada), errorAgente: null };
26
+ } catch (e) {
27
+ if (e instanceof ErrorAgente) return { agente: null, errorAgente: e.message };
28
+ throw e;
29
+ }
30
+ }
17
31
 
18
32
  const AQUI = dirname(fileURLToPath(import.meta.url));
19
33
 
@@ -46,6 +60,7 @@ export function cargarConfig(env = process.env) {
46
60
  home,
47
61
  salaId: env.LIENZO_SALA || archivo.salaId || null,
48
62
  nombre: env.LIENZO_NOMBRE || archivo.nombre || null,
63
+ ...agenteDe(env.LIENZO_AGENTE || archivo.agente || null),
49
64
  cliente: env.LIENZO_CLIENTE || archivo.cliente || 'claude-code',
50
65
  apiKey: firebase.apiKey,
51
66
  projectId,
package/lib/esperar.mjs CHANGED
@@ -119,13 +119,18 @@ async function sondear(consultar, o, limite, consultasPrevias) {
119
119
  }
120
120
  }
121
121
 
122
- /** Respuesta de lienzo_leer / lienzo_esperar que no entregó todo (lo dice formatearLote). */
122
+ /**
123
+ * Respuesta de lienzo_leer / lienzo_esperar que no entregó todo. OJO: el texto también trae lo que escribieron otros
124
+ * (una tarjeta puede decir «Había más de 20 personas»): mejor el `recortado` de formatearLoteDetalle.
125
+ */
123
126
  export const RE_RECORTADA = /Respuesta recortada|Había más de/;
124
127
 
125
128
  /**
126
129
  * Tras formatear lo que trajo una espera con timbre: si la respuesta entró entera, lo consultado ya está
127
130
  * entregado y la próxima espera no necesita volver a mirar (ahorra una pasada por cada novedad).
131
+ * `recortado`: el booleano de formatearLoteDetalle (o, por compatibilidad, el texto, que se mira con RE_RECORTADA).
128
132
  */
129
- export function entregado(timbre, r, texto) {
130
- if (timbre && r?.modo === 'timbre' && r.version != null && !RE_RECORTADA.test(texto)) timbre.visto = r.version;
133
+ export function entregado(timbre, r, recortado) {
134
+ const corto = typeof recortado === 'boolean' ? recortado : RE_RECORTADA.test(String(recortado ?? ''));
135
+ if (timbre && r?.modo === 'timbre' && r.version != null && !corto) timbre.visto = r.version;
131
136
  }
package/lib/firestore.mjs CHANGED
@@ -114,14 +114,19 @@ export class Firestore {
114
114
  /**
115
115
  * Arma una escritura para :commit (para poder usar la hora del servidor).
116
116
  * opciones: { crear: true } exige que no exista; { existe: true } exige que exista;
117
- * mascara: [campos] actualiza solo esos; horaServidor: [campos] = REQUEST_TIME.
117
+ * mascara: [campos] actualiza solo esos; horaServidor: [campos] = REQUEST_TIME;
118
+ * agregar / quitar: { campo: [valores] } = appendMissingElements / removeAllFromArray (atómico en el servidor:
119
+ * dos puentes que agregan a la vez no se pisan).
118
120
  */
119
- escritura(ruta, datos, { crear = false, existe = false, mascara = null, horaServidor = [] } = {}) {
121
+ escritura(ruta, datos, { crear = false, existe = false, mascara = null, horaServidor = [], agregar = null, quitar = null } = {}) {
120
122
  const write = { update: { name: this.nombre(ruta), fields: aCampos(datos) } };
121
123
  if (mascara) write.updateMask = { fieldPaths: mascara.map(campoRuta) };
122
- if (horaServidor.length) {
123
- write.updateTransforms = horaServidor.map((c) => ({ fieldPath: campoRuta(c), setToServerValue: 'REQUEST_TIME' }));
124
- }
124
+ const transformaciones = [
125
+ ...horaServidor.map((c) => ({ fieldPath: campoRuta(c), setToServerValue: 'REQUEST_TIME' })),
126
+ ...Object.entries(agregar ?? {}).map(([c, v]) => ({ fieldPath: campoRuta(c), appendMissingElements: { values: v.map(aValor) } })),
127
+ ...Object.entries(quitar ?? {}).map(([c, v]) => ({ fieldPath: campoRuta(c), removeAllFromArray: { values: v.map(aValor) } })),
128
+ ];
129
+ if (transformaciones.length) write.updateTransforms = transformaciones;
125
130
  if (crear) write.currentDocument = { exists: false };
126
131
  else if (existe) write.currentDocument = { exists: true };
127
132
  return write;
package/lib/lienzo.mjs CHANGED
@@ -9,6 +9,7 @@ import { envolver, limpiarLinea, neutralizar, abreviarId, aviso, nuevoCodigo } f
9
9
  import { rutaCompartible, revisarNombre, ErrorArchivo } from './archivos.mjs';
10
10
  import { INTERVALO_MS } from './esperar.mjs';
11
11
  import { cerrarTimbre } from './escucha.mjs';
12
+ import { idDeAgente, cuentaDe, esAgenteDe, TOPE_AGENTES } from './agentes.mjs';
12
13
 
13
14
  export const MAX_TEXTO = 20000;
14
15
  export const MAX_ARCHIVO_BYTES = 200 * 1024;
@@ -29,10 +30,13 @@ export function nombreDeCuenta(cuenta) {
29
30
  return local || null;
30
31
  }
31
32
 
32
- /** «Agente de <primer nombre>» (o null si la cuenta no tiene nombre ni correo). */
33
- export function nombreAgentePorDefecto(cuenta) {
33
+ /**
34
+ * «Agente de <primer nombre>» (o null si la cuenta no tiene nombre ni correo). Con un agente con nombre
35
+ * (LIENZO_AGENTE), «Agente de <primer nombre> · <slug>»: así tres agentes de la misma cuenta no se llaman igual.
36
+ */
37
+ export function nombreAgentePorDefecto(cuenta, slug = null) {
34
38
  const n = nombreDeCuenta(cuenta);
35
- return n ? limpiarLinea(`Agente de ${n.split(/\s+/)[0]}`, 80) : null;
39
+ return n ? limpiarLinea(`Agente de ${n.split(/\s+/)[0]}${slug ? ` · ${slug}` : ''}`, 80) : null;
36
40
  }
37
41
 
38
42
  // ── listas de puntos (docs/producto.md §4) ──────────────────────────────────
@@ -179,6 +183,7 @@ export class Lienzo {
179
183
  this.intervaloEspera = INTERVALO_MS; // lo que lleva creciendo la espera de lienzo_esperar
180
184
  this.participantes = new Map();
181
185
  this.participantesEn = 0;
186
+ this._ausentes = new Map(); // id → cuándo se confirmó que no está en la sala (no se recarga todo por él cada vez)
182
187
  this.tareasPedidas = new Map(); // id → { estado, respuesta, tomadaPor }
183
188
  this.tareasCargadas = false;
184
189
  this.listas = new Set(); // ids de las listas que pediste (sus puntos llegan agrupados)
@@ -195,14 +200,26 @@ export class Lienzo {
195
200
  /** La cuenta (la persona). */
196
201
  get uid() { return this.sesion.uid; }
197
202
 
198
- /** El participante de este agente en la sala: `{uid}_agente`. */
199
- get participanteId() { return this.uid ? `${this.uid}_agente` : null; }
203
+ /** El slug de este puente (LIENZO_AGENTE), o null: el agente de siempre. */
204
+ get slug() { return this.config.agente ?? null; }
205
+
206
+ /** El participante de este agente en la sala: `{uid}_agente` o, con LIENZO_AGENTE, `{uid}_agente_{slug}`. */
207
+ get participanteId() { return this.uid ? idDeAgente(this.uid, this.slug) : null; }
200
208
 
201
- /** ¿Este participante es de mi cuenta (mi persona o yo, su agente)? Lo mismo que esMio() en las reglas. */
209
+ /**
210
+ * ¿Esto es para mí? Mi persona o yo, ESTE agente. Más estrecho que esMio() de las reglas (que mira la cuenta
211
+ * entera): una tarea para otro agente de mi misma cuenta («Claude trabajo») no es de «Claude personal», aunque las
212
+ * reglas lo dejarían tocarla.
213
+ */
202
214
  esMio(id) {
203
215
  return Boolean(id) && (id === this.uid || id === this.participanteId);
204
216
  }
205
217
 
218
+ /** ¿Es otro agente de mi misma cuenta (no yo)? */
219
+ esOtroAgenteMio(id) {
220
+ return Boolean(id) && id !== this.participanteId && esAgenteDe(id, this.uid);
221
+ }
222
+
206
223
  link(salaId = this.salaId) {
207
224
  return `${this.config.webUrl}/sala/${salaId}`;
208
225
  }
@@ -231,7 +248,13 @@ export class Lienzo {
231
248
  return e;
232
249
  }
233
250
 
251
+ /** LIENZO_AGENTE que no deja un slug: el puente arranca igual y cada herramienta lo dice (no «MCP failed»). */
252
+ _exigirAgenteValido() {
253
+ if (this.config.errorAgente) throw new ErrorLienzo(this.config.errorAgente);
254
+ }
255
+
234
256
  async _asegurarSala() {
257
+ this._exigirAgenteValido();
235
258
  if (this.salaId && this.yo) {
236
259
  // otra ventana con el mismo LIENZO_HOME pudo conectar otra cuenta: token() recarga sesion.json
237
260
  await this.sesion.token().catch((e) => { throw this._traducir(e, 'iniciar sesión'); });
@@ -323,8 +346,19 @@ export class Lienzo {
323
346
 
324
347
  async _participante(id) {
325
348
  if (!id) return null;
326
- if (!this.participantes.has(id)) await this._cargarParticipantes(true);
327
- return this.participantes.get(id) || null;
349
+ if (this.participantes.has(id)) return this.participantes.get(id);
350
+ // Un id que no está fuerza una recarga (quien entró recién). Pero si ya se confirmó hace poco que no está (alguien
351
+ // que salió, o un paraId inventado), no se vuelve a leer la sala entera (hasta 500) por cada tarjeta que lo nombre.
352
+ const ausente = this._ausentes.get(id);
353
+ if (ausente != null && Date.now() - ausente < RECARGA_PARTICIPANTES_MS) return null;
354
+ await this._cargarParticipantes(true);
355
+ const p = this.participantes.get(id) || null;
356
+ if (p) this._ausentes.delete(id);
357
+ else {
358
+ if (this._ausentes.size >= 500) this._ausentes.clear();
359
+ this._ausentes.set(id, Date.now());
360
+ }
361
+ return p;
328
362
  }
329
363
 
330
364
  /** Cuántos participantes de la sala se llaman igual que `id` (1 = nombre único). */
@@ -392,6 +426,7 @@ export class Lienzo {
392
426
  * nombre: el que se pase, LIENZO_NOMBRE o «Agente de <primer nombre de la cuenta>».
393
427
  */
394
428
  async unirse({ salaId, nombre }) {
429
+ this._exigirAgenteValido();
395
430
  salaId = String(salaId || '').trim();
396
431
  // aceptar también el link completo de la web
397
432
  const m = /\/sala\/([A-Za-z0-9_-]{22})/.exec(salaId);
@@ -401,7 +436,7 @@ export class Lienzo {
401
436
  await this.sesion.token().catch((e) => { throw this._traducir(e, 'iniciar sesión'); });
402
437
  const cuenta = this.sesion.cuenta;
403
438
  const dueño = nombreDeCuenta(cuenta);
404
- nombre = String(nombre || this.config.nombre || nombreAgentePorDefecto(cuenta) || '').trim();
439
+ nombre = String(nombre || this.config.nombre || nombreAgentePorDefecto(cuenta, this.slug) || '').trim();
405
440
  if (!nombre) throw new ErrorLienzo('Falta tu nombre en la sala (p. ej. «Agente de Ana»): pásalo en nombre.');
406
441
  if (nombre.length > 80) throw new ErrorLienzo('El nombre admite hasta 80 caracteres.');
407
442
  if (/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(nombre)) throw new ErrorLienzo('El nombre va en una sola línea, sin caracteres de control ni invisibles.');
@@ -423,14 +458,23 @@ export class Lienzo {
423
458
  };
424
459
  let commit;
425
460
  try {
426
- if (previo) {
427
- datos.conectadoEn = previo.conectadoEn ? marcaDeTiempo(previo.conectadoEn) : null;
428
- commit = await this.fs.escribir(ruta, datos, { horaServidor: previo.conectadoEn ? ['vistoEn'] : ['vistoEn', 'conectadoEn'] });
429
- } else {
430
- commit = await this.fs.escribir(ruta, datos, { horaServidor: ['conectadoEn', 'vistoEn'] });
461
+ if (previo) datos.conectadoEn = previo.conectadoEn ? marcaDeTiempo(previo.conectadoEn) : null;
462
+ const escrituras = [{
463
+ ruta, datos, opciones: { horaServidor: previo?.conectadoEn ? ['vistoEn'] : ['vistoEn', 'conectadoEn'] },
464
+ }];
465
+ // Un agente con nombre entra en el MISMO :commit que su slug en el índice de la cuenta (las reglas lo exigen:
466
+ // así se cumple el tope y el índice nunca lista a alguien que no está). appendMissingElements: tres puentes
467
+ // de la misma cuenta que entran a la vez no se pisan el índice.
468
+ if (this.slug) {
469
+ escrituras.push({
470
+ ruta: `salas/${salaId}/cuentas/${this.uid}`, datos: {}, opciones: { mascara: [], agregar: { agentes: [this.slug] } },
471
+ });
431
472
  }
473
+ commit = await this.fs.escribirVarios(escrituras);
432
474
  } catch (e) {
433
- const texto = negadoDe(e) ? await this.explicarNegado(salaId, { alUnirse: true }).catch(() => null) : null;
475
+ const texto = negadoDe(e)
476
+ ? (await this.explicarNegado(salaId, { alUnirse: true }).catch(() => null)) ?? (await this._explicarTope(salaId).catch(() => null))
477
+ : null;
434
478
  throw texto ? new ErrorLienzo(texto, { cause: e }) : this._traducir(e, 'unirte a la sala');
435
479
  }
436
480
 
@@ -451,11 +495,22 @@ export class Lienzo {
451
495
  this.listas.clear();
452
496
  this.participantes.clear();
453
497
  this.participantesEn = 0;
498
+ this._ausentes.clear();
454
499
  }
455
500
  await this._anotarEnIndice(salaId);
456
501
  return { salaId, sala, link: this.link(salaId), yo: this.yo };
457
502
  }
458
503
 
504
+ /** Un agente con nombre que no entra: ¿es por el tope? (solo se mira cuando ya falló) */
505
+ async _explicarTope(salaId) {
506
+ if (!this.slug) return null;
507
+ const idx = await this.fs.leer(`salas/${salaId}/cuentas/${this.uid}`);
508
+ const agentes = Array.isArray(idx?.agentes) ? idx.agentes : [];
509
+ if (agentes.length < TOPE_AGENTES || agentes.includes(this.slug)) return null;
510
+ return `Tu cuenta ya tiene ${agentes.length} agentes con nombre en esta sala (${agentes.map((a) => limpiarLinea(a, 24)).join(', ')}), ` +
511
+ `que es el tope. Usa uno de esos en LIENZO_AGENTE, o sal de la sala desde la web y vuelve a entrar con los que necesites.`;
512
+ }
513
+
459
514
  // ── cuenta ─────────────────────────────────────────────────────────────────
460
515
  /** Olvida la sala (la sesión pasó a otra cuenta: lo de la sala era de la anterior). */
461
516
  _olvidarSala() {
@@ -498,7 +553,7 @@ export class Lienzo {
498
553
  * mensaje original). Solo corre cuando algo ya falló: el camino feliz no hace ninguna lectura de más.
499
554
  * 1. estás en `expulsados` → te quitaron (borra tu índice, como la web)
500
555
  * 2. la sala tiene `borradaEn` → se borró
501
- * 3. tu participante `{uid}_agente` no existe o no se deja leer → saliste (no aplica al unirse: ahí no eres miembro todavía)
556
+ * 3. tu participante (`{uid}_agente` o `{uid}_agente_{slug}`) no existe o no se deja leer → saliste (no aplica al unirse: ahí no eres miembro todavía)
502
557
  * 4. sigues siendo miembro → null
503
558
  * Con 1 a 3 se olvida la sala.
504
559
  */
@@ -578,6 +633,7 @@ export class Lienzo {
578
633
  conectado: p.vistoEn ? ahora - Date.parse(p.vistoEn) < CONECTADO_MS : false,
579
634
  soyYo: p.id === this.participanteId,
580
635
  miPersona: p.id === this.uid,
636
+ otroAgenteMio: this.esOtroAgenteMio(p.id),
581
637
  homonimos: this._homonimos(p.id),
582
638
  }));
583
639
  }
@@ -729,6 +785,12 @@ export class Lienzo {
729
785
  if (!soyDestinatario && !soyAutor) {
730
786
  throw new ErrorLienzo(`${que}: solo su destinatario o quien la pidió pueden cambiar su estado.`);
731
787
  }
788
+ // Una tarea para mi persona es de cualquiera de sus agentes, pero la atiende el primero que la toma: otro agente
789
+ // de la misma cuenta no la retoma ni responde por él (su respuesta saldría firmada por el que la tomó).
790
+ if (soyDestinatario && t.tomadaPor && this.esOtroAgenteMio(t.tomadaPor) && !CERRADOS.has(t.estado)) {
791
+ const quien = limpiarLinea(this.participantes.get(t.tomadaPor)?.nombre ?? abreviarId(t.tomadaPor));
792
+ throw new ErrorLienzo(`${que}: ya la tomó ${quien}, otro agente de tu usuario. Déjasela a él (o que la reabra).`);
793
+ }
732
794
  const cambios = { estado };
733
795
  const mascara = ['estado'];
734
796
  const tomadaPor = (v) => { cambios.tomadaPor = v; mascara.push('tomadaPor'); };
@@ -973,7 +1035,9 @@ export class Lienzo {
973
1035
  async _describir(id) {
974
1036
  if (id === this.participanteId) return 'ti';
975
1037
  const p = await this._autor(id);
976
- return `${limpiarLinea(p.nombre)} (id: ${abreviarId(id)}${p.homonimos > 1 ? `; OJO: ${p.homonimos} se llaman igual` : ''})`;
1038
+ // otro agente de tu misma cuenta: no eres tú, aunque las reglas te dejarían tocar lo suyo
1039
+ const mio = this.esOtroAgenteMio(id) ? 'otro agente de tu usuario, ' : '';
1040
+ return `${limpiarLinea(p.nombre)} (${mio}id: ${abreviarId(id)}${p.homonimos > 1 ? `; OJO: ${p.homonimos} se llaman igual` : ''})`;
977
1041
  }
978
1042
 
979
1043
  /**
@@ -1079,9 +1143,9 @@ export class Lienzo {
1079
1143
  const recortar = (t, n, nota = '') => { const u = String(t ?? ''); return u.length > n ? `${u.slice(0, Math.max(0, n - 1))}…${nota}` : u; };
1080
1144
  const contexto = recortar(String(padre.texto ?? '').split('\n').slice(1).join('\n').trim(), MAX_CONTEXTO_LISTA, ' (recortado)');
1081
1145
  const dest = padre.paraId ? await this._autor(padre.paraId) : null;
1082
- // Sin `dueño`, la persona de su cuenta: las reglas atan `{uid}_agente` a `uid` (solo entonces se lee).
1083
- const sinDueño = dest?.tipo === 'agente' && !String(dest['dueño'] ?? '').trim() && String(dest.id ?? '').endsWith('_agente');
1084
- const persona = sinDueño ? await this._autor(dest.id.slice(0, -'_agente'.length)) : null;
1146
+ // Sin `dueño`, la persona de su cuenta: las reglas atan `{uid}_agente` (y `{uid}_agente_{slug}`) a `uid`.
1147
+ const sinDueño = dest?.tipo === 'agente' && !String(dest['dueño'] ?? '').trim() && cuentaDe(dest.id) !== String(dest.id ?? '');
1148
+ const persona = sinDueño ? await this._autor(cuentaDe(dest.id)) : null;
1085
1149
  const quienEspera = dest ? quienEsperaLista(dest, persona) : null;
1086
1150
  const resumen = `lista: ${resumenLista(puntos.map((p) => p.estado), this.esMio(padre.paraId) ? 'tu usuario' : quienEspera)}`;
1087
1151
  const numero = (p) => numeroPunto(p.id) ?? '?';
@@ -1217,9 +1281,18 @@ export class Lienzo {
1217
1281
  * (creadaEn, editadaEn y el estado visto de tus tareas) avanzan SOLO hasta lo que se entregó, y un
1218
1282
  * bloque (una lista, o lo que nació en el mismo :commit) se entrega entero o no se entrega.
1219
1283
  */
1220
- async formatearLote(lote, { codigo = nuevoCodigo(), max = MAX_RESPUESTA } = {}) {
1221
- if (lote.una) return this._formatearUna(lote.una, { codigo, max, desdeCaracter: lote.desdeCaracter });
1222
- if (lote.lista) return `${aviso(codigo)}\n\n${await this._formatearLista(lote.lista.padre, lote.lista.puntos, { codigo, max })}`;
1284
+ async formatearLote(lote, o = {}) {
1285
+ return (await this.formatearLoteDetalle(lote, o)).texto;
1286
+ }
1287
+
1288
+ /**
1289
+ * formatearLote con el dato de si quedó algo sin entregar (`recortado`: tope de caracteres o más de
1290
+ * LIMITE_CONSULTA tarjetas), sin tener que buscarlo en el texto (que también trae lo que escribieron otros).
1291
+ * sinPie: sin los párrafos finales («Respuesta recortada…», «Había más de…», «marca: …»), para el canal.
1292
+ */
1293
+ async formatearLoteDetalle(lote, { codigo = nuevoCodigo(), max = MAX_RESPUESTA, sinPie = false } = {}) {
1294
+ if (lote.una) return { texto: await this._formatearUna(lote.una, { codigo, max, desdeCaracter: lote.desdeCaracter }), recortado: false };
1295
+ if (lote.lista) return { texto: `${aviso(codigo)}\n\n${await this._formatearLista(lote.lista.padre, lote.lista.puntos, { codigo, max })}`, recortado: false };
1223
1296
  const { tarjetas = [], editadas = [], cambios = [], soloAjenas = false, lleno = false } = lote;
1224
1297
  const nuevas = new Set(tarjetas.map((t) => t.id));
1225
1298
  const items = [
@@ -1259,6 +1332,8 @@ export class Lienzo {
1259
1332
  if (bloques.editadas.length) partes.push(`Tarjetas editadas (${piezas.editadas}):`, ...bloques.editadas);
1260
1333
  if (bloques.tarjetas.length) partes.push(`Tarjetas (${piezas.tarjetas}):`, ...bloques.tarjetas);
1261
1334
  if (!entregados && !cortado) partes.push('No hay tarjetas nuevas.');
1335
+ const recortado = cortado || Boolean(lleno);
1336
+ if (sinPie) return { texto: partes.join('\n\n'), recortado };
1262
1337
  if (cortado) {
1263
1338
  const total = quedan.cambios + quedan.editadas + quedan.tarjetas;
1264
1339
  const desglose = [
@@ -1273,6 +1348,6 @@ export class Lienzo {
1273
1348
  }
1274
1349
  const marca = soloAjenas ? this.marca : (ultimaCreada ?? lote.desde ?? this.marca);
1275
1350
  partes.push(`marca: ${marca ?? '(sin marca)'} — pásala como «desde» para pedir solo lo posterior.`);
1276
- return partes.join('\n\n');
1351
+ return { texto: partes.join('\n\n'), recortado };
1277
1352
  }
1278
1353
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ajuarezso/lienzo-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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",