wontopos 2.2.35 → 2.2.36

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.
@@ -44,6 +44,9 @@
44
44
  export declare function _resetStoreIdWarnings(): void;
45
45
  export declare const SEARCH_LIMIT_MIN = 5;
46
46
  export declare const SEARCH_LIMIT_MAX = 20;
47
+ /** `recall`'s surrounding-context count. 0 to 20 inclusive; 0 attaches none. */
48
+ export declare const CONTEXT_LIMIT_MIN = 0;
49
+ export declare const CONTEXT_LIMIT_MAX = 20;
47
50
  /** Quota from a response's `X-RateLimit-*` headers, or `null` if absent. */
48
51
  export interface RateLimit {
49
52
  limit: number | null;
@@ -547,6 +550,22 @@ export interface ClientOptions {
547
550
  * key on an options object is not an error in TS at runtime). Both names work;
548
551
  * `maxRetries` wins if somehow both are given. */
549
552
  retries?: number;
553
+ /** A total budget for one call, in ms, across every attempt.
554
+ *
555
+ * `timeoutMs` bounds ONE attempt. At the defaults — 30s, two retries — a single
556
+ * call can hold a connection for 30s + backoff + 30s + backoff + 30s, over a
557
+ * minute, and a server handler awaiting it has no way to say "I only have five
558
+ * seconds". This is that way. Unset means no overall budget. */
559
+ deadlineMs?: number;
560
+ /** The caller's `AbortSignal` — cancel work already in flight.
561
+ *
562
+ * The SDK aborts on its own timeout; this is the seam for the caller's reason.
563
+ * When someone closes the chat window, the recall that window asked for should
564
+ * end, and the backoff sleep waiting to retry it should end too, instead of
565
+ * running to completion and billing for an answer nobody will read.
566
+ *
567
+ * Per call, clone: `mem.withSignal(ctrl.signal).recall(...)`. */
568
+ signal?: AbortSignal;
550
569
  /** The `fetch` used for every request (default: the global `fetch`).
551
570
  *
552
571
  * This is the seam for anything the runtime cannot express through options.
@@ -576,6 +595,8 @@ export declare class Client {
576
595
  private readonly apiKey;
577
596
  private readonly model;
578
597
  private readonly maxRetries;
598
+ private readonly deadlineMs?;
599
+ private readonly signal?;
579
600
  /** Transport. Held as a field (not read off `globalThis` per call) so a caller
580
601
  * who passes one gets it for every request, including from cloned clients. */
581
602
  private readonly fetchImpl;
@@ -603,6 +624,18 @@ export declare class Client {
603
624
  withModel(model: string): Client;
604
625
  /** A client bound to `userId` as its default store (everything else kept). */
605
626
  withUser(userId: string): Client;
627
+ /** A client bound to a caller's `AbortSignal` (everything else kept).
628
+ *
629
+ * const ctrl = new AbortController();
630
+ * req.on("close", () => ctrl.abort());
631
+ * const r = await mem.withSignal(ctrl.signal).recall(q, user);
632
+ *
633
+ * Aborting ends the request in flight AND any backoff sleep waiting to retry it. */
634
+ withSignal(signal: AbortSignal): Client;
635
+ /** A client with a total budget per call, across every retry (everything else
636
+ * kept). `mem.withDeadline(5_000).search(q)` inside a handler that has five
637
+ * seconds. */
638
+ withDeadline(deadlineMs: number): Client;
606
639
  /** A client with a different per-request timeout in ms (everything else kept).
607
640
  * `mem.withTimeout(120_000).addBulk(bigBlob)` — this slow call only. */
608
641
  withTimeout(timeoutMs: number): Client;
@@ -907,6 +940,31 @@ export declare class Client {
907
940
  * a non-2xx we re-read the body as text and reuse `errorFor`, which keeps 404
908
941
  * (`NotFoundError`) and 401 behaving the same as everywhere else.
909
942
  */
943
+ /** Where this call's budget runs out, or `undefined` when it has none. Computed
944
+ * once per call, not per attempt — a budget recomputed each attempt is not a
945
+ * budget. */
946
+ private deadlineAt;
947
+ /** The abort wiring for ONE attempt: this SDK's per-attempt timeout, the caller's
948
+ * signal, and whatever is left of the overall deadline — whichever fires first.
949
+ *
950
+ * One function, two callers (`request` and `requestBytes`), because a guard living
951
+ * in one of two request paths is exactly how `getImage` ended up with no retry loop
952
+ * while the module doc promised every call had one. */
953
+ private beginAttempt;
954
+ /** What to say when an attempt was cut short. The three reasons need different
955
+ * words: a timeout may have landed server-side, a cancellation certainly did not
956
+ * matter to the caller, and an exhausted budget is the caller's own setting. */
957
+ private abortMessage;
958
+ /** Sleep between attempts — but wake the moment the caller aborts, and give up
959
+ * rather than wait out a backoff the budget cannot cover. A backoff that runs to
960
+ * completion after the caller gave up wastes exactly as long as the request it was
961
+ * waiting to repeat.
962
+ *
963
+ * Clamping the wait to what is left and retrying anyway is the same answer as
964
+ * having no deadline at all: the call still spends the whole allowance, and the one
965
+ * attempt it buys has nothing left to finish in. If the wait does not fit, the
966
+ * budget is already decided. */
967
+ private backoffSleep;
910
968
  private requestBytes;
911
969
  /** Validate an idempotency key BEFORE the network. The API answers a malformed
912
970
  * key with a 400, which on a retry path reads as "my write failed" when in fact
package/dist/wontopos.js CHANGED
@@ -40,7 +40,7 @@
40
40
  * runtime's (never disabled by this SDK; Node 18+ floors TLS at 1.2). Prefer
41
41
  * `Client.fromEnv()` over keys in source code.
42
42
  */
43
- const VERSION = "2.2.35";
43
+ const VERSION = "2.2.36";
44
44
  /** Runtime info helps support debug a report ("node 18 on Windows...") —
45
45
  * platform only, never anything identifying. Browsers have no `process` (and
46
46
  * silently drop the UA header anyway). */
@@ -76,7 +76,7 @@ const RETRY_IF_IDEMPOTENT = new Set([502, 503]);
76
76
  const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
77
77
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
78
78
  /** Refuse to buffer absurd responses (real ones are a few KB) — protects the
79
- * process if a self-hosted baseUrl points somewhere broken or hostile. */
79
+ * process if a custom baseUrl points somewhere broken or hostile. */
80
80
  const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
81
81
  /** Model names travel in a header — only header-safe characters. */
82
82
  const MODEL_RE = /^[A-Za-z0-9._-]+$/;
@@ -154,25 +154,50 @@ const NO_BODY_STATUS = new Set([204, 205, 304]);
154
154
  const MAX_PAGES = 20_000;
155
155
  export const SEARCH_LIMIT_MIN = 5;
156
156
  export const SEARCH_LIMIT_MAX = 20;
157
- /** `search`'s count, refused out of range rather than quietly adjusted.
157
+ /** `recall`'s surrounding-context count. 0 to 20 inclusive; 0 attaches none. */
158
+ export const CONTEXT_LIMIT_MIN = 0;
159
+ export const CONTEXT_LIMIT_MAX = 20;
160
+ /** The 5-to-20 count shared by `search` and `recall`, refused out of range rather
161
+ * than quietly adjusted.
158
162
  *
159
- * `recall` has carried this contract from the start — 5 to 20, and the service
160
- * refuses anything else, because asking for 20 and silently getting 10 reads as
161
- * "that is all there is". Search had no contract at all: the three clients sent
162
- * whatever they were given, the MCP server allowed 1 to 60, and the service quietly
163
- * capped at 50 with no floor. Four surfaces, four answers, and the caller could not
164
- * tell which one they got.
163
+ * The service has refused anything else from the start, because asking for 20 and
164
+ * silently getting 10 reads as "that is all there is". Search had no contract at
165
+ * all: the three clients sent whatever they were given, the MCP server allowed 1 to
166
+ * 60, and the service quietly capped at 50 with no floor. Four surfaces, four
167
+ * answers, and the caller could not tell which one they got.
168
+ *
169
+ * ★ `recall` said it enforced this and did not. Its doc comment promised "out of
170
+ * range is refused, not clamped" while the value went straight to the wire in all
171
+ * three SDKs — so `limit: 500` travelled to the engine and died there. A comment
172
+ * that describes a guard is not a guard, and this is the shape of defect a reviewer
173
+ * reading one diff can never see: the promise and the missing code were written
174
+ * months apart.
175
+ *
176
+ * Thrown as a plain `Error`, like every other argument check in this file. It is
177
+ * NOT a `WosError` — nothing was sent, and `WosError` with status 0 means
178
+ * `APIConnectionError`, "the request never got a response". Reusing that status for
179
+ * a typo told a caller with `if (e.status === 0) retry()` to retry a bad argument
180
+ * forever.
165
181
  *
166
182
  * Refusing rather than clamping is the same decision as 2.2.35's, where a Rust
167
183
  * `limit` of 0 had been rewritten to 10 and the caller was handed ten memories they
168
184
  * had not asked for, and the bill for them. */
169
- function checkSearchLimit(limit) {
185
+ function checkCount(limit, name) {
170
186
  if (!Number.isInteger(limit) || limit < SEARCH_LIMIT_MIN || limit > SEARCH_LIMIT_MAX) {
171
- throw new WosError(0, `limit must be an integer between ${SEARCH_LIMIT_MIN} and ${SEARCH_LIMIT_MAX}, got ${limit}. ` +
187
+ throw new Error(`${name} must be an integer between ${SEARCH_LIMIT_MIN} and ${SEARCH_LIMIT_MAX}, got ${limit}. ` +
172
188
  `Out of range is refused rather than adjusted, so a short answer always means ` +
173
189
  `the store was short.`);
174
190
  }
175
191
  }
192
+ /** `recall`'s `context_limit`, 0 to 20. Same reason as the count above: the doc
193
+ * comment on `recall` promises out-of-range is refused, and a promise the client
194
+ * does not keep is worse than no promise — the caller reads the doc, sends 50, and
195
+ * the failure arrives from the service with no hint that the SDK knew all along. */
196
+ function checkContextLimit(n) {
197
+ if (!Number.isInteger(n) || n < CONTEXT_LIMIT_MIN || n > CONTEXT_LIMIT_MAX) {
198
+ throw new Error(`context_limit must be an integer between ${CONTEXT_LIMIT_MIN} and ${CONTEXT_LIMIT_MAX}, got ${n}.`);
199
+ }
200
+ }
176
201
  /**
177
202
  * Put an image into the shape the API expects, and fail loudly on the parts we can
178
203
  * check from here.
@@ -416,6 +441,8 @@ export class Client {
416
441
  apiKey;
417
442
  model;
418
443
  maxRetries;
444
+ deadlineMs;
445
+ signal;
419
446
  /** Transport. Held as a field (not read off `globalThis` per call) so a caller
420
447
  * who passes one gets it for every request, including from cloned clients. */
421
448
  fetchImpl;
@@ -459,6 +486,11 @@ export class Client {
459
486
  // "retries exhausted"; Infinity would retry forever.
460
487
  const mr = opts.maxRetries ?? opts.retries;
461
488
  this.maxRetries = typeof mr === "number" && Number.isFinite(mr) ? Math.max(0, Math.floor(mr)) : 2;
489
+ const dl = opts.deadlineMs;
490
+ // A zero or negative budget would fail every call before it starts — treat it as
491
+ // "no budget", the same way a non-positive timeout falls back to the default.
492
+ this.deadlineMs = typeof dl === "number" && Number.isFinite(dl) && dl > 0 ? dl : undefined;
493
+ this.signal = opts.signal;
462
494
  // Bind the global so `fetch` is not called as a method of `globalThis`, which
463
495
  // throws "Illegal invocation" on some runtimes. A caller-supplied fetch is
464
496
  // taken as given — it is already whatever they meant to hand us.
@@ -479,7 +511,7 @@ export class Client {
479
511
  if (this.model)
480
512
  this.headers["X-WOS-Model"] = this.model;
481
513
  // An API key on plain HTTP travels readable by anyone on the path. Loopback
482
- // is fine (local dev / self-hosted on the same box); anything else gets a
514
+ // is fine (local dev, or a proxy on the same box); anything else gets a
483
515
  // warning, not an error, so private-network gateways keep working.
484
516
  // Parse, don't split. `http://127.0.0.1:9@evil.example` splits to "127.0.0.1" — the
485
517
  // USERINFO, not the host — so this check called it loopback and stayed quiet while
@@ -488,7 +520,7 @@ export class Client {
488
520
  let host;
489
521
  try {
490
522
  // `URL.hostname` keeps the brackets on an IPv6 literal — `http://[::1]:8080`
491
- // gives back "[::1]", which was never in LOOPBACK_HOSTS, so a self-hosted
523
+ // gives back "[::1]", which was never in LOOPBACK_HOSTS, so a local
492
524
  // client on the v6 loopback was told its key was travelling in cleartext when
493
525
  // it was not. Python's urlsplit strips them and Rust strips them by hand; this
494
526
  // was the copy that did not. Strip them here so all three agree.
@@ -567,6 +599,11 @@ export class Client {
567
599
  // friends would silently fall back to the global fetch and a proxy-bound
568
600
  // client would stop connecting the moment it was cloned.
569
601
  fetch: this.fetchImpl,
602
+ // Carried for the same reason as `fetch` above: a clone that quietly dropped
603
+ // the caller's signal would keep working right up until the moment someone
604
+ // needed to cancel it.
605
+ deadlineMs: this.deadlineMs,
606
+ signal: this.signal,
570
607
  ...overrides,
571
608
  });
572
609
  }
@@ -582,6 +619,22 @@ export class Client {
582
619
  withUser(userId) {
583
620
  return this.clone({ userId });
584
621
  }
622
+ /** A client bound to a caller's `AbortSignal` (everything else kept).
623
+ *
624
+ * const ctrl = new AbortController();
625
+ * req.on("close", () => ctrl.abort());
626
+ * const r = await mem.withSignal(ctrl.signal).recall(q, user);
627
+ *
628
+ * Aborting ends the request in flight AND any backoff sleep waiting to retry it. */
629
+ withSignal(signal) {
630
+ return this.clone({ signal });
631
+ }
632
+ /** A client with a total budget per call, across every retry (everything else
633
+ * kept). `mem.withDeadline(5_000).search(q)` inside a handler that has five
634
+ * seconds. */
635
+ withDeadline(deadlineMs) {
636
+ return this.clone({ deadlineMs });
637
+ }
585
638
  /** A client with a different per-request timeout in ms (everything else kept).
586
639
  * `mem.withTimeout(120_000).addBulk(bigBlob)` — this slow call only. */
587
640
  withTimeout(timeoutMs) {
@@ -641,7 +694,7 @@ export class Client {
641
694
  // Reserved fields win over ...opts: an app that forwards untrusted input as
642
695
  // opts must not be able to override the store (user_id), query, or limit.
643
696
  warnOnUnknownFilters(opts.filters);
644
- checkSearchLimit(limit);
697
+ checkCount(limit, "limit");
645
698
  const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
646
699
  return mergeResults(r);
647
700
  }
@@ -652,7 +705,7 @@ export class Client {
652
705
  * that does not keep them apart, `self_memories` is `[]`. `userId` may be omitted. */
653
706
  async searchSelf(query, userId, limit = 10, opts = {}) {
654
707
  warnOnUnknownFilters(opts.filters);
655
- checkSearchLimit(limit);
708
+ checkCount(limit, "limit");
656
709
  const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
657
710
  return { memories: asRecords(r.memories), self_memories: asRecords(r.self_memories) };
658
711
  }
@@ -672,7 +725,7 @@ export class Client {
672
725
  */
673
726
  async searchFull(query, userId, limit = 10, opts = {}) {
674
727
  warnOnUnknownFilters(opts.filters);
675
- checkSearchLimit(limit);
728
+ checkCount(limit, "limit");
676
729
  const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
677
730
  // Spread first so a field added later still arrives; the known ones are then
678
731
  // normalized, because a broken proxy can null any of them.
@@ -686,12 +739,20 @@ export class Client {
686
739
  /** One-call LLM context: short-term turns + long-term matches + surrounding context.
687
740
  * `form` ("memoir"/"archive", Scroll 1.2+) renders each long-term memory's time in
688
741
  * that form; `tz` is your UTC-offset hours for that rendering. */
689
- recall(query, userId, opts = {}) {
742
+ // `async` so an argument mistake arrives the way every other failure does — a
743
+ // rejected promise. Validating inside a non-async method threw synchronously, so
744
+ // `mem.recall(...).catch(h)` walked straight past the handler while the identical
745
+ // mistake in `search` (which is async) landed in it. Two shapes for one error.
746
+ async recall(query, userId, opts = {}) {
690
747
  const body = { user_id: this.uid(userId), query };
691
- if (opts.limit !== undefined)
748
+ if (opts.limit !== undefined) {
749
+ checkCount(opts.limit, "limit");
692
750
  body.limit = opts.limit;
693
- if (opts.context_limit !== undefined)
751
+ }
752
+ if (opts.context_limit !== undefined) {
753
+ checkContextLimit(opts.context_limit);
694
754
  body.context_limit = opts.context_limit;
755
+ }
695
756
  return this.post("/api/v1/memory/recall", withForm(body, opts.form, opts.tz));
696
757
  }
697
758
  /** Run a built-in engram ("deep_recall" | "timeline" | "gather" | "equilibrium" |
@@ -761,7 +822,7 @@ export class Client {
761
822
  seen.add(next);
762
823
  cursor = next;
763
824
  }
764
- throw new WosError(0, `stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
825
+ throw new Error(`stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
765
826
  `answer, not the whole store.`);
766
827
  }
767
828
  /** Collect ALL of a store's memories into an array (the text you stored, and its metadata). */
@@ -868,7 +929,7 @@ export class Client {
868
929
  before = p.next_before;
869
930
  skipIds = Array.isArray(p.next_skip_ids) ? p.next_skip_ids : undefined;
870
931
  }
871
- throw new WosError(0, `stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
932
+ throw new Error(`stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
872
933
  `answer, not the whole store.`);
873
934
  }
874
935
  // ----- how much has this memory been edited -----
@@ -1087,6 +1148,101 @@ export class Client {
1087
1148
  * a non-2xx we re-read the body as text and reuse `errorFor`, which keeps 404
1088
1149
  * (`NotFoundError`) and 401 behaving the same as everywhere else.
1089
1150
  */
1151
+ /** Where this call's budget runs out, or `undefined` when it has none. Computed
1152
+ * once per call, not per attempt — a budget recomputed each attempt is not a
1153
+ * budget. */
1154
+ deadlineAt() {
1155
+ return this.deadlineMs === undefined ? undefined : Date.now() + this.deadlineMs;
1156
+ }
1157
+ /** The abort wiring for ONE attempt: this SDK's per-attempt timeout, the caller's
1158
+ * signal, and whatever is left of the overall deadline — whichever fires first.
1159
+ *
1160
+ * One function, two callers (`request` and `requestBytes`), because a guard living
1161
+ * in one of two request paths is exactly how `getImage` ended up with no retry loop
1162
+ * while the module doc promised every call had one. */
1163
+ beginAttempt(deadlineAt) {
1164
+ let why = "timeout";
1165
+ let budget = this.timeoutMs;
1166
+ if (deadlineAt !== undefined) {
1167
+ const left = deadlineAt - Date.now();
1168
+ // Refuse rather than open a socket there is no time to use. Python's
1169
+ // `_attempt_budget` and Rust's `attempt_budget` have raised here from the start.
1170
+ // This path instead clamped the budget to 0 and began the attempt anyway, which
1171
+ // left `setTimeout(…, 0)` — it fires on the next timer phase — racing a fetch
1172
+ // that a fast server answers first. The same call then sometimes reported its
1173
+ // deadline and sometimes returned a result: one run in three, measured.
1174
+ if (left <= 0)
1175
+ throw new APIConnectionError(0, this.abortMessage("deadline"));
1176
+ if (left < budget) {
1177
+ budget = left;
1178
+ why = "deadline";
1179
+ }
1180
+ }
1181
+ const ctrl = new AbortController();
1182
+ const timer = setTimeout(() => ctrl.abort(), budget);
1183
+ const outer = this.signal;
1184
+ const onCaller = () => {
1185
+ why = "caller";
1186
+ ctrl.abort();
1187
+ };
1188
+ if (outer) {
1189
+ if (outer.aborted)
1190
+ onCaller();
1191
+ // Removed in clear(). `{ once: true }` would not be enough: one long-lived
1192
+ // signal per user session would still collect one listener per request until
1193
+ // the session ended.
1194
+ else
1195
+ outer.addEventListener("abort", onCaller);
1196
+ }
1197
+ return {
1198
+ signal: ctrl.signal,
1199
+ clear: () => {
1200
+ clearTimeout(timer);
1201
+ outer?.removeEventListener("abort", onCaller);
1202
+ },
1203
+ why: () => why,
1204
+ };
1205
+ }
1206
+ /** What to say when an attempt was cut short. The three reasons need different
1207
+ * words: a timeout may have landed server-side, a cancellation certainly did not
1208
+ * matter to the caller, and an exhausted budget is the caller's own setting. */
1209
+ abortMessage(why) {
1210
+ if (why === "caller")
1211
+ return "cancelled — the caller's AbortSignal fired";
1212
+ if (why === "deadline")
1213
+ return `deadline of ${this.deadlineMs}ms exhausted`;
1214
+ return `request timed out after ${this.timeoutMs}ms`;
1215
+ }
1216
+ /** Sleep between attempts — but wake the moment the caller aborts, and give up
1217
+ * rather than wait out a backoff the budget cannot cover. A backoff that runs to
1218
+ * completion after the caller gave up wastes exactly as long as the request it was
1219
+ * waiting to repeat.
1220
+ *
1221
+ * Clamping the wait to what is left and retrying anyway is the same answer as
1222
+ * having no deadline at all: the call still spends the whole allowance, and the one
1223
+ * attempt it buys has nothing left to finish in. If the wait does not fit, the
1224
+ * budget is already decided. */
1225
+ async backoffSleep(ms, deadlineAt) {
1226
+ if (deadlineAt !== undefined && ms > deadlineAt - Date.now()) {
1227
+ throw new APIConnectionError(0, this.abortMessage("deadline"));
1228
+ }
1229
+ const outer = this.signal;
1230
+ if (!outer) {
1231
+ await new Promise((r) => setTimeout(r, ms));
1232
+ return;
1233
+ }
1234
+ if (outer.aborted)
1235
+ return;
1236
+ await new Promise((resolve) => {
1237
+ const done = () => {
1238
+ clearTimeout(timer);
1239
+ outer.removeEventListener("abort", done);
1240
+ resolve();
1241
+ };
1242
+ const timer = setTimeout(done, ms);
1243
+ outer.addEventListener("abort", done);
1244
+ });
1245
+ }
1090
1246
  async requestBytes(path, body) {
1091
1247
  // This route had no retry loop at all, while the module doc promised "every call
1092
1248
  // retries transient failures … 429 always". Measured with maxRetries: 4 against a
@@ -1095,32 +1251,32 @@ export class Client {
1095
1251
  // immediately. The loop lives inside the function because honouring `Retry-After`
1096
1252
  // means still holding the response.
1097
1253
  const attempts = this.maxRetries + 1;
1254
+ const deadlineAt = this.deadlineAt();
1098
1255
  for (let attempt = 0;; attempt++) {
1099
- const ctrl = new AbortController();
1100
- const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
1256
+ const att = this.beginAttempt(deadlineAt);
1101
1257
  let res;
1102
1258
  try {
1103
1259
  res = await this.fetchImpl(`${this.base}${path}`, {
1104
1260
  method: "POST",
1105
1261
  headers: this.headers,
1106
1262
  body: JSON.stringify(body),
1107
- signal: ctrl.signal,
1263
+ signal: att.signal,
1108
1264
  redirect: "manual",
1109
1265
  });
1110
1266
  }
1111
1267
  catch (e) {
1112
- clearTimeout(timer);
1113
- const timedOut = e?.name === "AbortError" || ctrl.signal.aborted;
1268
+ att.clear();
1269
+ const timedOut = e?.name === "AbortError" || att.signal.aborted;
1114
1270
  // Same rule as `request()`: a connect-level failure never reached the server,
1115
1271
  // so re-sending cannot double-process anything. The 429 loop below was added
1116
1272
  // in 2.2.35 and this branch was not, which left the module doc's promise —
1117
1273
  // "every call retries transient failures" — still false for `getImage` on
1118
1274
  // exactly the failure a retry is for. A timeout is ambiguous and stays final.
1119
1275
  if (!timedOut && isConnectFailure(e) && attempt + 1 < attempts) {
1120
- await new Promise((r) => setTimeout(r, this.backoffMs(attempt)));
1276
+ await this.backoffSleep(this.backoffMs(attempt), deadlineAt);
1121
1277
  continue;
1122
1278
  }
1123
- throw new APIConnectionError(0, timedOut ? `request timed out after ${this.timeoutMs}ms` : `network error: ${e?.message ?? e}`);
1279
+ throw new APIConnectionError(0, timedOut ? this.abortMessage(att.why()) : `network error: ${e?.message ?? e}`);
1124
1280
  }
1125
1281
  try {
1126
1282
  this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
@@ -1132,8 +1288,8 @@ export class Client {
1132
1288
  if (RETRY_ALWAYS.has(res.status) && attempt + 1 < attempts) {
1133
1289
  const retryAfter = res.headers.get("Retry-After");
1134
1290
  void res.body?.cancel();
1135
- clearTimeout(timer);
1136
- await new Promise((r) => setTimeout(r, this.backoffMs(attempt, retryAfter)));
1291
+ att.clear();
1292
+ await this.backoffSleep(this.backoffMs(attempt, retryAfter), deadlineAt);
1137
1293
  continue;
1138
1294
  }
1139
1295
  let msg = await this.readCapped(res).catch(() => "");
@@ -1161,7 +1317,7 @@ export class Client {
1161
1317
  buf = await this.readCappedBytes(res);
1162
1318
  }
1163
1319
  catch (e) {
1164
- if (e?.name === "AbortError" || ctrl.signal.aborted) {
1320
+ if (e?.name === "AbortError" || att.signal.aborted) {
1165
1321
  throw new APIConnectionError(0, `request timed out after ${this.timeoutMs}ms`);
1166
1322
  }
1167
1323
  throw e;
@@ -1174,7 +1330,7 @@ export class Client {
1174
1330
  return { bytes: buf, contentType: res.headers.get("content-type") ?? "application/octet-stream" };
1175
1331
  }
1176
1332
  finally {
1177
- clearTimeout(timer);
1333
+ att.clear();
1178
1334
  }
1179
1335
  }
1180
1336
  }
@@ -1221,7 +1377,7 @@ export class Client {
1221
1377
  async readCappedBytes(res) {
1222
1378
  const cl = res.headers.get("content-length");
1223
1379
  if (cl && Number(cl) > MAX_RESPONSE_BYTES) {
1224
- throw new WosError(0, `response too large (${cl} bytes) — refusing to buffer it`);
1380
+ throw new WosError(res.status, `response too large (${cl} bytes) — refusing to buffer it`);
1225
1381
  }
1226
1382
  if (!res.body) {
1227
1383
  // `arrayBuffer()` has no ceiling, and only the content-length pre-check stands
@@ -1231,7 +1387,7 @@ export class Client {
1231
1387
  // body-less Response with no content-length buffered 73MB past the 64MB cap.
1232
1388
  const buf = new Uint8Array(await res.arrayBuffer());
1233
1389
  if (buf.byteLength > MAX_RESPONSE_BYTES) {
1234
- throw new WosError(0, `response too large (${buf.byteLength} bytes) — refusing it`);
1390
+ throw new WosError(res.status, `response too large (${buf.byteLength} bytes) — refusing it`);
1235
1391
  }
1236
1392
  return buf;
1237
1393
  }
@@ -1245,7 +1401,7 @@ export class Client {
1245
1401
  size += value.byteLength;
1246
1402
  if (size > MAX_RESPONSE_BYTES) {
1247
1403
  void reader.cancel();
1248
- throw new WosError(0, "response too large — refusing to buffer it");
1404
+ throw new WosError(res.status, "response too large — refusing to buffer it");
1249
1405
  }
1250
1406
  parts.push(value);
1251
1407
  }
@@ -1263,26 +1419,26 @@ export class Client {
1263
1419
  // Debug logging shows the path WITHOUT its query string — a query can carry
1264
1420
  // a store id (listSpeakers), and logs must never carry data.
1265
1421
  const logPath = path.split("?")[0];
1422
+ const deadlineAt = this.deadlineAt();
1266
1423
  for (let attempt = 0; attempt < attempts; attempt++) {
1267
1424
  const start = Date.now();
1268
- const ctrl = new AbortController();
1269
- const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
1425
+ const att = this.beginAttempt(deadlineAt);
1270
1426
  let res;
1271
1427
  try {
1272
1428
  res = await this.fetchImpl(`${this.base}${path}`, {
1273
1429
  method,
1274
1430
  headers: extraHeaders ? { ...this.headers, ...extraHeaders } : this.headers,
1275
1431
  body: body === undefined ? undefined : JSON.stringify(body),
1276
- signal: ctrl.signal,
1432
+ signal: att.signal,
1277
1433
  // Never follow a redirect: fetch would forward the API key to
1278
1434
  // wherever a 3xx points. The API never legitimately redirects.
1279
1435
  redirect: "manual",
1280
1436
  });
1281
1437
  }
1282
1438
  catch (e) {
1283
- clearTimeout(timer);
1439
+ att.clear();
1284
1440
  // Timeouts are ambiguous (the write may have landed) — don't retry those.
1285
- const timedOut = e?.name === "AbortError" || ctrl.signal.aborted;
1441
+ const timedOut = e?.name === "AbortError" || att.signal.aborted;
1286
1442
  // Other network errors retry only when a retry can't double-process a
1287
1443
  // write: idempotent methods always; writes only for connect-level
1288
1444
  // failures (the request never reached the server). A mid-stream drop on
@@ -1291,24 +1447,24 @@ export class Client {
1291
1447
  if (!timedOut && safe && attempt + 1 < attempts) {
1292
1448
  const delay = this.backoffMs(attempt);
1293
1449
  logDebug(`${method} ${logPath}: ${e?.code ?? e?.name ?? "network error"} — retrying in ${delay}ms (attempt ${attempt + 1}/${attempts})`);
1294
- await new Promise((r) => setTimeout(r, delay));
1450
+ await this.backoffSleep(delay, deadlineAt);
1295
1451
  continue;
1296
1452
  }
1297
- throw new APIConnectionError(0, timedOut ? `request timed out after ${this.timeoutMs}ms` : `network error: ${e?.message ?? e}`);
1453
+ throw new APIConnectionError(0, timedOut ? this.abortMessage(att.why()) : `network error: ${e?.message ?? e}`);
1298
1454
  }
1299
1455
  const retryable = RETRY_ALWAYS.has(res.status) ||
1300
1456
  (RETRY_IF_IDEMPOTENT.has(res.status) && IDEMPOTENT_METHODS.has(method.toUpperCase()));
1301
1457
  if (retryable && attempt + 1 < attempts) {
1302
- clearTimeout(timer);
1458
+ att.clear();
1303
1459
  const retryAfter = res.headers.get("Retry-After");
1304
1460
  void res.body?.cancel();
1305
1461
  const delay = this.backoffMs(attempt, retryAfter);
1306
1462
  logDebug(`${method} ${logPath} -> ${res.status} — retrying in ${delay}ms (attempt ${attempt + 1}/${attempts})`);
1307
- await new Promise((r) => setTimeout(r, delay));
1463
+ await this.backoffSleep(delay, deadlineAt);
1308
1464
  continue;
1309
1465
  }
1310
1466
  if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
1311
- clearTimeout(timer);
1467
+ att.clear();
1312
1468
  void res.body?.cancel(); // release the stream — otherwise the connection leaks
1313
1469
  throw new WosError(res.status, "unexpected redirect — refused (the API key never follows a redirect). Check baseUrl: exact host, https://.");
1314
1470
  }
@@ -1321,7 +1477,7 @@ export class Client {
1321
1477
  text = await this.readCapped(res);
1322
1478
  }
1323
1479
  catch (e) {
1324
- if (e?.name === "AbortError" || ctrl.signal.aborted) {
1480
+ if (e?.name === "AbortError" || att.signal.aborted) {
1325
1481
  throw new APIConnectionError(0, `request timed out after ${this.timeoutMs}ms`);
1326
1482
  }
1327
1483
  if (e instanceof WosError)
@@ -1331,7 +1487,7 @@ export class Client {
1331
1487
  throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
1332
1488
  }
1333
1489
  finally {
1334
- clearTimeout(timer);
1490
+ att.clear();
1335
1491
  }
1336
1492
  logDebug(`${method} ${logPath} -> ${res.status} in ${Date.now() - start}ms (attempt ${attempt + 1}/${attempts})`);
1337
1493
  if (!res.ok) {
@@ -1392,12 +1548,17 @@ export class Client {
1392
1548
  // `Idempotent-Replayed: true`, and it answers the question someone using an
1393
1549
  // idempotency key is asking: did my retry write, or is this the first response
1394
1550
  // coming back again?
1395
- if (res.headers.get("Idempotent-Replayed") === "true") {
1551
+ //
1552
+ // Only when the body does not already carry the field. These responses are
1553
+ // widening — the next engine adds `images`, `hop`, `tip_of_tongue`, `policy` —
1554
+ // and a client that writes into a server's object is one release away from
1555
+ // overwriting a real answer with its own guess. What the service said wins.
1556
+ if (res.headers.get("Idempotent-Replayed") === "true" && !("replayed" in data)) {
1396
1557
  data.replayed = true;
1397
1558
  }
1398
1559
  return data;
1399
1560
  }
1400
- throw new WosError(0, "retries exhausted"); // unreachable; keeps the compiler happy
1561
+ throw new Error("retries exhausted"); // unreachable; keeps the compiler happy
1401
1562
  }
1402
1563
  }
1403
1564
  /** Back-compat alias: older code used `WME`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wontopos",
3
- "version": "2.2.35",
3
+ "version": "2.2.36",
4
4
  "description": "Wontopos — long-term memory for AI agents. Identical recall in every language, ~100× lower LLM bill.",
5
5
  "keywords": [
6
6
  "memory",
@@ -15,13 +15,14 @@
15
15
  "license": "MIT",
16
16
  "author": "Wontopos",
17
17
  "type": "module",
18
- "main": "./dist/wontopos.js",
18
+ "main": "./dist/cjs/wontopos.js",
19
19
  "module": "./dist/wontopos.js",
20
20
  "types": "./dist/wontopos.d.ts",
21
21
  "exports": {
22
22
  ".": {
23
23
  "types": "./dist/wontopos.d.ts",
24
24
  "import": "./dist/wontopos.js",
25
+ "require": "./dist/cjs/wontopos.js",
25
26
  "default": "./dist/wontopos.js"
26
27
  }
27
28
  },
@@ -34,8 +35,8 @@
34
35
  "node": ">=18"
35
36
  },
36
37
  "scripts": {
37
- "build": "tsc",
38
- "test": "npm run build && node --test tests/client.test.mjs",
38
+ "build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist/cjs/package.json','{\\\"type\\\":\\\"commonjs\\\"}\\n')\"",
39
+ "test": "npm run build && node --test tests/client.test.mjs tests/cjs.test.cjs",
39
40
  "prepublishOnly": "npm run build && node scripts/pre-publish.mjs"
40
41
  },
41
42
  "devDependencies": {