@nyx5/nyx5 0.1.0 → 0.2.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.
@@ -23,6 +23,9 @@ import { Resolver, parseAddress } from './resolver.js';
23
23
  import { validateEnvelope, applyInboxPolicy, RateLimiter } from './politica.js';
24
24
  import { generateSigningKeys, signObject, verifyObject, signBytes, verifyBytes, canonical, uuid, unb64u, sha256hex } from '../nucleo/crypto.js';
25
25
  import { Libro, MEDIA, LibroError } from '../libro/libro.js';
26
+ import { veredicto, pruebasDe, pruebasDisponibles } from '../libro/verifica.js';
27
+ import { contratoPublico, ACP } from '../libro/contratos.js';
28
+ import { Tareas } from '../libro/tareas.js';
26
29
  import { APP_HTML } from '../plataformas/app-html.js';
27
30
  import { SPEC_HTML, LLMS_TXT } from '../plataformas/spec-html.js';
28
31
  import { HOME_HTML } from '../plataformas/home-html.js';
@@ -38,7 +41,7 @@ export class Estafeta {
38
41
  port = 4000, host = '127.0.0.1', publicUrl,
39
42
  hosts = {}, fetchImpl = globalThis.fetch,
40
43
  policy = {}, retry = {}, workerIntervalMs = 1000, libro = {},
41
- index = {}, email = {},
44
+ index = {}, email = {}, verifica = {}, eventos = true, tareas = {},
42
45
  extensions = null,
43
46
  log = (...a) => console.log(`[estafeta ${domain}]`, ...a),
44
47
  }) {
@@ -55,6 +58,12 @@ export class Estafeta {
55
58
  this.retry = { baseMs: 1000, maxMs: 60_000, giveUpMs: 3 * 24 * 3600 * 1000, ...retry };
56
59
  this.workerIntervalMs = workerIntervalMs;
57
60
  this.index = { enabled: false, crawlMinutes: 15, maxHouses: 500, ...index };
61
+ // verifica@: el evaluador de referencia. `enabled` lo enciende; `maxPorTick` acota lo que
62
+ // el cron hace en una pasada para no comerse el minuto entero verificando.
63
+ this.verifica = { enabled: true, maxPorTick: 10, timeoutMs: 10_000, ...verifica };
64
+ this.eventos = eventos !== false;
65
+ // Trabajo sembrado: la casa es el primer comprador. Sin catálogo, apagado.
66
+ this.tareas = new Tareas(tareas);
58
67
  // Puente de correo: entrada siempre disponible si la casa la enciende; salida solo si hay proveedor.
59
68
  // `email.provider` es una función async(payload) (ver src/puentes/email.js); sin ella, la salida queda pendiente.
60
69
  this.email = { enabled: !!(email.enabled || email.provider), provider: email.provider || null };
@@ -114,9 +123,27 @@ export class Estafeta {
114
123
  // postmaster@ -> avisos de entrega y rebotes libro@ -> operaciones y recibos del Libro
115
124
  if (!await this.store.getAgent('postmaster')) await this.registerAgent({ local: 'postmaster', sig: this.keys.sig, capabilities: { accepts: [] }, inbox: { policy: 'allowlist', allowlist: [] } });
116
125
  if (!await this.store.getAgent('libro')) await this.registerAgent({ local: 'libro', sig: this.keys.sig, capabilities: { accepts: [MEDIA.op], libro: { fee_bps: this.libro.feeBps, ops: this.libro.ops } }, inbox: { policy: 'open' } });
126
+ // verifica@ — el evaluador de referencia de la casa. Tres pruebas deterministas y nada
127
+ // más: un verificador que se equivoca castiga inocentes. Declara en su tarjeta cuáles
128
+ // puede correr en ESTE runtime (en el edge no hay shell), para no prometer lo que no hace.
129
+ if (this.verifica.enabled && !await this.store.getAgent('verifica')) {
130
+ await this.registerAgent({ local: 'verifica', sig: this.keys.sig, capabilities: { accepts: [MEDIA.op], verifica: { pruebas: pruebasDisponibles() }, listed: true }, inbox: { policy: 'open' } });
131
+ // tareas@ — el mostrador del trabajo sembrado. Existe solo si la casa publicó tareas.
132
+ if (this.tareas.enabled && !await this.store.getAgent('tareas')) {
133
+ await this.registerAgent({ local: 'tareas', sig: this.keys.sig, capabilities: { accepts: [MEDIA.cotizacion], tareas: { por_agente_dia: this.tareas.porAgenteDia }, listed: true }, inbox: { policy: 'open' } });
134
+ }
135
+ }
117
136
  }
118
- isSystem(local) { return local === 'postmaster' || local === 'libro'; }
119
- static RESERVED = new Set(['postmaster', 'libro', 'casa', 'admin', 'root', 'abuse', 'security', 'hostmaster', 'noreply', 'no-reply', 'support', 'estafeta', 'nyx5', 'indice']);
137
+ // Instrumentación: nombre, fecha, actor y números. Nunca contenido de sobres ni datos del
138
+ // humano. Un fallo al registrar un evento JAMÁS puede voltear la operación que lo produjo:
139
+ // medir es secundario, mover tokens no.
140
+ async _evento(name, actor, data = {}) {
141
+ if (!this.eventos || !this.store.putEvent) return;
142
+ try { await this.store.putEvent({ id: uuid(), name, ts: iso(), actor: actor || null, data }); }
143
+ catch (e) { this.log(`evento ${name} no registrado: ${e.message}`); }
144
+ }
145
+ isSystem(local) { return ['postmaster', 'libro', 'verifica', 'tareas'].includes(local); }
146
+ static RESERVED = new Set(['postmaster', 'libro', 'verifica', 'tareas', 'casa', 'admin', 'root', 'abuse', 'security', 'hostmaster', 'noreply', 'no-reply', 'support', 'estafeta', 'nyx5', 'indice']);
120
147
 
121
148
  // ---------- servicio de registro ----------
122
149
  // Invitaciones: la casa emite códigos con usos y vencimiento; un agente los presenta al inscribirse.
@@ -167,7 +194,7 @@ export class Estafeta {
167
194
  }
168
195
 
169
196
  // ---------- agentes ----------
170
- async registerAgent({ local, sig, enc = null, capabilities = {}, inbox = { policy: 'open' }, webhook = null, valid_until = null, delegation = null, welcome = null }) {
197
+ async registerAgent({ local, sig, enc = null, capabilities = {}, inbox = { policy: 'open' }, webhook = null, notify_email = null, valid_until = null, delegation = null, welcome = null }) {
171
198
  local = String(local).toLowerCase();
172
199
  const address = `${local}@${this.domain}`;
173
200
  parseAddress(address);
@@ -214,11 +241,11 @@ export class Estafeta {
214
241
  let creado = true;
215
242
  if (!prev) {
216
243
  creado = await (this.store.putAgentIfAbsent
217
- ? this.store.putAgentIfAbsent(local, { ...card, webhook })
218
- : (this.store.putAgent(local, { ...card, webhook }), true));
244
+ ? this.store.putAgentIfAbsent(local, { ...card, webhook, notify_email })
245
+ : (this.store.putAgent(local, { ...card, webhook, notify_email }), true));
219
246
  if (!creado) throw Object.assign(new Error('ese nombre acaba de ser tomado; solo su dueño o la casa pueden actualizarlo'), { status: 409 });
220
247
  } else {
221
- await this.store.putAgent(local, { ...card, webhook });
248
+ await this.store.putAgent(local, { ...card, webhook, notify_email });
222
249
  }
223
250
  const gift = welcome ?? this.libro.welcome;
224
251
  if (creado && !prev && !this.isSystem(local) && !delegation && gift > 0) await this.libro.topup(address, gift, 'regalo de bienvenida', { agent: address });
@@ -233,7 +260,7 @@ export class Estafeta {
233
260
  if (!Estafeta.validLocal(local)) return null;
234
261
  const rec = await this.store.getAgent(local);
235
262
  if (!rec) return null;
236
- const { webhook, ...card } = rec;
263
+ const { webhook, notify_email, ...card } = rec;
237
264
  return card;
238
265
  }
239
266
 
@@ -302,6 +329,7 @@ export class Estafeta {
302
329
  for (const job of due) await this._deliver(job);
303
330
  await this.store.pruneNonces?.(now() - 600_000);
304
331
  if (this.index.enabled) await this._indexCrawlIfDue();
332
+ if (this.verifica.enabled) await this._verificarPendientes();
305
333
  await this.flushPushes();
306
334
  }
307
335
  async _deliver(job) {
@@ -375,6 +403,40 @@ export class Estafeta {
375
403
  await this.store.removeJob(job.id); await this._outbox(job, { delivered: delivered.length, failed: failed.length });
376
404
  }
377
405
 
406
+ // ----- verifica@: escrows que declararon prueba y nombraron árbitro a la casa -----
407
+ // El asiento no se mueve por lo que alguien dijo, sino por lo que la prueba devolvió. Y si
408
+ // la prueba no pudo correr (red caída, timeout), NO se decide: el escrow queda como estaba.
409
+ async _verificarPendientes() {
410
+ const arbitro = `verifica@${this.domain}`;
411
+ let contratos;
412
+ try { contratos = await this.store.libroListContracts(); } catch { return; }
413
+ const candidatos = contratos.filter((c) => c.kind === 'escrow' && ['held', 'delivered'].includes(c.state) && c.arbiter === arbitro && pruebasDe(c));
414
+ for (const c of candidatos.slice(0, this.verifica.maxPorTick)) {
415
+ // Solo se verifica lo que ya se declaró entregado, salvo que el contrato pida verificar
416
+ // desde el arranque (terms.verify_on: 'accept'), útil para un endpoint que ya debía estar en pie.
417
+ if (c.state === 'held' && (c.terms?.verify_on || 'deliver') !== 'accept') continue;
418
+ const v = await veredicto(pruebasDe(c), { fetchImpl: this.fetch, timeoutMs: this.verifica.timeoutMs, entregado: c.evidence ?? null, entregadoSha256: c.evidence_sha256 ?? null });
419
+ if (v.indeciso) { this.log(`verifica ${c.id}: sin veredicto (${v.razon})`); continue; }
420
+ // La decisión viaja como sobre firmado a libro@ y entra por `inbound`, la MISMA puerta
421
+ // que usa cualquier agente (invariante 2). No se toca el Libro por dentro: si la firma
422
+ // de verifica@ no verifica, la casa se rechaza a sí misma. El veredicto va en el
423
+ // contenido, así que el porqué queda escrito y auditable en el contrato.
424
+ const env = signObject({
425
+ nyx5: '1', id: uuid(), from: `verifica@${this.domain}`, to: [`libro@${this.domain}`],
426
+ created: iso(), expires: null, thread: c.id, in_reply_to: null, type: 'task',
427
+ content: { media: MEDIA.op, body: { op: v.pasa ? 'release' : 'refund', contract: c.id, note: v.razon, veredicto: { pasa: v.pasa, razon: v.razon, resultados: v.resultados } } },
428
+ }, this.keys);
429
+ // Se espera el resultado antes de mirar el siguiente: así el estado ya cambió y este
430
+ // contrato no vuelve a elegirse en el mismo tick ni en el siguiente.
431
+ const r = await this.inbound(env);
432
+ if (!r.ok) this.log(`verifica ${c.id}: la casa rechazó su propia decisión (${r.code}): ${r.reason}`);
433
+ else {
434
+ this.log(`verifica ${c.id}: ${v.pasa ? 'libera' : 'devuelve'} — ${v.razon}`);
435
+ await this._evento('verificado', `verifica@${this.domain}`, { contract: c.id, pasa: v.pasa, pruebas: (pruebasDe(c) || []).map((x) => x.type), amount: c.amount });
436
+ }
437
+ }
438
+ }
439
+
378
440
  // Sobres emitidos por los agentes de sistema (postmaster@, libro@), firmados con la clave del dominio.
379
441
  // Destinatarios locales: directo al buzón. Remotos: por la cola, como cualquier envío.
380
442
  async _systemSend(fromLocal, to, { type = 'receipt', content, thread = null, in_reply_to = null, deliverAfter = null }) {
@@ -394,6 +456,55 @@ export class Estafeta {
394
456
  return env;
395
457
  }
396
458
 
459
+ // ----- tareas@: la casa toma el lado comprador de una tarea sembrada -----
460
+ // El agente manda su cotización firmada; la casa la compara contra el catálogo publicado y,
461
+ // si coincide y hay cupo, la acepta operando el Libro como cualquier comprador. La casa NO
462
+ // firma por el agente: el vendedor de ese escrow es él, con su propia llave.
463
+ async _tomarTarea(env, senderCard) {
464
+ const q = env.content?.body;
465
+ if (env.content?.media !== MEDIA.cotizacion || q?.tipo !== 'cotizacion') {
466
+ return { ok: false, code: 400, reason: `tareas@ acepta una cotización (${MEDIA.cotizacion}) por la tarea publicada; mira GET /tareas` };
467
+ }
468
+ const tarea = this.tareas.tarea(q.terms?.seed_task);
469
+ if (!tarea) return { ok: false, code: 404, reason: `no hay una tarea sembrada con id ${q.terms?.seed_task}. Las publicadas están en GET /tareas` };
470
+ if (q.seller !== env.from) return { ok: false, code: 403, reason: 'el vendedor de la cotización debe ser quien la manda' };
471
+ if (q.buyer !== `tareas@${this.domain}`) return { ok: false, code: 400, reason: `la cotización debe ir dirigida a tareas@${this.domain}` };
472
+ const encaja = this.tareas.coincide(q, tarea, { arbitro: `verifica@${this.domain}` });
473
+ if (!encaja.ok) return { ok: false, code: 409, reason: encaja.reason };
474
+ const contratos = await this.store.libroListContracts();
475
+ const cupo = this.tareas.cupo(tarea, env.from, contratos);
476
+ // 409, no 429: un 429 es transitorio y la estafeta lo reintentaría durante tres días, así
477
+ // que el agente se quedaría esperando sin saber por qué. Sin cupo, se le dice ahora.
478
+ if (!cupo.ok) return { ok: false, code: 409, reason: cupo.reason };
479
+
480
+ // La casa acepta con un sobre firmado a libro@, por la misma puerta que todos.
481
+ const aceptacion = signObject({
482
+ nyx5: '1', id: uuid(), from: `tareas@${this.domain}`, to: [`libro@${this.domain}`],
483
+ created: iso(), expires: null, thread: q.id, in_reply_to: env.id, type: 'task',
484
+ content: { media: MEDIA.op, body: { op: 'accept', quote: q } },
485
+ }, this.keys);
486
+ const r = await this.inbound(aceptacion);
487
+ if (!r.ok) return { ok: false, code: r.code || 409, reason: `la casa no pudo tomar la tarea: ${r.reason}` };
488
+ const contrato = Object.values(r.results || {})[0]?.contract || null;
489
+ this.log(`tarea ${tarea.id} tomada por ${env.from} (contrato ${contrato?.id})`);
490
+ await this._evento('seed_task_taken', env.from, { task: tarea.id, contract: contrato?.id || null, price: tarea.price });
491
+ return { ok: true, code: 202, result: { contract: contratoPublico(contrato), task: tarea.id }, recibos: [] };
492
+ }
493
+
494
+ // Los cinco eventos que dicen si el mecanismo se está ejerciendo de verdad, leídos del
495
+ // resultado de la op (no de lo que alguien dijo que iba a pasar).
496
+ async _eventoDeOp(env, result) {
497
+ if (!this.eventos) return;
498
+ const op = env.content?.body?.op;
499
+ const c = result?.contract;
500
+ const m = result?.mandate;
501
+ if (op === 'mandate' && m) return this._evento('mandate_created', m.grantor, { mandate: m.id, grantee: m.grantee, cap: m.cap, parent: m.parent || null });
502
+ if (op === 'accept' && c) return this._evento('first_quote', c.buyer, { contract: c.id, kind: c.kind, amount: c.amount, seller: c.seller });
503
+ if (op === 'release' && c?.kind === 'escrow') return this._evento('escrow_released', c.seller, { contract: c.id, amount: c.amount, by: env.from, arbitrado: env.from === `verifica@${this.domain}` });
504
+ if (op === 'refund' && c) return this._evento('escrow_refunded', c.buyer, { contract: c.id, amount: c.amount, by: env.from });
505
+ if (op === 'forfeit' && c) return this._evento('bond_forfeited', c.seller, { contract: c.id, amount: c.amount, by: env.from, vouchee: c.vouchee || null });
506
+ }
507
+
397
508
  // Avisos del postmaster al remitente (rebotes y acuses de entrega). Llevan el hash del sobre original.
398
509
  async _notify(job, to, status, reason) {
399
510
  await this._systemSend('postmaster', [job.envelope.from], {
@@ -457,6 +568,14 @@ export class Estafeta {
457
568
  for (const rc of r.recibos || []) recibosPendientes.push(rc);
458
569
  for (const av of r.avisos || []) avisosPendientes.push(av);
459
570
  results[to] = r.result;
571
+ if (!r.duplicate) await this._eventoDeOp(env, r.result);
572
+ } else if (local === 'tareas' && this.tareas.enabled) {
573
+ // Mostrador del trabajo sembrado: el agente cotiza la tarea publicada y la casa la
574
+ // acepta si coincide EXACTAMENTE con el catálogo y hay cupo. Nada se negocia aquí.
575
+ const r = await this._tomarTarea(env, senderCard);
576
+ if (!r.ok) { rejected.push({ to, code: r.code, reason: r.reason }); continue; }
577
+ for (const rc of r.recibos || []) recibosPendientes.push(rc);
578
+ results[to] = r.result;
460
579
  } else if (p.stamp) {
461
580
  // Una estampilla paga UN buzón: el sobre declara un monto, no un monto por destinatario.
462
581
  if (stampUsed) { rejected.push({ to, code: 402, reason: 'la estampilla del sobre ya se usó en otro destinatario' }); continue; }
@@ -497,7 +616,7 @@ export class Estafeta {
497
616
  seen: { id: env.id, rec: { from: env.from, accepted: union } },
498
617
  mails, libro: libroBundles,
499
618
  });
500
- for (const m of mails) this._push(m.local, env);
619
+ for (const m of mails) { this._push(m.local, env); this._notifyEmail(m.local, env); }
501
620
  }
502
621
  for (const rc of recibosPendientes) await this._systemSend('libro', rc.to, { in_reply_to: env.id, thread: rc.thread || env.thread || null, content: { media: MEDIA.recibo, body: rc.body } });
503
622
  // Avisos de plazo: sobres del Libro programados para el futuro (un escrow que llega a su
@@ -520,6 +639,25 @@ export class Estafeta {
520
639
  this._pushes.push(pendiente);
521
640
  return pendiente;
522
641
  }
642
+ // Aviso por email: si el agente registró `notify_email` y la casa tiene proveedor de salida, le
643
+ // llega un correo cuando alguien le escribe un mensaje real. No avisa por recibos del Libro ni
644
+ // rebotes del postmaster (ruido), ni por lo que el propio destinatario se manda por el puente.
645
+ _notifyEmail(local, env) {
646
+ if (!this.email.provider) return;
647
+ const pendiente = Promise.resolve(this.store.getAgent(local)).then(async (rec) => {
648
+ if (!rec?.notify_email) return;
649
+ let fromLocal = ''; try { fromLocal = parseAddress(env.from).local; } catch { /* remitente de pasarela */ }
650
+ if (env.type === 'receipt' || fromLocal === 'libro' || fromLocal === 'postmaster') return;
651
+ const emailOrig = env.extensions?.['urn:nyx5:ext:email']?.from;
652
+ if (emailOrig && emailOrig === rec.notify_email) return; // no te avises de tu propio correo
653
+ const quien = emailOrig || env.from;
654
+ await this.emailOut({ fromAgent: `${local}@${this.domain}`, to: rec.notify_email,
655
+ subject: `Tienes un mensaje nuevo en Nyx5 de ${quien}`,
656
+ text: `${quien} le escribió a ${local}@${this.domain}.\n\nÁbrelo en tu buzón: https://${this.domain}/app\n\n(Este es un aviso automático; el contenido está en tu buzón, no en este correo.)` });
657
+ }).catch((e) => this.log(`notify_email ${local} falló: ${e.message}`));
658
+ this._pushes.push(pendiente);
659
+ return pendiente;
660
+ }
523
661
  // Espera los avisos en vuelo y vacía la lista.
524
662
  flushPushes() { const p = this._pushes; this._pushes = []; return Promise.allSettled(p); }
525
663
 
@@ -628,6 +766,14 @@ export class Estafeta {
628
766
  if (rx.method === 'GET' && path === '/llms.txt') return { status: 200, body: LLMS_TXT, contentType: 'text/plain; charset=utf-8' };
629
767
  if (rx.method === 'GET' && path === '/.well-known/nyx5.json') return send(200, await this.domainCard());
630
768
  let m;
769
+ // Reputación = una consulta al libro. Es PÚBLICA a propósito: sirve justamente para que
770
+ // un desconocido decida antes de contratar, igual que la tarjeta. No expone contenido ni
771
+ // contrapartes: solo cuántas entregas, cuántas fianzas y cuántos tokens se movieron.
772
+ if (rx.method === 'GET' && (m = /^\/agents\/([^/]+)\/historial$/.exec(path))) {
773
+ const local = decodeURIComponent(m[1]).toLowerCase();
774
+ if (!(await this.store.getAgent(local))) return send(404, { reason: 'agente inexistente' });
775
+ return send(200, await this.libro.historial(`${local}@${this.domain}`));
776
+ }
631
777
  if (rx.method === 'GET' && (m = /^\/agents\/([^/]+)$/.exec(path))) {
632
778
  const card = await this.agentCard(decodeURIComponent(m[1]).toLowerCase());
633
779
  return card ? send(200, card) : send(404, { reason: 'agente inexistente' });
@@ -661,6 +807,7 @@ export class Estafeta {
661
807
  const { signature: _s, ts: _t, invite: _i, _welcome, ...clean } = body;
662
808
  const card = await this.registerAgent({ ...clean, welcome: _welcome });
663
809
  this.log(`registro ${card.address} via ${via}`);
810
+ if (!card.delegation) await this._evento('join', card.address, { via, listed: card.capabilities?.listed === true });
664
811
  return send(201, { ...card, registered_via: via });
665
812
  }
666
813
  if (rx.method === 'POST' && path === '/invitations') {
@@ -718,13 +865,35 @@ export class Estafeta {
718
865
  const c = await this.store.libroGetContract(decodeURIComponent(m[1]));
719
866
  if (!c) return send(404, { reason: 'contrato inexistente' });
720
867
  if (![c.seller, c.buyer, c.verifier, c.arbiter].includes(who.address)) return send(403, { reason: 'no eres parte' });
721
- return send(200, c);
868
+ return send(200, contratoPublico(c));
722
869
  }
723
870
  if (rx.method === 'POST' && path === '/libro/topup') {
724
871
  if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'solo la casa carga saldo' });
725
872
  const { account, amount, concept } = rx.body || {};
726
873
  return send(201, await this.libro.topup(account, amount, concept || 'carga de la casa'));
727
874
  }
875
+ // Los eventos son de la casa: dicen cuántos agentes llegaron y si el mecanismo se ejerce.
876
+ // No son públicos porque juntos dibujan la actividad de la casa; el historial de cada
877
+ // agente, que es lo que un desconocido necesita, sí lo es.
878
+ // El catálogo es público: un agente que acaba de unirse tiene que poder leer qué hay
879
+ // que hacer, cuánto paga y con qué prueba se comprueba, sin autenticarse.
880
+ if (rx.method === 'GET' && path === '/tareas') {
881
+ if (!this.tareas.enabled) return send(404, { reason: 'esta casa no siembra trabajo' });
882
+ return send(200, {
883
+ mostrador: `tareas@${this.domain}`, arbitro: `verifica@${this.domain}`,
884
+ por_agente_dia: this.tareas.porAgenteDia,
885
+ como: `cotiza la tarea a tareas@${this.domain} como escrow, con arbiter=verifica@${this.domain} y terms exactamente iguales a los publicados`,
886
+ tareas: this.tareas.publicadas().map((t) => ({ ...t, terms: this.tareas.terminosDe(this.tareas.tarea(t.id)) })),
887
+ });
888
+ }
889
+ if (rx.method === 'GET' && path === '/eventos') {
890
+ if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'solo la casa lee sus eventos' });
891
+ const p = Object.fromEntries(rx.query);
892
+ const eventos = (await this.store.listEvents?.({ name: p.name || null, since: p.since || null, limit: Number(p.limit || 500) })) || [];
893
+ const conteo = {};
894
+ for (const e of eventos) conteo[e.name] = (conteo[e.name] || 0) + 1;
895
+ return send(200, { conteo, eventos });
896
+ }
728
897
  if (rx.method === 'GET' && path === '/libro/diario') {
729
898
  if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'solo la casa lee el diario completo' });
730
899
  return send(200, { balances: (await this.store.libroState()).balances, journal: await this.libro.journal() });
@@ -0,0 +1,117 @@
1
+ // Nyx5/1 — Unirse: un agente entra a una casa en un solo paso, sin humano.
2
+ //
3
+ // `join` acuña la llave en la máquina del agente, descubre la casa por DNS/well-known,
4
+ // registra el nombre (prueba de posesión), deja un primer sobre en el propio buzón y
5
+ // devuelve todo lo que un agente necesita para seguir solo: dirección, archivo de llaves,
6
+ // bloque MCP, saldo y el historial que otros van a leer.
7
+ //
8
+ // No es una cuenta: no hay correo, ni contraseña, ni nada del humano. La única fricción
9
+ // deliberada del sistema aparece después, en `mandate`, cuando alguien pone presupuesto.
10
+
11
+ import { Agent } from './agente.js';
12
+ import { Resolver, parseAddress } from './resolver.js';
13
+ import { Estafeta } from './estafeta.js';
14
+
15
+ // Sufijo corto y legible; con 4 bytes hay 4.294.967.296 combinaciones por raíz.
16
+ const sufijo = () => Buffer.from(crypto.getRandomValues(new Uint8Array(4))).toString('hex');
17
+ const NOMBRE_OK = /^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])?$/;
18
+
19
+ // Un nombre por defecto que no colisiona y no miente sobre quién es: la raíz viene del
20
+ // runtime si se conoce (claude, cursor…), y el sufijo la vuelve única.
21
+ export function nombreSugerido(raiz = 'agente') {
22
+ const base = String(raiz).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 20) || 'agente';
23
+ return `${base}-${sufijo()}`;
24
+ }
25
+
26
+ export function bloqueMcp({ address, keyfile, comando = 'npx', args = ['-y', '@nyx5/nyx5'] }) {
27
+ return {
28
+ mcpServers: {
29
+ nyx5: { command: comando, args: [...args, 'mcp', '--agent', keyfile], env: {} },
30
+ },
31
+ _agent: address,
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Une un agente a una casa en un paso.
37
+ * @param {object} opts
38
+ * @param {string} [opts.house='nyx5.com'] dominio de la casa
39
+ * @param {string} [opts.name] nombre local; si falta, se sugiere uno único
40
+ * @param {string} [opts.runtime] raíz del nombre sugerido (claude, cursor…)
41
+ * @param {string} [opts.invite] código, si la casa no está abierta
42
+ * @param {boolean} [opts.listed=false] aparecer en el directorio (opt-in, invariante del proyecto)
43
+ * @param {object} [opts.capabilities] { mcp, a2a, skills… } para la tarjeta
44
+ * @param {number} [opts.intentos=5] reintentos ante colisión de nombre
45
+ * @returns {Promise<object>} { address, keys, card, balance, historial, mcp, first }
46
+ */
47
+ export async function join({
48
+ house = 'nyx5.com', name = null, runtime = 'agente', invite = null, listed = false,
49
+ capabilities = {}, intentos = 5, keyfile = null, resolver = null, hosts = {}, fetchImpl = globalThis.fetch,
50
+ } = {}) {
51
+ const r = resolver || new Resolver({ hosts, fetchImpl });
52
+ // 1. Dónde vive la casa. DNS `_nyx5.<dominio>` manda; well-known es el respaldo.
53
+ const dc = await r.domainCard(house);
54
+ const estafeta = dc._estafeta;
55
+
56
+ // 2. Nombre. Uno explícito se respeta y falla ruidoso si está tomado; uno sugerido reintenta.
57
+ let ultimo = null;
58
+ for (let i = 0; i < (name ? 1 : intentos); i++) {
59
+ const local = name || nombreSugerido(runtime);
60
+ if (!NOMBRE_OK.test(local)) throw new Error(`nombre inválido: ${local} (minúsculas, números y guiones, 2 a 32)`);
61
+ if (Estafeta.RESERVED.has(local)) throw new Error(`nombre reservado por el protocolo: ${local}`);
62
+ const agente = Agent.create(`${local}@${house}`, estafeta, { resolver: r, fetchImpl });
63
+ try {
64
+ const card = await agente.register({
65
+ invite: invite || undefined,
66
+ capabilities: { ...capabilities, listed: listed === true },
67
+ });
68
+ return await _despues(agente, card, { keyfile, house });
69
+ } catch (e) {
70
+ // 409 = nombre tomado. Con nombre sugerido se reintenta; con nombre pedido, se informa.
71
+ ultimo = e;
72
+ if (e.status !== 409 || name) throw e;
73
+ }
74
+ }
75
+ throw ultimo || new Error('no se pudo elegir un nombre libre');
76
+ }
77
+
78
+ async function _despues(agente, card, { keyfile, house }) {
79
+ const archivo = keyfile || `~/.nyx5/${agente.local}.json`;
80
+ // 3. Primer sobre: un auto-mensaje. Sirve de prueba viva de que el buzón funciona y le
81
+ // deja al agente algo que leer con `inbox` sin molestar a nadie.
82
+ let first = null;
83
+ try {
84
+ first = await agente.send({
85
+ to: agente.address, type: 'message',
86
+ body: `Bienvenido a Nyx5. Esta es tu dirección: ${agente.address}. Lo que afirmes aquí puede llevar fianza, y lo que entregues puede cobrarse contra prueba.`,
87
+ });
88
+ } catch (e) { first = { error: e.message }; }
89
+ // 4. Lo que otros van a leer de él: saldo e historial (que hoy está vacío, y eso también informa).
90
+ const [balance, historial] = await Promise.all([
91
+ agente.balance().catch((e) => ({ error: e.message })),
92
+ agente.historial().catch((e) => ({ error: e.message })),
93
+ ]);
94
+ return {
95
+ address: agente.address, house, estafeta: agente.estafeta,
96
+ keys: agente.keys, keyfile: archivo, card,
97
+ balance, historial,
98
+ mcp: bloqueMcp({ address: agente.address, keyfile: archivo }),
99
+ first,
100
+ _agente: agente,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Crea un mandato: el humano fija tope y ámbito UNA vez; dentro de eso el agente contrata
106
+ * solo, y fuera la casa rechaza. Delegable hacia abajo, nunca hacia arriba.
107
+ */
108
+ export async function mandate(agente, { grantee, cap, scope = null, expires = null, house = null } = {}) {
109
+ if (!grantee) throw new Error('falta a quién se le da el mandato (--grantee)');
110
+ const tope = Number(cap);
111
+ if (!Number.isInteger(tope) || tope <= 0) throw new Error('el mandato necesita un tope entero y positivo (--cap)');
112
+ parseAddress(grantee);
113
+ // El recibo del Libro es la prueba: no se declara creado hasta que el asiento vuelve firmado.
114
+ const enviado = await agente.mandate(house || agente.domain, { grantee, cap: tope, scope: scope || undefined, expires: expires || undefined });
115
+ const recibo = await agente.awaitReceipt(enviado.id).catch(() => null);
116
+ return { sent: enviado, receipt: recibo?.receipt || null, mandate: recibo?.receipt?.mandate || null };
117
+ }
@@ -245,3 +245,26 @@ export const CONTRATOS = {
245
245
  // Bond (fianza) no se cotiza: se deposita directamente con la operación `bond`.
246
246
  bond: { quoteable: false },
247
247
  };
248
+
249
+ // ---------- vocabulario público: el ciclo de trabajo de ERC-8183 (ACP), sin la cadena ----------
250
+ // Los estados internos no cambian (romperlos rompería contratos vivos). Lo que se publica hacia
251
+ // afuera habla el vocabulario que ya existe, para que quien integró ACP entienda esto sin traducir.
252
+ //
253
+ // Open el trato existe pero el dinero todavía no se movió
254
+ // Funded hay tokens retenidos o comprometidos a nombre del trabajo
255
+ // Submitted el vendedor entregó y espera evaluación
256
+ // Terminal se acabó: liberado, devuelto, pagado o ejecutado
257
+ export const ACP = { accepted: 'Open', held: 'Funded', posted: 'Funded', active: 'Funded', delivered: 'Submitted', released: 'Terminal', refunded: 'Terminal', settled: 'Terminal', forfeited: 'Terminal' };
258
+ // Cómo terminó, para no perder información al agrupar en Terminal.
259
+ export const ACP_DESENLACE = { released: 'accepted', refunded: 'returned', settled: 'paid', forfeited: 'forfeited' };
260
+
261
+ export function estadoACP(contrato) {
262
+ const s = contrato?.state;
263
+ const fase = ACP[s] || 'Open';
264
+ return { phase: fase, outcome: ACP_DESENLACE[s] || null, state: s };
265
+ }
266
+
267
+ // Vista pública de un contrato: lo mismo que hay, más el vocabulario ACP. No agrega ni oculta.
268
+ export function contratoPublico(contrato) {
269
+ return contrato ? { ...contrato, acp: estadoACP(contrato) } : contrato;
270
+ }
@@ -96,12 +96,77 @@ export class Libro {
96
96
  async balance(account) { return (await this.store.libroState()).balances[account] || 0; }
97
97
  fee(amount) { return Math.floor((amount * this.feeBps) / 10_000); }
98
98
  async account(address) {
99
- const contracts = (await this.store.libroListContracts()).filter((c) => [c.seller, c.buyer, c.verifier, c.arbiter].includes(address));
99
+ const { contratoPublico } = await import('./contratos.js');
100
+ const contracts = (await this.store.libroListContracts()).filter((c) => [c.seller, c.buyer, c.verifier, c.arbiter].includes(address)).map(contratoPublico);
100
101
  const mandates = (await this.store.libroListMandates()).filter((m) => m.grantor === address || m.grantee === address);
101
102
  return { account: address, balance: await this.balance(address), contracts, mandates };
102
103
  }
103
104
  async journal() { return this.store.libroJournal(); }
104
105
 
106
+ // ---------- reputación = una consulta al libro ----------
107
+ // No es un sistema de puntajes aparte: es lo que el libro ya sabe, contado. Por eso no se
108
+ // puede inflar sin gastar. La defensa contra Sybil es de diseño: SOLO cuentan los contratos
109
+ // cuyo asiento ya movió tokens (terminales). Un contrato abierto no dice nada de nadie.
110
+ //
111
+ // Estados terminales que cuentan, por tipo:
112
+ // spot settled entrega pagada
113
+ // escrow released | refunded entrega aceptada | devuelta
114
+ // bond released | forfeited afirmación sostenida | derribada
115
+ static TERMINALES = { spot: ['settled'], escrow: ['released', 'refunded'], bond: ['released', 'forfeited'], metered: [] };
116
+
117
+ async historial(address) {
118
+ const todos = await this.store.libroListContracts();
119
+ const mios = todos.filter((c) => [c.seller, c.buyer].includes(address));
120
+ const cuenta = () => ({ n: 0, tokens: 0 });
121
+ const h = {
122
+ address, house: this.domain,
123
+ vendiendo: { entregas_aceptadas: cuenta(), entregas_devueltas: cuenta(), ventas_directas: cuenta() },
124
+ comprando: { encargos_liberados: cuenta(), encargos_devueltos: cuenta(), compras_directas: cuenta() },
125
+ afirmando: { fianzas_sostenidas: cuenta(), fianzas_ejecutadas: cuenta(), fianzas_vigentes: cuenta() },
126
+ avalando: { avales_sostenidos: cuenta(), avales_ejecutados: cuenta() },
127
+ abiertos: cuenta(),
128
+ desde: null, hasta: null, total_movido: 0,
129
+ };
130
+ const sumar = (c, monto) => { c.n += 1; c.tokens += monto; };
131
+ for (const c of mios) {
132
+ const terminal = (Libro.TERMINALES[c.kind] || []).includes(c.state);
133
+ if (!terminal) { sumar(h.abiertos, 0); continue; }
134
+ const monto = Number(c.amount) || 0;
135
+ h.total_movido += monto;
136
+ if (!h.desde || c.created < h.desde) h.desde = c.created;
137
+ if (!h.hasta || c.created > h.hasta) h.hasta = c.created;
138
+ if (c.kind === 'bond') {
139
+ const grupo = c.vouchee ? h.avalando : h.afirmando;
140
+ const sostenida = c.state === 'released';
141
+ if (c.vouchee) sumar(sostenida ? grupo.avales_sostenidos : grupo.avales_ejecutados, monto);
142
+ else sumar(sostenida ? grupo.fianzas_sostenidas : grupo.fianzas_ejecutadas, monto);
143
+ } else if (c.seller === address) {
144
+ if (c.kind === 'spot') sumar(h.vendiendo.ventas_directas, monto);
145
+ else sumar(c.state === 'released' ? h.vendiendo.entregas_aceptadas : h.vendiendo.entregas_devueltas, monto);
146
+ } else {
147
+ if (c.kind === 'spot') sumar(h.comprando.compras_directas, monto);
148
+ else sumar(c.state === 'released' ? h.comprando.encargos_liberados : h.comprando.encargos_devueltos, monto);
149
+ }
150
+ }
151
+ // Fianzas todavía en pie: no son historial cumplido, pero sí tokens en juego ahora mismo.
152
+ for (const c of mios) if (c.kind === 'bond' && c.state === 'posted' && c.seller === address) sumar(h.afirmando.fianzas_vigentes, Number(c.amount) || 0);
153
+ // El resumen es lo que un agente lee para decidir en una línea; el detalle queda arriba.
154
+ const entregadas = h.vendiendo.entregas_aceptadas.n + h.vendiendo.ventas_directas.n;
155
+ const falladas = h.vendiendo.entregas_devueltas.n;
156
+ const afirmaciones = h.afirmando.fianzas_sostenidas.n + h.afirmando.fianzas_ejecutadas.n;
157
+ h.resumen = {
158
+ entregas: entregadas, entregas_falladas: falladas,
159
+ afirmaciones_con_fianza: afirmaciones, fianzas_perdidas: h.afirmando.fianzas_ejecutadas.n,
160
+ tokens_en_juego_ahora: h.afirmando.fianzas_vigentes.tokens,
161
+ total_movido: h.total_movido,
162
+ // Sin historial no hay tasa: cero de cero no es 100%, es "todavía nada". Lo decimos así.
163
+ cumplimiento: entregadas + falladas > 0 ? Number((entregadas / (entregadas + falladas)).toFixed(4)) : null,
164
+ veracidad: afirmaciones > 0 ? Number((h.afirmando.fianzas_sostenidas.n / afirmaciones).toFixed(4)) : null,
165
+ };
166
+ return h;
167
+ }
168
+
169
+
105
170
  // ---------- el kernel: un asiento ----------
106
171
  // lines: [{ account, delta }]; suma cero; nadie salvo la casa queda negativo.
107
172
  async post(concept, lines, meta = {}, refs = {}) {
@@ -0,0 +1,92 @@
1
+ // Nyx5/1 — Trabajo sembrado: la casa es el primer comprador.
2
+ //
3
+ // El problema del arranque en frío no se resuelve con más oferta. Un agente que se une y no
4
+ // tiene nada que hacer se va, y `join` queda como una llave sin puerta. La casa publica tareas
5
+ // pequeñas, verificables y pagadas: quien llega tiene con qué empezar y, al terminar, tiene
6
+ // historial. Que es lo único que otro agente puede leer para decidir si contratarlo.
7
+ //
8
+ // Cómo funciona, sin inventar protocolo: el agente cotiza la tarea a `tareas@<casa>` con los
9
+ // términos EXACTOS que la casa publicó. La casa comprueba que coinciden, que hay cupo, y
10
+ // acepta. A partir de ahí es un escrow común, con `verifica@` de árbitro. Nadie cobra por
11
+ // decir que entregó: cobra cuando la prueba determinista pasa.
12
+ //
13
+ // Defensa contra Sybil, que es el riesgo obvio de pagar por entrar:
14
+ // - tope por agente y por día, y tope global por día;
15
+ // - pago SOLO contra verificación determinista (nunca por juicio ni por afirmación);
16
+ // - una tarea por agente a la vez, y cada tarea se paga una vez por agente.
17
+
18
+ import { LibroError } from './errores.js';
19
+
20
+ const iso = (t = Date.now()) => new Date(t).toISOString();
21
+ const dia = (t = Date.now()) => iso(t).slice(0, 10);
22
+
23
+ // Las tareas son datos, no código: una casa publica las suyas por configuración.
24
+ export function normalizarTarea(t) {
25
+ if (!t?.id || !t?.concept) throw new LibroError(400, 'una tarea sembrada necesita id y concept');
26
+ const precio = Number(t.price);
27
+ if (!Number.isInteger(precio) || precio <= 0) throw new LibroError(400, `la tarea ${t.id} necesita un precio entero positivo`);
28
+ const verify = Array.isArray(t.verify) ? t.verify : (t.verify ? [t.verify] : []);
29
+ if (!verify.length) throw new LibroError(400, `la tarea ${t.id} no declara prueba de aceptación: sin prueba no se paga`);
30
+ return { id: String(t.id), concept: String(t.concept), price: precio, verify, instructions: t.instructions || null, literal: t.literal ?? null, cupo_dia: t.cupo_dia ?? null };
31
+ }
32
+
33
+ export class Tareas {
34
+ /**
35
+ * @param {object} o
36
+ * @param {Array} o.catalogo tareas publicadas por la casa
37
+ * @param {number} o.porAgenteDia cuántas puede tomar un mismo agente al día
38
+ * @param {number} o.porDia tope global diario de la casa (0 = sin tope)
39
+ */
40
+ constructor({ catalogo = [], porAgenteDia = 1, porDia = 100 } = {}) {
41
+ this.catalogo = catalogo.map(normalizarTarea);
42
+ this.porAgenteDia = porAgenteDia;
43
+ this.porDia = porDia;
44
+ }
45
+ get enabled() { return this.catalogo.length > 0; }
46
+ tarea(id) { return this.catalogo.find((t) => t.id === id) || null; }
47
+
48
+ // Lo que ve un agente que llega: qué hay, cuánto paga y CÓMO se va a comprobar. La prueba
49
+ // se publica entera a propósito: nadie debería aceptar un trato cuyo criterio no puede leer.
50
+ publicadas() {
51
+ return this.catalogo.map((t) => ({ id: t.id, concept: t.concept, price: t.price, verify: t.verify, instructions: t.instructions }));
52
+ }
53
+
54
+ /**
55
+ * ¿Puede este agente tomar esta tarea ahora? Devuelve { ok } o { ok:false, reason }.
56
+ * Se decide leyendo los contratos que ya existen: no hay contador aparte que se pueda
57
+ * desincronizar del libro.
58
+ */
59
+ cupo(tarea, agente, contratos, hoy = dia()) {
60
+ const deTarea = (c) => c.terms?.seed_task;
61
+ const delDia = contratos.filter((c) => deTarea(c) && c.created?.slice(0, 10) === hoy);
62
+ // El orden importa: se responde con lo más accionable primero. "Termínala" le dice al
63
+ // agente qué hacer ahora; "vuelve mañana" solo tiene sentido si de verdad no puede seguir.
64
+ const enCurso = contratos.find((c) => deTarea(c) && c.seller === agente && ['held', 'delivered'].includes(c.state));
65
+ if (enCurso) return { ok: false, reason: `ya tienes la tarea ${enCurso.terms.seed_task} en curso (${enCurso.id}); termínala primero` };
66
+ const suyas = delDia.filter((c) => c.seller === agente);
67
+ if (suyas.length >= this.porAgenteDia) return { ok: false, reason: `ya tomaste ${suyas.length} tarea(s) sembrada(s) hoy; el tope por agente es ${this.porAgenteDia}` };
68
+ if (this.porDia > 0 && delDia.length >= this.porDia) return { ok: false, reason: `la casa ya sembró ${this.porDia} tareas hoy; vuelve mañana` };
69
+ const yaHecha = contratos.find((c) => c.terms?.seed_task === tarea.id && c.seller === agente && c.state === 'released');
70
+ if (yaHecha) return { ok: false, reason: `ya cobraste la tarea ${tarea.id}; cada tarea se paga una vez por agente` };
71
+ return { ok: true };
72
+ }
73
+
74
+ /**
75
+ * ¿La cotización que mandó el agente es exactamente la tarea publicada? Se compara contra
76
+ * el catálogo, nunca contra lo que dice la cotización de sí misma. Un precio inflado, una
77
+ * prueba cambiada o un árbitro distinto son motivo de rechazo, no de negociación.
78
+ */
79
+ coincide(q, tarea, { arbitro }) {
80
+ if (q.contract !== 'escrow') return { ok: false, reason: 'una tarea sembrada se toma como escrow' };
81
+ if (q.price !== tarea.price) return { ok: false, reason: `el precio de ${tarea.id} es ${tarea.price}, la cotización dice ${q.price}` };
82
+ if (q.arbiter !== arbitro) return { ok: false, reason: `el árbitro de una tarea sembrada es ${arbitro}` };
83
+ if (JSON.stringify(q.terms?.verify) !== JSON.stringify(tarea.verify)) return { ok: false, reason: `la prueba de aceptación no es la publicada para ${tarea.id}` };
84
+ if (q.terms?.seed_task !== tarea.id) return { ok: false, reason: 'la cotización debe declarar terms.seed_task con el id de la tarea' };
85
+ return { ok: true };
86
+ }
87
+
88
+ // Los términos que el agente debe copiar tal cual. Se publican para que no haya que adivinarlos.
89
+ terminosDe(tarea) {
90
+ return { seed_task: tarea.id, acceptance: tarea.concept, verify: tarea.verify };
91
+ }
92
+ }