@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
+ import {
2
+ InsufficientCreditsError,
3
+ RateLimitError,
4
+ newCorrelationId
5
+ } from "./chunk-BWMPKLFG.js";
6
+
1
7
  // src/grpc/descriptor.ts
2
- var PROTO_SHA = "a58c9b031b41530d";
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 import("fs");
674
+ } catch {
675
+ _fs = null;
676
+ }
677
+ _fsResolved = true;
678
+ })();
679
+ }
680
+ await _fsPending;
681
+ }
682
+ function defaultOutboxRoot() {
683
+ try {
684
+ const env = globalThis.process?.env;
685
+ const home = env?.["SYNAP_STORAGE_PATH"] ?? env?.["HOME"] ?? env?.["USERPROFILE"];
686
+ if (home !== void 0 && home !== "") {
687
+ return env?.["SYNAP_STORAGE_PATH"] !== void 0 && env["SYNAP_STORAGE_PATH"] !== "" ? env["SYNAP_STORAGE_PATH"] : `${home}/.synap`;
688
+ }
689
+ } catch {
690
+ }
691
+ return "";
692
+ }
693
+ var OutboxJournal = class {
694
+ path;
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 {
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 {
767
+ break;
768
+ }
769
+ lines += 1;
770
+ const id = rec.op === OP_QUEUED || rec.op === OP_UNACKED ? rec.e?.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 {
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 {
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
+ this.#fs?.mkdirSync(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 = metadataValue(metadata, "request-id") ?? correlationId;
887
+ const manageUrl = metadataValue(metadata, "credit-manage-url");
888
+ if (reason === "overages_disabled") {
889
+ return new 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 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 InsufficientCreditsError) && !(error instanceof 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
  host;
630
915
  port;
@@ -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 = options.useTls ?? true;
653
951
  this.#sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
654
952
  this.#connectTimeoutMs = options.connectTimeoutMs ?? 1e4;
953
+ this.#journal = new OutboxJournal(
954
+ 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 {
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 : newCorrelationId(),
1082
+ sent_at_ms: 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(evicted?.event.event_id);
1096
+ console.warn(
1097
+ `[synap] send queue full (max=${SEND_QUEUE_MAX_DEPTH}); dropping oldest event (event_type=${evicted?.event.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 && (this.#sendQueue[0]?.queuedAt ?? 0) < cutoff) {
1106
+ const expired = this.#sendQueue.shift();
1107
+ this.#journal.recordDropped(expired?.event.event_id);
1108
+ console.warn(
1109
+ `[synap] queued event expired (age>${SEND_QUEUE_MAX_AGE_MS}ms); dropping (event_type=${expired?.event.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,6 +1155,21 @@ 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 {
@@ -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 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;
@@ -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 {
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
  }
@@ -864,6 +1383,10 @@ var GrpcStreamClient = class {
864
1383
 
865
1384
  export {
866
1385
  PROTO_SHA,
1386
+ CREDIT_ABORT_DETAILS,
1387
+ CREDIT_STOP_REASONS,
1388
+ creditErrorFromRpc,
1389
+ isCreditStop,
867
1390
  GrpcStreamClient
868
1391
  };
869
- //# sourceMappingURL=chunk-T5QQBJ6T.js.map
1392
+ //# sourceMappingURL=chunk-DJPYUPSV.js.map