@maximem/synap-js-sdk 0.4.8 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/anticipation-cache-LRE3TLK4.cjs.map +1 -1
  2. package/dist/{chunk-OCMM2Z5P.js → chunk-BWMPKLFG.js} +40 -2
  3. package/dist/chunk-BWMPKLFG.js.map +1 -0
  4. package/dist/{chunk-T5QQBJ6T.js → chunk-DJPYUPSV.js} +530 -7
  5. package/dist/chunk-DJPYUPSV.js.map +1 -0
  6. package/dist/{chunk-2TVL5IPQ.cjs → chunk-T372OL3Q.cjs} +41 -3
  7. package/dist/chunk-T372OL3Q.cjs.map +1 -0
  8. package/dist/chunk-T6U5PVAD.cjs.map +1 -1
  9. package/dist/{chunk-7CWBRHUO.cjs → chunk-WWTGJZA7.cjs} +542 -19
  10. package/dist/chunk-WWTGJZA7.cjs.map +1 -0
  11. package/dist/chunk-ZKOJDUN4.cjs.map +1 -1
  12. package/dist/env-55ULHNJZ.cjs.map +1 -1
  13. package/dist/errors-Bl3vY1eb.d.cts +382 -0
  14. package/dist/errors-Bl3vY1eb.d.ts +382 -0
  15. package/dist/grpc/index.cjs +7 -7
  16. package/dist/grpc/index.cjs.map +1 -1
  17. package/dist/grpc/index.d.cts +50 -4
  18. package/dist/grpc/index.d.ts +50 -4
  19. package/dist/grpc/index.js +5 -5
  20. package/dist/index.cjs +278 -161
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.cts +97 -148
  23. package/dist/index.d.ts +97 -148
  24. package/dist/index.js +171 -54
  25. package/dist/index.js.map +1 -1
  26. package/dist/stream-client-5CGJNTH4.js +16 -0
  27. package/dist/stream-client-GJM4YSXW.cjs +16 -0
  28. package/dist/stream-client-GJM4YSXW.cjs.map +1 -0
  29. package/package.json +1 -1
  30. package/dist/anticipation-cache-C1rUEpaW.d.cts +0 -211
  31. package/dist/anticipation-cache-C1rUEpaW.d.ts +0 -211
  32. package/dist/chunk-2TVL5IPQ.cjs.map +0 -1
  33. package/dist/chunk-7CWBRHUO.cjs.map +0 -1
  34. package/dist/chunk-OCMM2Z5P.js.map +0 -1
  35. package/dist/chunk-T5QQBJ6T.js.map +0 -1
  36. package/dist/stream-client-2C5KIOWP.js +0 -7
  37. package/dist/stream-client-GPU37E6F.cjs +0 -7
  38. package/dist/stream-client-GPU37E6F.cjs.map +0 -1
  39. /package/dist/{stream-client-2C5KIOWP.js.map → stream-client-5CGJNTH4.js.map} +0 -0
@@ -1,5 +1,11 @@
1
- "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }// src/grpc/descriptor.ts
2
- var PROTO_SHA = "a58c9b031b41530d";
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
2
+
3
+
4
+
5
+ var _chunkT372OL3Qcjs = require('./chunk-T372OL3Q.cjs');
6
+
7
+ // src/grpc/descriptor.ts
8
+ var PROTO_SHA = "c1d834043608ad4f";
3
9
  var SYNAP_PROTO_DESCRIPTOR = {
4
10
  "nested": {
5
11
  "synap": {
@@ -202,6 +208,14 @@ var SYNAP_PROTO_DESCRIPTOR = {
202
208
  "type": "string",
203
209
  "id": 11
204
210
  },
211
+ "tool_result_json": {
212
+ "type": "string",
213
+ "id": 15
214
+ },
215
+ "tool_call_id": {
216
+ "type": "string",
217
+ "id": 16
218
+ },
205
219
  "search_queries": {
206
220
  "rule": "repeated",
207
221
  "type": "string",
@@ -216,6 +230,14 @@ var SYNAP_PROTO_DESCRIPTOR = {
216
230
  "keyType": "string",
217
231
  "type": "string",
218
232
  "id": 14
233
+ },
234
+ "event_id": {
235
+ "type": "string",
236
+ "id": 17
237
+ },
238
+ "sent_at_ms": {
239
+ "type": "int64",
240
+ "id": 18
219
241
  }
220
242
  }
221
243
  },
@@ -257,7 +279,8 @@ var SYNAP_PROTO_DESCRIPTOR = {
257
279
  "oneof": [
258
280
  "context_bundle",
259
281
  "heartbeat_pong",
260
- "signal"
282
+ "signal",
283
+ "event_ack"
261
284
  ]
262
285
  }
263
286
  },
@@ -273,6 +296,23 @@ var SYNAP_PROTO_DESCRIPTOR = {
273
296
  "signal": {
274
297
  "type": "StreamSignal",
275
298
  "id": 3
299
+ },
300
+ "event_ack": {
301
+ "type": "EventAck",
302
+ "id": 4
303
+ }
304
+ }
305
+ },
306
+ "EventAck": {
307
+ "fields": {
308
+ "event_ids": {
309
+ "rule": "repeated",
310
+ "type": "string",
311
+ "id": 1
312
+ },
313
+ "timestamp_ms": {
314
+ "type": "int64",
315
+ "id": 2
276
316
  }
277
317
  }
278
318
  },
@@ -616,6 +656,192 @@ var SYNAP_PROTO_DESCRIPTOR = {
616
656
  }
617
657
  };
618
658
 
659
+ // src/grpc/outbox-journal.ts
660
+ var OP_QUEUED = "q";
661
+ var OP_UNACKED = "u";
662
+ var OP_ACKED = "a";
663
+ var OP_DROPPED = "d";
664
+ var COMPACT_AT_LINES = 500;
665
+ var _fs = null;
666
+ var _fsResolved = false;
667
+ var _fsPending = null;
668
+ async function resolveFs() {
669
+ if (_fsResolved) return;
670
+ if (_fsPending === null) {
671
+ _fsPending = (async () => {
672
+ try {
673
+ _fs = await Promise.resolve().then(() => _interopRequireWildcard(require("fs")));
674
+ } catch (e2) {
675
+ _fs = null;
676
+ }
677
+ _fsResolved = true;
678
+ })();
679
+ }
680
+ await _fsPending;
681
+ }
682
+ function defaultOutboxRoot() {
683
+ try {
684
+ const env = _optionalChain([globalThis, 'access', _ => _.process, 'optionalAccess', _2 => _2.env]);
685
+ const home = _nullishCoalesce(_nullishCoalesce(_optionalChain([env, 'optionalAccess', _3 => _3["SYNAP_STORAGE_PATH"]]), () => ( _optionalChain([env, 'optionalAccess', _4 => _4["HOME"]]))), () => ( _optionalChain([env, 'optionalAccess', _5 => _5["USERPROFILE"]])));
686
+ if (home !== void 0 && home !== "") {
687
+ return _optionalChain([env, 'optionalAccess', _6 => _6["SYNAP_STORAGE_PATH"]]) !== void 0 && env["SYNAP_STORAGE_PATH"] !== "" ? env["SYNAP_STORAGE_PATH"] : `${home}/.synap`;
688
+ }
689
+ } catch (e3) {
690
+ }
691
+ return "";
692
+ }
693
+ var OutboxJournal = class {
694
+
695
+ #fs;
696
+ #lines = 0;
697
+ #disabled = false;
698
+ /**
699
+ * @param root Directory the journal lives under. One file per instance id,
700
+ * because two clients on one instance share a stream in the
701
+ * registry and so the events are the instance's, not the
702
+ * object's.
703
+ */
704
+ /**
705
+ * Resolve `node:fs` once for the process. The stream client awaits this in
706
+ * `connect()` before any event can flow, so by the time anything is written
707
+ * the module is in hand and every append is a plain synchronous call. An
708
+ * async write still pending when the process dies is a write that did not
709
+ * happen, and this file exists for exactly that case.
710
+ */
711
+ static async init() {
712
+ await resolveFs();
713
+ }
714
+ constructor(root, instanceId, fsImpl) {
715
+ this.path = root === "" ? "" : `${root}/outbox/${instanceId}.jsonl`;
716
+ this.#fs = fsImpl !== void 0 ? fsImpl : _fs;
717
+ if (this.#fs === null || this.path === "") this.#disabled = true;
718
+ }
719
+ get enabled() {
720
+ return !this.#disabled;
721
+ }
722
+ // ------------------------------------------------------------------ writes
723
+ recordQueued(event) {
724
+ this.#append({ op: OP_QUEUED, e: event });
725
+ }
726
+ recordUnacked(event) {
727
+ this.#append({ op: OP_UNACKED, e: event });
728
+ }
729
+ recordAcked(id) {
730
+ this.#append({ op: OP_ACKED, id });
731
+ }
732
+ /** An event the buffers gave up on. It does not come back on restart either:
733
+ * replaying what the depth or age bound deliberately evicted would quietly
734
+ * undo the bound that evicted it. */
735
+ recordDropped(id) {
736
+ if (id === void 0 || id === "") return;
737
+ this.#append({ op: OP_DROPPED, id });
738
+ }
739
+ // ------------------------------------------------------------------- reads
740
+ /**
741
+ * Replay the file into `{queued, unacked}`, oldest first.
742
+ *
743
+ * A partial last line from a kill mid-append stops the read there. What came
744
+ * before it is the prefix that was durably written; a torn line is an event
745
+ * the caller was never told had been accepted.
746
+ */
747
+ load() {
748
+ const empty = { queued: [], unacked: [] };
749
+ if (this.#disabled || this.#fs === null) return empty;
750
+ let text;
751
+ try {
752
+ if (!this.#fs.existsSync(this.path)) return empty;
753
+ text = this.#fs.readFileSync(this.path, "utf8");
754
+ } catch (e4) {
755
+ return empty;
756
+ }
757
+ const queued = /* @__PURE__ */ new Map();
758
+ const unacked = /* @__PURE__ */ new Map();
759
+ let lines = 0;
760
+ for (const raw of text.split("\n")) {
761
+ const line = raw.trim();
762
+ if (line === "") continue;
763
+ let rec;
764
+ try {
765
+ rec = JSON.parse(line);
766
+ } catch (e5) {
767
+ break;
768
+ }
769
+ lines += 1;
770
+ const id = rec.op === OP_QUEUED || rec.op === OP_UNACKED ? _optionalChain([rec, 'access', _7 => _7.e, 'optionalAccess', _8 => _8.event_id]) : rec.id;
771
+ if (id === void 0 || id === "") continue;
772
+ if (rec.op === OP_QUEUED) {
773
+ queued.set(id, rec.e);
774
+ } else if (rec.op === OP_UNACKED) {
775
+ queued.delete(id);
776
+ unacked.set(id, rec.e);
777
+ } else if (rec.op === OP_ACKED || rec.op === OP_DROPPED) {
778
+ queued.delete(id);
779
+ unacked.delete(id);
780
+ }
781
+ }
782
+ this.#lines = lines;
783
+ return { queued: [...queued.values()], unacked: [...unacked.values()] };
784
+ }
785
+ // ------------------------------------------------------------- maintenance
786
+ /**
787
+ * Rewrite the file to hold exactly what the buffers hold now.
788
+ *
789
+ * Written beside the target and renamed over it, because rename is atomic: a
790
+ * kill during a compaction leaves either the whole old file or the whole new
791
+ * one, never a half-written mix.
792
+ */
793
+ compact(queued, unacked) {
794
+ if (this.#disabled || this.#fs === null) return;
795
+ if (queued.length === 0 && unacked.length === 0) {
796
+ this.clear();
797
+ return;
798
+ }
799
+ try {
800
+ this.#mkdir();
801
+ const body = [
802
+ ...unacked.map((e) => JSON.stringify({ op: OP_UNACKED, e })),
803
+ ...queued.map((e) => JSON.stringify({ op: OP_QUEUED, e }))
804
+ ].join("\n") + "\n";
805
+ const tmp = `${this.path}.${Date.now()}.tmp`;
806
+ this.#fs.writeFileSync(tmp, body, "utf8");
807
+ this.#fs.renameSync(tmp, this.path);
808
+ this.#lines = queued.length + unacked.length;
809
+ } catch (e6) {
810
+ }
811
+ }
812
+ /** Nothing is outstanding, so there is nothing to replay. */
813
+ clear() {
814
+ if (this.#disabled || this.#fs === null) return;
815
+ try {
816
+ this.#fs.unlinkSync(this.path);
817
+ } catch (e7) {
818
+ }
819
+ this.#lines = 0;
820
+ }
821
+ get needsCompaction() {
822
+ return this.#lines >= COMPACT_AT_LINES;
823
+ }
824
+ // ---------------------------------------------------------------- internal
825
+ #mkdir() {
826
+ const dir = this.path.slice(0, this.path.lastIndexOf("/"));
827
+ _optionalChain([this, 'access', _9 => _9.#fs, 'optionalAccess', _10 => _10.mkdirSync, 'call', _11 => _11(dir, { recursive: true })]);
828
+ }
829
+ #append(record) {
830
+ if (this.#disabled || this.#fs === null) return;
831
+ try {
832
+ this.#mkdir();
833
+ this.#fs.appendFileSync(this.path, `${JSON.stringify(record)}
834
+ `, "utf8");
835
+ this.#lines += 1;
836
+ } catch (err) {
837
+ console.warn(
838
+ `[synap] outbox journal write failed (${String(err)}); buffered events will not survive a restart from here on`
839
+ );
840
+ this.#disabled = true;
841
+ }
842
+ }
843
+ };
844
+
619
845
  // src/grpc/stream-client.ts
620
846
  var UNCACHEABLE_BUNDLE_TYPE = "reactive";
621
847
  var MAX_RECONNECT_ATTEMPTS = 10;
@@ -624,7 +850,66 @@ var BACKOFF_MAX_MS = 3e4;
624
850
  var HEARTBEAT_INTERVAL_MS = 3e4;
625
851
  var HEARTBEAT_TIMEOUT_MS = 1e4;
626
852
  var MAX_MISSED_HEARTBEATS = 3;
853
+ var SEND_QUEUE_MAX_DEPTH = 100;
854
+ var CLOSE_GRACE_MS = 2e3;
855
+ var SEND_QUEUE_MAX_AGE_MS = 5 * 60 * 1e3;
627
856
  var MIN_TTL_SECONDS = 60;
857
+ var GRPC_RESOURCE_EXHAUSTED = 8;
858
+ var CREDIT_ABORT_DETAILS = "insufficient_credits";
859
+ var CREDIT_STOP_REASONS = /* @__PURE__ */ new Set([
860
+ "overages_disabled",
861
+ "trial_limit_reached",
862
+ "subscription_inactive"
863
+ ]);
864
+ function metadataValue(metadata, key) {
865
+ if (metadata === null || typeof metadata !== "object") return null;
866
+ const getter = metadata.get;
867
+ const raw = typeof getter === "function" ? getter.call(metadata, key) : metadata[key];
868
+ const value = Array.isArray(raw) ? raw[0] : raw;
869
+ if (value === void 0 || value === null) return null;
870
+ const text = typeof value === "string" ? value : String(value);
871
+ return text === "" ? null : text;
872
+ }
873
+ function numberOrNull(value) {
874
+ if (value === null) return null;
875
+ const parsed = Number(value);
876
+ return Number.isFinite(parsed) ? parsed : null;
877
+ }
878
+ function creditErrorFromRpc(error, correlationId = null) {
879
+ if (error === null || typeof error !== "object") return null;
880
+ if (error.code !== GRPC_RESOURCE_EXHAUSTED) return null;
881
+ const metadata = error.metadata;
882
+ const reason = metadataValue(metadata, "credit-reason");
883
+ if (reason === null || !CREDIT_STOP_REASONS.has(reason)) return null;
884
+ const rawDetails = error.details;
885
+ const details = typeof rawDetails === "string" && rawDetails !== "" ? rawDetails : CREDIT_ABORT_DETAILS;
886
+ const requestId = _nullishCoalesce(metadataValue(metadata, "request-id"), () => ( correlationId));
887
+ const manageUrl = metadataValue(metadata, "credit-manage-url");
888
+ if (reason === "overages_disabled") {
889
+ return new (0, _chunkT372OL3Qcjs.InsufficientCreditsError)(details, {
890
+ correlationId: requestId,
891
+ balanceCredits: numberOrNull(metadataValue(metadata, "credit-balance")),
892
+ requiredCredits: numberOrNull(metadataValue(metadata, "credit-minimum-required")),
893
+ recoveryUrl: metadataValue(metadata, "credit-recovery-url"),
894
+ redeemUrl: metadataValue(metadata, "credit-redeem-url"),
895
+ reason,
896
+ manageUrl
897
+ });
898
+ }
899
+ return new (0, _chunkT372OL3Qcjs.RateLimitError)(details, {
900
+ correlationId: requestId,
901
+ reason,
902
+ upgradeUrl: metadataValue(metadata, "credit-upgrade-url"),
903
+ manageUrl
904
+ });
905
+ }
906
+ function isCreditStop(error) {
907
+ if (!(error instanceof _chunkT372OL3Qcjs.InsufficientCreditsError) && !(error instanceof _chunkT372OL3Qcjs.RateLimitError)) {
908
+ return false;
909
+ }
910
+ const reason = error.reason;
911
+ return typeof reason === "string" && CREDIT_STOP_REASONS.has(reason);
912
+ }
628
913
  var GrpcStreamClient = class {
629
914
 
630
915
 
@@ -636,6 +921,19 @@ var GrpcStreamClient = class {
636
921
  #missedHeartbeats = 0;
637
922
  #heartbeatTimer = null;
638
923
  #shutdownRequested = false;
924
+ // Events that could not be written because there was no stream.
925
+ #sendQueue = [];
926
+ // Events written to a live stream that the server has not acknowledged. The
927
+ // queue above covers "the stream was down"; this covers the half that
928
+ // actually loses sessions — the write succeeded, the stream broke before the
929
+ // server processed it, and nobody knows.
930
+ #unacked = /* @__PURE__ */ new Map();
931
+ // Why the stream stopped, when it stopped for a reason the caller can act on
932
+ // (today: a credit refusal). Mirrors Python's `last_error`.
933
+ #lastError = null;
934
+ // Disk backing for the two buffers above. They survive a dropped
935
+ // connection and not the process; this is what the next start reads.
936
+ #journal;
639
937
  #grpc = null;
640
938
  #ServiceStub = null;
641
939
  #credentials;
@@ -652,6 +950,10 @@ var GrpcStreamClient = class {
652
950
  this.useTls = _nullishCoalesce(options.useTls, () => ( true));
653
951
  this.#sleep = _nullishCoalesce(options.sleep, () => ( ((ms) => new Promise((r) => setTimeout(r, ms)))));
654
952
  this.#connectTimeoutMs = _nullishCoalesce(options.connectTimeoutMs, () => ( 1e4));
953
+ this.#journal = new OutboxJournal(
954
+ _nullishCoalesce(options.outboxPath, () => ( defaultOutboxRoot())),
955
+ credentials.instanceId
956
+ );
655
957
  }
656
958
  get currentState() {
657
959
  return this.#state;
@@ -659,21 +961,190 @@ var GrpcStreamClient = class {
659
961
  get isConnected() {
660
962
  return this.#state === "connected";
661
963
  }
964
+ /**
965
+ * The error that ended the stream, if one did.
966
+ *
967
+ * Set when the server refused the stream for credits, so an `onDisconnect`
968
+ * handler can read the balance, the reason and the URL to fix it. Null for an
969
+ * ordinary disconnect.
970
+ */
971
+ get lastError() {
972
+ return this.#lastError;
973
+ }
662
974
  async connect() {
663
975
  await this.#loadGrpc();
976
+ await OutboxJournal.init();
664
977
  this.#shutdownRequested = false;
978
+ this.#lastError = null;
979
+ this.#restoreFromJournal();
665
980
  await this.#establish();
666
981
  this.#startHeartbeat();
667
982
  }
983
+ /**
984
+ * Pull a previous process's unsent events back into the send queue.
985
+ *
986
+ * Everything lands in the send queue, including what was unacknowledged last
987
+ * time. The stream those events were written to is gone, so "the server may
988
+ * already have it" is not a state we can resume; it has to be written again.
989
+ * That is safe because each event carries the id it was first sent with and
990
+ * the server drops a second sighting of it before dispatch, so an event it
991
+ * DID process is deduplicated there rather than doubling the turn.
992
+ */
993
+ #restoreFromJournal() {
994
+ const { queued, unacked } = this.#journal.load();
995
+ if (queued.length === 0 && unacked.length === 0) return;
996
+ const now = Date.now();
997
+ for (const event of [...unacked, ...queued]) {
998
+ this.#sendQueue.push({
999
+ queuedAt: now,
1000
+ event
1001
+ });
1002
+ }
1003
+ console.warn(
1004
+ `[synap] restored ${unacked.length + queued.length} event(s) from the previous process (${unacked.length} unacknowledged, ${queued.length} queued); they go out on this stream`
1005
+ );
1006
+ this.#compactJournal();
1007
+ }
1008
+ #compactJournal() {
1009
+ this.#journal.compact(
1010
+ this.#sendQueue.map((h) => h.event),
1011
+ [...this.#unacked.values()].map(
1012
+ (h) => h.event
1013
+ )
1014
+ );
1015
+ }
668
1016
  async disconnect() {
669
1017
  this.#shutdownRequested = true;
670
1018
  this.#stopHeartbeat();
671
- this.#closeCall();
1019
+ this.#flushBeforeClose();
1020
+ const call = this.#call;
1021
+ if (call !== null) {
1022
+ try {
1023
+ call.end();
1024
+ } catch (e8) {
1025
+ }
1026
+ await new Promise((resolve) => {
1027
+ let settled = false;
1028
+ const done = () => {
1029
+ if (!settled) {
1030
+ settled = true;
1031
+ resolve();
1032
+ }
1033
+ };
1034
+ const timer = setTimeout(() => {
1035
+ const left = this.undelivered();
1036
+ if (left.unacknowledged > 0 || left.queued > 0) {
1037
+ console.warn(
1038
+ `[synap] closing with ${left.queued} queued and ${left.unacknowledged} unacknowledged event(s) the server did not confirm within ${CLOSE_GRACE_MS}ms`
1039
+ );
1040
+ }
1041
+ done();
1042
+ }, CLOSE_GRACE_MS);
1043
+ if (typeof timer.unref === "function") timer.unref();
1044
+ call.on("end", () => {
1045
+ clearTimeout(timer);
1046
+ done();
1047
+ });
1048
+ call.on("status", () => {
1049
+ clearTimeout(timer);
1050
+ done();
1051
+ });
1052
+ call.on("error", () => {
1053
+ clearTimeout(timer);
1054
+ done();
1055
+ });
1056
+ });
1057
+ }
1058
+ this.#call = null;
1059
+ this.#compactJournal();
672
1060
  this.#setState("closed");
673
1061
  }
674
- /** Fire-and-forget: a dropped event must never fail the caller's turn. */
1062
+ /**
1063
+ * What this client could not get to the server.
1064
+ *
1065
+ * Every send path returns void, so a caller had no way to tell a delivered
1066
+ * turn from a lost one. Mirrors Python's `undelivered()`.
1067
+ */
1068
+ undelivered() {
1069
+ return { queued: this.#sendQueue.length, unacknowledged: this.#unacked.size };
1070
+ }
1071
+ /**
1072
+ * Never fails the caller's turn, and no longer loses it either.
1073
+ *
1074
+ * A write that cannot go out is buffered and replayed on reconnect, and a
1075
+ * write that does go out is held until the server acknowledges it. Before
1076
+ * this, both cases ended at a silent `return`.
1077
+ */
675
1078
  sendConversationEvent(event) {
676
- this.#write({ conversation_event: { ...event } });
1079
+ const stamped = {
1080
+ ...event,
1081
+ event_id: event.event_id && event.event_id !== "" ? event.event_id : _chunkT372OL3Qcjs.newCorrelationId.call(void 0, ),
1082
+ sent_at_ms: _nullishCoalesce(event.sent_at_ms, () => ( Date.now()))
1083
+ };
1084
+ if (!this.isConnected || this.#call === null) {
1085
+ this.#enqueue(stamped);
1086
+ return;
1087
+ }
1088
+ this.#write({ conversation_event: { ...stamped } });
1089
+ this.#trackUnacked(stamped);
1090
+ }
1091
+ #enqueue(event) {
1092
+ this.#pruneQueue();
1093
+ if (this.#sendQueue.length >= SEND_QUEUE_MAX_DEPTH) {
1094
+ const evicted = this.#sendQueue.shift();
1095
+ this.#journal.recordDropped(_optionalChain([evicted, 'optionalAccess', _12 => _12.event, 'access', _13 => _13.event_id]));
1096
+ console.warn(
1097
+ `[synap] send queue full (max=${SEND_QUEUE_MAX_DEPTH}); dropping oldest event (event_type=${_optionalChain([evicted, 'optionalAccess', _14 => _14.event, 'access', _15 => _15.event_type])})`
1098
+ );
1099
+ }
1100
+ this.#sendQueue.push({ queuedAt: Date.now(), event });
1101
+ this.#journal.recordQueued(event);
1102
+ }
1103
+ #pruneQueue() {
1104
+ const cutoff = Date.now() - SEND_QUEUE_MAX_AGE_MS;
1105
+ while (this.#sendQueue.length > 0 && (_nullishCoalesce(_optionalChain([this, 'access', _16 => _16.#sendQueue, 'access', _17 => _17[0], 'optionalAccess', _18 => _18.queuedAt]), () => ( 0))) < cutoff) {
1106
+ const expired = this.#sendQueue.shift();
1107
+ this.#journal.recordDropped(_optionalChain([expired, 'optionalAccess', _19 => _19.event, 'access', _20 => _20.event_id]));
1108
+ console.warn(
1109
+ `[synap] queued event expired (age>${SEND_QUEUE_MAX_AGE_MS}ms); dropping (event_type=${_optionalChain([expired, 'optionalAccess', _21 => _21.event, 'access', _22 => _22.event_type])})`
1110
+ );
1111
+ }
1112
+ }
1113
+ #trackUnacked(event) {
1114
+ const id = event.event_id;
1115
+ if (id === void 0 || id === "") return;
1116
+ const cutoff = Date.now() - SEND_QUEUE_MAX_AGE_MS;
1117
+ for (const [oldestId, held] of this.#unacked) {
1118
+ if (held.queuedAt >= cutoff && this.#unacked.size < SEND_QUEUE_MAX_DEPTH) break;
1119
+ this.#unacked.delete(oldestId);
1120
+ this.#journal.recordDropped(oldestId);
1121
+ console.warn(
1122
+ `[synap] unacknowledged event dropped: the server never confirmed it (event_type=${held.event.event_type})`
1123
+ );
1124
+ }
1125
+ this.#unacked.set(id, { queuedAt: Date.now(), event });
1126
+ this.#journal.recordUnacked(event);
1127
+ }
1128
+ /**
1129
+ * Replay what the server never confirmed, then what never went out.
1130
+ *
1131
+ * That order is the order things happened in: an unacked event was written
1132
+ * to the previous stream, so it is older than anything that piled up while
1133
+ * there was no stream at all. Safe to repeat — every event carries the id it
1134
+ * was first sent with and the server drops a second sighting of it.
1135
+ */
1136
+ #replayBuffered() {
1137
+ const unacked = [...this.#unacked.values()];
1138
+ const queued = this.#sendQueue;
1139
+ this.#sendQueue = [];
1140
+ for (const held of [...unacked, ...queued]) {
1141
+ this.#write({ conversation_event: { ...held.event } });
1142
+ }
1143
+ if (unacked.length > 0 || queued.length > 0) {
1144
+ console.warn(
1145
+ `[synap] replayed ${unacked.length} unacknowledged and ${queued.length} queued event(s) after reconnect`
1146
+ );
1147
+ }
677
1148
  }
678
1149
  sendSessionControl(input) {
679
1150
  this.#write({ session_control: { ...input } });
@@ -684,11 +1155,26 @@ var GrpcStreamClient = class {
684
1155
  sendContextAssembled(event) {
685
1156
  this.#write({ context_assembled: { ...event } });
686
1157
  }
1158
+ #flushBeforeClose() {
1159
+ if (!this.isConnected || this.#call === null) {
1160
+ if (this.#sendQueue.length > 0 || this.#unacked.size > 0) {
1161
+ console.warn(
1162
+ `[synap] closing with ${this.#sendQueue.length} queued and ${this.#unacked.size} unacknowledged event(s) and no stream to send them on; they are lost`
1163
+ );
1164
+ }
1165
+ return;
1166
+ }
1167
+ const queued = this.#sendQueue;
1168
+ this.#sendQueue = [];
1169
+ for (const held of queued) {
1170
+ this.#write({ conversation_event: { ...held.event } });
1171
+ }
1172
+ }
687
1173
  #write(msg) {
688
1174
  if (!this.isConnected || this.#call === null) return;
689
1175
  try {
690
1176
  this.#call.write(msg);
691
- } catch (e) {
1177
+ } catch (e9) {
692
1178
  }
693
1179
  }
694
1180
  async #loadGrpc() {
@@ -741,6 +1227,11 @@ var GrpcStreamClient = class {
741
1227
  const call = stub["Listen"](metadata);
742
1228
  call.on("data", (msg) => this.#handleMessage(msg));
743
1229
  call.on("error", (err) => {
1230
+ const creditError = creditErrorFromRpc(err);
1231
+ if (creditError !== null) {
1232
+ this.#handleCreditStop(creditError);
1233
+ return;
1234
+ }
744
1235
  void this.#handleDisconnect(`error: ${String(err)}`);
745
1236
  });
746
1237
  call.on("end", () => {
@@ -750,8 +1241,18 @@ var GrpcStreamClient = class {
750
1241
  this.#setState("connected");
751
1242
  this.#reconnectAttempts = 0;
752
1243
  this.#lastPongAt = Date.now();
1244
+ this.#replayBuffered();
753
1245
  }
754
1246
  #handleMessage(msg) {
1247
+ const ack = msg["event_ack"];
1248
+ if (ack) {
1249
+ for (const id of _nullishCoalesce(ack.event_ids, () => ( []))) {
1250
+ this.#unacked.delete(id);
1251
+ this.#journal.recordAcked(id);
1252
+ }
1253
+ if (this.#journal.needsCompaction) this.#compactJournal();
1254
+ return;
1255
+ }
755
1256
  if (msg["context_bundle"]) {
756
1257
  this.#handleBundle(msg["context_bundle"]);
757
1258
  return;
@@ -762,7 +1263,7 @@ var GrpcStreamClient = class {
762
1263
  return;
763
1264
  }
764
1265
  const signal = msg["signal"];
765
- if (_optionalChain([signal, 'optionalAccess', _ => _.signal_type]) === "closing") {
1266
+ if (_optionalChain([signal, 'optionalAccess', _23 => _23.signal_type]) === "closing") {
766
1267
  void this.#handleDisconnect("server_signal_closing");
767
1268
  }
768
1269
  }
@@ -771,14 +1272,14 @@ var GrpcStreamClient = class {
771
1272
  const conversationId = String(_nullishCoalesce(bundle["anticipation_conversation_id"], () => ( "")));
772
1273
  if (bundleType !== UNCACHEABLE_BUNDLE_TYPE) this.#store(bundle, bundleType, conversationId);
773
1274
  if (bundleType === "compaction_update" && conversationId !== "") {
774
- _optionalChain([this, 'access', _2 => _2.#options, 'access', _3 => _3.onCompactionUpdate, 'optionalCall', _4 => _4(conversationId, bundle)]);
1275
+ _optionalChain([this, 'access', _24 => _24.#options, 'access', _25 => _25.onCompactionUpdate, 'optionalCall', _26 => _26(conversationId, bundle)]);
775
1276
  }
776
- _optionalChain([this, 'access', _5 => _5.#options, 'access', _6 => _6.onContext, 'optionalCall', _7 => _7(bundle)]);
1277
+ _optionalChain([this, 'access', _27 => _27.#options, 'access', _28 => _28.onContext, 'optionalCall', _29 => _29(bundle)]);
777
1278
  }
778
1279
  #store(bundle, bundleType, conversationId) {
779
1280
  const itemsByType = {};
780
1281
  for (const [type, list] of Object.entries(_nullishCoalesce(bundle["items_by_type"], () => ( {})))) {
781
- const items = _optionalChain([list, 'optionalAccess', _8 => _8.items]);
1282
+ const items = _optionalChain([list, 'optionalAccess', _30 => _30.items]);
782
1283
  itemsByType[type] = _nullishCoalesce(items, () => ( []));
783
1284
  }
784
1285
  const ttlHint = Number(_nullishCoalesce(bundle["ttl_hint_seconds"], () => ( 0)));
@@ -803,6 +1304,20 @@ var GrpcStreamClient = class {
803
1304
  ]
804
1305
  });
805
1306
  }
1307
+ /**
1308
+ * End the stream because the server refused it for credits.
1309
+ *
1310
+ * Reconnecting cannot clear a credit refusal, so the stream closes and the
1311
+ * typed error is kept on `lastError` for the application to read. Mirrors
1312
+ * Python's `_handle_credit_stop`.
1313
+ */
1314
+ #handleCreditStop(error) {
1315
+ this.#lastError = error;
1316
+ this.#shutdownRequested = true;
1317
+ this.#stopHeartbeat();
1318
+ this.#closeCall();
1319
+ this.#setState("disconnected");
1320
+ }
806
1321
  async #handleDisconnect(_reason) {
807
1322
  if (this.#shutdownRequested) return;
808
1323
  this.#closeCall();
@@ -820,7 +1335,11 @@ var GrpcStreamClient = class {
820
1335
  if (this.#shutdownRequested) return;
821
1336
  try {
822
1337
  await this.#establish();
823
- } catch (e2) {
1338
+ } catch (error) {
1339
+ if (isCreditStop(error)) {
1340
+ this.#handleCreditStop(error);
1341
+ return;
1342
+ }
824
1343
  void this.#handleDisconnect("reconnect_failed");
825
1344
  }
826
1345
  }
@@ -837,11 +1356,11 @@ var GrpcStreamClient = class {
837
1356
  }
838
1357
  try {
839
1358
  this.#call.write({ heartbeat_ping: { timestamp_ms: now } });
840
- } catch (e3) {
1359
+ } catch (e10) {
841
1360
  void this.#handleDisconnect("heartbeat_write_error");
842
1361
  }
843
1362
  }, HEARTBEAT_INTERVAL_MS);
844
- _optionalChain([this, 'access', _9 => _9.#heartbeatTimer, 'access', _10 => _10.unref, 'optionalCall', _11 => _11()]);
1363
+ _optionalChain([this, 'access', _31 => _31.#heartbeatTimer, 'access', _32 => _32.unref, 'optionalCall', _33 => _33()]);
845
1364
  }
846
1365
  #stopHeartbeat() {
847
1366
  if (this.#heartbeatTimer !== null) {
@@ -851,19 +1370,23 @@ var GrpcStreamClient = class {
851
1370
  }
852
1371
  #closeCall() {
853
1372
  try {
854
- _optionalChain([this, 'access', _12 => _12.#call, 'optionalAccess', _13 => _13.end, 'call', _14 => _14()]);
855
- } catch (e4) {
1373
+ _optionalChain([this, 'access', _34 => _34.#call, 'optionalAccess', _35 => _35.end, 'call', _36 => _36()]);
1374
+ } catch (e11) {
856
1375
  }
857
1376
  this.#call = null;
858
1377
  }
859
1378
  #setState(state) {
860
1379
  this.#state = state;
861
- _optionalChain([this, 'access', _15 => _15.#options, 'access', _16 => _16.onStateChange, 'optionalCall', _17 => _17(state)]);
1380
+ _optionalChain([this, 'access', _37 => _37.#options, 'access', _38 => _38.onStateChange, 'optionalCall', _39 => _39(state)]);
862
1381
  }
863
1382
  };
864
1383
 
865
1384
 
866
1385
 
867
1386
 
868
- exports.PROTO_SHA = PROTO_SHA; exports.GrpcStreamClient = GrpcStreamClient;
869
- //# sourceMappingURL=chunk-7CWBRHUO.cjs.map
1387
+
1388
+
1389
+
1390
+
1391
+ exports.PROTO_SHA = PROTO_SHA; exports.CREDIT_ABORT_DETAILS = CREDIT_ABORT_DETAILS; exports.CREDIT_STOP_REASONS = CREDIT_STOP_REASONS; exports.creditErrorFromRpc = creditErrorFromRpc; exports.isCreditStop = isCreditStop; exports.GrpcStreamClient = GrpcStreamClient;
1392
+ //# sourceMappingURL=chunk-WWTGJZA7.cjs.map