agentchatme 1.0.2 → 1.0.21

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.cjs CHANGED
@@ -212,7 +212,7 @@ function createAgentChatError(body, status, headers) {
212
212
  }
213
213
 
214
214
  // src/version.ts
215
- var VERSION = "1.0.2" ;
215
+ var VERSION = "1.0.21" ;
216
216
 
217
217
  // src/runtime.ts
218
218
  function detectRuntime() {
@@ -1257,22 +1257,36 @@ var AgentChatClient = class _AgentChatClient {
1257
1257
  // ─── Sync / read-state ────────────────────────────────────────────────────
1258
1258
  /**
1259
1259
  * Fetch undelivered envelopes accumulated while the realtime stream was
1260
- * disconnected. Each envelope's `delivery_id` is monotonically increasing
1261
- * per agent — acknowledge by passing the largest one to `syncAck()`.
1260
+ * disconnected. Returns a **bare array** of rows, oldest first — see
1261
+ * `SyncEnvelope` for the shape and cursor semantics.
1262
+ *
1263
+ * Non-destructive: nothing is marked delivered until `syncAck()` is called
1264
+ * with the last non-null `delivery_id` of the rows you actually processed
1265
+ * (positional cursor — `delivery_id` is opaque, never compare it
1266
+ * numerically). `after` pages forward without committing anything: pass
1267
+ * the last `delivery_id` of the previous batch.
1268
+ *
1262
1269
  * The WebSocket client drives this automatically on reconnect; most
1263
1270
  * callers never need it directly.
1264
1271
  */
1265
1272
  sync(opts) {
1266
1273
  const params = new URLSearchParams();
1267
1274
  if (opts?.limit) params.set("limit", String(opts.limit));
1268
- if (opts?.after !== void 0) params.set("after", String(opts.after));
1275
+ if (opts?.after !== void 0) params.set("after", opts.after);
1269
1276
  const qs = params.toString();
1270
1277
  return this.get(`/v1/messages/sync${qs ? `?${qs}` : ""}`, opts);
1271
1278
  }
1272
- syncAck(lastDeliveryId, opts) {
1279
+ /**
1280
+ * Commit every delivery at-or-before the cursor as delivered.
1281
+ * `lastDeliveryId` is the opaque string cursor from a `sync()` row.
1282
+ * Returns the number of envelopes that transitioned to `delivered`
1283
+ * (0 is a normal outcome — e.g. a repeated ack, or an ack while the
1284
+ * agent is owner-paused).
1285
+ */
1286
+ syncAck(lastDeliveryId2, opts) {
1273
1287
  return this.post(
1274
1288
  "/v1/messages/sync/ack",
1275
- { last_delivery_id: lastDeliveryId },
1289
+ { last_delivery_id: lastDeliveryId2 },
1276
1290
  opts
1277
1291
  );
1278
1292
  }
@@ -1309,6 +1323,36 @@ var HELLO_ACK_TIMEOUT_MS = 4e3;
1309
1323
  var GAP_FILL_WINDOW_MS = 2e3;
1310
1324
  var MAX_BUFFERED_PER_CONVERSATION = 500;
1311
1325
  var GAP_FILL_LIMIT = 200;
1326
+ var SYNC_DRAIN_PAGE_SIZE = 200;
1327
+ var DEFAULT_DEDUP_CACHE_SIZE = 2048;
1328
+ var TERMINAL_CLOSE_CODES = /* @__PURE__ */ new Set([1008, 4401, 4403]);
1329
+ function isValidSyncRow(row) {
1330
+ if (typeof row !== "object" || row === null || Array.isArray(row)) return false;
1331
+ const r = row;
1332
+ if (typeof r.id !== "string") return false;
1333
+ if (typeof r.conversation_id !== "string") return false;
1334
+ if (typeof r.delivery_id !== "string" && r.delivery_id !== null) return false;
1335
+ for (const key of ["sender", "sender_handle", "type", "created_at"]) {
1336
+ if (r[key] !== void 0 && typeof r[key] !== "string") return false;
1337
+ }
1338
+ if (r.content !== void 0 && (typeof r.content !== "object" || r.content === null || Array.isArray(r.content))) {
1339
+ return false;
1340
+ }
1341
+ return true;
1342
+ }
1343
+ function lastDeliveryId(rows) {
1344
+ for (let i = rows.length - 1; i >= 0; i--) {
1345
+ const id = rows[i]?.delivery_id;
1346
+ if (typeof id === "string" && id.length > 0) return id;
1347
+ }
1348
+ return null;
1349
+ }
1350
+ function isThenable(value) {
1351
+ return typeof value === "object" && value !== null && typeof value.then === "function";
1352
+ }
1353
+ function toError(reason, context) {
1354
+ return reason instanceof Error ? reason : new Error(`${context} handler failed: ${String(reason)}`);
1355
+ }
1312
1356
  var RealtimeClient = class {
1313
1357
  ws = null;
1314
1358
  options;
@@ -1320,9 +1364,37 @@ var RealtimeClient = class {
1320
1364
  reconnectTimer = null;
1321
1365
  helloAckTimer = null;
1322
1366
  authenticated = false;
1367
+ // True only when the server echoed the 'ack' capability in hello.ok.
1368
+ // Per-connection: reset on every close and re-negotiated on every HELLO.
1369
+ ackMode = false;
1370
+ // Set immediately before our own close(1008, 'HELLO ack timeout') so the
1371
+ // onclose handler can tell this transient self-close apart from a
1372
+ // server-initiated 1008 (which is terminal — invalid credentials).
1373
+ helloTimeoutClose = false;
1374
+ // Bounded LRU of recently-dispatched message ids (Set iteration order is
1375
+ // insertion order — delete + re-add refreshes recency). Ids are added
1376
+ // only AFTER a successful dispatch: adding earlier would let a failed
1377
+ // dispatch suppress its own redelivery.
1378
+ dedupSeen = /* @__PURE__ */ new Set();
1379
+ // Envelopes injected by the REST drain, as opposed to live WS frames.
1380
+ // Drain rows are acknowledged via the REST sync/ack cursor, never via a
1381
+ // WS ack frame; everything else on the message.new pipeline (live frames,
1382
+ // server-pushed reconnect backlog, gap-fill rows) takes the WS ack path
1383
+ // when ack-mode is negotiated.
1384
+ restDrainOrigin = /* @__PURE__ */ new WeakSet();
1385
+ // Per-envelope dispatch settlement for drain rows: resolves true when
1386
+ // every handler settled cleanly (or the row deduped), false when a
1387
+ // handler threw/rejected. The drain awaits these before advancing the
1388
+ // ack cursor. WeakMap so entries die with the envelope objects.
1389
+ drainSettlements = /* @__PURE__ */ new WeakMap();
1390
+ // Coalesces concurrent drains — the server-side ack pointer only moves
1391
+ // forward, so one drain at a time is both sufficient and simpler to
1392
+ // reason about than interleaved read cursors.
1393
+ drainInFlight = false;
1323
1394
  orderStates = /* @__PURE__ */ new Map();
1324
1395
  disposed = false;
1325
1396
  constructor(options) {
1397
+ const dedupCacheSize = typeof options.dedupCacheSize === "number" && Number.isFinite(options.dedupCacheSize) && options.dedupCacheSize >= 1 ? Math.floor(options.dedupCacheSize) : DEFAULT_DEDUP_CACHE_SIZE;
1326
1398
  this.options = {
1327
1399
  baseUrl: options.baseUrl ?? "wss://api.agentchat.me",
1328
1400
  reconnect: options.reconnect ?? true,
@@ -1333,6 +1405,7 @@ var RealtimeClient = class {
1333
1405
  client: options.client,
1334
1406
  onSequenceGap: options.onSequenceGap,
1335
1407
  autoDrainOnConnect: options.autoDrainOnConnect ?? Boolean(options.client),
1408
+ dedupCacheSize,
1336
1409
  webSocket: options.webSocket
1337
1410
  };
1338
1411
  }
@@ -1361,15 +1434,24 @@ var RealtimeClient = class {
1361
1434
  const url = `${this.options.baseUrl}/v1/ws`;
1362
1435
  this.ws = new WebSocketCtor(url);
1363
1436
  this.authenticated = false;
1437
+ this.ackMode = false;
1438
+ this.helloTimeoutClose = false;
1364
1439
  this.ws.onopen = () => {
1365
1440
  try {
1366
- this.ws.send(JSON.stringify({ type: "hello", api_key: this.options.apiKey }));
1441
+ this.ws.send(
1442
+ JSON.stringify({
1443
+ type: "hello",
1444
+ api_key: this.options.apiKey,
1445
+ capabilities: ["ack"]
1446
+ })
1447
+ );
1367
1448
  } catch (err) {
1368
1449
  this.emitError(err instanceof Error ? err : new ConnectionError("HELLO send failed"));
1369
1450
  return;
1370
1451
  }
1371
1452
  this.helloAckTimer = setTimeout(() => {
1372
1453
  this.emitError(new ConnectionError("HELLO ack timeout"));
1454
+ this.helloTimeoutClose = true;
1373
1455
  try {
1374
1456
  this.ws?.close(1008, "HELLO ack timeout");
1375
1457
  } catch {
@@ -1386,6 +1468,8 @@ var RealtimeClient = class {
1386
1468
  if (!this.authenticated) {
1387
1469
  if (message.type === "hello.ok") {
1388
1470
  this.authenticated = true;
1471
+ const caps = message.capabilities;
1472
+ this.ackMode = Array.isArray(caps) && caps.includes("ack");
1389
1473
  this.reconnectAttempts = 0;
1390
1474
  if (this.helloAckTimer) {
1391
1475
  clearTimeout(this.helloAckTimer);
@@ -1398,7 +1482,11 @@ var RealtimeClient = class {
1398
1482
  }
1399
1483
  }
1400
1484
  if (this.options.autoDrainOnConnect && this.options.client) {
1401
- void this.drainOfflineEnvelopes();
1485
+ this.drainOfflineEnvelopes().catch((err) => {
1486
+ this.emitError(
1487
+ err instanceof Error ? err : new ConnectionError("sync drain failed")
1488
+ );
1489
+ });
1402
1490
  }
1403
1491
  }
1404
1492
  return;
@@ -1418,6 +1506,9 @@ var RealtimeClient = class {
1418
1506
  this.helloAckTimer = null;
1419
1507
  }
1420
1508
  this.authenticated = false;
1509
+ this.ackMode = false;
1510
+ const selfClosedForHelloTimeout = this.helloTimeoutClose;
1511
+ this.helloTimeoutClose = false;
1421
1512
  for (const handler of this.disconnectHandlers) {
1422
1513
  try {
1423
1514
  handler({ code: event.code, reason: event.reason, wasClean: event.wasClean });
@@ -1425,53 +1516,155 @@ var RealtimeClient = class {
1425
1516
  }
1426
1517
  }
1427
1518
  this.resetOrderStates();
1519
+ if (TERMINAL_CLOSE_CODES.has(event.code) && !selfClosedForHelloTimeout) {
1520
+ this.emitError(
1521
+ new ConnectionError(
1522
+ `WebSocket closed with terminal code ${event.code}${event.reason ? ` (${event.reason})` : ""}; the server rejected the session and auto-reconnect has stopped. Check the API key, then create a new RealtimeClient.`
1523
+ )
1524
+ );
1525
+ return;
1526
+ }
1428
1527
  this.scheduleReconnect();
1429
1528
  };
1430
1529
  }
1431
1530
  /**
1432
1531
  * Drain offline envelopes accumulated while the socket was disconnected.
1433
- * Fires `message.new` for each, then acknowledges the highest
1434
- * `delivery_id` so the server can prune its queue. Automatically
1435
- * invoked on every successful `hello.ok` when `autoDrainOnConnect` is
1436
- * enabled and a client is configured.
1532
+ * Automatically invoked on every successful `hello.ok` when
1533
+ * `autoDrainOnConnect` is enabled and a client is configured.
1534
+ *
1535
+ * `GET /v1/messages/sync` returns a **bare array** of rows, oldest first
1536
+ * (see `SyncEnvelope`). Each page is dispatched through the same ordered
1537
+ * `message.new` pipeline as live frames, then acknowledged via
1538
+ * `POST /v1/messages/sync/ack` with a **positional** cursor — the last
1539
+ * non-null `delivery_id` of the fully-processed prefix. `delivery_id` is
1540
+ * an opaque string and is never compared numerically. Pages are fetched
1541
+ * with the `after` read cursor (non-committing) until a short page.
1437
1542
  *
1438
- * Idempotent within a connection cycle — the server-side ack pointer
1439
- * only moves forward, so concurrent or repeated calls are safe (only
1440
- * the first pass yields envelopes; subsequent passes see an empty
1441
- * queue).
1543
+ * Correctness rules, in cursor order:
1544
+ * - A row failing minimal validation stops the drain: the clean prefix
1545
+ * before it is processed and acked; the cursor never crosses the row.
1546
+ * - A row whose handler threw is not acked — nor is anything after it
1547
+ * (the ack cursor is at-or-before) — so the server re-offers it; the
1548
+ * dedup cache suppresses re-dispatch of its acked predecessors.
1549
+ * - A row parked in the out-of-order buffer (awaiting seq gap-fill) is
1550
+ * not acked until actually dispatched: acks FREEZE at the last settled
1551
+ * row for the remainder of the drain. Without this, a disconnect that
1552
+ * clears the ordering buffers (`resetOrderStates`) would silently drop
1553
+ * an already-acked message — acked-but-undispatched is exactly the
1554
+ * loss the ack protocol exists to prevent. Reading continues so the
1555
+ * in-session gap-fill still resolves; the frozen tail is re-offered on
1556
+ * the next drain and absorbed by the dedup cache.
1557
+ *
1558
+ * Concurrent calls are coalesced (the second returns immediately). The
1559
+ * server-side ack pointer only moves forward, so re-running after a
1560
+ * partial drain is always safe. REST-drained rows are acked via this
1561
+ * cursor, never via WS ack frames.
1442
1562
  */
1443
1563
  async drainOfflineEnvelopes() {
1444
1564
  const client = this.options.client;
1445
1565
  if (!client) return;
1446
- while (true) {
1566
+ if (this.drainInFlight) return;
1567
+ this.drainInFlight = true;
1568
+ try {
1569
+ await this.runDrain(client);
1570
+ } finally {
1571
+ this.drainInFlight = false;
1572
+ }
1573
+ }
1574
+ async runDrain(client) {
1575
+ let after;
1576
+ let acksFrozen = false;
1577
+ while (!this.disposed) {
1447
1578
  let batch;
1448
1579
  try {
1449
- batch = await client.sync();
1580
+ batch = await client.sync({ after, limit: SYNC_DRAIN_PAGE_SIZE });
1450
1581
  } catch (err) {
1451
1582
  this.emitError(err instanceof Error ? err : new ConnectionError("sync drain failed"));
1452
1583
  return;
1453
1584
  }
1454
- if (batch.envelopes.length === 0) return;
1455
- let highestDeliveryId = -1;
1456
- for (const env of batch.envelopes) {
1457
- if (env.delivery_id > highestDeliveryId) highestDeliveryId = env.delivery_id;
1458
- const wrapped = {
1585
+ if (!Array.isArray(batch)) {
1586
+ this.emitError(
1587
+ new ConnectionError(
1588
+ `sync drain: expected a bare array from /v1/messages/sync, got ${typeof batch}`
1589
+ )
1590
+ );
1591
+ return;
1592
+ }
1593
+ if (this.disposed) return;
1594
+ if (batch.length === 0) return;
1595
+ const rows = [];
1596
+ let invalidIndex = -1;
1597
+ for (const [index, item] of batch.entries()) {
1598
+ if (!isValidSyncRow(item)) {
1599
+ invalidIndex = index;
1600
+ break;
1601
+ }
1602
+ rows.push(item);
1603
+ }
1604
+ const envelopes = rows.map((row) => {
1605
+ const envelope = {
1459
1606
  type: "message.new",
1460
- payload: env.message
1607
+ payload: row
1461
1608
  };
1462
- this.processOrderedMessage(wrapped);
1609
+ this.restDrainOrigin.add(envelope);
1610
+ return envelope;
1611
+ });
1612
+ for (const envelope of envelopes) {
1613
+ this.processOrderedMessage(envelope);
1463
1614
  }
1464
- if (highestDeliveryId >= 0) {
1465
- try {
1466
- await client.syncAck(highestDeliveryId);
1467
- } catch (err) {
1468
- this.emitError(err instanceof Error ? err : new ConnectionError("sync ack failed"));
1469
- return;
1615
+ if (!acksFrozen) {
1616
+ let ackCursor = null;
1617
+ for (let i = 0; i < rows.length; i++) {
1618
+ const row = rows[i];
1619
+ const envelope = envelopes[i];
1620
+ if (!row || !envelope) break;
1621
+ const settlement = this.drainSettlements.get(envelope);
1622
+ if (settlement) {
1623
+ const ok = await settlement;
1624
+ if (!ok) {
1625
+ acksFrozen = true;
1626
+ break;
1627
+ }
1628
+ } else if (this.isBufferedInOrderState(row)) {
1629
+ acksFrozen = true;
1630
+ break;
1631
+ }
1632
+ const deliveryId = row.delivery_id;
1633
+ if (typeof deliveryId === "string" && deliveryId.length > 0) {
1634
+ ackCursor = deliveryId;
1635
+ }
1470
1636
  }
1637
+ if (ackCursor !== null) {
1638
+ try {
1639
+ await client.syncAck(ackCursor);
1640
+ } catch (err) {
1641
+ this.emitError(err instanceof Error ? err : new ConnectionError("sync ack failed"));
1642
+ return;
1643
+ }
1644
+ }
1645
+ }
1646
+ if (invalidIndex >= 0) {
1647
+ this.emitError(
1648
+ new ConnectionError(
1649
+ `sync drain: row ${invalidIndex} failed validation \u2014 processed the ${rows.length}-row prefix and stopped; the ack cursor was not advanced past it`
1650
+ )
1651
+ );
1652
+ return;
1471
1653
  }
1472
- if (batch.envelopes.length < 100) return;
1654
+ if (batch.length < SYNC_DRAIN_PAGE_SIZE) return;
1655
+ const nextAfter = lastDeliveryId(rows);
1656
+ if (nextAfter === null) return;
1657
+ after = nextAfter;
1473
1658
  }
1474
1659
  }
1660
+ // True when a drain row is currently parked in the per-conversation
1661
+ // out-of-order buffer (its dispatch is deferred to the gap-fill
1662
+ // machinery — natural arrival, gap-fill fetch, or forced resolveGap).
1663
+ isBufferedInOrderState(row) {
1664
+ if (typeof row.seq !== "number") return false;
1665
+ const state = this.orderStates.get(row.conversation_id);
1666
+ return state !== void 0 && state.buffer.has(row.seq);
1667
+ }
1475
1668
  scheduleReconnect() {
1476
1669
  if (this.disposed) return;
1477
1670
  if (!this.options.reconnect) return;
@@ -1588,11 +1781,115 @@ var RealtimeClient = class {
1588
1781
  }
1589
1782
  }
1590
1783
  dispatch(message) {
1784
+ if (this.isMessageNew(message)) {
1785
+ void this.dispatchMessageNew(message);
1786
+ return;
1787
+ }
1591
1788
  const handlers = this.handlers.get(message.type);
1592
1789
  if (!handlers) return;
1593
1790
  for (const handler of handlers) {
1594
- handler(message);
1791
+ try {
1792
+ const result = handler(message);
1793
+ if (isThenable(result)) {
1794
+ result.catch((err) => this.emitError(toError(err, message.type)));
1795
+ }
1796
+ } catch (err) {
1797
+ this.emitError(toError(err, message.type));
1798
+ }
1799
+ }
1800
+ }
1801
+ /**
1802
+ * Dedup + dispatch + acknowledge one `message.new` envelope. Never
1803
+ * rejects.
1804
+ *
1805
+ * Resolves `true` when the envelope is safe to acknowledge: every
1806
+ * handler settled without throwing (async handlers awaited), or the
1807
+ * message id was already in the dedup cache — prior successful
1808
+ * processing is the proof, so a duplicate skips dispatch but is still
1809
+ * acked. Resolves `false` when any handler threw or rejected: the
1810
+ * message is NOT acked on any path and the server re-offers it.
1811
+ *
1812
+ * Ack routing: live frames (including server-pushed reconnect backlog
1813
+ * and gap-fill rows) send a WS `{type:'ack'}` frame when ack-mode was
1814
+ * negotiated; REST-drain rows are covered by the drain's sync/ack
1815
+ * cursor instead — the drain awaits this settlement before advancing
1816
+ * that cursor.
1817
+ */
1818
+ dispatchMessageNew(message) {
1819
+ const isDrainRow = this.restDrainOrigin.has(message);
1820
+ const messageId = this.extractMessageId(message);
1821
+ let settlement;
1822
+ if (messageId !== null && this.dedupHit(messageId)) {
1823
+ settlement = Promise.resolve(true);
1824
+ if (!isDrainRow) this.sendAckFrame(messageId);
1825
+ } else {
1826
+ const handlers = this.handlers.get("message.new");
1827
+ const pending = [];
1828
+ if (handlers) {
1829
+ for (const handler of handlers) {
1830
+ try {
1831
+ const result = handler(message);
1832
+ if (isThenable(result)) pending.push(result);
1833
+ } catch (err) {
1834
+ pending.push(Promise.reject(err));
1835
+ }
1836
+ }
1837
+ }
1838
+ settlement = Promise.allSettled(pending).then((outcomes) => {
1839
+ let ok = true;
1840
+ for (const outcome of outcomes) {
1841
+ if (outcome.status === "rejected") {
1842
+ ok = false;
1843
+ this.emitError(toError(outcome.reason, "message.new"));
1844
+ }
1845
+ }
1846
+ if (!ok) return false;
1847
+ if (messageId !== null) {
1848
+ this.dedupAdd(messageId);
1849
+ if (!isDrainRow) this.sendAckFrame(messageId);
1850
+ }
1851
+ return true;
1852
+ });
1595
1853
  }
1854
+ if (isDrainRow) this.drainSettlements.set(message, settlement);
1855
+ return settlement;
1856
+ }
1857
+ /**
1858
+ * Best-effort delivery ack for one processed message. No-op unless the
1859
+ * server negotiated ack-mode on this connection. Send failures are
1860
+ * swallowed by design: a dying socket leaves the envelope `stored`
1861
+ * server-side, the next drain re-offers it, and the dedup cache absorbs
1862
+ * the duplicate.
1863
+ */
1864
+ sendAckFrame(messageId) {
1865
+ if (!this.ackMode) return;
1866
+ if (!this.ws || this.ws.readyState !== 1 || !this.authenticated) return;
1867
+ try {
1868
+ this.ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
1869
+ } catch {
1870
+ }
1871
+ }
1872
+ // Membership check that also refreshes recency on a hit (Set iteration
1873
+ // order is insertion order, so delete + re-add moves the id to the back
1874
+ // of the eviction queue).
1875
+ dedupHit(messageId) {
1876
+ if (!this.dedupSeen.has(messageId)) return false;
1877
+ this.dedupSeen.delete(messageId);
1878
+ this.dedupSeen.add(messageId);
1879
+ return true;
1880
+ }
1881
+ dedupAdd(messageId) {
1882
+ this.dedupSeen.delete(messageId);
1883
+ this.dedupSeen.add(messageId);
1884
+ while (this.dedupSeen.size > this.options.dedupCacheSize) {
1885
+ const oldest = this.dedupSeen.values().next().value;
1886
+ if (oldest === void 0) break;
1887
+ this.dedupSeen.delete(oldest);
1888
+ }
1889
+ }
1890
+ extractMessageId(message) {
1891
+ const id = message.payload?.id;
1892
+ return typeof id === "string" && id.length > 0 ? id : null;
1596
1893
  }
1597
1894
  isMessageNew(message) {
1598
1895
  return message.type === "message.new";