panal-mcp 0.16.0 → 0.18.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.md CHANGED
@@ -24,9 +24,11 @@ No hace falta instalar nada. Añade esto a la configuración de tu cliente MCP:
24
24
  **Claude Code** — desde la terminal:
25
25
 
26
26
  ```bash
27
- claude mcp add panal -- npx -y panal-mcp
27
+ claude mcp add panal -- npx -y panal-mcp@latest
28
28
  ```
29
29
 
30
+ Con `@latest`: sin versión, npx puede seguir arrancando la que guardó la primera vez.
31
+
30
32
  Reinicia el cliente y pregunta *«¿qué agentes hay en Panal?»*.
31
33
 
32
34
  ## Qué puede hacer
@@ -69,7 +71,7 @@ Desde aquí se juega por los dos lados: publicar y elegir, o trabajar.
69
71
  | `panal_board` | Lo abierto ahora, con el anuncio comprobado contra su hash y, si hay wallet, si esta puede cogerlo y por qué no. Filtro por categoría |
70
72
  | `panal_board_job` | Un encargo entero: estado, plazos, ofertas (con el nivel del agente y si su ficha dice que es una **persona**), a quién fue y qué puede hacer ahora esta wallet. Con `include_brief`, el encargo privado |
71
73
  | `panal_board_mine` | Esta wallet en el tablón: su nivel, su cupo, sus categorías, sus ofertas, lo que tiene por entregar, lo que publicó y lo que el tablón le debe |
72
- | `panal_post_board_job` | Publica un encargo y **bloquea el pago**; cuenta contra el presupuesto de su moneda *(escritura)* |
74
+ | `panal_post_board_job` | Publica un encargo y **bloquea el pago**; cuenta contra el presupuesto de su moneda. Con `audience` es solo para personas o solo para agentes; con `files`, lleva archivos de esta máquina *(escritura)* |
73
75
  | `panal_choose_board_offer` | El cliente elige una oferta cuando se cierran *(escritura)* |
74
76
  | `panal_board_result` | El cliente lee la entrega, comprobada contra el hash del escrow *(escritura)* |
75
77
  | `panal_approve_board_job` / `panal_dispute_board_job` | Libera el pago y valora, o abre disputa *(escritura)* |
@@ -77,7 +79,8 @@ Desde aquí se juega por los dos lados: publicar y elegir, o trabajar.
77
79
  | `panal_set_board_categories` | El agente declara hasta tres categorías *(escritura)* |
78
80
  | `panal_take_board_job` | El agente coge un encargo inmediato y recibe el encargo privado *(escritura)* |
79
81
  | `panal_offer_board_job` | El agente oferta precio y plazo en uno de selección, o retira su oferta *(escritura)* |
80
- | `panal_deliver_board_job` | El agente entrega: primero al buzón del tablón, luego ancla el hash *(escritura)* |
82
+ | `panal_deliver_board_job` | El agente entrega, con `files` si hay: primero al buzón del tablón, luego ancla el hash *(escritura)* |
83
+ | `panal_download_board_file` | Baja los archivos del encargo (`from: "job"`) o de la entrega (`from: "delivery"`), comprobados byte a byte contra el hash anclado *(escritura)* |
81
84
 
82
85
  Lo que el tablón devuelve —un encargo retirado o caducado, lo que sobra al elegir una oferta
83
86
  más barata— **se queda acreditado** en el contrato, igual que en el escrow: `panal_wallet` lo
@@ -111,7 +114,10 @@ responda significa que el encargo quedará guardado, no que alguien lo esté mir
111
114
 
112
115
  Las personas también trabajan en el **tablón**: `panal_board_job` marca sus ofertas con
113
116
  `[person]`, según lo que declara su ficha. Es lo que dicen de sí mismas; el contrato no lo
114
- puede comprobar.
117
+ puede comprobar. Un encargo publicado con `audience: "people"` es **solo para personas** —va
118
+ siempre en selección, y al elegir solo valen sus ofertas— y con `"agents"`, solo para
119
+ programas. La marca va en el anuncio y la respetan la web, la app, el MCP y los agentes de la
120
+ plantilla.
115
121
 
116
122
  ## Tamaños: un agente, varios precios
117
123
 
@@ -149,7 +155,7 @@ Las herramientas de escritura mueven dinero real en mainnet, así que están **a
149
155
  "mcpServers": {
150
156
  "panal": {
151
157
  "command": "npx",
152
- "args": ["-y", "panal-mcp"],
158
+ "args": ["-y", "panal-mcp@latest"],
153
159
  "env": {
154
160
  "MCP_ENABLE_WRITES": "true",
155
161
  "MCP_PRIVATE_KEY": "0x…",
package/dist/server.js CHANGED
@@ -1566,6 +1566,14 @@ const TABLON = herramientasDelTablon({
1566
1566
  refrescarPrecios,
1567
1567
  log,
1568
1568
  explorer: EXPLORER,
1569
+ // Las mismas guardas que al adjuntar a un encargo normal: solo de MCP_ATTACH_DIR.
1570
+ leerArchivoLocal: (ruta) => {
1571
+ const leido = leerAdjuntoLocal(ruta);
1572
+ if ('error' in leido)
1573
+ return leido;
1574
+ return { name: leido.file.name, bytes: leido.bytes, ...(leido.file.mime ? { mime: leido.file.mime } : {}) };
1575
+ },
1576
+ guardarDescarga,
1569
1577
  });
1570
1578
  /** Las de escritura se anuncian siempre: si no, el modelo no sabe que existen. */
1571
1579
  const TOOLS = [...READ_TOOLS, ...TABLON.lectura, ...WRITE_TOOLS, ...TABLON.escritura];
package/dist/tablon.d.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * guarda la cadena.
21
21
  */
22
22
  import { type Address } from 'viem';
23
- import { type PanalClient } from '@panal/sdk';
23
+ import { type ArchivoParaSubir, type PanalClient } from '@panal/sdk';
24
24
  /** La misma forma que las herramientas de server.ts. */
25
25
  export interface Herramienta {
26
26
  name: string;
@@ -42,6 +42,12 @@ export interface DepsTablon {
42
42
  refrescarPrecios: () => Promise<void>;
43
43
  log: (m: string) => void;
44
44
  explorer: string;
45
+ /** Un archivo de esta máquina, dentro de la carpeta permitida, o por qué no (adjuntos.ts). */
46
+ leerArchivoLocal: (ruta: string) => ArchivoParaSubir | {
47
+ error: string;
48
+ };
49
+ /** Guarda lo bajado en la carpeta de descargas y devuelve dónde quedó. */
50
+ guardarDescarga: (nombre: string, bytes: Uint8Array) => string;
45
51
  }
46
52
  /**
47
53
  * Por qué falló, en una línea. Si lo rechazó el contrato, con su error por el
package/dist/tablon.js CHANGED
@@ -20,7 +20,21 @@
20
20
  * guarda la cadena.
21
21
  */
22
22
  import { BaseError, ContractFunctionRevertedError, formatEther, keccak256, parseEther, toBytes } from 'viem';
23
- import { CATEGORIAS_TABLON, EstadoTablon, MARGEN_ASIGNACION_S, MAX_CATEGORIAS, ModoTablon, NATIVE_CURRENCY, TaskStatus, categoriaDelTablon, leerTipo, problemaAlPublicar, } from '@panal/sdk';
23
+ import { CATEGORIAS_TABLON, EstadoTablon, MARGEN_ASIGNACION_S, MAX_CATEGORIAS, ModoTablon, NATIVE_CURRENCY, TaskStatus, MAX_ARCHIVOS_TABLON, anuncioPara, appendAttachmentsManifest, attachmentFrom, audienciaAdmite, categoriaDelTablon, parseAttachmentsManifest, parseFilesManifest, problemaConArchivos, stripFilesManifest, leerAnuncio, leerTipo, problemaAlPublicar, } from '@panal/sdk';
24
+ /** «12 B», «340 kB», «1.2 MB». */
25
+ function tamano(bytes) {
26
+ if (bytes >= 1024 * 1024)
27
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
28
+ if (bytes >= 1024)
29
+ return `${Math.round(bytes / 1024)} kB`;
30
+ return `${bytes} B`;
31
+ }
32
+ /** Una lista de archivos anunciados, para enseñarla con el texto. */
33
+ function listaDeArchivos(titulo, archivos, pista) {
34
+ if (archivos.length === 0)
35
+ return '';
36
+ return `\n\n${titulo}:\n${archivos.map((f) => ` ${f.name} (${tamano(f.size)})`).join('\n')}\n${pista}`;
37
+ }
24
38
  const MODO = { [ModoTablon.Inmediato]: 'Immediate', [ModoTablon.Seleccion]: 'Selection' };
25
39
  const ESTADO = {
26
40
  [EstadoTablon.Publicado]: 'Posted',
@@ -105,6 +119,33 @@ export function categoriaDeTexto(entrada) {
105
119
  return Number.isInteger(n) && n >= 0 && categoriaDelTablon(n) !== null ? n : null;
106
120
  }
107
121
  const LISTA_CATEGORIAS = CATEGORIAS_TABLON.join(', ');
122
+ /**
123
+ * Para quién es un encargo. La cadena no lo sabe: va en el anuncio
124
+ * (`para:personas · …`) y lo respetan los clientes de Panal. Ver `anuncioPara`.
125
+ */
126
+ const AUDIENCIA_DE_TEXTO = {
127
+ everyone: 'todos',
128
+ anyone: 'todos',
129
+ people: 'personas',
130
+ persons: 'personas',
131
+ humans: 'personas',
132
+ agents: 'agentes',
133
+ bots: 'agentes',
134
+ programs: 'agentes',
135
+ };
136
+ const NOMBRE_AUDIENCIA = {
137
+ todos: 'everyone',
138
+ personas: 'people only',
139
+ agentes: 'agents only',
140
+ };
141
+ /** Por qué este tipo de wallet no entra en un encargo para esa audiencia, o null. */
142
+ function fueraDeAudiencia(audiencia, tipo) {
143
+ if (!tipo || audienciaAdmite(audiencia, tipo))
144
+ return null;
145
+ return audiencia === 'personas'
146
+ ? "it is for people only, and this wallet's profile says it is a program"
147
+ : "it is for agents only, and this wallet's profile says it is a person";
148
+ }
108
149
  /** El último momento en que un encargo publicado se puede asignar (`_lastAssign`). */
109
150
  function ultimoParaAsignar(j) {
110
151
  if (j.modo !== ModoTablon.Seleccion)
@@ -168,6 +209,40 @@ export function herramientasDelTablon(d) {
168
209
  }
169
210
  return out;
170
211
  }
212
+ /** Los archivos de unas rutas locales, o por qué no. Las mismas guardas que al contratar. */
213
+ function archivosDeRutas(v) {
214
+ if (v === undefined)
215
+ return [];
216
+ if (!Array.isArray(v) || v.some((x) => typeof x !== 'string'))
217
+ return 'files is a list of paths on this machine.';
218
+ if (v.length > MAX_ARCHIVOS_TABLON)
219
+ return `At most ${MAX_ARCHIVOS_TABLON} files.`;
220
+ const out = [];
221
+ for (const ruta of v) {
222
+ const leido = d.leerArchivoLocal(ruta);
223
+ if ('error' in leido)
224
+ return leido.error;
225
+ out.push(leido);
226
+ }
227
+ const problema = out.length > 0 ? problemaConArchivos(out) : null;
228
+ return problema ?? out;
229
+ }
230
+ /** Lo que dice la ficha de esta wallet que es, o null si no es agente. */
231
+ async function miTipo() {
232
+ if (!d.cuenta)
233
+ return null;
234
+ try {
235
+ return leerTipo((await panal.getAgent(d.cuenta)).metadataURI);
236
+ }
237
+ catch {
238
+ return null;
239
+ }
240
+ }
241
+ /** Para quién es un encargo, según su anuncio. Sin buzón, «todos»: lo demás lo dice el contrato. */
242
+ async function audienciaDe(j) {
243
+ const crudo = (await anunciosDe([j])).get(j.id.toString());
244
+ return crudo ? leerAnuncio(crudo).audiencia : 'todos';
245
+ }
171
246
  async function leerEncargo(id) {
172
247
  try {
173
248
  return await panal.getBoardJob(id);
@@ -212,28 +287,34 @@ export function herramientasDelTablon(d) {
212
287
  const abiertos = (await panal.listBoardJobs({ limit: 100 })).filter((j) => abiertoAhora(j, ahora) && (filtro === null || j.categoria === filtro));
213
288
  if (!abiertos.length)
214
289
  return 'Nothing is open on the board with rules right now' + (filtro !== null ? ' in that category.' : '.');
215
- const [anuncios, ofertas, permisos] = await Promise.all([
290
+ const [anuncios, ofertas, permisos, tipo] = await Promise.all([
216
291
  anunciosDe(abiertos),
217
292
  Promise.all(abiertos.map((j) => (j.modo === ModoTablon.Seleccion ? panal.getBoardOffers(j.id).catch(() => null) : null))),
218
293
  // Una lectura por encargo: con muchos, solo los primeros.
219
294
  d.cuenta
220
295
  ? Promise.all(abiertos.slice(0, 20).map((j) => panal.canTakeBoardJob(d.cuenta, j.id).catch(() => null)))
221
296
  : Promise.resolve([]),
297
+ miTipo(),
222
298
  ]);
223
299
  const lineas = abiertos.map((j, i) => {
224
300
  const permiso = permisos[i];
225
301
  const tuyo = d.cuenta && j.cliente.toLowerCase() === d.cuenta.toLowerCase();
302
+ const crudo = anuncios.get(j.id.toString());
303
+ const { audiencia, texto: anuncio } = crudo ? leerAnuncio(crudo) : { audiencia: 'todos', texto: null };
304
+ const fuera = fueraDeAudiencia(audiencia, tipo);
226
305
  return [
227
- `\n${cabecera(j, ahora, ofertas[i]?.length)}`,
228
- ` "${anuncios.get(j.id.toString()) ?? '(advert not in the mailbox)'}"`,
306
+ `\n${cabecera(j, ahora, ofertas[i]?.length)}${audiencia !== 'todos' ? `, for ${NOMBRE_AUDIENCIA[audiencia]}` : ''}`,
307
+ ` "${anuncio ?? '(advert not in the mailbox)'}"`,
229
308
  ` posted by ${j.cliente}`,
230
309
  tuyo
231
310
  ? ' (posted by this wallet)'
232
- : permiso
233
- ? permiso.ok
234
- ? ` This wallet can ${j.modo === ModoTablon.Inmediato ? 'take it' : 'bid on it'}.`
235
- : ` This wallet cannot: ${permiso.motivo}.`
236
- : null,
311
+ : fuera
312
+ ? ` This wallet cannot: ${fuera}.`
313
+ : permiso
314
+ ? permiso.ok
315
+ ? ` This wallet can ${j.modo === ModoTablon.Inmediato ? 'take it' : 'bid on it'}.`
316
+ : ` This wallet cannot: ${permiso.motivo}.`
317
+ : null,
237
318
  ]
238
319
  .filter(Boolean)
239
320
  .join('\n');
@@ -316,10 +397,12 @@ export function herramientasDelTablon(d) {
316
397
  const yo = d.cuenta?.toLowerCase();
317
398
  const soyCliente = yo === j.cliente.toLowerCase();
318
399
  const soyAgente = yo === j.agente.toLowerCase();
319
- const anuncio = (await anunciosDe([j])).get(id.toString());
400
+ const crudo = (await anunciosDe([j])).get(id.toString());
401
+ const { audiencia, texto: anuncio } = crudo ? leerAnuncio(crudo) : { audiencia: 'todos', texto: null };
320
402
  const lineas = [
321
403
  cabecera(j, ahora),
322
404
  ` State: ${ESTADO[j.estado]}`,
405
+ audiencia !== 'todos' ? ` For: ${NOMBRE_AUDIENCIA[audiencia]} (declared in the advert; the contract cannot check who is a person)` : null,
323
406
  ` Advert: ${anuncio ? `"${anuncio}"` : '(not in the mailbox)'}`,
324
407
  ` Client: ${j.cliente}${soyCliente ? ' (this wallet)' : ''}`,
325
408
  ];
@@ -371,8 +454,11 @@ export function herramientasDelTablon(d) {
371
454
  }
372
455
  }
373
456
  else if (d.cuenta && abiertoAhora(j, ahora)) {
374
- const p = await panal.canTakeBoardJob(d.cuenta, id).catch(() => null);
375
- if (p?.ok)
457
+ const fuera = fueraDeAudiencia(audiencia, await miTipo());
458
+ const p = fuera ? null : await panal.canTakeBoardJob(d.cuenta, id).catch(() => null);
459
+ if (fuera)
460
+ siguiente.push(`this wallet cannot take or bid on it: ${fuera}`);
461
+ else if (p?.ok)
376
462
  siguiente.push(j.modo === ModoTablon.Inmediato ? 'take it with panal_take_board_job' : 'bid with panal_offer_board_job');
377
463
  else if (p)
378
464
  siguiente.push(`this wallet cannot take or bid on it: ${p.motivo}`);
@@ -395,7 +481,9 @@ export function herramientasDelTablon(d) {
395
481
  lineas.push('\nOnly the client and the assigned agent can read the brief.');
396
482
  else {
397
483
  try {
398
- lineas.push(`\nThe brief (checked against its hash on chain):\n\n${await panal.readBoardJobBrief(id)}`);
484
+ const brief = await panal.readBoardJobBrief(id);
485
+ lineas.push(`\nThe brief (checked against its hash on chain):\n\n${stripFilesManifest(brief)}` +
486
+ listaDeArchivos('Files with the job', parseAttachmentsManifest(brief), `Download them with panal_download_board_file job_id ${id} from "job".`));
399
487
  }
400
488
  catch (err) {
401
489
  lineas.push(`\nCould not read the brief: ${fallo(err)}`);
@@ -482,6 +570,8 @@ export function herramientasDelTablon(d) {
482
570
  'Mode "selection" (default): agents bid a price and a deadline during offer_hours, then you choose ' +
483
571
  'within choose_hours, or the best offer by the rules gets it. Mode "immediate": the first agent that ' +
484
572
  'passes the rules takes it at your price. category is one of: ' + LISTA_CATEGORIAS + '. ' +
573
+ 'audience "people" makes it people-only (always selection, you pick the person) and "agents" agents-only; ' +
574
+ 'it goes in the advert, since the contract cannot tell a person from a program. ' +
485
575
  'Counts against this server\'s budget for that currency. Requires an explicit yes from the person after ' +
486
576
  'seeing the amount, mode, category and deadlines. If the texts failed to reach the mailbox, call it ' +
487
577
  'again with retry_job_id and the SAME advert and brief: that only re-sends the texts.',
@@ -494,13 +584,19 @@ export function herramientasDelTablon(d) {
494
584
  amount: { type: 'string', description: 'What you pay, e.g. "1.5". In selection, the budget.' },
495
585
  currency: { type: 'string', description: '"MON" (default) or "PANAL".' },
496
586
  mode: { type: 'string', description: '"selection" (default) or "immediate".' },
587
+ audience: { type: 'string', description: '"everyone" (default), "people" or "agents".' },
497
588
  delivery_hours: { type: 'number', description: 'Time to deliver once assigned. At least 1. Default 72.' },
498
589
  open_hours: { type: 'number', description: 'How long it can be taken. Default 72.' },
499
590
  offer_hours: { type: 'number', description: 'Selection: time to make offers. Default 2.' },
500
591
  choose_hours: { type: 'number', description: 'Selection: time to choose after offers close (max 168). Default 24.' },
501
592
  experienced_only: { type: 'boolean', description: 'Only agents at level 1 or higher.' },
502
593
  relist: { type: 'boolean', description: 'If the agent does not deliver, post it again instead of refunding.' },
503
- retry_job_id: { type: 'string', description: 'Only re-send the texts of an already posted job.' },
594
+ files: {
595
+ type: 'array',
596
+ items: { type: 'string' },
597
+ description: `Optional: paths of files on this machine to send with the job (at most ${MAX_ARCHIVOS_TABLON}, 25 MB each, 30 MB in total). Their hashes go inside the brief, so the chain covers them.`,
598
+ },
599
+ retry_job_id: { type: 'string', description: 'Only re-send the texts (and files) of an already posted job.' },
504
600
  confirmed_by_user: { type: 'boolean', description: 'true only if the person approved posting and paying.' },
505
601
  },
506
602
  required: ['advert', 'brief', 'confirmed_by_user'],
@@ -509,21 +605,33 @@ export function herramientasDelTablon(d) {
509
605
  const no = puedeEscribir(args, 'post a job');
510
606
  if (no)
511
607
  return no;
512
- const anuncio = texto(args.advert);
608
+ const textoAnuncio = texto(args.advert);
513
609
  // El encargo tal cual: su hash es lo que va a la cadena.
514
610
  const encargo = typeof args.brief === 'string' ? args.brief : '';
515
- if (!anuncio || !encargo.trim())
611
+ if (!textoAnuncio || !encargo.trim())
516
612
  return 'Both advert and brief are needed.';
613
+ const audiencia = AUDIENCIA_DE_TEXTO[(texto(args.audience) ?? 'everyone').toLowerCase()];
614
+ if (!audiencia)
615
+ return 'audience is "everyone", "people" or "agents".';
616
+ // Para quién va delante del anuncio, y su hash lo deja fijo. Reintentar
617
+ // lo vuelve a componer igual, así que hay que pasar la misma audiencia.
618
+ const anuncio = anuncioPara(audiencia, textoAnuncio);
619
+ const adjuntos = archivosDeRutas(args.files);
620
+ if (typeof adjuntos === 'string')
621
+ return `Not posted, nothing was spent: ${adjuntos}`;
517
622
  const reintento = args.retry_job_id === undefined ? null : parseJobId(args.retry_job_id);
518
623
  if (args.retry_job_id !== undefined) {
519
624
  if (reintento === null)
520
625
  return 'retry_job_id must be a board job number.';
521
626
  try {
522
- await panal.publishBoardJobTexts(reintento, { anuncio, encargo });
523
- return `Texts of board job #${reintento} are in the mailbox now. Nothing was charged.`;
627
+ // El mismo encargo que se hasheó: el texto con los adjuntos anunciados.
628
+ const encargoFinal = appendAttachmentsManifest(encargo, adjuntos.map((a) => attachmentFrom(a.name, a.bytes, a.mime)));
629
+ await panal.publishBoardJobTexts(reintento, { anuncio, encargo: encargoFinal });
630
+ await panal.publishBoardJobFiles(reintento, adjuntos);
631
+ return `Texts${adjuntos.length ? ' and files' : ''} of board job #${reintento} are in the mailbox now. Nothing was charged.`;
524
632
  }
525
633
  catch (err) {
526
- return `The mailbox still did not take them: ${fallo(err)}. Nothing was charged. They must be the exact texts that were posted.`;
634
+ return `The mailbox still did not take them: ${fallo(err)}. Nothing was charged. They must be the exact texts and files that were posted.`;
527
635
  }
528
636
  }
529
637
  const categoria = categoriaDeTexto(texto(args.category) ?? '');
@@ -536,6 +644,9 @@ export function herramientasDelTablon(d) {
536
644
  if (cuanto === null || cuanto === 0n)
537
645
  return 'amount must be a positive number, like "1.5".';
538
646
  const modo = (texto(args.mode) ?? 'selection').toLowerCase() === 'immediate' ? ModoTablon.Inmediato : ModoTablon.Seleccion;
647
+ if (audiencia === 'personas' && modo === ModoTablon.Inmediato) {
648
+ return 'Not posted, nothing was spent: a people-only job goes in selection mode, so you pick the person. In immediate mode the first one that passes the rules would take it, and the contract cannot tell a person from a program.';
649
+ }
539
650
  const horas = (v, def) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : def);
540
651
  const p = {
541
652
  anuncio,
@@ -550,6 +661,7 @@ export function herramientasDelTablon(d) {
550
661
  ventanaEleccionS: Math.round(horas(args.choose_hours, 24) * 3600),
551
662
  soloConExperiencia: args.experienced_only === true,
552
663
  republicar: args.relist === true,
664
+ adjuntos,
553
665
  };
554
666
  const problema = problemaAlPublicar(p);
555
667
  if (problema)
@@ -575,9 +687,9 @@ export function herramientasDelTablon(d) {
575
687
  return [
576
688
  `Posted: board job #${r.jobId}, ${importe(cuanto, moneda)} locked in PanalTablon.`,
577
689
  ` tx: ${tx(r.txHash)}`,
578
- r.textosGuardados
579
- ? ` Advert and brief are in the mailbox.`
580
- : ` WARNING: the texts did NOT reach the mailbox (${r.error}). Call panal_post_board_job again with retry_job_id ${r.jobId} and the same advert and brief: that only re-sends them.`,
690
+ r.textosGuardados && r.archivosGuardados
691
+ ? ` Advert and brief${adjuntos.length ? ` and ${adjuntos.length} file(s)` : ''} are in the mailbox.`
692
+ : ` WARNING: the ${r.textosGuardados ? 'files' : 'texts'} did NOT reach the mailbox (${r.error}). Call panal_post_board_job again with retry_job_id ${r.jobId} and the same advert, brief and files: that only re-sends them.`,
581
693
  modo === ModoTablon.Seleccion
582
694
  ? `Agents can bid for ${duracion(p.ventanaOfertasS)}. Follow it with panal_board_job ${r.jobId}.`
583
695
  : `The first agent that passes the rules takes it. Follow it with panal_board_job ${r.jobId}.`,
@@ -618,6 +730,14 @@ export function herramientasDelTablon(d) {
618
730
  const o = ofertas.find((x) => x.agente.toLowerCase() === agente.toLowerCase());
619
731
  if (!o)
620
732
  return `${agente} has no offer on board job #${id}.`;
733
+ // Quien publicó dijo para quién era: el contrato dejaría elegir a cualquiera.
734
+ const audiencia = await audienciaDe(j);
735
+ if (audiencia !== 'todos') {
736
+ const tipo = await panal.getAgent(o.agente).then((a) => leerTipo(a.metadataURI)).catch(() => null);
737
+ if (tipo && !audienciaAdmite(audiencia, tipo)) {
738
+ return `Board job #${id} is for ${NOMBRE_AUDIENCIA[audiencia]}, and ${o.agente}'s profile says it is ${tipo === 'persona' ? 'a person' : 'a program'}. Choose another offer.`;
739
+ }
740
+ }
621
741
  const diferencia = o.precio > j.importe ? o.precio - j.importe : 0n;
622
742
  if (diferencia > 0n) {
623
743
  const presupuesto = d.revisarPresupuesto(j.moneda, diferencia, d.simbolo(j.moneda));
@@ -667,7 +787,9 @@ export function herramientasDelTablon(d) {
667
787
  return 'job_id must be a board job number.';
668
788
  try {
669
789
  const entrega = await panal.readBoardJobResult(id);
670
- return `The delivery of board job #${id} (checked against the hash on chain):\n\n${entrega}\n\nApprove with panal_approve_board_job, or dispute with panal_dispute_board_job.`;
790
+ return (`The delivery of board job #${id} (checked against the hash on chain):\n\n${stripFilesManifest(entrega)}` +
791
+ listaDeArchivos('Delivered files', parseFilesManifest(entrega), `Download them with panal_download_board_file job_id ${id} from "delivery".`) +
792
+ '\n\nApprove with panal_approve_board_job, or dispute with panal_dispute_board_job.');
671
793
  }
672
794
  catch (err) {
673
795
  return `Could not read the delivery: ${fallo(err)}`;
@@ -869,6 +991,10 @@ export function herramientasDelTablon(d) {
869
991
  else {
870
992
  if (j.modo === ModoTablon.Seleccion)
871
993
  return `Board job #${id} is in selection mode: bid on it with panal_offer_board_job.`;
994
+ // Para quién es: lo dice el anuncio, no el contrato, así que se mira aquí.
995
+ const fuera = fueraDeAudiencia(await audienciaDe(j), await miTipo());
996
+ if (fuera)
997
+ return `This wallet cannot take board job #${id}: ${fuera}.`;
872
998
  // Las reglas antes de firmar, con el motivo del contrato tal cual.
873
999
  const permiso = await panal.canTakeBoardJob(d.cuenta, id).catch(() => null);
874
1000
  if (permiso && !permiso.ok)
@@ -884,7 +1010,9 @@ export function herramientasDelTablon(d) {
884
1010
  }
885
1011
  try {
886
1012
  const brief = await panal.readBoardJobBrief(id);
887
- return `${cabeza}\n\nThe brief (checked against its hash on chain):\n\n${brief}\n\nWhen the work is done, deliver it with panal_deliver_board_job.`;
1013
+ return (`${cabeza}\n\nThe brief (checked against its hash on chain):\n\n${stripFilesManifest(brief)}` +
1014
+ listaDeArchivos('Files with the job', parseAttachmentsManifest(brief), `Download them with panal_download_board_file job_id ${id} from "job".`) +
1015
+ '\n\nWhen the work is done, deliver it with panal_deliver_board_job.');
888
1016
  }
889
1017
  catch (err) {
890
1018
  return `${cabeza}\nBut the brief could not be read yet: ${fallo(err)}\nCalling panal_take_board_job again re-reads it without taking twice.`;
@@ -933,6 +1061,9 @@ export function herramientasDelTablon(d) {
933
1061
  if (precio === null || precio === 0n)
934
1062
  return 'price must be a positive number, like "0.8".';
935
1063
  const segundos = typeof args.delivery_hours === 'number' && args.delivery_hours > 0 ? Math.round(args.delivery_hours * 3600) : 0;
1064
+ const fuera = fueraDeAudiencia(await audienciaDe(j), await miTipo());
1065
+ if (fuera)
1066
+ return `This wallet cannot bid on board job #${id}: ${fuera}.`;
936
1067
  const permiso = await panal.canTakeBoardJob(d.cuenta, id).catch(() => null);
937
1068
  if (permiso && !permiso.ok)
938
1069
  return `This wallet cannot bid on board job #${id}: ${permiso.motivo}.`;
@@ -955,13 +1086,18 @@ export function herramientasDelTablon(d) {
955
1086
  name: 'panal_deliver_board_job',
956
1087
  description: 'As the agent of a board job, deliver the work. The text is left in the board\'s mailbox first, signed ' +
957
1088
  'with its hash inside, and only then its hash is anchored in the escrow — so the client can always read ' +
958
- 'what was anchored. If the mailbox refuses it, nothing is anchored and it can be retried. Only text ' +
959
- 'travels this way. Costs gas.',
1089
+ 'what was anchored. If the mailbox refuses it, nothing is anchored and it can be retried. With files, ' +
1090
+ 'they are uploaded first and their hashes go inside the anchored text. Costs gas.',
960
1091
  inputSchema: {
961
1092
  type: 'object',
962
1093
  properties: {
963
1094
  job_id: { type: 'string', description: 'The board job number.' },
964
1095
  result: { type: 'string', description: 'The finished work, exactly as the client will receive it.' },
1096
+ files: {
1097
+ type: 'array',
1098
+ items: { type: 'string' },
1099
+ description: `Optional: paths of files on this machine to deliver (at most ${MAX_ARCHIVOS_TABLON}, 25 MB each, 30 MB in total).`,
1100
+ },
965
1101
  confirmed_by_user: { type: 'boolean', description: 'true only if the person approved delivering this.' },
966
1102
  },
967
1103
  required: ['job_id', 'result', 'confirmed_by_user'],
@@ -972,15 +1108,18 @@ export function herramientasDelTablon(d) {
972
1108
  return no;
973
1109
  // Tal cual, sin recortar: lo que se ancla es el hash de estos bytes.
974
1110
  const resultado = typeof args.result === 'string' ? args.result : '';
975
- if (!resultado.trim())
1111
+ const archivos = archivosDeRutas(args.files);
1112
+ if (typeof archivos === 'string')
1113
+ return `Nothing was delivered: ${archivos}`;
1114
+ if (!resultado.trim() && archivos.length === 0)
976
1115
  return 'result is empty: there is nothing to deliver.';
977
1116
  const id = parseJobId(args.job_id);
978
1117
  if (id === null)
979
1118
  return 'job_id must be a board job number.';
980
1119
  try {
981
- const { txHash, resultHash } = await panal.deliverBoardJob(id, resultado);
1120
+ const { txHash, resultHash } = await panal.deliverBoardJob(id, resultado, archivos);
982
1121
  d.log(`entregado del tablón #${id}`);
983
- return (`Board job #${id} delivered.\n tx: ${tx(txHash)}\n result hash: ${resultHash}\n\n` +
1122
+ return (`Board job #${id} delivered${archivos.length ? ` with ${archivos.length} file(s)` : ''}.\n tx: ${tx(txHash)}\n result hash: ${resultHash}\n\n` +
984
1123
  'The client can now read it and approve. Once approved, the payment is CREDITED in the escrow: ' +
985
1124
  'collect it with panal_withdraw. If the client does nothing for 72 h, panal_close_board_job settles it.');
986
1125
  }
@@ -989,6 +1128,51 @@ export function herramientasDelTablon(d) {
989
1128
  }
990
1129
  },
991
1130
  },
1131
+ {
1132
+ name: 'panal_download_board_file',
1133
+ description: 'Download the files of a board job to this machine, each one checked byte by byte against the hash in ' +
1134
+ 'the anchored text: from "job", the files the client sent with it (its client and its agent can); from ' +
1135
+ '"delivery", the files the agent delivered (its client can). Signs a read credential (no transaction, ' +
1136
+ 'no money). Files land in MCP_DOWNLOAD_DIR.',
1137
+ inputSchema: {
1138
+ type: 'object',
1139
+ properties: {
1140
+ job_id: { type: 'string', description: 'The board job number.' },
1141
+ from: { type: 'string', description: '"job" (the attachments) or "delivery" (the delivered files).' },
1142
+ file: { type: 'string', description: 'Optional: the name of one file. Without it, all of them.' },
1143
+ },
1144
+ required: ['job_id', 'from'],
1145
+ },
1146
+ handler: async (args) => {
1147
+ const no = disponible();
1148
+ if (no)
1149
+ return no;
1150
+ if (!d.cuenta)
1151
+ return 'Downloading needs this server\'s wallet (MCP_PRIVATE_KEY) to sign: there is none.';
1152
+ const id = parseJobId(args.job_id);
1153
+ if (id === null)
1154
+ return 'job_id must be a board job number.';
1155
+ const de = texto(args.from)?.toLowerCase() === 'delivery' ? 'entrega' : texto(args.from)?.toLowerCase() === 'job' ? 'adjunto' : null;
1156
+ if (!de)
1157
+ return 'from is "job" or "delivery".';
1158
+ try {
1159
+ // El hash sale del texto anclado —encargo o entrega—, nunca del buzón.
1160
+ const anunciados = de === 'adjunto' ? parseAttachmentsManifest(await panal.readBoardJobBrief(id)) : parseFilesManifest(await panal.readBoardJobResult(id));
1161
+ const pedido = texto(args.file);
1162
+ const lista = pedido ? anunciados.filter((f) => f.name === pedido) : anunciados;
1163
+ if (anunciados.length === 0)
1164
+ return `Board job #${id} has no files ${de === 'adjunto' ? 'with the job' : 'in its delivery'}.`;
1165
+ if (lista.length === 0)
1166
+ return `"${pedido}" is not one of them: ${anunciados.map((f) => f.name).join(', ')}.`;
1167
+ const bytes = await panal.downloadBoardJobFiles(id, lista, de);
1168
+ const guardados = lista.map((f, i) => ` ${f.name} (${tamano(f.size)}) → ${d.guardarDescarga(f.name, bytes[i])}`);
1169
+ return `Downloaded and checked against the hash on chain:\n${guardados.join('\n')}`;
1170
+ }
1171
+ catch (err) {
1172
+ return `Could not download: ${fallo(err)}`;
1173
+ }
1174
+ },
1175
+ },
992
1176
  ];
993
1177
  return { lectura, escritura };
994
1178
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "panal-mcp",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Servidor MCP de Panal: busca y contrata agentes de IA on-chain desde Claude",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "node": ">=20"
31
31
  },
32
32
  "dependencies": {
33
- "@panal/sdk": "^0.23.0",
33
+ "@panal/sdk": "^0.25.0",
34
34
  "viem": "^2.21.0"
35
35
  },
36
36
  "devDependencies": {