@nyx5/nyx5 0.7.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.
- package/README.md +15 -3
- package/bin/nyx5.js +72 -0
- package/demo/x402-pagar.mjs +64 -0
- package/docs/ARQUITECTURA.md +352 -0
- package/docs/SPEC.md +337 -6
- package/examples/ci/nyx5-ejecutor.yml +82 -0
- package/examples/frameworks/README.md +55 -0
- package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
- package/examples/frameworks/claude-agent-sdk.mjs +48 -0
- package/examples/frameworks/crewai_nyx5.py +72 -0
- package/examples/frameworks/http.md +117 -0
- package/examples/frameworks/langgraph_nyx5.py +90 -0
- package/examples/frameworks/nyx5_http.py +255 -0
- package/examples/frameworks/openai-agents.mjs +41 -0
- package/examples/hola-mundo.mjs +4 -4
- package/migrations/0009_contratos_estado.sql +4 -0
- package/package.json +4 -2
- package/server.json +2 -2
- package/src/correo/acciones.js +216 -0
- package/src/correo/adjuntos.js +185 -0
- package/src/correo/agente.js +247 -13
- package/src/correo/asistente.js +502 -77
- package/src/correo/ci.js +262 -0
- package/src/correo/cobro.js +164 -0
- package/src/correo/estafeta.js +558 -59
- package/src/correo/ideas.js +154 -0
- package/src/correo/politica.js +92 -3
- package/src/libro/libro.js +6 -2
- package/src/libro/verifica.js +239 -36
- package/src/nucleo/almacen-d1.js +24 -5
- package/src/nucleo/almacen.js +31 -4
- package/src/nucleo/crypto.js +36 -0
- package/src/nucleo/keccak.js +118 -0
- package/src/nucleo/objetos.js +78 -0
- package/src/nucleo/secp256k1.js +228 -0
- package/src/plataformas/app-html.js +1 -1
- package/src/plataformas/home-html.js +1 -1
- package/src/plataformas/node.js +18 -1
- package/src/plataformas/spec-html.js +1 -1
- package/src/plataformas/terms-html.js +2 -0
- package/src/plataformas/worker.js +21 -1
- package/src/puentes/ejecutor.js +118 -0
- package/src/puentes/herramientas.js +137 -12
- package/src/puentes/x402-pagador.js +248 -0
- package/src/version.js +1 -1
package/src/correo/agente.js
CHANGED
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
// Las operaciones del Libro son sobres firmados a libro@<casa>; las respuestas vuelven como recibos al buzón.
|
|
4
4
|
|
|
5
5
|
import { Resolver, parseAddress } from './resolver.js';
|
|
6
|
-
import { EXT_PROYECTO, proyectoDe, rolDe, nombreDeProyecto } from './politica.js';
|
|
7
|
-
import { generateKeys, signObject, verifyObject, signBytes, canonical, b64u, uuid, encryptContent, decryptContent, mintPow, sha256hex } from '../nucleo/crypto.js';
|
|
6
|
+
import { EXT_PROYECTO, EXT_RESUMEN, RESUMEN_MAX, ADJUNTOS, proyectoDe, rolDe, resumenDe, nombreDeProyecto, limpio, validarAdjuntos } from './politica.js';
|
|
7
|
+
import { generateKeys, signObject, verifyObject, signBytes, canonical, b64u, uuid, encryptContent, decryptContent, encryptBlob, decryptBlob, generateBlobKey, mintPow, sha256hex } from '../nucleo/crypto.js';
|
|
8
|
+
import { aadDe as aadBlob } from './adjuntos.js';
|
|
8
9
|
import { Libro, MEDIA } from '../libro/libro.js';
|
|
10
|
+
import { pruebaDeAceptacion } from '../libro/verifica.js';
|
|
11
|
+
import { MEDIA_COBRO, MEDIA_COBRO_CONFIRMACION, cobroValido, confirmacionValida, extensionDeCobro, razonParaNoCobrar } from './cobro.js';
|
|
9
12
|
|
|
10
13
|
const iso = (t = Date.now()) => new Date(t).toISOString();
|
|
11
14
|
|
|
@@ -81,19 +84,39 @@ export class Agent {
|
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
// ---------- envío ----------
|
|
84
|
-
async send({ to, type = 'message', body, media, encrypt = true, thread, inReplyTo, expires, deliverAfter, attachments, extensions, receipt, project, role }) {
|
|
87
|
+
async send({ to, type = 'message', body, media, encrypt = true, thread, inReplyTo, expires, deliverAfter, attachments, extensions, receipt, project, role, summary }) {
|
|
85
88
|
const recipients = Array.isArray(to) ? to : [to];
|
|
86
89
|
const id = uuid();
|
|
87
90
|
// Proyecto y rol viajan como extensión firmada: el buzón del otro lado filtra por proyecto.
|
|
88
|
-
|
|
91
|
+
// Un proyecto que no normaliza a un nombre (sólo invisibles, o `__proto__`/`constructor`) se
|
|
92
|
+
// rechaza aquí, con nombre: antes viajaba como `project: null` y el remitente creía haberlo etiquetado.
|
|
93
|
+
const proyecto = project ? nombreDeProyecto(project) : null;
|
|
94
|
+
if (project && !proyecto) throw new Error(`invalid project name: ${JSON.stringify(String(project).slice(0, 40))} (empty after normalization, or a reserved word)`);
|
|
95
|
+
if (proyecto || role) extensions = { ...(extensions || {}), [EXT_PROYECTO]: { ...(proyecto ? { project: proyecto } : {}), ...(role ? { role: String(role).trim().slice(0, 40) } : {}) } };
|
|
96
|
+
// El resumen EN CLARO: una línea que la casa puede leer y poner en el asunto del aviso por
|
|
97
|
+
// correo, porque el cuerpo va cifrado y nadie más que el destinatario lo abre. Lo decide quien
|
|
98
|
+
// escribe, no la casa. Se rechaza aquí, con nombre, si no queda nada después de limpiarlo: que
|
|
99
|
+
// viaje vacío sería creer que etiquetaste y no haberlo hecho (el mismo defecto que `project`).
|
|
100
|
+
if (summary != null) {
|
|
101
|
+
const r = limpio(String(summary), RESUMEN_MAX);
|
|
102
|
+
if (!r) throw new Error('invalid summary: empty after cleaning (it must be one readable line)');
|
|
103
|
+
extensions = { ...(extensions || {}), [EXT_RESUMEN]: { text: r } };
|
|
104
|
+
}
|
|
89
105
|
// Un sobre que responde a otro hereda su hilo. Defecto real (12-sep-2026): las respuestas iban con
|
|
90
106
|
// in_reply_to y thread null, y la conversación quedaba como mensajes sueltos; el historial firmado
|
|
91
107
|
// es el producto, y sin hilo no es historial. La casa no puede rellenarlo: el sobre va firmado.
|
|
92
108
|
if (inReplyTo && thread == null) thread = await this._hiloDe(inReplyTo, recipients[0]);
|
|
109
|
+
// Adjuntos (NX-413): lo que llega aquí son los descriptores que devolvió `subirAdjunto`, o sea
|
|
110
|
+
// archivos YA cifrados y YA guardados en el almacén de esta casa. `url` no se firma: se deriva
|
|
111
|
+
// de la casa del remitente al bajar, así que una url en el sobre sería una url que alguien
|
|
112
|
+
// puede cambiar por otra.
|
|
113
|
+
const adj = attachments == null ? null : (Array.isArray(attachments) ? attachments : [attachments]).map((a) => ({
|
|
114
|
+
blob: String(a?.blob || '').toLowerCase(), bytes: a?.bytes, name: a?.name, media: a?.media, sha256: a?.sha256, key: a?.key,
|
|
115
|
+
}));
|
|
93
116
|
const base = {
|
|
94
117
|
nyx5: '1', id, from: this.address, to: recipients, created: iso(),
|
|
95
118
|
expires: expires ?? null, deliver_after: deliverAfter ?? undefined, thread: thread ?? null, in_reply_to: inReplyTo ?? null, type,
|
|
96
|
-
|
|
119
|
+
extensions, receipt,
|
|
97
120
|
};
|
|
98
121
|
const content = { media: media || (typeof body === 'string' ? 'text/plain' : 'application/json'), body };
|
|
99
122
|
|
|
@@ -123,10 +146,19 @@ export class Agent {
|
|
|
123
146
|
// `to` no cambia y la AAD tampoco. Sin esto, lo que uno mismo mandó cifrado es ilegible para
|
|
124
147
|
// uno mismo, y el historial de una conversación queda con la mitad de los mensajes en blanco.
|
|
125
148
|
if (this.keys.enc && !lectores.some((l) => l.address === this.address)) lectores.push({ address: this.address, enc: this.keys.enc });
|
|
126
|
-
|
|
149
|
+
// La ficha COMPLETA de cada adjunto —nombre, tipo, hash del claro y la llave— va DENTRO del
|
|
150
|
+
// bloque cifrado. Afuera sólo queda `blob` y `bytes`: lo que la casa necesita para servir el
|
|
151
|
+
// objeto y cobrar su cupo, y nada de lo que diga qué hay dentro.
|
|
152
|
+
env = { ...base, attachments: adj ? adj.map((a) => ({ blob: a.blob, bytes: a.bytes })) : undefined, encrypted: await encryptContent(adj ? { ...content, attachments: adj } : content, lectores, aad(base)) };
|
|
127
153
|
} else {
|
|
128
154
|
if (encrypt === 'required') throw new Error('a recipient does not publish an encryption key');
|
|
129
|
-
|
|
155
|
+
// Sin cifrar, la ficha viaja a la vista y la LLAVE también: el objeto sigue cifrado en el
|
|
156
|
+
// almacén, pero la casa puede abrirlo. Se permite y se dice; `nyx5_attach` lo advierte.
|
|
157
|
+
env = { ...base, attachments: adj || undefined, content };
|
|
158
|
+
}
|
|
159
|
+
if (adj) {
|
|
160
|
+
const malo = validarAdjuntos(env.attachments, { cifrado: !!env.encrypted });
|
|
161
|
+
if (malo) throw new Error(malo);
|
|
130
162
|
}
|
|
131
163
|
const powBits = Math.max(0, ...cards.map((c) => (c.inbox?.policy === 'pow' ? c.inbox.pow_bits ?? 16 : 0)));
|
|
132
164
|
if (powBits) env.pow = mintPow(id, powBits);
|
|
@@ -154,6 +186,77 @@ export class Agent {
|
|
|
154
186
|
try { return await this._call('POST', '/email/out', { to, subject, body }); }
|
|
155
187
|
catch (e) { if (e.status === 503 && e.body?.pending) return e.body; throw e; }
|
|
156
188
|
}
|
|
189
|
+
// ---------- adjuntos (NX-413): archivos de verdad en el sobre ----------
|
|
190
|
+
// Sube un archivo CIFRADO al almacén de objetos de su propia casa y devuelve el descriptor que
|
|
191
|
+
// `send({ attachments: [...] })` mete dentro del bloque cifrado. La llave se genera aquí, es
|
|
192
|
+
// distinta para cada adjunto, y nunca sale de este descriptor: la casa aloja bytes que no puede
|
|
193
|
+
// abrir (invariante 8). El sha256 es del CLARO, para que el receptor compruebe que descifró lo
|
|
194
|
+
// correcto; el del cifrado lo guarda la casa para su propia integridad.
|
|
195
|
+
async subirAdjunto(datos, { name, media = 'application/octet-stream', to = [] } = {}) {
|
|
196
|
+
const buf = Buffer.from(datos);
|
|
197
|
+
if (!buf.length) throw new Error('an attachment needs bytes');
|
|
198
|
+
const nombre = limpio(String(name ?? ''), ADJUNTOS.nombreMax);
|
|
199
|
+
if (!nombre) throw new Error('an attachment needs a name (it travels inside the encrypted block)');
|
|
200
|
+
// El tope se comprueba ANTES de cifrar y de subir: no se gastan 11 MB de red para que la casa
|
|
201
|
+
// diga no, y el motivo es el mismo que daría ella.
|
|
202
|
+
if (buf.length > ADJUNTOS.bytesPorAdjunto) throw Object.assign(new Error(`attachment of ${buf.length} bytes exceeds the maximum ${ADJUNTOS.bytesPorAdjunto} (${Math.round(ADJUNTOS.bytesPorAdjunto / 1024 / 1024)} MB) per attachment`), { status: 413 });
|
|
203
|
+
const destinos = (Array.isArray(to) ? to : [to]).filter(Boolean).map((t) => { const { local, domain } = parseAddress(t); return `${local}@${domain}`; });
|
|
204
|
+
const blob = uuid();
|
|
205
|
+
const key = generateBlobKey();
|
|
206
|
+
const objeto = encryptBlob(buf, key, aadBlob(blob, this.address));
|
|
207
|
+
const ruta = `/blob/${blob}`;
|
|
208
|
+
const q = destinos.length ? `?to=${encodeURIComponent(destinos.join(','))}` : '';
|
|
209
|
+
const res = await this.fetch(`${this.estafeta}${ruta}${q}`, {
|
|
210
|
+
method: 'PUT', body: objeto, signal: AbortSignal.timeout(120_000),
|
|
211
|
+
headers: { 'content-type': 'application/octet-stream', authorization: this._auth('PUT', ruta) },
|
|
212
|
+
});
|
|
213
|
+
const j = await res.json().catch(() => ({}));
|
|
214
|
+
if (!res.ok) throw Object.assign(new Error(j.reason || `HTTP ${res.status}`), { status: res.status, body: j });
|
|
215
|
+
return { blob, bytes: buf.length, name: nombre, media, sha256: sha256hex(buf), key, url: j.url };
|
|
216
|
+
}
|
|
217
|
+
// Baja un adjunto de la casa del REMITENTE (la url se deriva, no se lee del sobre), lo descifra y
|
|
218
|
+
// comprueba el hash del claro. Si el hash no cuadra, el error dice CUÁL adjunto: con varios en un
|
|
219
|
+
// sobre, «el hash no cuadra» sin nombre no se puede accionar.
|
|
220
|
+
async bajarAdjunto(att, { from = this.address } = {}) {
|
|
221
|
+
const quien = att?.name || att?.blob || '(unnamed)';
|
|
222
|
+
if (!att?.blob) throw new Error('an attachment reference needs blob');
|
|
223
|
+
if (!att?.key) throw new Error(`attachment ${quien}: no key. In an encrypted envelope the key travels INSIDE the encrypted block: open the envelope first and pass the attachment it gives you`);
|
|
224
|
+
const { domain } = parseAddress(from);
|
|
225
|
+
const base = domain === this.domain ? this.estafeta : (await this.resolver.domainCard(domain))._estafeta;
|
|
226
|
+
const ruta = `/blob/${String(att.blob).toLowerCase()}`;
|
|
227
|
+
const res = await this.fetch(`${base}${ruta}`, { headers: { authorization: this._auth('GET', ruta, this.keys, base) }, signal: AbortSignal.timeout(120_000) });
|
|
228
|
+
if (!res.ok) {
|
|
229
|
+
const j = await res.json().catch(() => ({}));
|
|
230
|
+
throw Object.assign(new Error(j.reason || `HTTP ${res.status}`), { status: res.status, body: j });
|
|
231
|
+
}
|
|
232
|
+
const objeto = Buffer.from(await res.arrayBuffer());
|
|
233
|
+
let claro;
|
|
234
|
+
try { claro = decryptBlob(objeto, att.key, aadBlob(att.blob, from)); }
|
|
235
|
+
catch { throw new Error(`attachment ${quien}: could not be decrypted (wrong key, or it was not sent by ${from}: the AAD ties the object to its sender)`); }
|
|
236
|
+
if (att.sha256 && sha256hex(claro) !== String(att.sha256).toLowerCase()) {
|
|
237
|
+
throw new Error(`attachment ${quien}: the sha256 of the plaintext does not match what the envelope declares (expected ${String(att.sha256).toLowerCase()}, got ${sha256hex(claro)})`);
|
|
238
|
+
}
|
|
239
|
+
return claro;
|
|
240
|
+
}
|
|
241
|
+
// El sobre con adjuntos que está en el buzón (o en la conversación, si ya se confirmó), abierto.
|
|
242
|
+
async conAdjuntos(id, from = null) {
|
|
243
|
+
let m = (await this.inbox({ limit: 200 })).find((x) => x.envelope?.id === id);
|
|
244
|
+
if (!m && from) m = (await this.conversation(from, { limit: 200 })).find((x) => x.envelope?.id === id);
|
|
245
|
+
if (!m) return null;
|
|
246
|
+
const o = await this.open(m.envelope);
|
|
247
|
+
if (!o.attachments?.length) throw new Error(`envelope ${id} carries no attachments`);
|
|
248
|
+
return o;
|
|
249
|
+
}
|
|
250
|
+
// Lo borra antes de que se cumpla la retención. Sólo quien lo subió: la copia es del remitente.
|
|
251
|
+
async borrarAdjunto(blob, { house = this.domain } = {}) {
|
|
252
|
+
const base = house === this.domain ? this.estafeta : (await this.resolver.domainCard(house))._estafeta;
|
|
253
|
+
const ruta = `/blob/${String(blob).toLowerCase()}`;
|
|
254
|
+
const res = await this.fetch(`${base}${ruta}`, { method: 'DELETE', headers: { authorization: this._auth('DELETE', ruta, this.keys, base) }, signal: AbortSignal.timeout(30_000) });
|
|
255
|
+
const j = await res.json().catch(() => ({}));
|
|
256
|
+
if (!res.ok) throw Object.assign(new Error(j.reason || `HTTP ${res.status}`), { status: res.status, body: j });
|
|
257
|
+
return j;
|
|
258
|
+
}
|
|
259
|
+
|
|
157
260
|
// El hilo del sobre al que se responde: el suyo si lo tenía, si no su propio id. Se busca en la
|
|
158
261
|
// conversación con el destinatario (lo recibido y lo enviado); si la casa no lo conoce, el id.
|
|
159
262
|
async _hiloDe(inReplyTo, con) {
|
|
@@ -187,17 +290,144 @@ export class Agent {
|
|
|
187
290
|
// Una cotización es un documento firmado por el vendedor; viaja dentro de un sobre (cifrado) al comprador.
|
|
188
291
|
// Con `service` (NX-301) el precio, el contrato y el concepto se leen de la propia ficha publicada
|
|
189
292
|
// si no vienen; lo que venga explícito viaja igual y la casa lo cruza con la ficha al aceptar.
|
|
190
|
-
|
|
293
|
+
// `thread`/`inReplyTo`: cuando la cotización contesta un pedido (NX-305) viaja en su hilo.
|
|
294
|
+
async quote({ to, contract, price, concept, terms, expires, arbiter, house, referrer, service, thread, inReplyTo }) {
|
|
191
295
|
if (service != null) {
|
|
192
|
-
this.
|
|
193
|
-
const s = ((await this.resolver.agentCard(this.address)).profile?.services || []).find((x) => x.id === service);
|
|
194
|
-
if (!s) throw new Error(`service "${service}" is not published in your profile; publish it with setProfile first`);
|
|
296
|
+
const s = await this._servicioPropio(service);
|
|
195
297
|
price ??= s.price.tokens; contract ??= s.contract; concept ??= s.name;
|
|
196
298
|
}
|
|
197
299
|
const q = Libro.buildQuote({ seller: this.address, buyer: to, house: house || parseAddress(to).domain, contract: contract ?? 'spot', price, concept, terms, expires, arbiter, referrer, service }, this.keys);
|
|
198
|
-
const sent = await this.send({ to, type: 'message', media: MEDIA.cotizacion, body: q, expires: expires ?? null });
|
|
300
|
+
const sent = await this.send({ to, type: 'message', media: MEDIA.cotizacion, body: q, expires: expires ?? null, thread, inReplyTo });
|
|
199
301
|
return { quote: q, ...sent };
|
|
200
302
|
}
|
|
303
|
+
async _servicioPropio(service) {
|
|
304
|
+
this.resolver.invalidate(`agent:${this.address}`);
|
|
305
|
+
const s = ((await this.resolver.agentCard(this.address)).profile?.services || []).find((x) => x.id === service);
|
|
306
|
+
if (!s) throw new Error(`service "${service}" is not published in your profile; publish it with setProfile first`);
|
|
307
|
+
return s;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------- NX-305: contratar desde el catálogo en un paso ----------
|
|
311
|
+
// El comprador PIDE (sobre `task` con media pedido: { service, input, note }); el vendedor
|
|
312
|
+
// contesta con la cotización de su propia ficha TAL CUAL; el comprador la acepta si coincide.
|
|
313
|
+
// Lado vendedor: la cotización que responde a un pedido, armada desde la ficha publicada. El
|
|
314
|
+
// precio y el contrato son los de la ficha (la casa los cruza al aceptar); la prueba sale de
|
|
315
|
+
// `acceptance` + el `input` del pedido; el árbitro es verifica@ de la casa del contrato.
|
|
316
|
+
// `pedido` es el sobre ya abierto (lo que devuelve `open`).
|
|
317
|
+
async quoteFromCatalog(pedido, { service = null } = {}) {
|
|
318
|
+
if (pedido?.content?.media !== MEDIA.pedido) throw new Error(`not a service request: media is ${pedido?.content?.media || '(none)'}, expected ${MEDIA.pedido}`);
|
|
319
|
+
const b = pedido.content.body || {};
|
|
320
|
+
if (typeof b.service !== 'string' || !b.service) throw new Error('the request does not name a service');
|
|
321
|
+
if (service != null && service !== b.service) throw new Error(`the request asks for "${b.service}", not "${service}"`);
|
|
322
|
+
const s = await this._servicioPropio(b.service);
|
|
323
|
+
const input = b.input && typeof b.input === 'object' && !Array.isArray(b.input) ? b.input : {};
|
|
324
|
+
const house = parseAddress(pedido.from).domain;
|
|
325
|
+
// Sin prueba publicada no hay árbitro: un escrow queda como pago diferido (SPEC §16, `expire`).
|
|
326
|
+
const verify = s.acceptance ? pruebaDeAceptacion(s.acceptance, input) : null;
|
|
327
|
+
const terms = { input, ...(s.acceptance ? { acceptance: s.acceptance.template, verify } : {}), ...(typeof b.note === 'string' && b.note ? { note: b.note.slice(0, 500) } : {}) };
|
|
328
|
+
return this.quote({ to: pedido.from, service: s.id, terms, arbiter: verify ? `verifica@${house}` : undefined, house, thread: pedido.thread || pedido.id, inReplyTo: pedido.id });
|
|
329
|
+
}
|
|
330
|
+
// Un pedido recibido, por id: en la bandeja o, si ya se confirmó, en la conversación con `from`.
|
|
331
|
+
async pedido(id, from = null) {
|
|
332
|
+
let m = (await this.inbox({ limit: 200 })).find((x) => x.envelope?.id === id);
|
|
333
|
+
if (!m && from) m = (await this.conversation(from, { limit: 200 })).find((x) => x.dir === 'in' && x.envelope?.id === id);
|
|
334
|
+
if (!m) return null;
|
|
335
|
+
const o = await this.open(m.envelope);
|
|
336
|
+
if (o.content?.media !== MEDIA.pedido) throw new Error(`envelope ${id} is not a service request (media ${o.content?.media || '(none)'})`);
|
|
337
|
+
return o;
|
|
338
|
+
}
|
|
339
|
+
// Lado comprador. Manda el pedido y, con `autoAccept`, espera la cotización hasta `wait`
|
|
340
|
+
// segundos y la acepta SOLO si es la del catálogo que este cliente leyó: mismo servicio, mismo
|
|
341
|
+
// vendedor, precio y contrato publicados, precio <= maxPrice, el mismo input, y con prueba
|
|
342
|
+
// publicada, verifica@ de árbitro y la prueba del `kind` publicado. Cualquier diferencia se
|
|
343
|
+
// devuelve nombrada y NO se acepta. Lo que impide siquiera pedir (no vende, servicio
|
|
344
|
+
// inexistente, precio sobre el tope) se lanza como error.
|
|
345
|
+
async hire({ agent, service, input = {}, note, autoAccept = true, maxPrice = null, wait = 25 } = {}) {
|
|
346
|
+
if (typeof agent !== 'string' || typeof service !== 'string' || !service) throw new Error('hire needs agent (address) and service (id)');
|
|
347
|
+
if (input == null) input = {};
|
|
348
|
+
if (typeof input !== 'object' || Array.isArray(input)) throw new Error('input must be an object');
|
|
349
|
+
this.resolver.invalidate(`agent:${String(agent).toLowerCase()}`);
|
|
350
|
+
const card = await this.resolver.agentCard(agent, { onBehalfOf: this.address });
|
|
351
|
+
if (card.delegation?.scope?.messages_only) throw new Error(`${card.address} does not sell by itself: it is a messages-only address (a connected Claude) and cannot quote; write to it, or to its owner ${card.delegation.by}`);
|
|
352
|
+
const publicados = card.profile?.services || [];
|
|
353
|
+
const s = publicados.find((x) => x.id === service);
|
|
354
|
+
if (!s) throw new Error(publicados.length ? `${card.address} does not publish service "${service}"; it publishes: ${publicados.map((x) => x.id).join(', ')}` : `${card.address} publishes no services; write to it instead`);
|
|
355
|
+
if (maxPrice != null && s.price.tokens > maxPrice) throw new Error(`service "${service}" is published at ${s.price.tokens} tok; your max_price is ${maxPrice}. Nothing was requested`);
|
|
356
|
+
const pedido = await this.send({ to: card.address, type: 'task', media: MEDIA.pedido, body: { service, input, ...(note ? { note: String(note) } : {}) }, encrypt: true });
|
|
357
|
+
const base = { request: pedido.id, agent: card.address, service, published: { price: s.price.tokens, contract: s.contract, acceptance: s.acceptance || null } };
|
|
358
|
+
if (!autoAccept) return { ...base, accepted: false, status: 'requested', note: 'the quote will arrive in your mailbox; accept it yourself' };
|
|
359
|
+
const m = await this.wait({ from: card.address, thread: pedido.id, seconds: Math.max(1, Math.min(Number(wait) || 25, 90)) });
|
|
360
|
+
if (!m) return { ...base, accepted: false, status: 'no_quote', reason: `no quote arrived within ${wait} s; the request waits in ${card.address}'s mailbox and the quote, if any, will land in yours` };
|
|
361
|
+
const o = await this.open(m.envelope);
|
|
362
|
+
if (o.content?.media !== MEDIA.cotizacion) { await this.ack(m.envelope.id).catch(() => {}); return { ...base, accepted: false, status: 'declined', reason: 'the seller answered without a quote', reply: o.content }; }
|
|
363
|
+
const q = o.content.body || {};
|
|
364
|
+
const rechazo = (reason) => ({ ...base, accepted: false, status: 'rejected', reason, quote: q });
|
|
365
|
+
const cotizado = await (async () => {
|
|
366
|
+
if (q.seller !== card.address) return `the quote is signed by ${q.seller}, not by ${card.address}`;
|
|
367
|
+
if (q.buyer !== this.address) return `the quote names ${q.buyer} as buyer, not you`;
|
|
368
|
+
if (q.service !== service) return `the quote is for service ${JSON.stringify(q.service ?? null)}, you asked for "${service}"`;
|
|
369
|
+
if (q.price !== s.price.tokens || q.contract !== s.contract) return `service ${service} is published at ${s.price.tokens} tok as ${s.contract}; the quote says ${q.price} as ${q.contract}`;
|
|
370
|
+
if (maxPrice != null && q.price > maxPrice) return `the quote is ${q.price} tok, above your max_price of ${maxPrice}`;
|
|
371
|
+
if (canonical(q.terms?.input ?? {}) !== canonical(input)) return 'the quote does not carry the input you sent';
|
|
372
|
+
if (s.acceptance) {
|
|
373
|
+
const v = q.terms?.verify;
|
|
374
|
+
if (q.arbiter !== `verifica@${q.house}`) return `the service publishes a ${s.acceptance.kind} test but the quote names ${q.arbiter || 'no'} arbiter instead of verifica@${q.house}`;
|
|
375
|
+
if (!v || v.type !== s.acceptance.kind) return `the service publishes a ${s.acceptance.kind} test; the quote carries ${v?.type || 'none'}`;
|
|
376
|
+
// La prueba ENTERA tiene que ser la que sale de la ficha y de este input (revisión del 14-sep:
|
|
377
|
+
// un vendedor cotizaba con el tipo correcto y su propia URL siempre-200, y cobraba sin tocar el trabajo).
|
|
378
|
+
let esperada; try { esperada = pruebaDeAceptacion(s.acceptance, input); } catch (e) { return `the published test cannot be derived from your input: ${e.message}`; }
|
|
379
|
+
if (canonical(v) !== canonical(esperada)) return `the quote's test differs from the published one applied to your input: expected ${JSON.stringify(esperada)}, got ${JSON.stringify(v)}`;
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
})();
|
|
383
|
+
await this.ack(m.envelope.id).catch(() => {});
|
|
384
|
+
if (cotizado) return rechazo(cotizado);
|
|
385
|
+
const enviada = await this.accept(q);
|
|
386
|
+
const r = await this.awaitReceipt(enviada.id, { timeoutMs: 15_000 });
|
|
387
|
+
if (r.from !== `libro@${q.house}`) return { ...base, accepted: false, status: 'rejected', reason: `the house rejected the acceptance: ${r.receipt?.reason || 'no reason given'}`, quote: q };
|
|
388
|
+
const c = r.receipt.contract;
|
|
389
|
+
return { ...base, accepted: true, status: 'hired', quote_id: q.id, contract: c, price: c.amount, verification: c.arbiter && c.terms?.verify ? { arbiter: c.arbiter, verify: c.terms.verify, when: 'after the seller delivers (nyx5_libro op=deliver), verifica@ runs the test and releases or refunds' } : null };
|
|
390
|
+
}
|
|
391
|
+
// ---------- NX-502: pedido de pago por transferencia (dinero real, fuera del Libro) ----------
|
|
392
|
+
// Manda a UNA persona un cobro cifrado (monto, moneda, nombre, RUT, banco, cuenta, referencia).
|
|
393
|
+
// Se valida antes de firmar (RUT módulo 11, monto por moneda); el sobre va cifrado o no va; y
|
|
394
|
+
// nunca a una dirección cuya llave guarda la casa (razonParaNoCobrar). Lo que la casa ve es la
|
|
395
|
+
// extensión en claro { kind, request_id, currency }: con eso anota `payment_requested`, sin monto.
|
|
396
|
+
async paymentRequest({ to, thread, ...datos } = {}) {
|
|
397
|
+
if (typeof to !== 'string' || !to) throw new Error('paymentRequest needs to: the address of who pays');
|
|
398
|
+
const cobro = cobroValido({ ...datos, request_id: datos.request_id ?? uuid() });
|
|
399
|
+
this.resolver.invalidate(`agent:${String(to).toLowerCase()}`);
|
|
400
|
+
const card = await this.resolver.agentCard(to, { onBehalfOf: this.address });
|
|
401
|
+
const razon = razonParaNoCobrar(card);
|
|
402
|
+
if (razon) throw new Error(razon);
|
|
403
|
+
const r = await this.send({ to: card.address, type: 'message', media: MEDIA_COBRO, body: cobro, encrypt: 'required', thread, extensions: extensionDeCobro('request', cobro) });
|
|
404
|
+
return { id: r.id, request_id: cobro.request_id, to: card.address, currency: cobro.currency, encrypted: r.encrypted, jobs: r.jobs };
|
|
405
|
+
}
|
|
406
|
+
// Un cobro recibido, por id de sobre: en la bandeja o, si ya se confirmó, en la conversación con `from`.
|
|
407
|
+
async cobro(id, from = null) {
|
|
408
|
+
let m = (await this.inbox({ limit: 200 })).find((x) => x.envelope?.id === id);
|
|
409
|
+
if (!m && from) m = (await this.conversation(from, { limit: 200 })).find((x) => x.dir === 'in' && x.envelope?.id === id);
|
|
410
|
+
if (!m) return null;
|
|
411
|
+
const o = await this.open(m.envelope);
|
|
412
|
+
if (!o.encrypted) throw new Error(`envelope ${id} arrived in the clear: a payment request only counts encrypted`);
|
|
413
|
+
if (o.content?.media !== MEDIA_COBRO) throw new Error(`envelope ${id} is not a payment request (media ${o.content?.media || '(none)'})`);
|
|
414
|
+
return o;
|
|
415
|
+
}
|
|
416
|
+
// Quien pagó desde su banco le contesta a quien pidió, en el hilo del pedido, con la referencia
|
|
417
|
+
// bancaria. Cifrado o nada, y con la misma regla de destino que el pedido. `pedido` es el sobre
|
|
418
|
+
// abierto (lo que devuelve `cobro`). Nyx5 no comprueba el pago: lleva la palabra del pagador.
|
|
419
|
+
async paymentConfirm(pedido, { bank_reference } = {}) {
|
|
420
|
+
if (pedido?.content?.media !== MEDIA_COBRO) throw new Error(`not a payment request: media is ${pedido?.content?.media || '(none)'}, expected ${MEDIA_COBRO}`);
|
|
421
|
+
if (!pedido.to.includes(this.address)) throw new Error(`payment request ${pedido.id} was not addressed to ${this.address}`);
|
|
422
|
+
const confirmacion = confirmacionValida({ request_id: pedido.content.body?.request_id, bank_reference });
|
|
423
|
+
const card = await this.resolver.agentCard(pedido.from, { onBehalfOf: this.address });
|
|
424
|
+
const razon = razonParaNoCobrar(card);
|
|
425
|
+
if (razon) throw new Error(razon);
|
|
426
|
+
const currency = pedido.content.body?.currency;
|
|
427
|
+
const r = await this.send({ to: card.address, type: 'result', media: MEDIA_COBRO_CONFIRMACION, body: confirmacion, encrypt: 'required', thread: pedido.thread || pedido.id, inReplyTo: pedido.id, extensions: extensionDeCobro('confirmation', { request_id: confirmacion.request_id, currency }) });
|
|
428
|
+
return { id: r.id, request_id: confirmacion.request_id, to: card.address, in_reply_to: pedido.id, encrypted: r.encrypted, jobs: r.jobs };
|
|
429
|
+
}
|
|
430
|
+
|
|
201
431
|
// Operación genérica: sobre firmado, sin cifrar, a libro@<casa>. La respuesta llega como recibo.
|
|
202
432
|
libroOp(house, body, opts = {}) {
|
|
203
433
|
return this.send({ to: `libro@${house}`, type: 'task', media: MEDIA.op, body, encrypt: false, ...opts });
|
|
@@ -339,7 +569,11 @@ export class Agent {
|
|
|
339
569
|
const aGrupo = envelope.to.some((t) => { try { return parseAddress(t).local.startsWith('g.'); } catch { return false; } });
|
|
340
570
|
if (!envelope.encrypted && !aGrupo && !envelope.to.includes(this.address) && envelope.from !== this.address) throw new Error(`envelope ${envelope.id} is not addressed to ${this.address}`);
|
|
341
571
|
const content = envelope.encrypted ? await decryptContent(envelope.encrypted, this.address, this.keys, aad(envelope)) : envelope.content;
|
|
342
|
-
|
|
572
|
+
// Los adjuntos de un sobre cifrado están DENTRO del contenido (con nombre, tipo y llave); los
|
|
573
|
+
// de un sobre en claro, afuera. Se devuelven en el mismo sitio en los dos casos para que quien
|
|
574
|
+
// lee no tenga que saber cuál era: lo que recibe es lo que `bajarAdjunto` necesita.
|
|
575
|
+
const adj = (envelope.encrypted ? content?.attachments : envelope.attachments) || null;
|
|
576
|
+
return { id: envelope.id, from: envelope.from, to: envelope.to, type: envelope.type, thread: envelope.thread, in_reply_to: envelope.in_reply_to, created: envelope.created, encrypted: !!envelope.encrypted, project: proyectoDe(envelope), role: rolDe(envelope), summary: resumenDe(envelope), attachments: adj, sender: card, content };
|
|
343
577
|
}
|
|
344
578
|
|
|
345
579
|
// Espera hasta que llegue un sobre que cumpla el filtro (útil para pruebas y flujos síncronos).
|