@nyx5/nyx5 0.6.0 → 0.8.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.
Files changed (64) hide show
  1. package/README.md +50 -7
  2. package/bin/nyx5.js +81 -2
  3. package/demo/edge-local.mjs +41 -0
  4. package/demo/x402-pagar.mjs +64 -0
  5. package/demo/x402-testnet.mjs +80 -0
  6. package/docs/ARQUITECTURA.md +354 -0
  7. package/docs/SPEC.md +414 -15
  8. package/examples/ci/nyx5-ejecutor.yml +82 -0
  9. package/examples/frameworks/README.md +55 -0
  10. package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
  11. package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
  12. package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
  13. package/examples/frameworks/claude-agent-sdk.mjs +48 -0
  14. package/examples/frameworks/crewai_nyx5.py +72 -0
  15. package/examples/frameworks/http.md +117 -0
  16. package/examples/frameworks/langgraph_nyx5.py +90 -0
  17. package/examples/frameworks/nyx5_http.py +255 -0
  18. package/examples/frameworks/openai-agents.mjs +41 -0
  19. package/examples/hola-mundo.mjs +4 -4
  20. package/migrations/0006_puente_remoto.sql +19 -0
  21. package/migrations/0007_indice_reputacion.sql +31 -0
  22. package/migrations/0008_notaria.sql +18 -0
  23. package/migrations/0009_contratos_estado.sql +4 -0
  24. package/package.json +4 -2
  25. package/server.json +2 -2
  26. package/src/correo/acciones.js +216 -0
  27. package/src/correo/adjuntos.js +185 -0
  28. package/src/correo/agente.js +370 -22
  29. package/src/correo/asistente.js +599 -0
  30. package/src/correo/ci.js +262 -0
  31. package/src/correo/cobro.js +164 -0
  32. package/src/correo/estafeta.js +1548 -58
  33. package/src/correo/ideas.js +154 -0
  34. package/src/correo/indice.js +178 -0
  35. package/src/correo/politica.js +280 -2
  36. package/src/correo/resolver.js +35 -11
  37. package/src/libro/contratos.js +116 -7
  38. package/src/libro/estado.js +150 -0
  39. package/src/libro/informe.js +147 -3
  40. package/src/libro/libro.js +83 -12
  41. package/src/libro/notaria.js +74 -0
  42. package/src/libro/verifica.js +239 -36
  43. package/src/nucleo/almacen-d1.js +143 -14
  44. package/src/nucleo/almacen.js +137 -13
  45. package/src/nucleo/boveda.js +37 -0
  46. package/src/nucleo/crypto.js +55 -12
  47. package/src/nucleo/keccak.js +118 -0
  48. package/src/nucleo/objetos.js +78 -0
  49. package/src/nucleo/secp256k1.js +228 -0
  50. package/src/plataformas/app-html.js +1 -1
  51. package/src/plataformas/home-html.js +1 -1
  52. package/src/plataformas/iconos.js +6 -0
  53. package/src/plataformas/node.js +28 -4
  54. package/src/plataformas/spec-html.js +1 -1
  55. package/src/plataformas/terms-html.js +2 -0
  56. package/src/plataformas/worker.js +52 -7
  57. package/src/puentes/ejecutor.js +118 -0
  58. package/src/puentes/herramientas.js +315 -0
  59. package/src/puentes/mcp-remoto.js +71 -0
  60. package/src/puentes/mcp.js +5 -99
  61. package/src/puentes/oauth.js +361 -0
  62. package/src/puentes/x402-pagador.js +248 -0
  63. package/src/puentes/x402.js +266 -0
  64. package/src/version.js +4 -0
@@ -13,10 +13,13 @@
13
13
  // NYX5_FEE_BPS fee de la casa en basis points (default 1000 = 10%)
14
14
  // NYX5_INDEX 'on' para operar el índice federado (default off)
15
15
  // NYX5_EMAIL 'on' para habilitar el puente de correo
16
+ // NYX5_IDEAS 'on' para el buzón ideas@ (registra y confirma, nunca ejecuta; exige NYX5_VAULT_KEY)
16
17
  // DB binding D1
17
18
 
18
19
  import { Estafeta } from '../correo/estafeta.js';
20
+ import { TERMS_HTML } from './terms-html.js';
19
21
  import { D1Store } from '../nucleo/almacen-d1.js';
22
+ import { ObjetosR2 } from '../nucleo/objetos.js';
20
23
  import { extractText, resendProvider, addressFromHeader, decodeMimeWords } from '../puentes/email.js';
21
24
 
22
25
  // El catálogo sembrado de esta casa. Tres tareas deterministas y baratas: su función NO es
@@ -59,15 +62,30 @@ function estafetaDesde(env) {
59
62
  publicUrl: cfg('PUBLIC_URL') || `https://${domain}`,
60
63
  adminToken: cfg('ADMIN_TOKEN'),
61
64
  store: new D1Store(env.DB),
65
+ // Adjuntos cifrados (NX-413): el almacén de objetos es el bucket R2 del binding BLOBS. Sin
66
+ // binding la casa NO aloja adjuntos y /blob lo dice con un 503, en vez de aceptar un archivo
67
+ // que se perdería. El bucket y el binding los pone Nicholas al desplegar (ARQUITECTURA §4.9).
68
+ objetos: env.BLOBS ? new ObjetosR2(env.BLOBS) : null,
62
69
  policy: { registration: cfg('REGISTRATION') || 'invite' },
63
- libro: { welcome: Number(cfg('WELCOME') || 0), feeBps: Number(cfg('FEE_BPS') || 1000) },
70
+ // /terms existe sólo si la casa lo enciende: son declaraciones vinculantes del dueño (aprobadas 14-sep-2026).
71
+ terms: cfg('TERMS') === 'on' ? TERMS_HTML : null,
72
+ libro: { welcome: Number(cfg('WELCOME') || 0), feeBps: Number(cfg('FEE_BPS') || 1000),
73
+ // Escrow que vence (NX-503), en horas: gracia para que el comprador recupere, y ventana de revisión.
74
+ reclaimGraceMs: (Number(cfg('RECLAIM_GRACE_HOURS')) || 24) * 3600_000, reviewWindowMs: (Number(cfg('REVIEW_WINDOW_HOURS')) || 72) * 3600_000 },
64
75
  index: { enabled: cfg('INDEX') === 'on' },
65
- email: { enabled: cfg('EMAIL') === 'on' || !!provider, provider, footer: cfg('EMAIL_FOOTER') === 'on' },
76
+ email: { enabled: cfg('EMAIL') === 'on' || !!provider, provider, footer: cfg('EMAIL_FOOTER') === 'on', senders: String(cfg('EMAIL_SENDERS') || '').split(',') },
66
77
  // Trabajo sembrado: lo que la casa publica para que un agente recién unido tenga algo
67
78
  // que hacer y salga con historial. El catálogo vive en el código (cambiarlo es un
68
79
  // despliegue, con revisión y vuelta atrás) pero se ENCIENDE por casa: las dos casas
69
80
  // comparten este archivo, y una casa sin presupuesto que publica tareas solo frustra.
70
81
  tareas: cfg('SEED') === 'on' ? NYX5_TAREAS : {},
82
+ // Conector MCP remoto (OAuth + subagentes delegados). Se enciende por casa y exige la llave de
83
+ // la bóveda (secret NYX5_VAULT_KEY): sin ella no hay dónde guardar una llave, y /mcp no existe.
84
+ remoto: { enabled: cfg('MCP_REMOTE') === 'on', vaultKey: env.NYX5_VAULT_KEY, dias: Number(cfg('REMOTE_DAYS')) || 30 },
85
+ // Asistentes que contestan solos (src/correo/asistente.js). Sin clave de la API, no existen.
86
+ asistente: { apiKey: env.ANTHROPIC_API_KEY },
87
+ // ideas@ (src/correo/ideas.js): el buzón de vacaciones. Se enciende por casa y usa la bóveda.
88
+ ideas: { enabled: cfg('IDEAS') === 'on' },
71
89
  log: (...a) => console.log(...a),
72
90
  });
73
91
  return instancia;
@@ -81,12 +99,16 @@ function estafetaDesde(env) {
81
99
  // interpretado como otro tipo, ni embebido en un iframe ajeno, ni filtrar de dónde vino.
82
100
  // - CSP: la portada y la spec traen su CSS y un script inline propios y NADA externo, así que
83
101
  // se declara exactamente eso. Si algún día se añade un recurso de fuera, esto grita.
102
+ // `connect-src 'self'` es OBLIGATORIO: sin él, `default-src 'none'` bloquea todo fetch y la app
103
+ // de /app no podía crear una dirección ni mandar un mensaje. Estuvo así en producción hasta el
104
+ // 10-sep-2026 y nadie lo vio: la página cargaba bien y fallaba en silencio al primer botón.
105
+ // `manifest-src` e `img-src 'self'` son para instalarla en la pantalla de inicio.
84
106
  const SEGURIDAD = {
85
107
  'strict-transport-security': 'max-age=31536000; includeSubDomains',
86
108
  'x-content-type-options': 'nosniff',
87
109
  'x-frame-options': 'DENY',
88
110
  'referrer-policy': 'no-referrer',
89
- 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
111
+ 'content-security-policy': "default-src 'none'; connect-src 'self'; manifest-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data: 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
90
112
  };
91
113
 
92
114
  // Cuánto puede cachear cada superficie. La tarjeta del dominio y la spec son estables y se
@@ -97,6 +119,8 @@ function cacheDe(path, method) {
97
119
  if (path === '/.well-known/nyx5.json') return 'public, max-age=300, stale-while-revalidate=600';
98
120
  if (path === '/' || path === '/spec' || path === '/spec/' || path === '/llms.txt' || path === '/robots.txt' || path === '/sitemap.xml' || path === '/favicon.ico') return 'public, max-age=3600';
99
121
  if (path === '/tareas' || path.startsWith('/agents') || path === '/report' || path === '/report.json') return 'public, max-age=60';
122
+ if (path === '/manifest.webmanifest' || /^\/(icon-\d+|apple-touch-icon)\.png$/.test(path)) return 'public, max-age=86400';
123
+ if (path.startsWith('/.well-known/oauth-')) return 'public, max-age=300';
100
124
  return 'no-store';
101
125
  }
102
126
 
@@ -113,10 +137,23 @@ export default {
113
137
  const estafeta = estafetaDesde(env);
114
138
  const url = new URL(request.url);
115
139
  let body = null;
116
- if (request.method === 'POST' || request.method === 'PUT') {
140
+ let raw = null;
141
+ // La subida de un adjunto (PUT /blob/<id>) llega como BYTES, no como JSON: un archivo no es
142
+ // JSON y pasarlo en base64 lo infla un tercio para nada. El tope de aquí es mayor que el de la
143
+ // ruta a propósito, para que el motivo del rechazo lo dé la ruta nombrando el límite real.
144
+ if (request.method === 'PUT' && /^\/blob\/[^/]+$/.test(url.pathname)) {
145
+ if (Number(request.headers.get('content-length') || 0) > 16 * 1024 * 1024) return Response.json({ reason: 'cuerpo demasiado grande' }, { status: 413 });
146
+ raw = new Uint8Array(await request.arrayBuffer());
147
+ if (raw.length > 16 * 1024 * 1024) return Response.json({ reason: 'cuerpo demasiado grande' }, { status: 413 });
148
+ } else if (request.method === 'POST' || request.method === 'PUT') {
117
149
  const len = Number(request.headers.get('content-length') || 0);
118
150
  if (len > 2 * 1024 * 1024) return Response.json({ reason: 'cuerpo demasiado grande' }, { status: 413 });
119
- try { body = await request.json(); } catch { return Response.json({ reason: 'invalid JSON' }, { status: 400 }); }
151
+ // /oauth/token recibe form-urlencoded (RFC 6749 §4.1.3): Claude manda el canje y el refresco
152
+ // así. Leerlo todo como JSON devolvía 400 y el conector no llegaba nunca a tener token.
153
+ const texto = await request.text();
154
+ if ((request.headers.get('content-type') || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(texto));
155
+ else if (!texto.trim()) body = {};
156
+ else { try { body = JSON.parse(texto); } catch { return Response.json({ reason: 'invalid JSON' }, { status: 400 }); } }
120
157
  }
121
158
  // www redirige al apex con 301. Existe como registro para que el nombre no dé NXDOMAIN
122
159
  // (quien lo teclea o lo pega en un chat llegaba a la nada), pero la casa vive en el apex:
@@ -135,14 +172,22 @@ export default {
135
172
  query: url.searchParams,
136
173
  headers: Object.fromEntries([...request.headers].map(([k, v]) => [k.toLowerCase(), v])),
137
174
  body,
175
+ raw,
138
176
  ip: request.headers.get('cf-connecting-ip') || null,
139
177
  };
140
178
  const out = await estafeta.handleRequest(rx);
141
179
  if (out.pending) ctx.waitUntil(out.pending);
142
- if (out.kick) ctx.waitUntil(estafeta.tick().catch((e) => console.log('tick error', e.message)));
180
+ // Un tick disparado por una petición no atiende asistentes (programado: false): tras responder,
181
+ // el edge sólo da 30 segundos y una respuesta de Claude puede tardar más. Los atiende el cron.
182
+ if (out.kick) ctx.waitUntil(estafeta.tick({ programado: false }).catch((e) => console.log('tick error', e.message)));
143
183
  // Un cuerpo binario (la imagen de compartir) llega como Buffer; el edge quiere bytes.
144
- const cuerpo = out.contentType ? (Buffer.isBuffer(out.body) ? new Uint8Array(out.body) : out.body) : JSON.stringify(out.body);
184
+ // 204 y 304 no llevan cuerpo: un Response con cuerpo (aunque sea '') y uno de esos estados revienta.
185
+ const cuerpo = out.status === 204 || out.status === 304 ? null : out.contentType ? (Buffer.isBuffer(out.body) ? new Uint8Array(out.body) : out.body) : JSON.stringify(out.body);
145
186
  const headers = {
187
+ // Cabeceras que pone la ruta (hoy: el sobre x402 en PAYMENT-REQUIRED / PAYMENT-RESPONSE).
188
+ // Van PRIMERO para que una ruta no pueda pisar por descuido una cabecera de seguridad ni el
189
+ // content-type: lo que va debajo gana siempre.
190
+ ...(out.headers || {}),
146
191
  ...SEGURIDAD,
147
192
  'content-type': out.contentType || 'application/json',
148
193
  'cache-control': cacheDe(url.pathname, request.method),
@@ -0,0 +1,118 @@
1
+ // Nyx5/1 — El lado CI del ejecutor (NX-412, 27-oct-2026): lo que corre DENTRO del job.
2
+ //
3
+ // La casa dispara el CI con un aviso de nueve campos y sin el contenido del sobre (ver
4
+ // `src/correo/ci.js`). Este módulo es lo que el job hace con ese aviso, y son tres pasos:
5
+ //
6
+ // 1. VERIFICAR el aviso. Un `repository_dispatch` lo puede lanzar cualquiera que tenga el token
7
+ // del repositorio, y el payload lo escribe quien dispara. Así que el aviso se comprueba contra
8
+ // la tarjeta de la casa: `sig` tiene que ser una firma Ed25519 de `ci:<sobre>:<dirección>` por
9
+ // una de las llaves que la casa publica. Un aviso que no verifica NO se trabaja: ni se lee el
10
+ // buzón, ni se gasta un minuto de runner.
11
+ // 2. RECOGER el encargo. El aviso no trae el contenido: el job lee `GET /mailbox/<local>` con la
12
+ // llave del rol (un secret del repositorio) y descifra el sobre. La llave nunca pasó por la
13
+ // casa ni por el chat.
14
+ // 3. RESPONDER y CONFIRMAR. La respuesta va firmada por la dirección del rol, en el mismo hilo, y
15
+ // recién entonces se confirma el sobre: si el job muere a mitad, el sobre sigue pendiente y la
16
+ // casa lo vuelve a despachar. Confirmar antes de responder perdería el encargo en silencio.
17
+ //
18
+ // Cero dependencias y nada propio de GitHub: el aviso es un objeto JSON y de dónde salió es asunto
19
+ // del workflow.
20
+
21
+ import { Agent } from '../correo/agente.js';
22
+ import { verifyBytes } from '../nucleo/crypto.js';
23
+
24
+ const CAMPOS = ['nyx5', 'house', 'to', 'envelope', 'from', 'type', 'at', 'sig'];
25
+
26
+ export function parsearAviso(texto) {
27
+ let a;
28
+ try { a = typeof texto === 'string' ? JSON.parse(texto) : texto; } catch { throw new Error('the notice is not JSON'); }
29
+ if (!a || typeof a !== 'object') throw new Error('the notice is not an object');
30
+ const faltan = CAMPOS.filter((c) => typeof a[c] !== 'string' || !a[c]);
31
+ if (faltan.length) throw new Error(`the notice is missing: ${faltan.join(', ')}`);
32
+ if (a.nyx5 !== '1') throw new Error(`unknown protocol version: ${a.nyx5}`);
33
+ if (!a.to.endsWith(`@${a.house}`)) throw new Error(`the notice says house ${a.house} but address ${a.to}`);
34
+ return a;
35
+ }
36
+
37
+ // La tarjeta de la casa, pedida a la casa misma. `estafeta` se pasa cuando no se quiere resolver por
38
+ // DNS (pruebas, o una casa que todavía no publica su TXT).
39
+ export async function tarjetaDeCasa(house, { estafeta = null, fetchImpl = globalThis.fetch } = {}) {
40
+ const base = (estafeta || `https://${house}`).replace(/\/$/, '');
41
+ const r = await fetchImpl(`${base}/.well-known/nyx5.json`);
42
+ if (!r.ok) throw new Error(`the house card of ${house} answered ${r.status}`);
43
+ const card = await r.json();
44
+ if (card.domain !== house) throw new Error(`that card says domain ${card.domain}, not ${house}`);
45
+ if (!Array.isArray(card.keys) || !card.keys.length) throw new Error(`the card of ${house} publishes no keys`);
46
+ return card;
47
+ }
48
+
49
+ // Verifica el aviso contra las llaves que la casa publica. Cualquiera de ellas sirve: una casa que
50
+ // rota su llave firma con la nueva y sigue publicando la vieja un tiempo.
51
+ export function verificarAviso(aviso, tarjeta) {
52
+ const mensaje = `ci:${aviso.envelope}:${aviso.to}`;
53
+ return tarjeta.keys.some((k) => { try { return verifyBytes(mensaje, aviso.sig, k.sig); } catch { return false; } });
54
+ }
55
+
56
+ const COMPLETA = (k) => !!(k?.sig && k?.sigPriv && k?.enc && k?.encPriv);
57
+
58
+ // Qué formas de llave se aceptan, y por qué tres y no una. La canónica es la que escribe
59
+ // `nyx5 keygen`: `{ address, estafeta, keys: { sig, sigPriv, enc, encPriv } }`. La plana es la misma
60
+ // sin el envoltorio, que es lo que tiene a mano quien arma el secret desde otro lado. Y el MAPA
61
+ // `{ "<dirección>": <una de las dos> }` existe porque un repositorio suele ser la casa de VARIOS
62
+ // roles: la fábrica de Rosetta tiene siete que ejecutan, y pedir siete secrets para un workflow que
63
+ // ya sabe a quién va dirigido el aviso es fricción sin nada a cambio. Del mapa se usa SÓLO la
64
+ // entrada del destinatario del aviso.
65
+ export function llaveDe(bulto, destino) {
66
+ if (!bulto || typeof bulto !== 'object') throw new Error('the role key must be a JSON object');
67
+ const dir = String(destino).toLowerCase();
68
+ const una = (k) => {
69
+ if (COMPLETA(k?.keys)) return { address: String(k.address || dir).toLowerCase(), keys: k.keys };
70
+ if (COMPLETA(k)) return { address: String(k.address || dir).toLowerCase(), keys: { sig: k.sig, sigPriv: k.sigPriv, enc: k.enc, encPriv: k.encPriv } };
71
+ return null;
72
+ };
73
+ const sola = una(bulto);
74
+ if (sola) return sola;
75
+ // Un mapa: se busca por la dirección del aviso y se ignora todo lo demás.
76
+ if (Object.hasOwn(bulto, dir)) {
77
+ const del = una(bulto[dir]);
78
+ if (del) return { address: dir, keys: del.keys };
79
+ }
80
+ const direcciones = Object.keys(bulto).filter((k) => k.includes('@'));
81
+ if (direcciones.length) throw new Error(`that key bundle has no entry for ${dir} (it carries ${direcciones.length})`);
82
+ throw new Error('the role key must carry sig, sigPriv, enc and encPriv (or be a map of address to key)');
83
+ }
84
+
85
+ function agenteDe(aviso, bulto, { estafeta = null, hosts = {}, fetchImpl = globalThis.fetch } = {}) {
86
+ const { address, keys } = llaveDe(bulto, aviso.to);
87
+ if (address !== String(aviso.to).toLowerCase()) throw new Error(`that key is of ${address}, and the notice is for ${aviso.to}`);
88
+ return new Agent({ address, keys, estafeta: estafeta || `https://${aviso.house}`, hosts, fetchImpl });
89
+ }
90
+
91
+ // Paso 1 + 2: verificar y recoger. Devuelve el sobre y el encargo ya descifrado.
92
+ export async function recoger({ aviso, keys, estafeta = null, hosts = {}, fetchImpl = globalThis.fetch }) {
93
+ const a = parsearAviso(aviso);
94
+ const tarjeta = await tarjetaDeCasa(a.house, { estafeta, fetchImpl });
95
+ if (!verificarAviso(a, tarjeta)) throw new Error(`the notice is not signed by ${a.house}: nothing was read and nothing was done`);
96
+ const agente = agenteDe(a, keys, { estafeta, hosts, fetchImpl });
97
+ const bandeja = await agente.inbox({ limit: 200 });
98
+ const m = bandeja.find((x) => x.envelope?.id === a.envelope);
99
+ // Que no esté no es un error del aviso: el sobre pudo ser confirmado por una corrida anterior.
100
+ if (!m) return { aviso: a, agente, envelope: null, abierto: null, encargo: null, reason: 'that envelope is no longer in the mailbox: another run already took it' };
101
+ if (m.envelope.from !== a.from) throw new Error(`the notice says ${a.from} and the envelope in the mailbox is from ${m.envelope.from}`);
102
+ const abierto = await agente.open(m.envelope);
103
+ // `encargo` es lo normalizado: el workflow no tiene por que saber que el cuerpo vive bajo
104
+ // `content` ni que un sobre en claro y uno cifrado llegan por caminos distintos.
105
+ const encargo = { media: abierto.content?.media ?? null, body: abierto.content?.body ?? null, project: abierto.project || null, role: abierto.role || null };
106
+ return { aviso: a, agente, envelope: m.envelope, abierto, encargo, reason: null };
107
+ }
108
+
109
+ // Paso 3: responder y recién entonces confirmar. El orden importa: si el job muere entre las dos
110
+ // cosas, el sobre sigue pendiente y la casa lo vuelve a despachar. Al revés se perdería callado.
111
+ export async function responder({ aviso, keys, texto, estafeta = null, hosts = {}, fetchImpl = globalThis.fetch, ack = true, type = 'result' }) {
112
+ const a = parsearAviso(aviso);
113
+ if (typeof texto !== 'string' || !texto.trim()) throw new Error('an empty answer is not an answer');
114
+ const agente = agenteDe(a, keys, { estafeta, hosts, fetchImpl });
115
+ const env = await agente.send({ to: a.from, type, body: texto, thread: a.envelope, inReplyTo: a.envelope, project: a.project || undefined });
116
+ const acked = ack ? await agente.ack([a.envelope]) : [];
117
+ return { sent: env.id, acked };
118
+ }
@@ -0,0 +1,315 @@
1
+ // Nyx5/1 — Las herramientas que un modelo usa para operar Nyx5, en UN solo lugar.
2
+ // Las sirven dos puentes: el local por stdio (src/puentes/mcp.js, la llave del agente en el disco
3
+ // del usuario) y el remoto por HTTP (src/puentes/mcp-remoto.js, un subagente delegado cuya llave
4
+ // guarda la casa). Si cada puente tuviera su copia, una descripción o un arreglo llegaría a uno
5
+ // solo y el otro seguiría prometiendo lo de antes.
6
+ //
7
+ // Las descripciones son lo único que el modelo lee para decidir si usa Nyx5: dicen la capacidad,
8
+ // la garantía y el momento de uso, no el mecanismo. Las cuida test/mcp.test.js.
9
+
10
+ import { proyectoDe, nombreDeProyecto } from '../correo/politica.js';
11
+ import { MEDIA_GATE } from '../correo/asistente.js';
12
+ import { MEDIA_COBRO_CONFIRMACION, MONEDAS, TIPOS_CUENTA } from '../correo/cobro.js';
13
+
14
+ export const TOOLS = [
15
+ { name: 'nyx5_send', description: 'Delegate a task to another agent even if it is switched off: it waits in their mailbox and their reply reaches you signed when they answer. Use it when you need someone to do something and do not know whether they are available now. The result says whether it went encrypted (it does when the recipient has a key).',
16
+ inputSchema: { type: 'object', required: ['to', 'body'], properties: {
17
+ to: { type: 'array', items: { type: 'string' }, description: 'Direcciones destino, ej. ["asistente@beta.local"]' },
18
+ body: { description: 'Contenido: texto o JSON' },
19
+ type: { type: 'string', enum: ['message', 'task', 'result', 'receipt', 'intro'], default: 'message' },
20
+ thread: { type: 'string' }, in_reply_to: { type: 'string' },
21
+ project: { type: 'string', description: 'which project or chat this belongs to (e.g. "sigo", "rosetta"); the other side can filter its mailbox by it' },
22
+ role: { type: 'string', description: 'your role in that project, if any (e.g. "main", "lab")' },
23
+ summary: { type: 'string', description: 'ONE line, IN THE CLEAR, saying what this is about (max 140 chars). The body travels encrypted and nobody but the recipient opens it; this line does not, so the house can put it in the subject of the email notice and its owner can decide from a phone without opening anything. Write what it is ABOUT, never what the envelope protects: the house and their email provider read it. Optional, and it only reaches someone who registered an email for notices.' },
24
+ aval: { type: 'object', description: 'para entrar a un buzón con lista blanca sin estar en ella: { voucher, bond } de un tercero de la allowlist que te respaldó con una fianza', properties: { voucher: { type: 'string' }, bond: { type: 'string' } } },
25
+ encrypt: { type: 'boolean', default: true } } } },
26
+ { name: 'nyx5_inbox', description: 'What others sent you while you were not looking. Every envelope arrives with a verified signature (you know who really sent it) and comes decrypted. Check it when you start and before treating anything as unanswered. A request for a service you publish (media nyx5.pedido) is answered with nyx5_quote {service, to, in_reply_to}.',
27
+ inputSchema: { type: 'object', properties: { limit: { type: 'integer', default: 20 }, project: { type: 'string', description: 'only messages of this project (see nyx5_send)' } } } },
28
+ { name: 'nyx5_ack', description: 'Close the envelopes in your mailbox that you already handled so they stop coming back. Use it after acting on a message; what you acknowledge stays in the record.',
29
+ inputSchema: { type: 'object', required: ['ids'], properties: { ids: { type: 'array', items: { type: 'string' } } } } },
30
+ { name: 'nyx5_resolve', description: 'Check who an address really is before trusting it: returns their card, certified by their domain (verified identity, what they can do, how they charge). Use it before sending anything sensitive or paying them.',
31
+ inputSchema: { type: 'object', required: ['address'], properties: { address: { type: 'string' } } } },
32
+ { name: 'nyx5_outbox', description: 'What happened to the signed envelopes you sent: delivered, retrying, or bounced with the reason. Use it when you are unsure whether your message arrived; the estafeta records every attempt.',
33
+ inputSchema: { type: 'object', properties: {} } },
34
+ { name: 'nyx5_directory', description: 'Which agents a house offers and what each one does, every card certified by the domain. Use it when you are looking for a provider inside a house you already know.',
35
+ inputSchema: { type: 'object', properties: { house: { type: 'string' }, capability: { type: 'string' }, accepts: { type: 'string' }, q: { type: 'string' }, limit: { type: 'integer' } } } },
36
+ { name: 'nyx5_search', description: 'Find an agent that does what you need in any house, ranked by verified reputation (ledger-backed, null when unproven) with tag, language, price and min_score filters. Use it when you know nobody who solves your problem. The index answers signed and you verify the card before trusting: a hint, not an authority.',
37
+ inputSchema: { type: 'object', required: ['index'], properties: { index: { type: 'string', description: 'domain of the house that runs the index, or its URL' }, q: { type: 'string' }, tag: { type: 'string' }, lang: { type: 'string', description: 'language tag like "es" or "en-US"' }, capability: { type: 'string' }, accepts: { type: 'string' }, house: { type: 'string' }, price_max: { type: 'integer', description: 'tokens; agents without a published price never match' }, min_score: { type: 'number', description: '0..1; agents without history never match' }, limit: { type: 'integer' }, cursor: { type: 'string', description: 'next_cursor of the previous page; omit for the first page' } } } },
38
+ // ----- Libro -----
39
+ { name: 'nyx5_quote', description: 'Offer another agent a service with a price and the exact condition that must be met to get paid. In escrow the payment is held until the proof passes. Use it to sell something with an agreement that carries weight, not a spoken promise. Name a service from your profile and the published price and contract apply.',
40
+ inputSchema: { type: 'object', required: ['to'], properties: {
41
+ to: { type: 'string' }, contract: { type: 'string', enum: ['spot', 'escrow', 'metered'], default: 'spot' },
42
+ service: { type: 'string', description: 'id of a service in your profile: price, contract and concept default to what you published, and the house rejects a quote that differs from it' },
43
+ price: { type: 'integer', description: 'tokens, entero (required without service)' }, concept: { type: 'string', description: 'required without service' },
44
+ terms: { type: 'object', description: 'criterio de aceptación, plazo, scope del mandato, etc.' },
45
+ arbiter: { type: 'string' }, expires: { type: 'string', description: 'ISO-8601' },
46
+ referrer: { type: 'object', description: 'comisión de referido: { address, share } en basis points; la paga el vendedor de su parte, el comprador paga igual', properties: { address: { type: 'string' }, share: { type: 'integer' } } },
47
+ in_reply_to: { type: 'string', description: 'id of a service request (media nyx5.pedido) you are answering: the quote goes in its thread with the published terms, the test derived from your acceptance and the request input, and verifica@ as arbiter' } } } },
48
+ { name: 'nyx5_accept', description: 'Accept an offer and commit the payment. In escrow the money is held: the seller does not get paid until they deliver and meet the condition. You receive a signed receipt that no party can deny later.',
49
+ inputSchema: { type: 'object', required: ['quote'], properties: { quote: { type: 'object' } } } },
50
+ { name: 'nyx5_hire', description: 'Hire a service from an agent profile in one step: sends the request, waits for the quote and accepts it only if it matches the published catalogue (price, contract, your input, verifica@ as arbiter when a test is published); in escrow the payment is held until that test passes. Use it when you found a provider and want the deal closed.',
51
+ inputSchema: { type: 'object', required: ['agent', 'service'], properties: {
52
+ agent: { type: 'string', description: 'address of the seller' }, service: { type: 'string', description: 'id of a service in its profile (nyx5_profile op=get)' },
53
+ input: { type: 'object', description: 'what the seller needs to do the job; for a published test it also fills its fields (http_status: url; sha256: expect; json_path: url, path, expect)' },
54
+ note: { type: 'string' }, auto_accept: { type: 'boolean', default: true, description: 'false: only send the request; the quote lands in your mailbox for you to accept' },
55
+ max_price: { type: 'integer', description: 'tokens; above it nothing is requested nor accepted' }, wait: { type: 'number', description: 'seconds to wait for the quote, 1 to 90 (default 25)' } } } },
56
+ { name: 'nyx5_libro', description: 'Move a deal forward in the ledger, leaving a signed, irreversible entry at every step: deliver, release the payment if the proof passed, refund if it failed, back a claim with money (you lose it if you lied), or delegate spending with a cap. ops: pay {to, amount, concept}, deliver {contract, evidence_sha256}, release {contract}, refund {contract}, reclaim {contract}, bond {amount, claim, verifier}, forfeit {contract, reason}, mandate {grantee, cap, scope, expires, parent}, charge {mandate, amount, concept}, revoke {mandate}, balance, statement {limit, since, until}, contract {contract}. The answer arrives as a signed receipt in your mailbox.',
57
+ inputSchema: { type: 'object', required: ['op'], properties: { house: { type: 'string', description: 'dominio de la casa; por defecto el propio' }, op: { type: 'string' }, args: { type: 'object' } } } },
58
+ { name: 'nyx5_balance', description: 'How much you hold, which contracts and which spending permissions are active. Check it before committing a payment. A direct read authenticated with your signature, without going through the mail. With since or until, your statement for that range: opening balance, every entry with the house fee as its own line, closing balance.',
59
+ inputSchema: { type: 'object', properties: { house: { type: 'string' }, since: { type: 'string', description: 'ISO-8601: statement from this date (inclusive)' }, until: { type: 'string', description: 'ISO-8601: statement up to this date (exclusive)' }, limit: { type: 'integer', description: 'statement: how many entries, newest kept (max 1000)' } } } },
60
+ { name: 'nyx5_remind', description: 'Leave yourself a message that reaches you in the future, in your own mailbox, encrypted. Use it when a task must be picked up in hours or days and your session will end before then: your future self finds the context with the full thread, without depending on anyone waking you.',
61
+ inputSchema: { type: 'object', required: ['cuando', 'body'], properties: { cuando: { type: 'string', description: 'ISO-8601: cuándo debe llegarte' }, body: { description: 'lo que tu yo futuro necesita saber' }, thread: { type: 'string' } } } },
62
+ { name: 'nyx5_contract', description: 'The state and full history of a deal you are party to: every step with its hash and its signature. Use it to see where an escrow or a bond stands.',
63
+ inputSchema: { type: 'object', required: ['contract'], properties: { house: { type: 'string' }, contract: { type: 'string' } } } },
64
+ { name: 'nyx5_historial', description: 'The reputation of an agent is its ledger: deliveries accepted against returned, bonds standing against forfeited, with amounts. Check it before hiring a stranger. Every point of it cost tokens and is tied to a verified delivery, so it cannot be inflated by talking; with no record it returns null (nothing yet, not perfect).',
65
+ inputSchema: { type: 'object', properties: { address: { type: 'string', description: 'a quién mirar; por defecto, tú mismo' } } } },
66
+ { name: 'nyx5_tareas', description: 'Paid work a house publishes that you can take right now: what to do, what it pays, and the deterministic check it will be verified with. Use it when you just joined and have no record yet, or when you need tokens to back your own claims with a bond.',
67
+ inputSchema: { type: 'object', properties: { house: { type: 'string' } } } },
68
+ { name: 'nyx5_tomar', description: 'Take a published task: the house holds the payment in a signed entry before you work, and releases it on its own when the deterministic check passes. If it fails, it is refunded and stays in your record. Use it to earn your first tokens; terms are copied from the catalogue and are not negotiable.',
69
+ inputSchema: { type: 'object', required: ['id'], properties: { id: { type: 'string', description: 'id de la tarea, de nyx5_tareas' }, house: { type: 'string' } } } },
70
+ { name: 'nyx5_email', description: 'Write by email to a human who is not on Nyx5 yet. Use it when the recipient has no agent address: their reply comes back to your mailbox (Reply-To). It enters unsigned, marked as not verified, never disguised; when they want the real thing, they register.',
71
+ inputSchema: { type: 'object', required: ['to', 'body'], properties: { to: { type: 'string', description: 'dirección de correo, ej. persona@gmail.com' }, subject: { type: 'string' }, body: { description: 'el texto del correo' } } } },
72
+ { name: 'nyx5_wait', description: 'Wait, up to a limit, for the next message in your mailbox (optionally only from one sender or one thread) and get it opened, with its signature verified, the moment it lands. Use it right after sending when the other side is live: it is how two agents hold a real-time conversation instead of polling.',
73
+ inputSchema: { type: 'object', properties: { from: { type: 'string', description: 'only messages from this address' }, thread: { type: 'string', description: 'only messages in this thread' }, since: { type: 'string', description: 'ISO time: only messages received after it. Default: now, so earlier unread mail is never mistaken for the reply' }, project: { type: 'string', description: 'only messages of this project (see nyx5_send)' }, seconds: { type: 'number', description: 'how long to wait, 1 to 90 (default 60)' } } } },
74
+ { name: 'nyx5_conversation', description: 'The signed history between you and one address, both directions and oldest first, including what you already acknowledged; without an address, the list of your conversations, each with its projects and what is pending per project. Use it to pick up where you left off, from any device: the history lives in the house.',
75
+ inputSchema: { type: 'object', properties: { with: { type: 'string', description: 'the other address; omit it to list your conversations' }, limit: { type: 'number', description: 'how many messages, newest kept (default 30)' }, project: { type: 'string', description: 'only messages of this project (see nyx5_send)' } } } },
76
+ { name: 'nyx5_group', description: 'A group address (g.name@house): one signed message reaches every member, encrypted for each, with a shared history nobody outside can read. Use it when work involves several agents. ops: create {name, members, post}, members {group}, add {group, members}, remove {group, members}, leave {group}. Only members post; admins change members.',
77
+ inputSchema: { type: 'object', required: ['op'], properties: { op: { type: 'string', enum: ['create', 'members', 'add', 'remove', 'leave'] }, name: { type: 'string', description: 'create: the group name (becomes g.name@house)' }, group: { type: 'string', description: 'the group address or name' }, members: { type: 'array', items: { type: 'string' }, description: 'addresses of this house' }, post: { type: 'string', enum: ['members', 'admins'], description: 'who can post (default members)' } } } },
78
+ { name: 'nyx5_profile', description: 'What an agent says about itself, certified by its house: name, what it does, languages, owner, tags, links, and what it sells at what price and contract. Read it before hiring a stranger (declared, not verified: the ledger record is). Set yours so others find you. ops: get {address}, set {profile} (an unknown key is rejected by name).',
79
+ inputSchema: { type: 'object', required: ['op'], properties: { op: { type: 'string', enum: ['get', 'set'] }, address: { type: 'string', description: 'get: whose profile (default: yours)' }, profile: { type: 'object', description: 'set: { display_name, summary, description, languages, tags, owner: {kind, name}, links, services: [{ id, name, summary, price: {tokens, usd?}, unit: job|call|hour, contract: spot|escrow|metered, acceptance?: {kind, template} }] }; null clears it' } } } },
80
+ { name: 'nyx5_notarize', description: 'Seal the hash of a document in the house ledger: a signed, dated record that anyone can verify later without an account. Use it before sharing work whose date or integrity may be disputed (a report, a dataset, an offer). Free; the same hash sealed twice by you returns the same seal. The seal arrives as a signed receipt.',
81
+ inputSchema: { type: 'object', required: ['sha256'], properties: { sha256: { type: 'string', description: 'hex sha256 of the document (64 characters)' }, name: { type: 'string' }, media: { type: 'string' }, note: { type: 'string' }, house: { type: 'string', description: 'house that seals; by default your own' } } } },
82
+ { name: 'nyx5_notarized', description: 'Verify a document hash against the house notary: the seals on it, who declared it and when, each signed by the house and checked against its domain card. Use it when someone claims a document existed at a date: the seal proves the hash was declared no later than then. No account needed.',
83
+ inputSchema: { type: 'object', required: ['sha256'], properties: { sha256: { type: 'string', description: 'hex sha256 of the document (64 characters)' }, house: { type: 'string', description: 'house to ask; by default your own' } } } },
84
+ // ----- qa@ como servicio (NX-606): dos envoltorios de nyx5_send con el media correcto -----
85
+ { name: 'nyx5_qa_spec', description: 'Turn a request into an acceptance contract: qa@<house> replies with numbered, checkable criteria and the sha256 of its text so you can seal it in the notary. Use it before delegating work whose "done" could be disputed. Paid from credit you pay to qa@ beforehand; the reply lands signed in your mailbox.',
86
+ inputSchema: { type: 'object', required: ['request'], properties: { to: { type: 'string', description: 'the qa address; default qa@<your house>' }, request: { type: 'string', description: 'what you want done, in your words' } } } },
87
+ { name: 'nyx5_qa_gate', description: 'Have a delivery judged against a sealed acceptance contract: qa@<house> answers pass, fail or abstain per criterion as JSON signed by the house. Use it before releasing an escrow. The contract hash must be sealed in the notary first; otherwise it is rejected before any cost. Abstention costs half.',
88
+ inputSchema: { type: 'object', required: ['spec_sha256', 'spec', 'delivery'], properties: { to: { type: 'string', description: 'the qa address; default qa@<your house>' }, spec_sha256: { type: 'string', description: 'hex sha256 of the contract text, already sealed with nyx5_notarize' }, spec: { type: 'string', description: 'the exact contract text (its sha256 must be spec_sha256)' }, delivery: { type: 'object', required: ['text'], properties: { text: { type: 'string', description: 'the delivery to judge (qa@ does not fetch urls)' }, url: { type: 'string' }, sha256: { type: 'string' } } }, note: { type: 'string' } } } },
89
+ // ----- NX-502: pedido de pago por transferencia (dinero real, fuera del Libro; NO en el remoto) -----
90
+ { name: 'nyx5_payment_request', description: 'Ask someone for a real-money bank transfer (Chile: CLP or USD). The account details travel encrypted to that person only; the house never sees nor holds them, and their signed confirmation with the bank reference reaches your mailbox when they pay. Use it to get paid outside the ledger. Nyx5 never touches the money.',
91
+ inputSchema: { type: 'object', required: ['to', 'amount', 'name', 'rut', 'bank', 'account_type', 'account_number'], properties: {
92
+ to: { type: 'string', description: 'address of who pays (a person, not a connected Claude: the house holds those keys)' },
93
+ amount: { type: ['string', 'number'], description: 'CLP: whole pesos, e.g. 15000; USD: up to two decimals, e.g. "12.50"' },
94
+ currency: { type: 'string', enum: MONEDAS, default: 'CLP' },
95
+ name: { type: 'string', description: 'name on the account (up to 120 characters)' }, rut: { type: 'string', description: 'RUT with its check digit, e.g. 12.345.678-5' },
96
+ bank: { type: 'string', description: 'bank name (up to 60 characters)' }, account_type: { type: 'string', enum: TIPOS_CUENTA },
97
+ account_number: { type: 'string', description: 'digits and dashes, up to 30' }, reference: { type: 'string', description: 'what this is for (up to 140 characters)' },
98
+ thread: { type: 'string' } } } },
99
+ { name: 'nyx5_payment_confirm', description: 'Tell whoever sent you a payment request that you paid it from your bank: your confirmation with the bank reference travels encrypted and signed, in reply to their request, so they hold your word and nothing else. Use it right after you made the transfer yourself. Nyx5 does not move the money and does not check the bank.',
100
+ inputSchema: { type: 'object', required: ['in_reply_to', 'bank_reference'], properties: {
101
+ in_reply_to: { type: 'string', description: 'id of the payment request envelope (media nyx5.cobro) in your mailbox' },
102
+ from: { type: 'string', description: 'who sent it, if you already acknowledged it (to find it in the conversation)' },
103
+ bank_reference: { type: 'string', description: 'the transfer reference your bank gave you (up to 80 characters)' } } } },
104
+ // ----- NX-413: adjuntos cifrados. La descripción dice el límite y qué NO sale del cifrado: es
105
+ // lo único que un agente lee antes de decidir qué expone. -----
106
+ { name: 'nyx5_attach', description: 'Encrypt a file so a message carries it for real: the recipient downloads it with their signature and checks the hash. Use it when you say "here are the files" and the other agent is on another machine. Up to 10 MB; the key never leaves the encrypted block, so the house holds bytes it cannot read.',
107
+ inputSchema: { type: 'object', required: ['name', 'to'], properties: {
108
+ name: { type: 'string', description: 'file name the recipient will see (max 120 chars); it travels INSIDE the encrypted block' },
109
+ to: { type: 'array', items: { type: 'string' }, description: 'addresses allowed to download it: the same recipients you will send the envelope to' },
110
+ path: { type: 'string', description: 'local file to read (this bridge runs beside you); use it or content' },
111
+ content: { type: 'string', description: 'the file content when you have it in hand, not on disk' },
112
+ base64: { type: 'boolean', description: 'true if content is base64 (a binary file)' },
113
+ media: { type: 'string', description: 'media type like application/pdf (default application/octet-stream)' } } } },
114
+ { name: 'nyx5_attachment', description: 'Download an attachment that arrived in an envelope and get the real bytes: it is decrypted with the key that came inside the encrypted block and rejected if the sha256 of the plaintext does not match what the sender signed. Use it when a message says it carries files, before acting on them.',
115
+ inputSchema: { type: 'object', required: ['id'], properties: {
116
+ id: { type: 'string', description: 'id of the envelope that carries it (nyx5_inbox or nyx5_conversation)' },
117
+ from: { type: 'string', description: 'who sent it, if you already acknowledged the envelope' },
118
+ blob: { type: 'string', description: 'which attachment, when the envelope carries several (default: all of them)' },
119
+ out_dir: { type: 'string', description: 'directory to write the files into (default: the current one)' } } } },
120
+ { name: 'nyx5_whoami', description: 'Your own address and what it may do: who delegated it, until when, whether the house holds its keys, and who may write to it. Check it before promising anything on behalf of your owner; the card is certified by the domain.',
121
+ inputSchema: { type: 'object', properties: {} } },
122
+ ];
123
+
124
+ // Anotaciones MCP: le dicen al cliente qué herramienta sólo LEE. Claude las usa para decidir cuándo
125
+ // pedir permiso (y el directorio oficial de conectores las exige). Son pistas, no garantías: la casa
126
+ // sigue haciendo cumplir cada límite por su cuenta. Nació de un reporte de Nicholas: conectar desde
127
+ // el teléfono le pidió tres permisos (inbox, send, wait) antes del primer mensaje.
128
+ const SOLO_LECTURA = new Set(['nyx5_inbox', 'nyx5_resolve', 'nyx5_outbox', 'nyx5_directory', 'nyx5_search', 'nyx5_balance', 'nyx5_contract', 'nyx5_historial', 'nyx5_tareas', 'nyx5_wait', 'nyx5_conversation', 'nyx5_notarized', 'nyx5_whoami']);
129
+ // `nyx5_attach` ESCRIBE en la casa (sube un objeto y gasta cupo) y `nyx5_attachment` escribe en el
130
+ // disco de quien corre el puente: ninguna de las dos es de sólo lectura, aunque no mueva dinero.
131
+ const MUEVE_DINERO = new Set(['nyx5_accept', 'nyx5_libro', 'nyx5_tomar', 'nyx5_hire']);
132
+ const TITULOS = { nyx5_attach: 'Attach a file', nyx5_attachment: 'Download an attachment', nyx5_send: 'Send a message', nyx5_inbox: 'Read my mailbox', nyx5_ack: 'Mark messages as handled', nyx5_resolve: 'Check who an address is', nyx5_outbox: 'Delivery status of what I sent', nyx5_directory: 'Agents in a house', nyx5_search: 'Find an agent', nyx5_quote: 'Offer a service', nyx5_accept: 'Accept an offer and pay', nyx5_hire: 'Hire a published service', nyx5_libro: 'Ledger operation', nyx5_balance: 'My balance', nyx5_remind: 'Remind myself later', nyx5_contract: 'A deal and its history', nyx5_historial: 'Reputation of an agent', nyx5_tareas: 'Paid tasks available', nyx5_tomar: 'Take a paid task', nyx5_email: 'Email a person', nyx5_wait: 'Wait for a reply', nyx5_conversation: 'Conversation history', nyx5_group: 'Group of agents', nyx5_profile: 'Public profile', nyx5_notarize: 'Seal a document hash', nyx5_notarized: 'Verify a document seal', nyx5_whoami: 'Who am I', nyx5_qa_spec: 'Ask qa@ for an acceptance contract', nyx5_qa_gate: 'Ask qa@ to judge a delivery', nyx5_payment_request: 'Request a payment', nyx5_payment_confirm: 'Confirm a payment' };
133
+ for (const t of TOOLS) {
134
+ t.title = TITULOS[t.name] || t.name;
135
+ t.annotations = { title: t.title, readOnlyHint: SOLO_LECTURA.has(t.name), destructiveHint: MUEVE_DINERO.has(t.name), idempotentHint: SOLO_LECTURA.has(t.name) || t.name === 'nyx5_ack' || t.name === 'nyx5_notarize', openWorldHint: true };
136
+ }
137
+
138
+ // Lo que el conector remoto expone: mensajería y nada que mueva saldo. El subagente de un teléfono
139
+ // es de alcance `messages_only` (la casa se lo niega igual si lo intenta); ofrecerle herramientas
140
+ // que van a fallar sólo le enseñaría al modelo a prometer lo que no puede cumplir.
141
+ // Tampoco el pedido de pago (NX-502): la llave del subagente vive en la bóveda y la casa cifraría en
142
+ // su nombre, o sea que el RUT y la cuenta pasarían por la casa en claro. Va sólo por el puente local.
143
+ export const MENSAJERIA = new Set(['nyx5_send', 'nyx5_inbox', 'nyx5_ack', 'nyx5_resolve', 'nyx5_outbox', 'nyx5_directory', 'nyx5_search', 'nyx5_remind', 'nyx5_historial', 'nyx5_wait', 'nyx5_conversation', 'nyx5_group', 'nyx5_qa_spec', 'nyx5_qa_gate', 'nyx5_whoami']);
144
+
145
+ export const INSTRUCCIONES = `Nyx5 gives an agent three things it has no other way of getting: an address of its own, a mailbox that holds while it is off, and a ledger where an agreement carries weight (payment is held until the proof passes; a false claim forfeits its bond). Use it to reach an agent that may not be available now, to find someone who does X in any house, or to close a deal that must be worth more than a promise. Before trusting a stranger, read their record: it is a query on the ledger, so every point of it cost tokens. Every message is signed and every movement of money leaves a receipt no party can deny. Tag what you send with a project name and filter by it to keep several chats apart.`;
146
+
147
+ export function instrucciones({ remoto = false, address = null, contactos = [] } = {}) {
148
+ if (!remoto) return INSTRUCCIONES;
149
+ // Sus contactos van aquí para que el dueño pueda decir "escríbele a Nico" sin dictar direcciones.
150
+ const extra = contactos.length ? ` Your contacts, who can write to you and whom you can reach: ${contactos.join(', ')}. When your owner names one of them, write to their Claude (claude.<name>@<house>) if it is on this list.` : '';
151
+ // Lo que este camino NO puede hacer, dicho en voz alta (NX-808). MENSAJERIA excluye las
152
+ // herramientas de adjunto a propósito: la casa guarda esta llave, así que el archivo le pasaría
153
+ // en claro por delante. Callarlo dejaba al recién llegado concluyendo que Nyx5 no manda archivos.
154
+ // Texto aprobado por Nicholas el 3-nov-2026, palabra por palabra: no se edita sin volver a él.
155
+ const limites = 'This connector cannot send files: the house holds this key, so an attachment would pass through it in the clear.'
156
+ + " Because it holds this key, the house can also read this sub-agent's messages — your root key never passes through it."
157
+ + ' To send files, run `npx @nyx5/nyx5 join` once; the attachment tools come with a key only you hold.';
158
+ return `You act through ${address}, a messages-only address your owner delegated to you. You can send signed messages to any agent address, read your mailbox, and wait for a reply live, which is how you hold a real-time conversation with another agent. You cannot move money or operate the ledger. The house holds this key on behalf of your owner, who can revoke it at any time. ${limites} Before trusting a stranger, check who they really are: every card is certified by its domain.${extra}`;
159
+ }
160
+
161
+ export async function llamar(agent, name, args = {}, { permitidas = null, esperaMaxS = 90 } = {}) {
162
+ const text = (v) => ({ content: [{ type: 'text', text: typeof v === 'string' ? v : JSON.stringify(v, null, 2) }] });
163
+ if (permitidas && !permitidas.has(name)) return { ...text(`${name} is not available here: this is a messages-only address (no ledger, no payments)`), isError: true };
164
+ switch (name) {
165
+ case 'nyx5_send': { const r = await agent.send({ to: args.to, body: args.body, type: args.type, thread: args.thread, inReplyTo: args.in_reply_to, encrypt: args.encrypt ?? true, project: args.project, role: args.role, summary: args.summary, extensions: args.aval ? { 'urn:nyx5:ext:aval': args.aval } : undefined }); return text({ id: r.id, jobs: r.jobs, encrypted: r.encrypted, summary: r.envelope?.extensions?.['urn:nyx5:ext:resumen']?.text ?? null }); }
166
+ case 'nyx5_inbox': {
167
+ // La MISMA normalización que al enviar y que la casa (NFKC, sin invisibles): `trim().toLowerCase()`
168
+ // dejaba pasar un homógrafo por el filtro del puente mientras la casa lo colapsaba (14-sep-2026).
169
+ const proyecto = args.project ? nombreDeProyecto(args.project) : null;
170
+ const msgs = (await agent.inbox({ limit: args.limit ?? 20 })).filter((m) => !proyecto || proyectoDe(m.envelope) === proyecto);
171
+ const opened = [];
172
+ // `received` es la hora que entiende `since` de nyx5_wait (la de llegada al buzón, no la de
173
+ // creación): sin exponerla, el filtro no se podía usar bien (defecto reportado el 12-sep-2026).
174
+ for (const m of msgs) { try { const { sender, ...o } = await agent.open(m.envelope); opened.push({ ...o, received: m.received }); } catch (e) { opened.push({ id: m.envelope.id, from: m.envelope.from, error: e.message, received: m.received }); } }
175
+ return text(opened);
176
+ }
177
+ case 'nyx5_ack': return text({ acked: await agent.ack(args.ids) });
178
+ case 'nyx5_resolve': { const { _domain, ...card } = await agent.resolver.agentCard(args.address, { onBehalfOf: agent.address }); const last_seen = await agent.presence(args.address); return text({ ...card, ...(last_seen ? { presence: { last_seen } } : {}) }); }
179
+ case 'nyx5_outbox': return text(await agent.outbox());
180
+ case 'nyx5_directory': return text(await agent.directory(args.house, args));
181
+ case 'nyx5_search': return text(await agent.search(args.index, args));
182
+ case 'nyx5_quote': {
183
+ if (args.in_reply_to) {
184
+ // Contestar un pedido (NX-305): la cotización sale de la ficha y del input del pedido, en su hilo.
185
+ const pedido = await agent.pedido(args.in_reply_to, args.to || null);
186
+ if (!pedido) return { ...text(`no service request with id ${args.in_reply_to} in your mailbox (pass to if you already acknowledged it)`), isError: true };
187
+ const r = await agent.quoteFromCatalog(pedido, { service: args.service ?? null });
188
+ return text({ id: r.id, quote_id: r.quote.id, contract: r.quote.contract, price: r.quote.price, service: r.quote.service, arbiter: r.quote.arbiter || null, verify: r.quote.terms?.verify || null, in_reply_to: pedido.id });
189
+ }
190
+ if (args.service == null && (args.price == null || args.concept == null)) return { ...text('nyx5_quote needs price and concept, or a service id from your profile'), isError: true }; const r = await agent.quote({ to: args.to, contract: args.contract, price: args.price, concept: args.concept, terms: args.terms, arbiter: args.arbiter, expires: args.expires, referrer: args.referrer, service: args.service }); return text({ id: r.id, quote_id: r.quote.id, contract: r.quote.contract, price: r.quote.price, ...(r.quote.service ? { service: r.quote.service } : {}) }); }
191
+ case 'nyx5_accept': { const r = await agent.accept(args.quote); return text({ id: r.id, note: 'el recibo de libro@ llegará al buzón (nyx5_inbox)' }); }
192
+ case 'nyx5_hire': return text(await agent.hire({ agent: args.agent, service: args.service, input: args.input ?? {}, note: args.note, autoAccept: args.auto_accept ?? true, maxPrice: args.max_price ?? null, wait: Math.max(1, Math.min(Number(args.wait ?? 25) || 25, esperaMaxS)) }));
193
+ case 'nyx5_libro': { const r = await agent.libroOp(args.house || agent.domain, { op: args.op, ...(args.args || {}) }); return text({ id: r.id, note: 'la respuesta llega como recibo de libro@ al buzón' }); }
194
+ case 'nyx5_balance': return text(args.since || args.until ? await agent.statement(args.house, { since: args.since, until: args.until, limit: args.limit }) : await agent.balance(args.house));
195
+ case 'nyx5_remind': { const r = await agent.recordar({ cuando: args.cuando, body: args.body, thread: args.thread }); return text({ id: r.id, note: `te llegará a tu buzón el ${args.cuando}` }); }
196
+ case 'nyx5_historial': return text(await agent.historial(args.address || agent.address));
197
+ case 'nyx5_tareas': {
198
+ const dc = await agent.resolver.domainCard(args.house || agent.domain);
199
+ const res = await agent.fetch(`${dc._estafeta}/tareas`);
200
+ const j = await res.json();
201
+ if (!res.ok) return text({ error: j.reason || `HTTP ${res.status}` });
202
+ return text(j);
203
+ }
204
+ case 'nyx5_tomar': {
205
+ const dc = await agent.resolver.domainCard(args.house || agent.domain);
206
+ const j = await (await agent.fetch(`${dc._estafeta}/tareas`)).json();
207
+ const t = (j.tasks || j.tareas || []).find((x) => x.id === args.id);
208
+ if (!t) return text({ error: `no task with id ${args.id}`, available: (j.tasks || j.tareas || []).map((x) => x.id) });
209
+ const enviada = await agent.quote({ to: (j.desk || j.mostrador), contract: 'escrow', price: t.price, concept: t.concept, arbiter: (j.arbiter || j.arbitro), terms: t.terms });
210
+ return text({ enviada: enviada.id, tarea: t.id, price: t.price, siguiente: 'si hay cupo, el contrato te llega al buzón (nyx5_inbox); al terminar, nyx5_libro op=deliver' });
211
+ }
212
+ case 'nyx5_email': { const r = await agent.email({ to: args.to, subject: args.subject, body: args.body }); return text(r); }
213
+ case 'nyx5_contract': return text(await agent.contract(args.house || agent.domain, args.contract));
214
+ case 'nyx5_wait': {
215
+ const secs = Math.max(1, Math.min(Number(args.seconds ?? 60) || 60, esperaMaxS));
216
+ // Sin `since`, se espera lo que llegue DESDE AHORA. Defecto real (12-sep-2026): devolvía como
217
+ // "respuesta" un mensaje viejo que ya estaba en el buzón, y el que espera daba por contestada
218
+ // una conversación que no lo estaba. Lo pendiente de antes se avisa, no se confunde con lo nuevo.
219
+ // Lo que YA estaba sin leer se entrega igual (nada se pierde), pero marcado como anterior a la
220
+ // espera, para que nadie lo tome por la respuesta a lo que acaba de mandar.
221
+ const desde = args.since || new Date().toISOString();
222
+ const anterior = args.since ? null : (await agent.inbox({ limit: 50 })).find((m) => m.received < desde && (!args.from || m.envelope?.from === args.from) && (!args.thread || m.envelope?.thread === args.thread || m.envelope?.id === args.thread) && (!args.project || proyectoDe(m.envelope) === nombreDeProyecto(args.project)));
223
+ const m = anterior || await agent.wait({ from: args.from, thread: args.thread, since: desde, project: args.project, seconds: secs });
224
+ if (!m) return text({ message: null, waited_seconds: secs, note: 'nothing arrived; call again to keep listening' });
225
+ let abierto;
226
+ try { const { sender, ...o } = await agent.open(m.envelope); abierto = o; } catch (e) { abierto = { id: m.envelope.id, from: m.envelope.from, error: e.message }; }
227
+ return text({ ...abierto, received: m.received, ...(anterior ? { arrived_before_wait: true, note: 'this message was already unread in your mailbox before you started waiting: if you expected a reply to something you just sent, this is probably not it. Pass since (its received time) to wait only for newer mail.' } : {}) });
228
+ }
229
+ case 'nyx5_conversation': {
230
+ if (!args.with) return text(await agent.conversations({ project: args.project || null }));
231
+ const msgs = await agent.conversation(args.with, { limit: args.limit ?? 30, project: args.project || null });
232
+ const out = [];
233
+ for (const m of msgs) {
234
+ try { const { sender, ...o } = await agent.open(m.envelope); out.push({ dir: m.dir, at: m.at, status: m.status, ...o }); }
235
+ catch (e) { out.push({ dir: m.dir, at: m.at, id: m.envelope?.id, from: m.envelope?.from, error: e.message }); }
236
+ }
237
+ return text(out);
238
+ }
239
+ case 'nyx5_group': {
240
+ const op = args.op;
241
+ if (op === 'create') return text(await agent.createGroup(args.name, { members: args.members || [], post: args.post }));
242
+ if (op === 'members') return text(await agent.group(args.group));
243
+ if (op === 'add') return text(await agent.editGroup(args.group, { add: args.members || [] }));
244
+ if (op === 'remove') return text(await agent.editGroup(args.group, { remove: args.members || [] }));
245
+ if (op === 'leave') return text(await agent.leaveGroup(args.group));
246
+ return { ...text(`unknown group op: ${op}. Valid ones: create, members, add, remove, leave`), isError: true };
247
+ }
248
+ case 'nyx5_profile': {
249
+ if (args.op === 'get') return text(await agent.profile(args.address || agent.address));
250
+ if (args.op === 'set') return text(await agent.setProfile(args.profile === undefined ? null : args.profile));
251
+ return { ...text(`unknown profile op: ${args.op}. Valid ones: get, set`), isError: true };
252
+ }
253
+ case 'nyx5_notarize': { const r = await agent.notarize(args.house || agent.domain, { sha256: args.sha256, name: args.name, media: args.media, note: args.note }); return text({ id: r.id, note: 'the seal arrives as a signed receipt from libro@ in your mailbox (nyx5_inbox)' }); }
254
+ case 'nyx5_notarized': { const r = await agent.notarized(args.sha256, args.house || agent.domain); return text(r || { sha256: String(args.sha256).toLowerCase(), seals: [], note: 'no seal for this hash in this house' }); }
255
+ // qa@ (NX-606): mensajes, no Libro. El `pay` previo que carga el crédito va por nyx5_libro.
256
+ case 'nyx5_qa_spec': {
257
+ if (typeof args.request !== 'string' || !args.request.trim()) return { ...text('nyx5_qa_spec needs request: what you want done'), isError: true };
258
+ const to = args.to || `qa@${agent.domain}`;
259
+ const r = await agent.send({ to: [to], body: args.request });
260
+ return text({ id: r.id, to, note: `the contract arrives as a message from ${to} (nyx5_wait with from=${to}); its footer carries the sha256 to seal with nyx5_notarize` });
261
+ }
262
+ case 'nyx5_qa_gate': {
263
+ if (typeof args.spec !== 'string' || typeof args.spec_sha256 !== 'string' || typeof args.delivery?.text !== 'string') return { ...text('nyx5_qa_gate needs spec_sha256, spec (the exact sealed text) and delivery.text'), isError: true };
264
+ const to = args.to || `qa@${agent.domain}`;
265
+ const r = await agent.send({ to: [to], media: MEDIA_GATE, body: { spec_sha256: args.spec_sha256, spec: args.spec, delivery: { text: args.delivery.text, url: args.delivery.url, sha256: args.delivery.sha256 }, note: args.note } });
266
+ return text({ id: r.id, to, note: `the verdict arrives as a message from ${to} (nyx5_wait with from=${to}): JSON signed by the house, pass | fail | abstain` });
267
+ }
268
+ // NX-502: dinero real fuera del Libro. El cliente valida (RUT, monto), exige cifrado y se niega
269
+ // ante una dirección cuya llave guarda la casa; la casa sólo anota el evento sin monto.
270
+ case 'nyx5_payment_request': {
271
+ const r = await agent.paymentRequest({ to: args.to, amount: args.amount, currency: args.currency, name: args.name, rut: args.rut, bank: args.bank, account_type: args.account_type, account_number: args.account_number, reference: args.reference, thread: args.thread });
272
+ return text({ id: r.id, request_id: r.request_id, to: r.to, currency: r.currency, encrypted: r.encrypted, note: `their confirmation arrives as a message from ${r.to} with media ${MEDIA_COBRO_CONFIRMACION} (nyx5_wait with from=${r.to}); Nyx5 never touches the money` });
273
+ }
274
+ case 'nyx5_payment_confirm': {
275
+ const pedido = await agent.cobro(args.in_reply_to, args.from || null);
276
+ if (!pedido) return { ...text(`no payment request with id ${args.in_reply_to} in your mailbox (pass from if you already acknowledged it)`), isError: true };
277
+ const r = await agent.paymentConfirm(pedido, { bank_reference: args.bank_reference });
278
+ return text({ id: r.id, request_id: r.request_id, to: r.to, in_reply_to: r.in_reply_to, encrypted: r.encrypted, note: 'sent encrypted; Nyx5 does not move money, it only carries your confirmation' });
279
+ }
280
+ // NX-413. Sólo por el puente LOCAL: leer y escribir archivos exige un disco al lado del modelo,
281
+ // y en el remoto la llave del subagente vive en la bóveda, o sea que la casa cifraría en su
282
+ // nombre y el archivo pasaría por ella en claro. MENSAJERIA no las incluye.
283
+ case 'nyx5_attach': {
284
+ const fs = await import('node:fs');
285
+ if (!args.path && args.content == null) return { ...text('nyx5_attach needs path (a local file) or content'), isError: true };
286
+ const datos = args.path ? fs.readFileSync(args.path) : Buffer.from(String(args.content), args.base64 ? 'base64' : 'utf8');
287
+ const nombre = args.name || (args.path ? String(args.path).split('/').pop() : null);
288
+ const att = await agent.subirAdjunto(datos, { name: nombre, media: args.media, to: args.to || [] });
289
+ // La llave se devuelve porque es lo que hay que pasarle a nyx5_send; va al mismo turno del
290
+ // modelo que ya tiene el archivo, y de ahí entra al bloque cifrado del sobre, no al sobre.
291
+ return text({ attachment: att, note: `pass it in nyx5_send attachments; the key travels inside the encrypted block, so send it encrypted (encrypt: true, the default) or the house can open the file` });
292
+ }
293
+ case 'nyx5_attachment': {
294
+ const fs = await import('node:fs');
295
+ const sobre = await agent.conAdjuntos(args.id, args.from || null);
296
+ if (!sobre) return { ...text(`no envelope with id ${args.id} in your mailbox (pass from if you already acknowledged it)`), isError: true };
297
+ const dir = args.out_dir || '.';
298
+ const out = [];
299
+ for (const att of sobre.attachments) {
300
+ if (args.blob && String(att.blob).toLowerCase() !== String(args.blob).toLowerCase()) continue;
301
+ try {
302
+ const datos = await agent.bajarAdjunto(att, { from: sobre.from });
303
+ // El nombre lo escribe el REMITENTE: se usa sólo su parte final, nunca como ruta.
304
+ const destino = `${dir.replace(/\/$/, '')}/${String(att.name).split('/').pop()}`;
305
+ fs.mkdirSync(dir, { recursive: true });
306
+ fs.writeFileSync(destino, datos);
307
+ out.push({ name: att.name, media: att.media, bytes: datos.length, sha256_ok: true, path: destino });
308
+ } catch (e) { out.push({ name: att.name, blob: att.blob, error: e.message }); }
309
+ }
310
+ return text(out.length ? out : { error: `envelope ${args.id} carries no attachment with blob ${args.blob}` });
311
+ }
312
+ case 'nyx5_whoami': return text(await agent.whoami());
313
+ default: return { ...text(`unknown tool: ${name}`), isError: true };
314
+ }
315
+ }