@belticlabs/agent-risk-sdk 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.
package/dist/index.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import {
2
2
  openCall
3
- } from "./chunk-ZMPKY7AX.js";
3
+ } from "./chunk-JGVVQUXQ.js";
4
4
  import {
5
- summaryOf
6
- } from "./chunk-M4I3FGZG.js";
5
+ SESSION_EXTENSION,
6
+ SESSION_HEADER,
7
+ summaryOf,
8
+ x402Moments
9
+ } from "./chunk-DI3LZR65.js";
7
10
  import {
8
11
  Chain,
9
12
  canonicalBytes,
@@ -12,7 +15,13 @@ import {
12
15
  memorySigner,
13
16
  sha256,
14
17
  toHex
15
- } from "./chunk-77D74TWX.js";
18
+ } from "./chunk-MWXXX35V.js";
19
+ import {
20
+ ApiErrorSchema,
21
+ Hex64Schema,
22
+ JsonValueSchema,
23
+ Money
24
+ } from "./chunk-PDE55ZZV.js";
16
25
 
17
26
  // src/core/api-client.ts
18
27
  var TIMEOUT_MS = 1e4;
@@ -41,15 +50,26 @@ var ApiClient = class {
41
50
  "user-agent": userAgent
42
51
  };
43
52
  }
44
- async post(path, body) {
53
+ post(path, body) {
54
+ return this.request("POST", path, JSON.stringify(body));
55
+ }
56
+ /** A read; `query` entries left undefined are not sent. */
57
+ get(path, query = {}) {
58
+ const params = new URLSearchParams();
59
+ for (const [key, value] of Object.entries(query))
60
+ if (value !== void 0) params.set(key, String(value));
61
+ const search = params.toString();
62
+ return this.request("GET", search ? `${path}?${search}` : path);
63
+ }
64
+ async request(method, path, body) {
45
65
  const controller = new AbortController();
46
66
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
47
67
  let res;
48
68
  try {
49
69
  res = await globalThis.fetch(`${this.baseUrl}${path}`, {
50
- method: "POST",
70
+ method,
51
71
  headers: this.headers,
52
- body: JSON.stringify(body),
72
+ ...body !== void 0 ? { body } : {},
53
73
  signal: controller.signal
54
74
  });
55
75
  } catch (err) {
@@ -69,7 +89,7 @@ var ApiClient = class {
69
89
  json = null;
70
90
  }
71
91
  if (!res.ok) {
72
- const e = json?.error;
92
+ const e = ApiErrorSchema.safeParse(json).data?.error;
73
93
  throw new BelticApiError(
74
94
  res.status,
75
95
  e?.code ?? `HTTP_${res.status}`,
@@ -134,10 +154,11 @@ var Decision = class _Decision {
134
154
  };
135
155
 
136
156
  // src/core/identity.ts
137
- var SEED_HEX = /^[0-9a-f]{64}$/i;
138
157
  function identityFromSeed(seedHex) {
139
- if (!SEED_HEX.test(seedHex))
140
- throw new BelticConfigError("agentSeed must be 64 hex characters (a 32-byte Ed25519 seed)");
158
+ if (!Hex64Schema.safeParse(seedHex).success)
159
+ throw new BelticConfigError(
160
+ "agentSeed must be 64 lowercase hex characters (a 32-byte Ed25519 seed)"
161
+ );
141
162
  const signer = memorySigner(fromHex(seedHex));
142
163
  const did = didKeyFromEd25519(signer.publicKey);
143
164
  return { did, signer: { ...signer, keyId: did } };
@@ -157,14 +178,16 @@ var Memory = class {
157
178
  }
158
179
  };
159
180
  var Session = class _Session {
160
- constructor(deps, key, opts = {}) {
181
+ constructor(deps, opts = {}, resume = null) {
161
182
  this.deps = deps;
162
- this.key = key;
163
183
  this.opts = opts;
184
+ this.conversationId = resume;
164
185
  this.openedWith = opts.intent ? _Session.hash(opts.intent) : null;
165
186
  }
166
187
  opened = null;
167
188
  current = null;
189
+ /** The conversation id: what was resumed, then what the platform answered. */
190
+ conversationId;
168
191
  /** JCS hash of the mandate on the chain, and of the one the options carried. */
169
192
  declared = null;
170
193
  openedWith;
@@ -172,18 +195,29 @@ var Session = class _Session {
172
195
  timer = null;
173
196
  calls = new Memory();
174
197
  decisions = new Memory();
175
- /** The platform's id for this session — opened on first use, `null` while there is none. */
198
+ /**
199
+ * The conversation id — what the host stores and resumes with (GAP-84).
200
+ * Opened on first use; `null` while the platform has not answered and
201
+ * nothing was resumed.
202
+ */
176
203
  id() {
177
- return this.stream().then((stream) => stream?.id ?? null);
204
+ return this.stream().then((stream) => stream?.conversationId ?? this.conversationId);
178
205
  }
179
206
  /**
180
- * The stream this session records into — opened on first use, `null`
207
+ * The stream this session records into — opened on first use, reopened
208
+ * in the same conversation once the platform ended it (GAP-85), `null`
181
209
  * when there is none. For the integrations; a host never holds it.
182
210
  * @internal
183
211
  */
184
212
  stream() {
185
- const next = this.deps.streams.open(this.key, this.opts);
213
+ const prior = this.opened ?? Promise.resolve(null);
214
+ const next = prior.catch(() => null).then(
215
+ (stream) => stream && (this.deps.streams.halted(stream) || !stream.isClosed) ? stream : this.open()
216
+ );
186
217
  this.opened = next;
218
+ next.catch(() => {
219
+ if (this.opened === next) this.opened = null;
220
+ });
187
221
  return next.then((stream) => {
188
222
  if (stream !== this.current) {
189
223
  this.current = stream;
@@ -192,6 +226,17 @@ var Session = class _Session {
192
226
  return stream;
193
227
  });
194
228
  }
229
+ async open() {
230
+ const stream = await this.deps.streams.open({
231
+ ...this.opts,
232
+ resume: this.conversationId ?? void 0
233
+ });
234
+ if (stream) {
235
+ this.conversationId = stream.conversationId;
236
+ this.deps.onOpened(this, stream.conversationId);
237
+ }
238
+ return stream;
239
+ }
195
240
  /**
196
241
  * The platform's verdict on a payment about to be presented. Asked once
197
242
  * per call id: a host that re-runs its approval step reads the same
@@ -205,7 +250,7 @@ var Session = class _Session {
205
250
  if (!stream) return Decision.absent();
206
251
  if (opts.intent) await this.declare(stream, opts.intent);
207
252
  const summary = summaryOf(payment);
208
- const decision = await this.deps.evaluate(stream.id, summary);
253
+ const decision = await this.deps.evaluate(stream.id, summary, opts.callId);
209
254
  if (decision.absent) return decision;
210
255
  if (opts.callId) this.decisions.set(opts.callId, decision);
211
256
  const call = opts.callId ? this.calls.get(opts.callId)?.call : void 0;
@@ -304,86 +349,46 @@ var Session = class _Session {
304
349
  }
305
350
  };
306
351
 
307
- // src/core/stream.ts
308
- var Stream = class {
309
- constructor(deps, id, source, born) {
310
- this.deps = deps;
311
- this.id = id;
312
- this.source = source;
313
- this.born = born;
314
- this.chain = Chain.genesis(id, source);
315
- }
316
- chain;
317
- building = Promise.resolve();
318
- dropped = 0;
319
- droppedFirstTs = null;
320
- droppedLastTs = null;
321
- closed = false;
322
- get isClosed() {
323
- return this.closed;
324
- }
325
- /**
326
- * Resolves once the event is sequenced and buffered — not once it is
327
- * acknowledged. `false` when the event was dropped for lack of room.
328
- */
329
- async emit(kind, payload) {
330
- const halted = this.deps.transport.haltedError(this.id, this.source);
331
- if (halted) throw halted;
332
- const ts = (/* @__PURE__ */ new Date()).toISOString();
333
- if (!this.deps.transport.hasRoom()) {
334
- this.dropped++;
335
- this.droppedFirstTs ??= ts;
336
- this.droppedLastTs = ts;
337
- return false;
338
- }
339
- if (this.dropped > 0) {
340
- this.deps.transport.enqueue(
341
- await this.next(
342
- "transport.gap",
343
- { dropped: this.dropped, firstTs: this.droppedFirstTs, lastTs: this.droppedLastTs },
344
- ts
345
- )
346
- );
347
- this.dropped = 0;
348
- this.droppedFirstTs = this.droppedLastTs = null;
349
- }
350
- this.deps.transport.enqueue(await this.next(kind, payload, ts));
351
- return true;
352
- }
353
- /** The tool call whose `execute` the host runs itself; see `ToolCallSpan`. */
354
- toolCall(call) {
355
- const { callId, ...start } = call;
356
- return openCall(this, "tool_call", callId, { transport: "local", ...start });
357
- }
358
- async close(reason = "completed", extra = {}) {
359
- if (this.closed) return;
360
- this.closed = true;
361
- await this.emit("session.close", { reason, ...extra });
362
- await this.flush();
363
- this.deps.onClosed?.(this);
364
- }
365
- /** Read-your-writes: the platform must hold the evidence before anyone judges it (GAP-16/66). */
366
- flush() {
367
- return this.deps.transport.flush();
368
- }
369
- /** Serialized: two concurrent emits get consecutive seqs, never the same one. */
370
- next(kind, payload, ts) {
371
- const run = this.building.then(async () => {
372
- const built = await this.chain.append({ ts, kind, payload }, this.deps.signer);
373
- this.chain = built.chain;
374
- return built.event;
375
- });
376
- this.building = run.catch(() => void 0);
377
- return run;
352
+ // src/core/prompt-ledger.ts
353
+ var PromptLedger = class _PromptLedger {
354
+ last;
355
+ constructor(base = null) {
356
+ this.last = base;
357
+ }
358
+ /** How the platform digests one message: over its canonical bytes. */
359
+ static digest(message) {
360
+ return toHex(sha256(canonicalBytes(message)));
361
+ }
362
+ /** The prompt as the wire carries it — whole, or against the last one — and this call becomes the last. */
363
+ encode(callId, messages) {
364
+ const digests = messages.map(_PromptLedger.digest);
365
+ const last = this.last;
366
+ this.last = { prior: callId, digests };
367
+ if (!last) return { prompt: messages };
368
+ let shared = 0;
369
+ while (shared < last.digests.length && shared < digests.length && last.digests[shared] === digests[shared])
370
+ shared++;
371
+ if (shared === 0) return { prompt: messages };
372
+ return { promptDelta: { prior: last.prior, shared, messages: messages.slice(shared) } };
373
+ }
374
+ /** The last call never reached the platform: the next one ships whole. */
375
+ forget() {
376
+ this.last = null;
378
377
  }
379
378
  };
380
379
 
381
380
  // src/core/transport.ts
382
381
  var FLUSH_MS = 1e3;
383
382
  var MAX_BATCH = 50;
383
+ var MAX_BATCH_BYTES = 8 * 1024 * 1024;
384
+ var MAX_EVENT_BYTES = 1024 * 1024;
384
385
  var MAX_BUFFERED = 5e3;
386
+ var MAX_BUFFERED_BYTES = 32 * 1024 * 1024;
387
+ var MAX_IN_FLIGHT = 8;
388
+ var ENVELOPE_BYTES = 512;
385
389
  var BACKOFF_BASE_MS = 200;
386
390
  var BACKOFF_MAX_MS = 3e4;
391
+ var ENDED_CODES = /* @__PURE__ */ new Set(["SESSION_CLOSED", "SESSION_EXPIRED"]);
387
392
  var ChainRejectedError = class extends Error {
388
393
  constructor(sessionId, source, result, options) {
389
394
  super(
@@ -396,18 +401,15 @@ var ChainRejectedError = class extends Error {
396
401
  this.name = "ChainRejectedError";
397
402
  }
398
403
  };
399
- var TransportClosedError = class extends Error {
400
- constructor() {
401
- super("transport is closed");
402
- this.name = "TransportClosedError";
403
- }
404
- };
405
404
  var Transport = class _Transport {
406
405
  constructor(api) {
407
406
  this.api = api;
408
407
  }
409
408
  chains = /* @__PURE__ */ new Map();
409
+ waiting = [];
410
+ running = 0;
410
411
  buffered = 0;
412
+ bufferedBytes = 0;
411
413
  timer = null;
412
414
  closed = false;
413
415
  /**
@@ -419,35 +421,62 @@ var Transport = class _Transport {
419
421
  static outage(err) {
420
422
  return err instanceof BelticApiError && err.retryable;
421
423
  }
422
- get size() {
423
- return this.buffered;
424
+ /** The bytes the platform will measure for an event with this payload (GAP-59), known before a `seq` is spent. */
425
+ static measure(payload) {
426
+ return canonicalBytes(payload).length + ENVELOPE_BYTES;
427
+ }
428
+ /** Whether the platform would take an event of this size at all. */
429
+ static fits(bytes) {
430
+ return bytes <= MAX_EVENT_BYTES;
424
431
  }
425
- hasRoom() {
426
- return !this.closed && this.buffered < MAX_BUFFERED;
432
+ hasRoom(bytes) {
433
+ return !this.closed && this.buffered < MAX_BUFFERED && this.bufferedBytes + bytes <= MAX_BUFFERED_BYTES;
427
434
  }
428
435
  haltedError(sessionId, source) {
429
436
  return this.chains.get(`${sessionId}:${source}`)?.halted ?? null;
430
437
  }
431
- /** Callers check `hasRoom()` first and assign `seq` only then (GAP-38). */
432
- enqueue(ev) {
433
- if (this.closed) throw new TransportClosedError();
438
+ /** Whether the platform ended the session under this chain (GAP-85). */
439
+ ended(sessionId, source) {
440
+ return this.chains.get(`${sessionId}:${source}`)?.ended ?? false;
441
+ }
442
+ /** Callers check `hasRoom(bytes)` first and assign `seq` only then (GAP-38). */
443
+ enqueue(ev, bytes) {
444
+ if (this.closed) throw new Error("transport is closed");
434
445
  const key = `${ev.sessionId}:${ev.source}`;
435
446
  let chain = this.chains.get(key);
436
447
  if (!chain) {
437
- chain = { pending: [], inFlight: null, retry: null, attempts: 0, halted: null };
448
+ chain = {
449
+ sessionId: ev.sessionId,
450
+ pending: [],
451
+ inFlight: null,
452
+ start: null,
453
+ retry: null,
454
+ attempts: 0,
455
+ halted: null,
456
+ ended: false
457
+ };
438
458
  this.chains.set(key, chain);
439
459
  }
440
460
  if (chain.halted) throw chain.halted;
441
- if (!this.hasRoom()) throw new Error("transport buffer is full");
442
- chain.pending.push(ev);
461
+ if (chain.ended) return;
462
+ if (!this.hasRoom(bytes)) throw new Error("transport buffer is full");
463
+ chain.pending.push({ ev, bytes });
443
464
  this.buffered++;
465
+ this.bufferedBytes += bytes;
444
466
  if (chain.pending.length >= MAX_BATCH) void this.deliver(chain, false);
445
467
  else this.schedule();
446
468
  }
447
- /** One attempt per chain, now — a chain waiting out its backoff included; resolves once every attempt settled. */
448
- async flush() {
449
- this.unschedule();
450
- await Promise.all([...this.chains.values()].map((chain) => this.deliver(chain, true)));
469
+ /**
470
+ * One attempt per chain, now — a chain waiting out its backoff included;
471
+ * resolves once every attempt settled. With a scope, only that session's
472
+ * chains (GAP-88).
473
+ */
474
+ async flush(scope) {
475
+ if (!scope) this.unschedule();
476
+ const chains = [...this.chains.values()].filter(
477
+ (chain) => !scope || chain.sessionId === scope.sessionId
478
+ );
479
+ await Promise.all(chains.map((chain) => this.deliver(chain, true)));
451
480
  }
452
481
  async close() {
453
482
  await this.flush();
@@ -470,28 +499,72 @@ var Transport = class _Transport {
470
499
  clearTimeout(this.timer);
471
500
  this.timer = null;
472
501
  }
473
- /** A chain waiting out its backoff is left alone unless forced: only `flush` cuts a backoff short. */
502
+ /**
503
+ * Queues the chain for a delivery slot — at the front when forced. A
504
+ * chain waiting out its backoff is left alone unless forced: only
505
+ * `flush` cuts a backoff short.
506
+ */
474
507
  deliver(chain, force) {
475
- if (chain.inFlight) return chain.inFlight;
476
- if (chain.halted || chain.pending.length === 0) return Promise.resolve();
508
+ if (chain.inFlight) {
509
+ if (force && chain.start) this.promote(chain);
510
+ return chain.inFlight;
511
+ }
512
+ if (chain.halted || chain.ended || chain.pending.length === 0) return Promise.resolve();
477
513
  if (chain.retry) {
478
514
  if (!force) return Promise.resolve();
479
515
  clearTimeout(chain.retry);
480
516
  chain.retry = null;
481
517
  }
482
- chain.inFlight = this.attempt(chain).finally(() => {
483
- chain.inFlight = null;
518
+ chain.inFlight = new Promise((resolve) => {
519
+ chain.start = () => {
520
+ chain.start = null;
521
+ this.running++;
522
+ void this.attempt(chain).finally(() => {
523
+ this.running--;
524
+ chain.inFlight = null;
525
+ resolve();
526
+ this.pump();
527
+ });
528
+ };
484
529
  });
530
+ if (force) this.waiting.unshift(chain);
531
+ else this.waiting.push(chain);
532
+ this.pump();
485
533
  return chain.inFlight;
486
534
  }
535
+ promote(chain) {
536
+ const at = this.waiting.indexOf(chain);
537
+ if (at > 0) {
538
+ this.waiting.splice(at, 1);
539
+ this.waiting.unshift(chain);
540
+ }
541
+ }
542
+ pump() {
543
+ while (this.running < MAX_IN_FLIGHT && this.waiting.length > 0) this.waiting.shift().start?.();
544
+ }
545
+ /** The head of the queue that fits one batch: by count or by bytes, whichever comes first, and never empty. */
546
+ static batchOf(chain) {
547
+ const batch = [];
548
+ let bytes = 0;
549
+ for (const item of chain.pending) {
550
+ if (batch.length >= MAX_BATCH) break;
551
+ if (batch.length > 0 && bytes + item.bytes > MAX_BATCH_BYTES) break;
552
+ batch.push(item);
553
+ bytes += item.bytes;
554
+ }
555
+ return batch;
556
+ }
487
557
  /** Batches until the chain drains; a retryable failure schedules the next attempt and returns. */
488
558
  async attempt(chain) {
489
559
  while (chain.pending.length > 0 && !chain.halted) {
490
- const batch = chain.pending.slice(0, MAX_BATCH);
491
- const first = batch[0];
560
+ const batch = _Transport.batchOf(chain);
561
+ const first = batch[0].ev;
492
562
  let ack;
493
563
  try {
494
- ack = await this.api.post("/v1/evidence", batch);
564
+ ack = await this.api.post(
565
+ "/v1/evidence",
566
+ batch.map((item) => item.ev)
567
+ );
495
568
  } catch (err) {
496
569
  console.error("[beltic]", err);
497
570
  if (!_Transport.outage(err)) {
@@ -522,19 +595,142 @@ var Transport = class _Transport {
522
595
  chain.retry.unref?.();
523
596
  return;
524
597
  }
525
- chain.pending.splice(0, batch.length);
526
- this.buffered -= batch.length;
598
+ this.release(chain, batch.length);
527
599
  chain.attempts = 0;
528
600
  const bad = ack.results.find((r) => r.status === "fork" || r.status === "rejected");
529
- if (bad) this.halt(chain, new ChainRejectedError(first.sessionId, first.source, bad));
601
+ if (!bad) continue;
602
+ if (bad.status === "rejected" && bad.code && ENDED_CODES.has(bad.code)) this.end(chain);
603
+ else this.halt(chain, new ChainRejectedError(first.sessionId, first.source, bad));
604
+ }
605
+ }
606
+ release(chain, count) {
607
+ for (const item of chain.pending.splice(0, count)) {
608
+ this.buffered--;
609
+ this.bufferedBytes -= item.bytes;
530
610
  }
531
611
  }
612
+ drop(chain) {
613
+ this.release(chain, chain.pending.length);
614
+ }
532
615
  halt(chain, error) {
533
616
  chain.halted = error;
534
- this.buffered -= chain.pending.length;
535
- chain.pending = [];
617
+ this.drop(chain);
536
618
  console.error("[beltic]", error);
537
619
  }
620
+ end(chain) {
621
+ chain.ended = true;
622
+ this.drop(chain);
623
+ }
624
+ };
625
+
626
+ // src/core/stream.ts
627
+ var Stream = class {
628
+ constructor(deps, id, conversationId, source, born) {
629
+ this.deps = deps;
630
+ this.id = id;
631
+ this.conversationId = conversationId;
632
+ this.source = source;
633
+ this.born = born;
634
+ this.chain = Chain.genesis(id, source);
635
+ this.ledger = new PromptLedger(deps.prompt ?? null);
636
+ }
637
+ chain;
638
+ ledger;
639
+ building = Promise.resolve();
640
+ dropped = 0;
641
+ oversized = 0;
642
+ droppedFirstTs = null;
643
+ droppedLastTs = null;
644
+ closed = false;
645
+ /** Closed by this side, or ended by the platform (GAP-85). */
646
+ get isClosed() {
647
+ return this.closed || this.deps.transport.ended(this.id, this.source);
648
+ }
649
+ /**
650
+ * Resolves once the event is sequenced and buffered — not once it is
651
+ * acknowledged. `false` when the event was dropped for lack of room.
652
+ */
653
+ async emit(kind, payload) {
654
+ const halted = this.deps.transport.haltedError(this.id, this.source);
655
+ if (halted) throw halted;
656
+ if (this.deps.transport.ended(this.id, this.source)) return false;
657
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
658
+ const bytes = Transport.measure(payload);
659
+ const gap = this.dropped > 0 ? this.gap() : null;
660
+ const gapBytes = gap ? Transport.measure(gap) : 0;
661
+ if (!Transport.fits(bytes) || !this.deps.transport.hasRoom(bytes + gapBytes)) {
662
+ this.dropped++;
663
+ if (!Transport.fits(bytes)) this.oversized++;
664
+ this.droppedFirstTs ??= ts;
665
+ this.droppedLastTs = ts;
666
+ return false;
667
+ }
668
+ if (gap) {
669
+ this.dropped = this.oversized = 0;
670
+ this.droppedFirstTs = this.droppedLastTs = null;
671
+ this.deps.transport.enqueue(
672
+ await this.next("transport.gap", gap, ts),
673
+ gapBytes
674
+ );
675
+ }
676
+ this.deps.transport.enqueue(await this.next(kind, payload, ts), bytes);
677
+ return true;
678
+ }
679
+ /** What the drops since the last accepted event add up to (GAP-38/87). */
680
+ gap() {
681
+ return {
682
+ dropped: this.dropped,
683
+ ...this.oversized > 0 ? { oversized: this.oversized } : {},
684
+ firstTs: this.droppedFirstTs,
685
+ lastTs: this.droppedLastTs
686
+ };
687
+ }
688
+ /**
689
+ * A model call as a span: `llm_call.start` now, with the prompt as a
690
+ * delta against the last one on this chain (GAP-89), `llm_call.end` when
691
+ * the host reports the result. A start that never left (dropped) forgets
692
+ * the ledger, so the next call ships its prompt whole.
693
+ */
694
+ llmCall(call) {
695
+ const { callId, params, ...start } = call;
696
+ const { prompt, ...rest } = params;
697
+ const encoded = Array.isArray(prompt) ? this.ledger.encode(callId, prompt) : prompt === void 0 ? {} : { prompt };
698
+ const span = openCall(this, "llm_call", callId, { ...start, params: { ...rest, ...encoded } });
699
+ if (Array.isArray(prompt))
700
+ span.opened.then(
701
+ (sequenced) => {
702
+ if (!sequenced) this.ledger.forget();
703
+ },
704
+ () => this.ledger.forget()
705
+ );
706
+ return span;
707
+ }
708
+ /** The tool call whose `execute` the host runs itself; see `ToolCallSpan`. */
709
+ toolCall(call) {
710
+ const { callId, ...start } = call;
711
+ return openCall(this, "tool_call", callId, { transport: "local", ...start });
712
+ }
713
+ async close(reason = "completed", extra = {}) {
714
+ if (this.closed) return;
715
+ this.closed = true;
716
+ await this.emit("session.close", { reason, ...extra });
717
+ await this.flush();
718
+ this.deps.onClosed?.(this);
719
+ }
720
+ /** Read-your-writes for this session's chains, and only them (GAP-16/66/88). */
721
+ flush() {
722
+ return this.deps.transport.flush({ sessionId: this.id });
723
+ }
724
+ /** Serialized: two concurrent emits get consecutive seqs, never the same one. */
725
+ next(kind, payload, ts) {
726
+ const run = this.building.then(async () => {
727
+ const built = await this.chain.append({ ts, kind, payload }, this.deps.signer);
728
+ this.chain = built.chain;
729
+ return built.event;
730
+ });
731
+ this.building = run.catch(() => void 0);
732
+ return run;
733
+ }
538
734
  };
539
735
 
540
736
  // src/core/streams.ts
@@ -544,35 +740,12 @@ var Streams = class {
544
740
  this.deps = deps;
545
741
  }
546
742
  attached = /* @__PURE__ */ new Map();
547
- /** Buyer streams by the host's own key (GAP-71). */
548
- opened = /* @__PURE__ */ new Map();
549
743
  retryAt = 0;
550
744
  /**
551
- * Buyer half: the stream for a key of the host's own, opened on first
552
- * use and reused after. A halted chain is reopened as a fresh session
553
- * that continues the same key; a closed key is forgotten.
745
+ * Buyer half: a fresh AGENT_TRACE stream — a new conversation, or a new
746
+ * session of the one `resume` names. `null` while the platform is out.
554
747
  */
555
- open(key, input = {}) {
556
- const prior = this.opened.get(key) ?? Promise.resolve(null);
557
- const next = prior.catch(() => null).then(
558
- (stream) => stream && !stream.isClosed && !this.deps.transport.haltedError(stream.id, stream.source) ? stream : this.openFresh(input, () => this.forget(key, next))
559
- );
560
- this.opened.set(key, next);
561
- next.catch(() => this.forget(key, next));
562
- return next;
563
- }
564
- /** Seller half: emit INTERNAL_NETWORK evidence into a session the buyer bound, or open a seller-born one. */
565
- async ensure(sessionId) {
566
- if (sessionId) return this.attach(sessionId, "INTERNAL_NETWORK", "buyer");
567
- const out = await this.deps.api.post("/v1/sessions", {
568
- source: "INTERNAL_NETWORK"
569
- });
570
- return this.attach(out.sessionId, "INTERNAL_NETWORK", "seller");
571
- }
572
- forget(key, entry) {
573
- if (this.opened.get(key) === entry) this.opened.delete(key);
574
- }
575
- async openFresh(input, onClosed) {
748
+ async open(input = {}, onClosed) {
576
749
  const identity = this.deps.identity;
577
750
  if (!identity)
578
751
  throw new BelticConfigError(
@@ -590,14 +763,30 @@ var Streams = class {
590
763
  return null;
591
764
  }
592
765
  }
766
+ /** Seller half: emit INTERNAL_NETWORK evidence into a session the buyer bound, or open a seller-born one. */
767
+ async ensure(sessionId) {
768
+ if (sessionId) return this.attach(sessionId, sessionId, "INTERNAL_NETWORK", "buyer");
769
+ const out = await this.deps.api.post("/v1/sessions", {
770
+ source: "INTERNAL_NETWORK"
771
+ });
772
+ return this.attach(out.sessionId, out.conversationId, "INTERNAL_NETWORK", "seller");
773
+ }
774
+ /** Whether the platform refused this stream's chain: its next `emit` throws (GAP-85). */
775
+ halted(stream) {
776
+ return this.deps.transport.haltedError(stream.id, stream.source) !== null;
777
+ }
593
778
  async create(identity, input, onClosed) {
594
779
  const body = {
595
780
  source: "AGENT_TRACE",
596
781
  agent: { did: identity.did, credential: identity.did },
597
- ...input.intent ? { intent: input.intent } : {}
782
+ ...input.intent ? { intent: input.intent } : {},
783
+ ...input.resume ? { resume: input.resume } : {}
598
784
  };
599
785
  const out = await this.deps.api.post("/v1/sessions", body);
600
- const stream = this.attach(out.sessionId, "AGENT_TRACE", "buyer", onClosed);
786
+ const stream = this.attach(out.sessionId, out.conversationId, "AGENT_TRACE", "buyer", {
787
+ prompt: out.prompt,
788
+ onClosed
789
+ });
601
790
  await stream.emit("session.open", {
602
791
  runtime: {
603
792
  sdk: "@belticlabs/agent-risk-sdk",
@@ -609,7 +798,7 @@ var Streams = class {
609
798
  if (input.intent) await stream.emit("intent.declared", input.intent);
610
799
  return stream;
611
800
  }
612
- attach(id, source, born, onClosed) {
801
+ attach(id, conversationId, source, born, opts = {}) {
613
802
  const key = `${id}:${source}`;
614
803
  const existing = this.attached.get(key);
615
804
  if (existing) return existing;
@@ -617,12 +806,14 @@ var Streams = class {
617
806
  {
618
807
  transport: this.deps.transport,
619
808
  signer: source === "AGENT_TRACE" ? this.deps.identity?.signer : void 0,
809
+ prompt: opts.prompt,
620
810
  onClosed: () => {
621
811
  this.attached.delete(key);
622
- onClosed?.();
812
+ opts.onClosed?.();
623
813
  }
624
814
  },
625
815
  id,
816
+ conversationId,
626
817
  source,
627
818
  born
628
819
  );
@@ -631,13 +822,201 @@ var Streams = class {
631
822
  }
632
823
  };
633
824
 
825
+ // src/x402/headers.ts
826
+ import { z } from "zod";
827
+ var Base64Json = z.string().regex(/^[A-Za-z0-9+/]*={0,2}$/).transform((value, ctx) => {
828
+ try {
829
+ return JSON.parse(Buffer.from(value, "base64").toString("utf8"));
830
+ } catch {
831
+ ctx.addIssue({ code: "custom", message: "not base64 JSON" });
832
+ return z.NEVER;
833
+ }
834
+ });
835
+ var Accepts = z.looseObject({
836
+ payTo: z.string().optional().catch(void 0),
837
+ amount: z.string().optional().catch(void 0),
838
+ network: z.string().optional().catch(void 0),
839
+ asset: z.string().optional().catch(void 0)
840
+ });
841
+ var PaymentRequired = Base64Json.pipe(
842
+ z.looseObject({ accepts: z.array(Accepts).optional().catch(void 0) })
843
+ );
844
+ var PaymentPayload = Base64Json.pipe(
845
+ z.looseObject({
846
+ accepted: Accepts.optional().catch(void 0),
847
+ payload: z.record(z.string(), z.unknown()).optional().catch(void 0),
848
+ extensions: z.record(z.string(), z.unknown()).optional().catch(void 0)
849
+ })
850
+ );
851
+ var Requirement = z.looseObject({
852
+ scheme: z.string().min(1),
853
+ network: z.string().min(1),
854
+ asset: z.string().min(1),
855
+ amount: z.string().regex(/^\d+$/),
856
+ payTo: z.string().min(1),
857
+ maxTimeoutSeconds: z.number().int().positive().optional()
858
+ });
859
+ var Shape = JsonValueSchema.optional().catch(void 0);
860
+ var Challenge = Base64Json.pipe(
861
+ z.looseObject({
862
+ x402Version: z.literal(2),
863
+ accepts: z.array(z.unknown()),
864
+ resource: z.looseObject({ url: z.string(), description: z.string().optional().catch(void 0) }).optional().catch(void 0),
865
+ extensions: z.looseObject({
866
+ bazaar: z.looseObject({
867
+ info: z.looseObject({ input: Shape }).optional().catch(void 0),
868
+ schema: z.looseObject({
869
+ properties: z.looseObject({ input: Shape }).optional().catch(void 0)
870
+ }).optional().catch(void 0)
871
+ }).optional().catch(void 0)
872
+ }).optional().catch(void 0)
873
+ })
874
+ ).transform((c) => ({
875
+ resource: c.resource ? { url: c.resource.url, description: c.resource.description } : null,
876
+ accepts: c.accepts.flatMap((a) => {
877
+ const r = Requirement.safeParse(a);
878
+ return r.success ? [r.data] : [];
879
+ }),
880
+ input: c.extensions?.bazaar?.info?.input ?? null,
881
+ inputSchema: c.extensions?.bazaar?.schema?.properties?.input ?? null
882
+ }));
883
+ var Settlement = Base64Json.pipe(
884
+ z.looseObject({
885
+ success: z.boolean(),
886
+ transaction: z.string().optional().catch(void 0),
887
+ network: z.string().optional().catch(void 0),
888
+ payer: z.string().optional().catch(void 0),
889
+ errorReason: z.string().optional().catch(void 0),
890
+ errorMessage: z.string().optional().catch(void 0)
891
+ })
892
+ );
893
+
894
+ // src/x402/x402.ts
895
+ var X402 = class _X402 {
896
+ constructor(api) {
897
+ this.api = api;
898
+ }
899
+ /**
900
+ * x402-payable APIs matching `q`, grouped by vendor, as the platform finds
901
+ * them live in public catalogs (GAP-90); with `network`, only what can be
902
+ * paid there. `sources` says which catalogs answered. Null when the
903
+ * platform could not be reached (GAP-70); a rejected query throws.
904
+ */
905
+ async catalog(search) {
906
+ try {
907
+ return await this.api.get("/v1/x402/catalog", {
908
+ q: search.q,
909
+ network: search.network,
910
+ limit: search.limit
911
+ });
912
+ } catch (err) {
913
+ if (!Transport.outage(err)) throw err;
914
+ console.error("[beltic]", err);
915
+ return null;
916
+ }
917
+ }
918
+ /**
919
+ * A fetch bound to `session` that records what x402 crosses it. A request
920
+ * made while the session has no stream (the platform could not open one,
921
+ * GAP-70) goes through unrecorded.
922
+ */
923
+ fetch(session) {
924
+ return async (input, init) => {
925
+ const stream = await session.stream();
926
+ if (!stream) return globalThis.fetch(input, init);
927
+ const headers = new Headers(
928
+ init?.headers ?? (input instanceof Request ? input.headers : void 0)
929
+ );
930
+ headers.set(SESSION_HEADER, stream.id);
931
+ const payload = PaymentPayload.safeParse(headers.get("PAYMENT-SIGNATURE")).data;
932
+ if (payload) {
933
+ const bound = {
934
+ ...payload,
935
+ extensions: { ...payload.extensions, [SESSION_EXTENSION]: stream.id }
936
+ };
937
+ headers.set("PAYMENT-SIGNATURE", _X402.encode(bound));
938
+ await stream.emit("payment.presented", x402Moments.payload(bound));
939
+ await stream.flush();
940
+ }
941
+ const res = await globalThis.fetch(input, { ...init, headers });
942
+ if (res.status !== 402) return res;
943
+ const required = PaymentRequired.safeParse(res.headers.get("PAYMENT-REQUIRED")).data;
944
+ if (required) await stream.emit("payment.requested", x402Moments.required(required));
945
+ return res;
946
+ };
947
+ }
948
+ /** The unpaid request, sent as it will be paid, and the challenge it answered. */
949
+ async quote(session, url, request = {}) {
950
+ const res = await this.fetch(session)(url, _X402.init(request));
951
+ await res.body?.cancel();
952
+ if (res.status !== 402) return { status: res.status, challenge: null };
953
+ const challenge = Challenge.safeParse(res.headers.get("PAYMENT-REQUIRED"));
954
+ return { status: 402, challenge: challenge.success ? challenge.data : null };
955
+ }
956
+ /**
957
+ * The request, sent as it was quoted, carrying `signed` as an x402 v2
958
+ * `PAYMENT-SIGNATURE` through `fetch` — so the presentation is recorded,
959
+ * bound to the session and flushed before it leaves (GAP-66). What the
960
+ * seller settled is read, not recorded (GAP-77).
961
+ */
962
+ async pay(session, url, signed, request = {}) {
963
+ const payment = {
964
+ x402Version: 2,
965
+ resource: { url },
966
+ accepted: signed.accepted,
967
+ payload: signed.payload
968
+ };
969
+ const response = await this.fetch(session)(
970
+ url,
971
+ _X402.init(request, { "PAYMENT-SIGNATURE": _X402.encode(payment) })
972
+ );
973
+ const settlement = Settlement.safeParse(response.headers.get("PAYMENT-RESPONSE"));
974
+ return { response, settlement: settlement.success ? settlement.data : null };
975
+ }
976
+ /** An `accepts` entry as the payment `session.decide` takes — the normalization the recorded moments use. */
977
+ summary(accepts, opts = {}) {
978
+ return x402Moments.summary(accepts, opts);
979
+ }
980
+ /**
981
+ * The declared intent for an x402 mandate (Fraud SDK RFC › Session ›
982
+ * `intent.declared`). The cap must be in the currency the rail's moments
983
+ * carry — `<network>/<asset>`, atomic units (GAP-49) — or the platform's
984
+ * spend detectors compare two currencies and never meet.
985
+ */
986
+ intent(input) {
987
+ return {
988
+ mandate: input.mandate,
989
+ maxAmount: Money.x402(input.network, input.asset, input.maxAmount).toAmount(),
990
+ validUntil: new Date(input.validUntil).toISOString(),
991
+ ...input.merchantAllowlist ? { merchantAllowlist: input.merchantAllowlist } : {}
992
+ };
993
+ }
994
+ /** The request as `quote` and `pay` send it: `GET` bare, `POST` with a JSON body, unless told otherwise. */
995
+ static init(request, extra = {}) {
996
+ const headers = { accept: "*/*", ...extra };
997
+ if (request.body === void 0) return { method: request.method ?? "GET", headers };
998
+ return {
999
+ method: request.method ?? "POST",
1000
+ headers: { ...headers, "content-type": "application/json" },
1001
+ body: JSON.stringify(request.body)
1002
+ };
1003
+ }
1004
+ /** An x402 header value: base64 of the JSON. */
1005
+ static encode(value) {
1006
+ return Buffer.from(JSON.stringify(value), "utf8").toString("base64");
1007
+ }
1008
+ };
1009
+
634
1010
  // src/client.ts
635
- var SDK_VERSION = "0.6.0";
1011
+ var SDK_VERSION = "0.8.0";
636
1012
  var Beltic = class _Beltic {
1013
+ /** x402 for the buyer: `catalog`, `fetch`, `quote`, `pay`, `summary`, `intent`. */
1014
+ x402;
637
1015
  /** The stream registry, for the protocol adapters. @internal */
638
1016
  streams;
639
1017
  api;
640
1018
  transport;
1019
+ /** Open handles by conversation id (GAP-84). */
641
1020
  sessions = /* @__PURE__ */ new Map();
642
1021
  /**
643
1022
  * The client the environment describes: `BELTIC_API_KEY` and
@@ -665,6 +1044,7 @@ var Beltic = class _Beltic {
665
1044
  `@belticlabs/agent-risk-sdk/${SDK_VERSION}`
666
1045
  );
667
1046
  this.transport = new Transport(this.api);
1047
+ this.x402 = new X402(this.api);
668
1048
  this.streams = new Streams({
669
1049
  api: this.api,
670
1050
  transport: this.transport,
@@ -673,38 +1053,48 @@ var Beltic = class _Beltic {
673
1053
  });
674
1054
  }
675
1055
  /**
676
- * The session for a key of the host's own (its session, run or
677
- * conversation id) — one object per key until it closes; the options
678
- * count on the first call only. See `Session`.
1056
+ * A conversation: new without an id, resumed with one — the id `session.id()`
1057
+ * answered in this or any earlier process (GAP-84). One handle per
1058
+ * conversation in-process until it closes; the options count when a
1059
+ * session is opened. See `Session`.
679
1060
  */
680
- session(key, opts = {}) {
681
- const existing = this.sessions.get(key);
1061
+ session(id, opts = {}) {
1062
+ const existing = id ? this.sessions.get(id) : void 0;
682
1063
  if (existing) return existing;
683
1064
  const session = new Session(
684
1065
  {
685
1066
  streams: this.streams,
686
- evaluate: (sessionId, payment) => this.evaluate(sessionId, payment),
1067
+ evaluate: (sessionId, payment, callId) => this.evaluate(sessionId, payment, { callId }),
1068
+ onOpened: (opened, conversationId) => {
1069
+ if (!this.sessions.has(conversationId)) this.sessions.set(conversationId, opened);
1070
+ },
687
1071
  onClosed: (closed) => {
688
- if (this.sessions.get(key) === closed) this.sessions.delete(key);
1072
+ for (const [key, s] of this.sessions) if (s === closed) this.sessions.delete(key);
689
1073
  }
690
1074
  },
691
- key,
692
- opts
1075
+ opts,
1076
+ id ?? null
693
1077
  );
694
- this.sessions.set(key, session);
1078
+ if (id) this.sessions.set(id, session);
695
1079
  return session;
696
1080
  }
697
1081
  /**
698
1082
  * The platform's verdict on a payment — the seller's before it verifies,
699
1083
  * the buyer's before it presents (Fraud SDK RFC › Evaluation Client).
700
- * Read-your-writes: the buffered evidence is flushed first so the
701
- * platform judges what the caller already saw (GAP-16). Absent when the
702
- * platform could not be reached (GAP-70).
1084
+ * Read-your-writes: the session's buffered evidence is flushed first so
1085
+ * the platform judges what the caller already saw (GAP-16/88). With a `callId`
1086
+ * the platform answers the decision already taken for that call anywhere
1087
+ * in the conversation (GAP-84). Absent when the platform could not be
1088
+ * reached (GAP-70).
703
1089
  */
704
- async evaluate(sessionId, payment) {
705
- const input = { sessionId, payment };
1090
+ async evaluate(sessionId, payment, opts = {}) {
1091
+ const input = {
1092
+ sessionId,
1093
+ payment,
1094
+ ...opts.callId ? { callId: opts.callId } : {}
1095
+ };
706
1096
  try {
707
- await this.transport.flush();
1097
+ await this.transport.flush({ sessionId });
708
1098
  return Decision.of(await this.api.post("/v1/evaluate", input));
709
1099
  } catch (err) {
710
1100
  if (!Transport.outage(err)) throw err;
@@ -712,7 +1102,7 @@ var Beltic = class _Beltic {
712
1102
  return Decision.absent();
713
1103
  }
714
1104
  }
715
- /** Send everything buffered now and wait for that attempt. */
1105
+ /** Send everything buffered now — every session's — and wait for that attempt. */
716
1106
  flush() {
717
1107
  return this.transport.flush();
718
1108
  }
@@ -731,5 +1121,6 @@ export {
731
1121
  ChainRejectedError,
732
1122
  Decision,
733
1123
  SDK_VERSION,
734
- Session
1124
+ Session,
1125
+ X402
735
1126
  };