@nyx5/nyx5 0.6.0 → 0.7.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.
@@ -3,6 +3,7 @@
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';
6
7
  import { generateKeys, signObject, verifyObject, signBytes, canonical, b64u, uuid, encryptContent, decryptContent, mintPow, sha256hex } from '../nucleo/crypto.js';
7
8
  import { Libro, MEDIA } from '../libro/libro.js';
8
9
 
@@ -15,7 +16,9 @@ export class Agent {
15
16
  this.keys = keys;
16
17
  this.estafeta = estafeta.replace(/\/$/, '');
17
18
  this.fetch = (...a) => fetchImpl(...a); // envuelto: workerd exige fetch con this=globalThis
18
- this.resolver = resolver || new Resolver({ hosts: { [domain]: { url: this.estafeta }, ...hosts }, fetchImpl });
19
+ // El cliente firma ante su propia casa cuando una tarjeta no aparece sin autenticar (NX-202):
20
+ // así un contacto de un agente secreto lo resuelve, y un extraño ve lo mismo que si no existiera.
21
+ this.resolver = resolver || new Resolver({ hosts: { [domain]: { url: this.estafeta }, ...hosts }, fetchImpl, auth: { domain, url: this.estafeta, header: (method, path) => this._auth(method, path) } });
19
22
  }
20
23
 
21
24
  static create(address, estafeta, opts = {}) { return new Agent({ address, estafeta, keys: generateKeys(), ...opts }); }
@@ -30,12 +33,12 @@ export class Agent {
30
33
  const token = b64u(canonical(claims));
31
34
  return `Nyx5 ${token}.${signBytes(canonical(claims), keys)}`;
32
35
  }
33
- async _call(method, path, body, { admin, noAuth, authKeys } = {}) {
36
+ async _call(method, path, body, { admin, noAuth, authKeys, timeoutMs = 10_000 } = {}) {
34
37
  const headers = { 'content-type': 'application/json' };
35
38
  if (admin) headers.authorization = `Bearer ${admin}`; else if (!noAuth) headers.authorization = this._auth(method, path.split('?')[0], authKeys);
36
39
  const res = await this.fetch(`${this.estafeta}${path}`, {
37
40
  method, headers,
38
- body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(10_000),
41
+ body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs),
39
42
  });
40
43
  const json = await res.json().catch(() => ({}));
41
44
  if (!res.ok) throw Object.assign(new Error(json.reason || `HTTP ${res.status}`), { status: res.status, body: json });
@@ -45,10 +48,12 @@ export class Agent {
45
48
  // ---------- registro ----------
46
49
  // Tres caminos: adminToken (la casa inscribe), invite (código de la casa) o abierto si la casa lo permite.
47
50
  // Sin adminToken y sin estar registrado, el cuerpo va firmado con la propia clave (prueba de posesión).
48
- async register({ adminToken, invite, capabilities, inbox, webhook, notify_email, valid_until, source } = {}) {
51
+ async register({ adminToken, invite, capabilities, inbox, wallet, wallets, webhook, notify_email, valid_until, source, visibility, profile } = {}) {
49
52
  // `source` es atribución de distribución: viaja al alta, se registra en el evento `join`
50
53
  // y NO entra en la tarjeta. Nadie puede leer de dónde vino un agente mirando su tarjeta.
51
- const body = { local: this.local, sig: this.keys.sig, enc: this.keys.enc, capabilities, inbox, webhook, notify_email, valid_until, source };
54
+ // `wallet` es la dirección a la que este agente quiere que le paguen en dinero real. Es
55
+ // PÚBLICA y va en la tarjeta: la casa no la controla ni puede mover nada de ella.
56
+ const body = { local: this.local, sig: this.keys.sig, enc: this.keys.enc, capabilities, inbox, wallet, wallets, webhook, notify_email, valid_until, source, visibility, profile };
52
57
  if (adminToken) this.card = await this._call('POST', '/agents', body, { admin: adminToken });
53
58
  else if (this.card) this.card = await this._call('POST', '/agents', body);
54
59
  else this.card = await this._call('POST', '/agents', signObject({ ...body, invite: invite || undefined, ts: iso() }, this.keys), { noAuth: true });
@@ -76,9 +81,15 @@ export class Agent {
76
81
  }
77
82
 
78
83
  // ---------- envío ----------
79
- async send({ to, type = 'message', body, media, encrypt = true, thread, inReplyTo, expires, deliverAfter, attachments, extensions, receipt }) {
84
+ async send({ to, type = 'message', body, media, encrypt = true, thread, inReplyTo, expires, deliverAfter, attachments, extensions, receipt, project, role }) {
80
85
  const recipients = Array.isArray(to) ? to : [to];
81
86
  const id = uuid();
87
+ // Proyecto y rol viajan como extensión firmada: el buzón del otro lado filtra por proyecto.
88
+ if (project || role) extensions = { ...(extensions || {}), [EXT_PROYECTO]: { ...(project ? { project: nombreDeProyecto(project) } : {}), ...(role ? { role: String(role).trim().slice(0, 40) } : {}) } };
89
+ // Un sobre que responde a otro hereda su hilo. Defecto real (12-sep-2026): las respuestas iban con
90
+ // in_reply_to y thread null, y la conversación quedaba como mensajes sueltos; el historial firmado
91
+ // es el producto, y sin hilo no es historial. La casa no puede rellenarlo: el sobre va firmado.
92
+ if (inReplyTo && thread == null) thread = await this._hiloDe(inReplyTo, recipients[0]);
82
93
  const base = {
83
94
  nyx5: '1', id, from: this.address, to: recipients, created: iso(),
84
95
  expires: expires ?? null, deliver_after: deliverAfter ?? undefined, thread: thread ?? null, in_reply_to: inReplyTo ?? null, type,
@@ -87,13 +98,32 @@ export class Agent {
87
98
  const content = { media: media || (typeof body === 'string' ? 'text/plain' : 'application/json'), body };
88
99
 
89
100
  // Tarjetas de los destinatarios: para cifrar (clave enc) y para saber si exigen proof-of-work.
90
- const cards = await Promise.all(recipients.map((r) => this.resolver.agentCard(r).catch((e) => ({ address: r, _error: e.message }))));
101
+ // La tarjeta de un grupo cambia cada vez que entra o sale alguien: se pide fresca, siempre.
102
+ // Defecto real de la primera prueba: con la tarjeta en caché, el miembro nuevo recibía un sobre
103
+ // que no podía abrir.
104
+ for (const r of recipients) { try { if (parseAddress(r).local.startsWith('g.')) this.resolver.invalidate(`agent:${r}`); } catch { /* dirección inválida: la resolución lo dirá */ } }
105
+ const cards = await Promise.all(recipients.map((r) => this.resolver.agentCard(r, { onBehalfOf: this.address }).catch((e) => ({ address: r, _error: e.message }))));
91
106
  const missing = cards.filter((c) => c._error);
92
107
  if (missing.length) throw new Error(`could not resolve: ${missing.map((c) => `${c.address} (${c._error})`).join(', ')}`);
93
108
 
109
+ // Un grupo no tiene llave: se cifra para cada uno de sus miembros, que sí la publican. La casa
110
+ // reparte el sobre tal cual y nunca puede abrirlo.
111
+ const lectores = [];
112
+ for (const c of cards) {
113
+ if (!c.group) { lectores.push({ address: c.address, enc: c.enc }); continue; }
114
+ for (const dir of c.group.members) {
115
+ if (dir === this.address) continue;
116
+ const mc = await this.resolver.agentCard(dir, { onBehalfOf: this.address }).catch(() => null);
117
+ lectores.push({ address: dir, enc: mc?.enc || null });
118
+ }
119
+ }
94
120
  let env;
95
- if (encrypt && cards.every((c) => c.enc)) {
96
- env = { ...base, encrypted: encryptContent(content, cards.map((c) => ({ address: c.address, enc: c.enc })), aad(base)) };
121
+ if (encrypt && lectores.every((l) => l.enc)) {
122
+ // El remitente también recibe una copia de la llave del contenido. No es un destinatario más:
123
+ // `to` no cambia y la AAD tampoco. Sin esto, lo que uno mismo mandó cifrado es ilegible para
124
+ // uno mismo, y el historial de una conversación queda con la mitad de los mensajes en blanco.
125
+ if (this.keys.enc && !lectores.some((l) => l.address === this.address)) lectores.push({ address: this.address, enc: this.keys.enc });
126
+ env = { ...base, encrypted: await encryptContent(content, lectores, aad(base)) };
97
127
  } else {
98
128
  if (encrypt === 'required') throw new Error('a recipient does not publish an encryption key');
99
129
  env = { ...base, content };
@@ -108,7 +138,9 @@ export class Agent {
108
138
 
109
139
  const signed = signObject(env, this.keys);
110
140
  const r = await this._call('POST', '/outbound', signed);
111
- return { id, envelope: signed, jobs: r.jobs };
141
+ // `encrypted` dice lo que de verdad pasó: un destinatario sin llave de cifrado recibe en claro,
142
+ // y quien manda tiene que poder saberlo en vez de creer que se cifró.
143
+ return { id, envelope: signed, jobs: r.jobs, encrypted: !!env.encrypted };
112
144
  }
113
145
  // Un mensaje a tu yo futuro: llega a tu propio buzón en la fecha indicada, cifrado (solo tú lo abres).
114
146
  // La cola ya lo sostiene; esto le da a un agente memoria operativa entre sesiones.
@@ -122,6 +154,16 @@ export class Agent {
122
154
  try { return await this._call('POST', '/email/out', { to, subject, body }); }
123
155
  catch (e) { if (e.status === 503 && e.body?.pending) return e.body; throw e; }
124
156
  }
157
+ // El hilo del sobre al que se responde: el suyo si lo tenía, si no su propio id. Se busca en la
158
+ // conversación con el destinatario (lo recibido y lo enviado); si la casa no lo conoce, el id.
159
+ async _hiloDe(inReplyTo, con) {
160
+ try {
161
+ const msgs = await this.conversation(con, { limit: 200 });
162
+ const m = msgs.find((x) => (x.envelope || x).id === inReplyTo);
163
+ if (m) return (m.envelope || m).thread || inReplyTo;
164
+ } catch { /* sin historial disponible: el id basta como raíz del hilo */ }
165
+ return inReplyTo;
166
+ }
125
167
  reply(envelope, body, opts = {}) {
126
168
  return this.send({ to: envelope.from, thread: envelope.thread || envelope.id, inReplyTo: envelope.id, type: opts.type || 'result', body, ...opts });
127
169
  }
@@ -143,8 +185,16 @@ export class Agent {
143
185
 
144
186
  // ---------- Libro: cotizaciones y contratos ----------
145
187
  // Una cotización es un documento firmado por el vendedor; viaja dentro de un sobre (cifrado) al comprador.
146
- async quote({ to, contract = 'spot', price, concept, terms, expires, arbiter, house, referrer }) {
147
- const q = Libro.buildQuote({ seller: this.address, buyer: to, house: house || parseAddress(to).domain, contract, price, concept, terms, expires, arbiter, referrer }, this.keys);
188
+ // Con `service` (NX-301) el precio, el contrato y el concepto se leen de la propia ficha publicada
189
+ // si no vienen; lo que venga explícito viaja igual y la casa lo cruza con la ficha al aceptar.
190
+ async quote({ to, contract, price, concept, terms, expires, arbiter, house, referrer, service }) {
191
+ if (service != null) {
192
+ this.resolver.invalidate(`agent:${this.address}`);
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`);
195
+ price ??= s.price.tokens; contract ??= s.contract; concept ??= s.name;
196
+ }
197
+ 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);
148
198
  const sent = await this.send({ to, type: 'message', media: MEDIA.cotizacion, body: q, expires: expires ?? null });
149
199
  return { quote: q, ...sent };
150
200
  }
@@ -156,6 +206,7 @@ export class Agent {
156
206
  deliver(house, contract, { evidence_sha256, note } = {}) { return this.libroOp(house, { op: 'deliver', contract, evidence_sha256, note }); }
157
207
  release(house, contract) { return this.libroOp(house, { op: 'release', contract }); }
158
208
  refund(house, contract, note) { return this.libroOp(house, { op: 'refund', contract, note }); }
209
+ reclaim(house, contract) { return this.libroOp(house, { op: 'reclaim', contract }); }
159
210
  bond(house, { amount, claim, verifier, beneficiary, arbiter, evidence_sha256, expires, vouchee }) { return this.libroOp(house, { op: 'bond', amount, claim, verifier, beneficiary, arbiter, evidence_sha256, expires, vouchee }); }
160
211
  // Avalar a un desconocido para que entre a un buzón con lista blanca: una fianza en la casa del
161
212
  // receptor, con el receptor como verificador y beneficiario. Si la presentación es basura, la ejecuta.
@@ -166,9 +217,19 @@ export class Agent {
166
217
  mandate(house, { grantee, cap, scope, expires, parent }) { return this.libroOp(house, { op: 'mandate', grantee, cap, scope, expires, parent }); }
167
218
  charge(house, { mandate, amount, concept }) { return this.libroOp(house, { op: 'charge', mandate, amount, concept }); }
168
219
  revoke(house, mandate) { return this.libroOp(house, { op: 'revoke', mandate }); }
220
+ pay(house, { to, amount, concept }) { return this.libroOp(house, { op: 'pay', to, amount, concept }); }
221
+ // Notaría (NX-601): la casa sella el hash de un documento con fecha y firma; el sello llega como
222
+ // recibo. Gratis; el mismo hash sellado dos veces por el mismo agente devuelve el mismo sello.
223
+ notarize(house, { sha256, name, media, note }) { return this.libroOp(house, { op: 'notarize', sha256, name, media, note }); }
169
224
 
170
225
  // Lecturas directas (sin pasar por correo) en la casa indicada; por defecto, la propia estafeta.
171
226
  async balance(house) { return this._callAt(house, 'GET', `/libro/cuenta/${encodeURIComponent(this.address)}`); }
227
+ // Estado de cuenta por rango (NX-501), lectura directa firmada: opening + in − out = closing.
228
+ // `since`/`until` ISO-8601 ([since, until)); `limit` acota cuántos asientos, los más recientes.
229
+ async statement(house, { since, until, limit } = {}) {
230
+ const q = new URLSearchParams(Object.entries({ desde: since, hasta: until, limit }).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]));
231
+ return this._callAt(house, 'GET', `/libro/estado${q.size ? `?${q}` : ''}`);
232
+ }
172
233
  async contract(house, id) { return this._callAt(house, 'GET', `/libro/contrato/${encodeURIComponent(id)}`); }
173
234
  // Historial público de cualquier agente (por defecto, el propio): la reputación es el libro.
174
235
  async historial(address = this.address) {
@@ -179,6 +240,20 @@ export class Agent {
179
240
  if (!res.ok) throw Object.assign(new Error(json.reason || `HTTP ${res.status}`), { status: res.status });
180
241
  return json;
181
242
  }
243
+ // Sellos PÚBLICOS de un hash en una casa (sin cuenta): null si no hay ninguno. Cada sello se
244
+ // verifica contra la tarjeta del dominio; si la casa sirviera uno que no firmó, se rechaza entero.
245
+ async notarized(sha256, house = this.domain) {
246
+ const dc = await this.resolver.domainCard(house);
247
+ const res = await this.fetch(`${dc._estafeta}/notaria/${encodeURIComponent(String(sha256).toLowerCase())}`, { signal: AbortSignal.timeout(10_000) });
248
+ if (res.status === 404) return null;
249
+ const json = await res.json().catch(() => ({}));
250
+ if (!res.ok) throw Object.assign(new Error(json.reason || `HTTP ${res.status}`), { status: res.status });
251
+ const kids = (dc.keys || []).map((k) => k.sig);
252
+ for (const s of json.seals || []) {
253
+ if (!kids.includes(s.signature?.kid) || !verifyObject(s, s.signature.kid)) throw new Error(`seal ${s.id} is not signed by ${house}`);
254
+ }
255
+ return json;
256
+ }
182
257
  async _callAt(house, method, path) {
183
258
  if (!house || house === this.domain) return this._call(method, path);
184
259
  const dc = await this.resolver.domainCard(house);
@@ -197,8 +272,10 @@ export class Agent {
197
272
 
198
273
  // Búsqueda en un índice federado (urn:nyx5:ext:indice): por casa que lo opera o URL directa.
199
274
  // El índice es una pista: cada tarjeta se re-verifica por la cadena normal al usarla.
200
- async search(index, { q, capability, accepts, house, limit, offset } = {}) {
201
- const params = new URLSearchParams(Object.entries({ q, capability, accepts, house, limit, offset }).filter(([, v]) => v != null));
275
+ // Filtros: q, tag, lang, capability, accepts, house, price_max, min_score, limit; y `cursor`
276
+ // (el next_cursor de la página anterior). No hay offset: el índice lo rechaza con 400.
277
+ async search(index, { q, tag, lang, capability, accepts, house, price_max, min_score, limit, cursor } = {}) {
278
+ const params = new URLSearchParams(Object.entries({ q, tag, lang, capability, accepts, house, price_max, min_score, limit, cursor }).filter(([, v]) => v != null));
202
279
  const base = index.startsWith('http') ? index.replace(/\/$/, '') : (await this.resolver.domainCard(index))._estafeta;
203
280
  const res = await this.fetch(`${base}/index/agents?${params}`, { signal: AbortSignal.timeout(10_000) });
204
281
  const json = await res.json().catch(() => ({}));
@@ -211,6 +288,41 @@ export class Agent {
211
288
  async ack(ids) { return (await this._call('POST', `/mailbox/${this.local}/ack`, { ids: Array.isArray(ids) ? ids : [ids] })).acked; }
212
289
  async outbox() { return (await this._call('GET', `/outbox/${this.local}`)).sent; }
213
290
 
291
+ // ---------- conversación y tiempo real ----------
292
+ // El historial vive en la casa, no en la sesión: desde otro dispositivo se retoma igual.
293
+ async conversation(withAddress, { limit = 30, project = null } = {}) {
294
+ const q = new URLSearchParams({ with: String(withAddress), limit: String(limit), ...(project ? { project } : {}) });
295
+ return (await this._call('GET', `/conversations/${this.local}?${q}`)).messages;
296
+ }
297
+ async conversations({ project = null } = {}) { const q = project ? `?${new URLSearchParams({ project })}` : ''; return (await this._call('GET', `/conversations/${this.local}${q}`)).conversations; }
298
+ // Ficha pública: la propia (o la de un delegado propio) se declara; la de cualquiera se lee de su tarjeta.
299
+ setProfile(profile, { of = null } = {}) { const l = of ? parseAddress(of).local : this.local; return this._call('POST', `/agents/${encodeURIComponent(l)}/profile`, { profile }); }
300
+ async profile(address) { this.resolver.invalidate(`agent:${String(address).toLowerCase()}`); const c = await this.resolver.agentCard(address, { onBehalfOf: this.address }); return { address: c.address, profile: c.profile || null, capabilities: c.capabilities || {} }; }
301
+ // «Visto por última vez» de una dirección de una casa (null si el dueño no lo activó).
302
+ async presence(address) {
303
+ const { local, domain } = parseAddress(address);
304
+ const r = await this._callAt(domain, 'GET', `/agents/${encodeURIComponent(local)}/presence`).catch(() => null);
305
+ return r?.last_seen || null;
306
+ }
307
+ // ---------- grupos ----------
308
+ // `g` acepta la dirección completa (g.equipo@casa) o el nombre (equipo). Los miembros son de la casa.
309
+ static _localDeGrupo(g) { const s = String(g || '').toLowerCase(); const l = s.includes('@') ? parseAddress(s).local : s; return l.startsWith('g.') ? l : `g.${l}`; }
310
+ createGroup(name, { members = [], post = 'members' } = {}) { return this._call('POST', '/groups', { name: String(name).replace(/^g\./, ''), members, post }); }
311
+ group(g) { return this._call('GET', `/groups/${encodeURIComponent(Agent._localDeGrupo(g))}/members`); }
312
+ editGroup(g, { add = [], remove = [], admins = [] } = {}) { return this._call('POST', `/groups/${encodeURIComponent(Agent._localDeGrupo(g))}/members`, { add, remove, admins }); }
313
+ leaveGroup(g) { return this.editGroup(g, { remove: [this.address] }); }
314
+ // Espera el próximo sobre pendiente que cumpla el filtro. No sondea desde aquí: la casa responde
315
+ // apenas llega. Devuelve null si se acabó el tiempo sin nada.
316
+ async wait({ from, thread, since, project, seconds = 25 } = {}) {
317
+ const q = new URLSearchParams(Object.entries({ from, thread, since, project, timeout: String(seconds) }).filter(([, v]) => v != null && v !== ''));
318
+ return (await this._call('GET', `/mailbox/${this.local}/wait?${q}`, undefined, { timeoutMs: (Number(seconds) + 15) * 1000 })).message;
319
+ }
320
+ // Quién soy y qué puedo hacer: la tarjeta certificada, resumida.
321
+ async whoami() {
322
+ const c = await this.resolver.agentCard(this.address);
323
+ return { address: c.address, delegated_by: c.delegation?.by || null, valid_until: c.valid_until || null, scope: c.delegation?.scope || null, custody: c.custody || null, inbox: c.inbox || null, encryption: !!c.enc };
324
+ }
325
+
214
326
  // Verifica la cadena de confianza del remitente y descifra si corresponde.
215
327
  async open(envelope) {
216
328
  // Correo entrante por el puente (urn:nyx5:ext:email): sin firma, en claro, marcado como NO
@@ -219,13 +331,15 @@ export class Agent {
219
331
  if (em && !envelope.signature) {
220
332
  return { id: envelope.id, from: em.from, to: envelope.to, type: envelope.type, created: envelope.created, verified: false, via: 'email', subject: em.subject || null, content: envelope.content };
221
333
  }
222
- const card = await this.resolver.agentCardForKid(envelope.from, envelope.signature?.kid);
334
+ const card = await this.resolver.agentCardForKid(envelope.from, envelope.signature?.kid, { onBehalfOf: this.address });
223
335
  const verified = Resolver.acceptedKids(card).includes(envelope.signature?.kid) && verifyObject(envelope, envelope.signature.kid);
224
336
  if (!verified) throw new Error(`invalid signature on envelope ${envelope.id} from ${envelope.from}`);
225
337
  if (envelope.expires && Date.parse(envelope.expires) < Date.now()) throw new Error(`sobre vencido: ${envelope.id}`);
226
- if (!envelope.encrypted && !envelope.to.includes(this.address)) throw new Error(`envelope ${envelope.id} is not addressed to ${this.address}`);
227
- const content = envelope.encrypted ? decryptContent(envelope.encrypted, this.address, this.keys, aad(envelope)) : envelope.content;
228
- 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, sender: card, content };
338
+ // Un sobre a un grupo llega con la dirección del grupo, no la del miembro que lo abre.
339
+ const aGrupo = envelope.to.some((t) => { try { return parseAddress(t).local.startsWith('g.'); } catch { return false; } });
340
+ 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
+ const content = envelope.encrypted ? await decryptContent(envelope.encrypted, this.address, this.keys, aad(envelope)) : envelope.content;
342
+ 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), sender: card, content };
229
343
  }
230
344
 
231
345
  // Espera hasta que llegue un sobre que cumpla el filtro (útil para pruebas y flujos síncronos).
@@ -0,0 +1,174 @@
1
+ // Nyx5/1 — Asistentes: una dirección que contesta SOLA, con la API de Anthropic, dentro de un tope.
2
+ //
3
+ // Nació de una necesidad concreta: Nicholas se va un mes (desde el 14-sep-2026) y Bastián, el
4
+ // desarrollador de Sigo, necesita preguntarle al agente de Sigo a cualquier hora. Un Claude en una
5
+ // app sólo contesta cuando alguien lo abre; un asistente contesta aunque nadie esté mirando.
6
+ //
7
+ // Límites, y por qué cada uno:
8
+ // - Sólo contesta a quien está en SU lista (la política del buzón ya rechaza al resto antes de
9
+ // que llegue). Un asistente abierto sería un grifo de gasto para cualquiera.
10
+ // - Nunca a sí mismo ni a los agentes de sistema (rebotes, recibos): un asistente que le contesta
11
+ // a un rebote genera otro rebote, y el bucle se come el presupuesto.
12
+ // - Cada respuesta se descuenta de un tope mensual en dólares, calculado del `usage` real que
13
+ // devuelve la API. Al llegar al tope deja de llamar a la API, avisa a quien pregunta y, una vez,
14
+ // a su dueño. El tope también se pone en la consola de Anthropic (el workspace): dos candados.
15
+ // - Contesta desde el reloj programado, no dentro de una petición: en el edge, lo que corre
16
+ // después de responder una petición tiene 30 s, y una respuesta de Claude puede tardar más.
17
+ // - Su llave vive en la bóveda (custodia de la casa, declarada en su tarjeta), igual que la de
18
+ // un Claude conectado. Sólo mensajes: no toca el Libro.
19
+ // La llamada va por HTTP directo porque este repo no admite dependencias.
20
+ import { parseAddress } from './resolver.js';
21
+ import { sha256hex } from '../nucleo/crypto.js';
22
+
23
+ export const API_MENSAJES = 'https://api.anthropic.com/v1/messages';
24
+ // Dólares por millón de tokens. La escritura en caché de 5 minutos cuesta 1,25x la entrada y la
25
+ // lectura 0,1x. Si la API responde con otro modelo (respaldo ante un rechazo), se cobra igual.
26
+ // Un modelo que no está aquí NO se puede configurar (la alta y el cambio de config lo rechazan):
27
+ // sin su precio el tope mensual se calcularía con el de otro.
28
+ export const PRECIOS = {
29
+ 'claude-opus-5': { entrada: 5, salida: 25, escrituraCache: 6.25, lecturaCache: 0.5 },
30
+ 'claude-sonnet-5': { entrada: 2, salida: 10, escrituraCache: 2.5, lecturaCache: 0.2 },
31
+ };
32
+ // Modelos cuyo respaldo del servidor (`fallbacks: "default"`, beta server-side-fallback-2026-07-01)
33
+ // está documentado. A otro modelo no se le manda: si la API no lo aceptara, CADA pregunta fallaría.
34
+ // Sin respaldo, un rechazo llega como stop_reason "refusal" y se contesta como rechazo.
35
+ export const CON_RESPALDO = new Set(['claude-opus-5']);
36
+ const SISTEMA = new Set(['postmaster', 'libro', 'verifica', 'tareas']);
37
+ const mesDe = (t = Date.now()) => new Date(t).toISOString().slice(0, 7);
38
+
39
+ export function costoDe(modelo, u = {}) {
40
+ const p = PRECIOS[modelo] || PRECIOS['claude-opus-5'];
41
+ return ((u.input_tokens || 0) * p.entrada + (u.cache_creation_input_tokens || 0) * p.escrituraCache
42
+ + (u.cache_read_input_tokens || 0) * p.lecturaCache + (u.output_tokens || 0) * p.salida) / 1e6;
43
+ }
44
+ // Lee el flujo SSE de la API y devuelve la misma forma que una respuesta sin flujo:
45
+ // { content: [{ type: 'text', text }], stop_reason, usage }. Un evento `error` corta con su mensaje.
46
+ export async function leerFlujo(r) {
47
+ const texto = await r.text();
48
+ const out = { content: [{ type: 'text', text: '' }], stop_reason: null, usage: {} };
49
+ for (const bloque of texto.split('\n\n')) {
50
+ const linea = bloque.split('\n').find((l) => l.startsWith('data:'));
51
+ if (!linea) continue;
52
+ let ev; try { ev = JSON.parse(linea.slice(5).trim()); } catch { continue; }
53
+ if (ev.type === 'message_start') Object.assign(out.usage, ev.message?.usage || {});
54
+ else if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta') out.content[0].text += ev.delta.text;
55
+ else if (ev.type === 'message_delta') { out.stop_reason = ev.delta?.stop_reason ?? out.stop_reason; Object.assign(out.usage, ev.usage || {}); }
56
+ else if (ev.type === 'error') throw new Error(`la API cortó el flujo: ${ev.error?.message || 'sin detalle'}`);
57
+ }
58
+ return out;
59
+ }
60
+ function textoDe(c) {
61
+ const b = c?.body;
62
+ if (typeof b === 'string') return b;
63
+ return b == null ? '' : JSON.stringify(b);
64
+ }
65
+
66
+ export async function atenderAsistentes(est, { maxPorTick = 3 } = {}) {
67
+ if (!est.asistente?.apiKey || !est.store.kvGet) return 0;
68
+ const indice = (await est.store.kvGet('asistente', '_indice')) || [];
69
+ let atendidos = 0;
70
+ for (const local of indice) {
71
+ const cfg = await est.store.kvGet('asistente', local);
72
+ if (!cfg?.enabled) continue;
73
+ const propia = `${local}@${est.domain}`;
74
+ const pendientes = (await est.store.listMail(local)).filter((m) => {
75
+ const e = m.envelope || {};
76
+ if (!e.signature || e.from === propia || !['message', 'result'].includes(e.type)) return false;
77
+ // Un asistente contesta correo directo, nunca un grupo: su tope en dólares es de su dueño, y un
78
+ // grupo es un sobre que llega por un camino que su lista no eligió (revisión del 13-sep-2026).
79
+ if ((e.to || []).some((t) => { try { return parseAddress(t).local.startsWith('g.'); } catch { return false; } })) return false;
80
+ try { const { local: l, domain } = parseAddress(e.from); return !(domain === est.domain && SISTEMA.has(l)); } catch { return false; }
81
+ }).slice(0, maxPorTick);
82
+ for (const m of pendientes) {
83
+ // Un turno por mensaje: dos relojes que se pisan no contestan dos veces. Si la respuesta falla,
84
+ // el turno vence a los 15 minutos y se reintenta; a la tercera falla se avisa y se cierra.
85
+ if (!(await est.store.kvPutIfAbsent('asistente-turno', m.envelope.id, { at: new Date().toISOString() }, Date.now() + 15 * 60_000))) continue;
86
+ try { await responder(est, local, cfg, m); atendidos++; }
87
+ catch (e) {
88
+ est.log(`asistente ${local}: ${e.message}`);
89
+ const fallas = ((await est.store.kvGet('asistente-fallas', m.envelope.id)) || 0) + 1;
90
+ await est.store.kvPut('asistente-fallas', m.envelope.id, fallas, Date.now() + 24 * 3600_000);
91
+ if (fallas >= 3) {
92
+ const agente = await est.agenteDeBoveda(local);
93
+ await agente?.send({ to: m.envelope.from, body: 'No pude contestar este mensaje después de tres intentos. Quedó guardado; si es urgente, escríbelo de nuevo más tarde.', thread: m.envelope.thread || m.envelope.id, inReplyTo: m.envelope.id }).catch(() => {});
94
+ await est.store.ackMail(local, m.envelope.id);
95
+ }
96
+ }
97
+ }
98
+ }
99
+ return atendidos;
100
+ }
101
+
102
+ async function responder(est, local, cfg, m) {
103
+ const agente = await est.agenteDeBoveda(local);
104
+ if (!agente) throw new Error('la bóveda no tiene la llave de este asistente');
105
+ const de = m.envelope.from;
106
+ const hilo = { thread: m.envelope.thread || m.envelope.id, inReplyTo: m.envelope.id };
107
+ const pregunta = textoDe((await agente.open(m.envelope)).content); // verifica la firma y descifra
108
+ const clave = `${local}:${mesDe()}`;
109
+ const gasto = (await est.store.kvGet('asistente-gasto', clave)) || { usd: 0, llamadas: 0, avisado: false };
110
+ if (gasto.usd >= cfg.budget_usd) {
111
+ await agente.send({ to: de, body: `Llegué a mi tope de gasto de este mes (US$${cfg.budget_usd}) y no puedo contestar hasta el próximo. Tu mensaje quedó guardado.`, ...hilo });
112
+ if (!gasto.avisado && cfg.owner) {
113
+ await agente.send({ to: cfg.owner, body: `Tu asistente ${local}@${est.domain} llegó al tope de US$${cfg.budget_usd} de ${mesDe()} y dejó de contestar.` }).catch(() => {});
114
+ gasto.avisado = true;
115
+ await est.store.kvPut('asistente-gasto', clave, gasto);
116
+ }
117
+ await est.store.ackMail(local, m.envelope.id);
118
+ return;
119
+ }
120
+ // La conversación con esa persona, en orden: los turnos se alternan y el primero es suyo.
121
+ // Lo que todavía no se contestó (otras pedidas en cola) no entra al historial: el 14-sep qa@ vio
122
+ // cinco pedidas juntas en un solo turno y contestó una con los contratos de otras dos.
123
+ const enCola = new Set((await est.store.listMail(local)).map((x) => x.envelope?.id));
124
+ const mensajes = [];
125
+ for (const h of await est.conversacion(local, { con: de, limit: 13 })) {
126
+ if (h.id === m.envelope.id || (h.dir === 'in' && enCola.has(h.id))) continue;
127
+ let t; try { t = textoDe((await agente.open(h.envelope)).content); } catch { continue; }
128
+ const rol = h.dir === 'in' ? 'user' : 'assistant';
129
+ if (mensajes.length && mensajes[mensajes.length - 1].role === rol) mensajes[mensajes.length - 1].content += `\n\n${t}`;
130
+ else mensajes.push({ role: rol, content: t });
131
+ }
132
+ while (mensajes.length && mensajes[0].role !== 'user') mensajes.shift();
133
+ const actual = `(Te escribe ${de})\n\n${pregunta}`;
134
+ if (mensajes.length && mensajes[mensajes.length - 1].role === 'user') mensajes[mensajes.length - 1].content += `\n\n${actual}`;
135
+ else mensajes.push({ role: 'user', content: actual });
136
+ const conocimiento = (await est.store.kvGet('asistente-conocimiento', local))?.texto || '';
137
+ // Lo estable primero y en caché (la persona y la base de conocimiento); lo que cambia, después.
138
+ const cuerpo = {
139
+ model: cfg.model || 'claude-opus-5',
140
+ max_tokens: cfg.max_tokens || 8000,
141
+ thinking: { type: 'adaptive' },
142
+ output_config: { effort: cfg.effort || 'medium' },
143
+ system: [{ type: 'text', text: cfg.persona || '' }, ...(conocimiento ? [{ type: 'text', text: conocimiento, cache_control: { type: 'ephemeral' } }] : [])],
144
+ messages: mensajes,
145
+ };
146
+ // Si un clasificador de seguridad rechaza la pregunta, la API la corre en otro modelo en vez de
147
+ // devolver un rechazo; un rechazo final llega igual como stop_reason "refusal".
148
+ const respaldo = CON_RESPALDO.has(cuerpo.model);
149
+ if (respaldo) cuerpo.fallbacks = 'default';
150
+ // Siempre en streaming (14-sep-2026): una respuesta larga (qa@ con esfuerzo alto) tardaba más de
151
+ // 100 s y el fetch del edge volvía con 524 antes de que la API terminara. Con el flujo abierto
152
+ // no hay ese tope; el texto se arma con los deltas y el usage llega en el último evento.
153
+ cuerpo.stream = true;
154
+ const r = await est.asistente.fetch(API_MENSAJES, {
155
+ method: 'POST',
156
+ headers: { 'content-type': 'application/json', 'x-api-key': est.asistente.apiKey, 'anthropic-version': '2023-06-01', ...(respaldo ? { 'anthropic-beta': 'server-side-fallback-2026-07-01' } : {}) },
157
+ body: JSON.stringify(cuerpo),
158
+ });
159
+ if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(`la API respondió ${r.status}: ${e?.error?.message || 'sin detalle'}`); }
160
+ const j = (r.headers.get('content-type') || '').includes('text/event-stream') ? await leerFlujo(r) : await r.json();
161
+ const usd = costoDe(cuerpo.model, j.usage);
162
+ gasto.usd += usd; gasto.llamadas += 1;
163
+ await est.store.kvPut('asistente-gasto', clave, gasto);
164
+ let respuesta = j.stop_reason === 'refusal' ? 'No puedo responder eso.' : (j.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
165
+ if (!respuesta) respuesta = 'No tengo una respuesta para eso.';
166
+ if (j.stop_reason === 'max_tokens') respuesta += '\n\n(La respuesta quedó cortada por largo. Pídeme que siga.)';
167
+ // Sello (NX-606, qa@): la respuesta termina con su propio sha256, calculado por la casa (el
168
+ // modelo no sabe calcularlo), para que quien la pidió la selle en la notaría tal cual llegó.
169
+ let sello = null;
170
+ if (cfg.seal) { sello = sha256hex(respuesta); respuesta += `\n\n---\nsha256: ${sello}\nSéllalo tal cual con notarize { sha256 } en libro@${est.domain}, o guárdalo: es la huella de este texto.`; }
171
+ await agente.send({ to: de, body: respuesta, ...hilo });
172
+ await est.store.ackMail(local, m.envelope.id);
173
+ await est._evento('assistant_answered', `${local}@${est.domain}`, { usd: Math.round(usd * 10000) / 10000, ...(sello ? { sha256: sello } : {}) });
174
+ }