wontopos 2.2.35 → 2.2.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/wontopos.js CHANGED
@@ -1,56 +1,9 @@
1
- /**
2
- * Wontopos — long-term memory for AI, in three lines.
3
- *
4
- * npm install wontopos
5
- *
6
- * import { Client } from "wontopos";
7
- * const mem = new Client({ apiKey: "wos-...", userId: "alice" }); // set the store once
8
- * await mem.createStore(); // create it (uses the client's userId)
9
- * await mem.add("she prefers tea over coffee"); // no userId needed
10
- * const hits = await mem.search("what does alice drink?");
11
- *
12
- * Set `userId` once on the client and every call uses it; override any single call
13
- * by passing `userId` to it. Stores are explicit: the store must exist first
14
- * (`createStore`) or calls return 404. Every account starts with a `default` store,
15
- * so with no `userId` anywhere the zero-setup path just works.
16
- *
17
- * The API key picks *which memory* (your account); `model` picks *which engine*
18
- * reads it. Models on the shared pool read the same memory, so you can store with
19
- * one and recall with another; `listModels()` reports each model's `memory` as
20
- * `"shared"` or `"isolated"`, and an isolated one starts empty. Set a default in
21
- * the constructor, or override a single call with
22
- * `mem.withModel("tablet-1").recall(...)`.
23
- *
24
- * Recall quality does not depend on which language a memory was written in: a
25
- * memory stored in one language is found by a question asked in another. Storing
26
- * and searching call no LLM.
27
- *
28
- * Reliability: every call retries transient failures with exponential backoff +
29
- * jitter, honoring `Retry-After` — 429 always; 502/503 and connection errors only
30
- * when a retry can never double-process a write (idempotent calls, or a failure
31
- * at connect time). Tune with `maxRetries` (0 disables) and `timeoutMs`, or per
32
- * call site via the `withTimeout()` / `withRetries()` clones.
33
- *
34
- * Debugging: set `WONTOPOS_LOG=debug` to log method/path/status/timing/retries
35
- * to stderr — never memory content, request bodies, or the API key.
36
- *
37
- * Security posture (not configurable off): redirects are refused so the API key
38
- * can never follow one to another host; responses over 64MB are refused; the key
39
- * is masked in `toJSON`/inspect output. TLS certificate verification is the
40
- * runtime's (never disabled by this SDK; Node 18+ floors TLS at 1.2). Prefer
41
- * `Client.fromEnv()` over keys in source code.
42
- */
43
- const VERSION = "2.2.35";
44
- /** Runtime info helps support debug a report ("node 18 on Windows...") —
45
- * platform only, never anything identifying. Browsers have no `process` (and
46
- * silently drop the UA header anyway). */
1
+ const VERSION = "2.2.37";
47
2
  const RUNTIME = (() => {
48
3
  const p = globalThis?.process;
49
4
  return p?.version ? ` (node/${p.version}; ${p.platform}-${p.arch})` : "";
50
5
  })();
51
6
  const USER_AGENT = `wontopos-node/${VERSION}${RUNTIME}`;
52
- /** Wire-level debug logging: set WONTOPOS_LOG=debug. Logs method/path/status/
53
- * timing/retries to stderr — NEVER memory content, request bodies, or the key. */
54
7
  function logDebug(msg) {
55
8
  const env = globalThis?.process?.env;
56
9
  if (typeof env?.WONTOPOS_LOG === "string" && env.WONTOPOS_LOG.toLowerCase() === "debug") {
@@ -58,63 +11,19 @@ function logDebug(msg) {
58
11
  }
59
12
  }
60
13
  const DEFAULT_BASE_URL = "https://api.wontopos.com";
61
- /** The engine every call uses unless the caller names another.
62
- *
63
- * Tablet 2 costs the same per token as Tablet 1 and is the one that
64
- * serves images, re-ask passes (`verify`), and `self_memories`, so a caller who
65
- * names nothing gets the engine that can answer the most. Models on the shared
66
- * pool read the same memory, so switching between them is a header, not a
67
- * migration. Pin an older one explicitly with
68
- * `new Client({ apiKey, model: "tablet-1" })` or `withModel("tablet-1")`. */
69
14
  const DEFAULT_MODEL = "tablet-2";
70
- /** 429 = rate-limited BEFORE processing → always safe to retry (no write, no bill). */
71
15
  const RETRY_ALWAYS = new Set([429]);
72
- /** 502/503 are ambiguous for a write (gateway may return them AFTER the backend
73
- * processed + billed the request), so retry them only for idempotent methods —
74
- * a retried POST could double-store / double-bill. */
75
16
  const RETRY_IF_IDEMPOTENT = new Set([502, 503]);
76
17
  const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
77
18
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
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. */
80
19
  const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
81
- /** Model names travel in a header — only header-safe characters. */
82
20
  const MODEL_RE = /^[A-Za-z0-9._-]+$/;
83
- /** Cap a server-controlled error message so a hostile body can't blow up a log. */
84
21
  const MAX_ERR_MSG = 4096;
85
- /** What the API accepts as an `Idempotency-Key`. Checked client-side so a bad key
86
- * fails before the request instead of coming back as a 400 mid-retry. */
87
22
  const IDEMPOTENCY_KEY_RE = /^[A-Za-z0-9._:\-]{1,128}$/;
88
- /**
89
- * The API normalizes a store id: lowercased, and every character outside
90
- * `[a-z0-9_]` becomes `_`. So `Alice.Smith`, `alice-smith` and `alice_smith` are
91
- * ALL the same store.
92
- *
93
- * That is a data-exposure hazard for the most common way this SDK is used — one
94
- * store per end user. Two accounts whose ids differ only by punctuation or case
95
- * (`bob.lee@x.com` / `bob-lee@x.com`) silently share every memory, and nothing in
96
- * the response says so: the note only appears when `createStore` creates one, and
97
- * an app that reuses an existing store never sees it.
98
- *
99
- * We cannot refuse the id — the API accepts it, and callers may have written it
100
- * this way for a year. So we say it once, loudly, at the moment it happens.
101
- */
102
23
  function normalizeStoreId(id) {
103
24
  return id.toLowerCase().replace(/[^a-z0-9_]/g, "_");
104
25
  }
105
- /** Ids already warned about, so each is reported once.
106
- *
107
- * Bounded on purpose. The warning fires on ids that fold, and the shape it exists
108
- * to catch is an email — which this SDK's own documented pattern ("one store per
109
- * end user") turns into one entry PER USER, held for the life of the process and
110
- * never released, even when the client is. A server that forwards 50,000 distinct user
111
- * ids would grow this to 50,000 entries, leaking in exactly the case the warning
112
- * exists to catch. A `Set`
113
- * keeps insertion order, so the oldest entry is the one evicted; dropping the
114
- * oldest rather than refusing new ones means a collision that first shows up late
115
- * still gets its one warning. */
116
26
  const WARNED_STORE_IDS_MAX = 1024;
117
- /** Same cap, same reason, for the unknown-filter warn set. */
118
27
  const WARNED_FILTER_KEYS_MAX = 1024;
119
28
  const warnedStoreIds = new Set();
120
29
  function warnIfStoreIdCollapses(id) {
@@ -135,56 +44,27 @@ function warnIfStoreIdCollapses(id) {
135
44
  `punctuation share ONE store and therefore one set of memories — if these ids come from your ` +
136
45
  `end users, normalize them yourself first so two people can never collide.`);
137
46
  }
138
- /** Test hook — the warn-once set is process-global by design. */
139
47
  export function _resetStoreIdWarnings() {
140
48
  warnedStoreIds.clear();
141
49
  }
142
- /** Statuses that legitimately carry NO body (RFC 9110). Everything else must
143
- * answer with a JSON object — see the empty-body check in `request`. */
144
50
  const NO_BODY_STATUS = new Set([204, 205, 304]);
145
- /** Paging backstop: bound the walk so a fresh-cursor-forever server can't loop us. */
146
- // A page walk has to stop somewhere, and 1,000,000 pages was not a stop: at 100 per
147
- // page that is 100 million memories, so a server minting a fresh cursor every time
148
- // would spend hours and a million billed requests before it fired. 20,000 pages is two
149
- // million memories — past any real store, reached in minutes.
150
- //
151
- // ★And it has to be LOUD. Falling out of the loop yielded a truncated list that looks
152
- // exactly like a complete one; the caller writes it to a file believing it is the
153
- // whole store. Hitting this now throws.
154
51
  const MAX_PAGES = 20_000;
155
52
  export const SEARCH_LIMIT_MIN = 5;
156
53
  export const SEARCH_LIMIT_MAX = 20;
157
- /** `search`'s count, refused out of range rather than quietly adjusted.
158
- *
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.
165
- *
166
- * Refusing rather than clamping is the same decision as 2.2.35's, where a Rust
167
- * `limit` of 0 had been rewritten to 10 and the caller was handed ten memories they
168
- * had not asked for, and the bill for them. */
169
- function checkSearchLimit(limit) {
54
+ export const CONTEXT_LIMIT_MIN = 0;
55
+ export const CONTEXT_LIMIT_MAX = 20;
56
+ function checkCount(limit, name) {
170
57
  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}. ` +
58
+ throw new Error(`${name} must be an integer between ${SEARCH_LIMIT_MIN} and ${SEARCH_LIMIT_MAX}, got ${limit}. ` +
172
59
  `Out of range is refused rather than adjusted, so a short answer always means ` +
173
60
  `the store was short.`);
174
61
  }
175
62
  }
176
- /**
177
- * Put an image into the shape the API expects, and fail loudly on the parts we can
178
- * check from here.
179
- *
180
- * Deliberately NOT checked: the byte ceiling. It is a service setting, so a number
181
- * baked in here would drift the first time the service is reconfigured and would
182
- * reject an image the service would have taken.
183
- * What we do check is what cannot change: an empty payload, and a `data:` URL
184
- * prefix — browsers and file pickers hand you `data:image/jpeg;base64,…`, the API
185
- * wants the part after the comma, and the difference is invisible until the write
186
- * fails with "not a readable image".
187
- */
63
+ function checkContextLimit(n) {
64
+ if (!Number.isInteger(n) || n < CONTEXT_LIMIT_MIN || n > CONTEXT_LIMIT_MAX) {
65
+ throw new Error(`context_limit must be an integer between ${CONTEXT_LIMIT_MIN} and ${CONTEXT_LIMIT_MAX}, got ${n}.`);
66
+ }
67
+ }
188
68
  function normalizeImage(image) {
189
69
  if (!image || typeof image !== "object") {
190
70
  throw new Error("image must be an object — { data: '<base64>' }.");
@@ -192,15 +72,8 @@ function normalizeImage(image) {
192
72
  if (typeof image.data !== "string" || image.data.trim() === "") {
193
73
  throw new Error("image.data is required — base64 of the image (a data: URL is fine).");
194
74
  }
195
- // Trim before looking for the prefix. Checked against the raw string, one leading
196
- // space (` data:image/png;base64,…`) hides the prefix, and the literal
197
- // `data:image/png;base64,` travels as part of the base64. The engine answers 400
198
- // "image could not be read" and the caller has no way to tell why.
199
75
  const raw = image.data.trim();
200
76
  const comma = raw.startsWith("data:") ? raw.indexOf(",") : -1;
201
- // Strip whitespace in the middle as well. `base64` and `openssl base64` wrap at 76
202
- // columns, and trimming only the ends leaves those line breaks in the payload. The
203
- // base64 alphabet contains no whitespace, so removing all of it is safe.
204
77
  const data = (comma === -1 ? raw : raw.slice(comma + 1)).replace(/\s+/g, "");
205
78
  if (data === "")
206
79
  throw new Error("image.data is empty after stripping its data: prefix.");
@@ -211,10 +84,6 @@ function normalizeImage(image) {
211
84
  out.taken_at = image.taken_at;
212
85
  return out;
213
86
  }
214
- /** Error codes that only occur while ESTABLISHING a connection — the request
215
- * never reached the server, so a retry can't double-process a write. Mid-stream
216
- * codes (ECONNRESET, EPIPE, UND_ERR_SOCKET, ETIMEDOUT) are ambiguous: the server
217
- * may already have processed (and billed) the request, so they're excluded. */
218
87
  const CONNECT_FAIL_CODES = new Set([
219
88
  "ECONNREFUSED",
220
89
  "ENOTFOUND",
@@ -225,8 +94,6 @@ const CONNECT_FAIL_CODES = new Set([
225
94
  "EADDRNOTAVAIL",
226
95
  "UND_ERR_CONNECT_TIMEOUT",
227
96
  ]);
228
- /** Walk an error's `cause` chain (and AggregateError members — happy-eyeballs
229
- * connects) looking for a connect-level failure code. */
230
97
  function isConnectFailure(e) {
231
98
  const stack = [e];
232
99
  for (let steps = 0; stack.length && steps < 24; steps++) {
@@ -242,7 +109,6 @@ function isConnectFailure(e) {
242
109
  }
243
110
  return false;
244
111
  }
245
- /** `wos-abc...wxyz` — enough to tell keys apart, never enough to use. */
246
112
  function maskKey(key) {
247
113
  return key.length > 12 ? `${key.slice(0, 4)}...${key.slice(-4)}` : "***";
248
114
  }
@@ -250,7 +116,7 @@ function parseRateLimit(h) {
250
116
  const num = (name) => {
251
117
  const v = h.get(name);
252
118
  if (v == null || v.trim() === "")
253
- return null; // Number("") is 0 — treat blank as absent
119
+ return null;
254
120
  const n = Number(v);
255
121
  return Number.isFinite(n) ? n : null;
256
122
  };
@@ -261,30 +127,9 @@ function parseRateLimit(h) {
261
127
  return null;
262
128
  return { limit, remaining, reset };
263
129
  }
264
- // A list/object field the API promises. `x ?? []` only replaces null/undefined — a
265
- // broken or hostile server sending a truthy wrong type (`"memories": "oops"`) would
266
- // slip through as a string and blow up the caller's `for (…of…)`. Coerce anything
267
- // that isn't actually an array/object to the empty value.
268
130
  const asList = (x) => (Array.isArray(x) ? x : []);
269
131
  const asObj = (x) => (x != null && typeof x === "object" && !Array.isArray(x) ? x : {});
270
- // A list of API records (memories, turns, models...) — every element is an object
271
- // by contract. `asList` only fixes the CONTAINER: a hostile/broken server sending
272
- // `{"memories": [null, 1, "x", {...}]}` still handed the caller those non-objects,
273
- // typed as Memory[], and the first `m.content` threw a raw TypeError from inside
274
- // user code. Drop elements that aren't objects and keep the valid records, so one
275
- // bad element never poisons the batch (and never destroys it).
276
132
  const asRecords = (x) => asList(x).filter((m) => m != null && typeof m === "object" && !Array.isArray(m));
277
- /** Every memory a search returned, from both fields, as one array.
278
- *
279
- * Some models answer with the assistant's own words in `self_memories`, not
280
- * repeated in `memories`. `search()` used to return `memories` alone, so an
281
- * assistant turn stored with `addTurn` was missing from its results on those
282
- * models while the same query returned it on others — upgrading made search
283
- * return LESS, and what went missing had already been retrieved and paid for.
284
- *
285
- * Both fields are returned here, de-duplicated by id, each memory keeping its
286
- * `speaker` so a caller can still tell who said what. Callers who want them kept
287
- * apart use `searchSelf()`, which is what that method is for. */
288
133
  function mergeResults(r) {
289
134
  const main = asRecords(r?.memories);
290
135
  const mine = asRecords(r?.self_memories);
@@ -293,9 +138,6 @@ function mergeResults(r) {
293
138
  const seen = new Set(main.map((m) => m.id).filter((id) => typeof id === "string"));
294
139
  return main.concat(mine.filter((m) => !(typeof m.id === "string" && seen.has(m.id))));
295
140
  }
296
- /** Attach a delivery-form override ("memoir"/"archive") + tz to a request body, the
297
- * way search passes them; on Scroll 1.2+ this renders each returned memory's time in
298
- * that form. The server validates the form (400 on an unknown one). */
299
141
  function withForm(body, form, tz) {
300
142
  if (form !== undefined)
301
143
  body.form = form;
@@ -305,53 +147,30 @@ function withForm(body, form, tz) {
305
147
  }
306
148
  export class WosError extends Error {
307
149
  status;
308
- /** The server's id for the request when it sent one — include it when contacting support. */
309
150
  requestId;
310
151
  constructor(status, message, requestId) {
311
152
  super(`[${status}] ${message}${requestId ? ` (request_id: ${requestId})` : ""}`);
312
- this.name = new.target.name; // the concrete subclass name (RateLimitError, …)
153
+ this.name = new.target.name;
313
154
  this.status = status;
314
155
  this.requestId = requestId;
315
156
  }
316
157
  }
317
- // Typed subclasses so callers can branch on the failure — `catch (e) { if (e instanceof
318
- // RateLimitError) … }`. Each is a WosError, so a broad `instanceof WosError` still works.
319
- /** The request never got a response (DNS/TLS/timeout/connection). `status` is 0. */
320
158
  export class APIConnectionError extends WosError {
321
159
  }
322
- /** 400 — the request was malformed (bad arguments). */
323
160
  export class BadRequestError extends WosError {
324
161
  }
325
- /** 401 — the API key is missing, wrong, or revoked. */
326
162
  export class AuthenticationError extends WosError {
327
163
  }
328
- /** 402 — no card on file or the balance is depleted. Top up to continue. */
329
164
  export class PaymentRequiredError extends WosError {
330
165
  }
331
- /** 403 — the key/model isn't allowed to do this. */
332
166
  export class PermissionDeniedError extends WosError {
333
167
  }
334
- /** 404 — the store or resource doesn't exist (create the store first). */
335
168
  export class NotFoundError extends WosError {
336
169
  }
337
- /** 409 — a concurrent write to the same store. Retry. */
338
170
  export class ConflictError extends WosError {
339
171
  }
340
- /** 429 — too many requests. Back off and retry (the client already retries these). */
341
172
  export class RateLimitError extends WosError {
342
173
  }
343
- /**
344
- * 5xx — the service failed.
345
- *
346
- * `502` / `503` are transient: the client already retries them for calls where a
347
- * retry cannot double-process a write, and retrying yourself is reasonable.
348
- *
349
- * `501` is NOT transient. It means the engine behind the model you selected does
350
- * not implement that endpoint at all (the error carries `model` and `endpoint`).
351
- * Retrying can never succeed — pick a model that supports it (`listModels`) or
352
- * drop the call. Treating the whole 5xx range as retryable sends a caller into a
353
- * loop that cannot end.
354
- */
355
174
  export class ServerError extends WosError {
356
175
  }
357
176
  const STATUS_ERRORS = {
@@ -363,17 +182,12 @@ const STATUS_ERRORS = {
363
182
  409: ConflictError,
364
183
  429: RateLimitError,
365
184
  };
366
- /** Build the most specific WosError subclass for an HTTP status. */
367
- // Internal factory — the typed error CLASSES are the public surface.
368
185
  function errorFor(status, message, requestId) {
369
186
  const Cls = status === 0
370
187
  ? APIConnectionError
371
188
  : STATUS_ERRORS[status] ?? (status >= 500 && status < 600 ? ServerError : WosError);
372
189
  return new Cls(status, message, requestId);
373
190
  }
374
- /** The filter keys the API acts on. Anything else it DROPS silently, so a typo
375
- * widens the search instead of failing — we warn rather than reject, because the
376
- * API may grow a key before this package is updated. */
377
191
  const KNOWN_FILTER_KEYS = new Set([
378
192
  "categories",
379
193
  "event_from",
@@ -390,8 +204,6 @@ function warnOnUnknownFilters(filters) {
390
204
  if (KNOWN_FILTER_KEYS.has(k) || warnedFilterKeys.has(k))
391
205
  continue;
392
206
  warnedFilterKeys.add(k);
393
- // 2.2.27 capped the store-id warn set and left this sibling unbounded. An app that
394
- // forwards user-supplied filter keys grows it forever, one entry per distinct typo.
395
207
  while (warnedFilterKeys.size > WARNED_FILTER_KEYS_MAX) {
396
208
  const oldest = warnedFilterKeys.values().next().value;
397
209
  if (oldest === undefined)
@@ -403,12 +215,10 @@ function warnOnUnknownFilters(filters) {
403
215
  `Known keys: ${[...KNOWN_FILTER_KEYS].join(", ")}.`);
404
216
  }
405
217
  }
406
- /** Test hook — the warn-once set is process-global by design. */
407
218
  export function _resetFilterWarnings() {
408
219
  warnedFilterKeys.clear();
409
220
  }
410
221
  const DEFAULT_USER = "default";
411
- /** Wontopos memory client. Store and recall memories, isolated per `userId`. */
412
222
  export class Client {
413
223
  base;
414
224
  headers;
@@ -416,37 +226,24 @@ export class Client {
416
226
  apiKey;
417
227
  model;
418
228
  maxRetries;
419
- /** Transport. Held as a field (not read off `globalThis` per call) so a caller
420
- * who passes one gets it for every request, including from cloned clients. */
229
+ deadlineMs;
230
+ signal;
421
231
  fetchImpl;
422
- /** The store every call uses unless one passes `userId`. */
423
232
  defaultUser;
424
233
  _rateLimit = null;
425
- /** Quota from the MOST RECENT call: `{ limit, remaining, reset }` (or `null` before
426
- * the first call). Read it to self-throttle — the client already retries 429s, but
427
- * this lets you slow down before hitting the wall. */
428
234
  get rateLimit() {
429
235
  return this._rateLimit;
430
236
  }
431
237
  constructor(opts) {
432
- // Trim the key and reject inner whitespace — a stray newline from a file or
433
- // env var otherwise turns into a mystery 401 (or a mangled header).
434
238
  const key = (opts?.apiKey ?? "").trim();
435
239
  if (!key)
436
240
  throw new Error("apiKey is required");
437
241
  if (/\s/.test(key))
438
242
  throw new Error("apiKey contains whitespace - check for a stray newline or paste error");
439
- // `\s` does not cover NUL, 0x01 or DEL, and those travel into the header and fail
440
- // deep inside fetch as the mystery 401 this check exists to prevent. Python has
441
- // `_HEADER_CTL_RE` and Rust tests `is_control`; this was the client that did not,
442
- // while all three READMEs announced the guard without naming a language.
443
243
  if (/[\x00-\x1f\x7f]/.test(key))
444
244
  throw new Error("apiKey contains a control character - check for a stray byte or paste error");
445
245
  this.apiKey = key;
446
246
  this.base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
447
- // Coerce a non-finite or non-positive timeout back to the default — NaN
448
- // (e.g. Number() of an unset env var) would otherwise make the abort timer
449
- // fire immediately and every request "time out after NaNms".
450
247
  const tm = opts.timeoutMs;
451
248
  this.timeoutMs = typeof tm === "number" && Number.isFinite(tm) && tm > 0 ? tm : 30_000;
452
249
  this.model = opts.model ?? DEFAULT_MODEL;
@@ -454,14 +251,11 @@ export class Client {
454
251
  throw new Error(`invalid model name: ${JSON.stringify(this.model)} (letters, digits, '.', '_', '-' only)`);
455
252
  }
456
253
  this.defaultUser = opts.userId || DEFAULT_USER;
457
- // Coerce a non-finite maxRetries (NaN/Infinity) back to the default — otherwise
458
- // NaN makes `attempts = NaN + 1` and the loop runs zero requests then throws
459
- // "retries exhausted"; Infinity would retry forever.
460
254
  const mr = opts.maxRetries ?? opts.retries;
461
255
  this.maxRetries = typeof mr === "number" && Number.isFinite(mr) ? Math.max(0, Math.floor(mr)) : 2;
462
- // Bind the global so `fetch` is not called as a method of `globalThis`, which
463
- // throws "Illegal invocation" on some runtimes. A caller-supplied fetch is
464
- // taken as given — it is already whatever they meant to hand us.
256
+ const dl = opts.deadlineMs;
257
+ this.deadlineMs = typeof dl === "number" && Number.isFinite(dl) && dl > 0 ? dl : undefined;
258
+ this.signal = opts.signal;
465
259
  const f = opts.fetch;
466
260
  if (f !== undefined && typeof f !== "function") {
467
261
  throw new Error("fetch must be a function (a fetch-compatible transport)");
@@ -473,80 +267,37 @@ export class Client {
473
267
  this.headers = {
474
268
  "X-API-Key": this.apiKey,
475
269
  "Content-Type": "application/json",
476
- // Browsers silently drop User-Agent (forbidden header) — harmless.
477
270
  "User-Agent": USER_AGENT,
478
271
  };
479
272
  if (this.model)
480
273
  this.headers["X-WOS-Model"] = this.model;
481
- // 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
483
- // warning, not an error, so private-network gateways keep working.
484
- // Parse, don't split. `http://127.0.0.1:9@evil.example` splits to "127.0.0.1" — the
485
- // USERINFO, not the host — so this check called it loopback and stayed quiet while
486
- // the key travelled in cleartext to evil.example. `new URL()` knows the difference.
487
- // (Python already used urlsplit here; this was the copy that did not.)
488
274
  let host;
489
275
  try {
490
- // `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
492
- // client on the v6 loopback was told its key was travelling in cleartext when
493
- // it was not. Python's urlsplit strips them and Rust strips them by hand; this
494
- // was the copy that did not. Strip them here so all three agree.
495
276
  host = new URL(this.base).hostname.toLowerCase().replace(/^\[|\]$/g, "");
496
277
  }
497
278
  catch {
498
- host = ""; // unparseable → not loopback → warn, which is the safe direction
279
+ host = "";
499
280
  }
500
- // Scheme compare is case-insensitive — `HTTP://` connects in plaintext too.
501
281
  if (/^http:\/\//i.test(this.base) && !LOOPBACK_HOSTS.has(host)) {
502
282
  console.warn("wontopos: baseUrl uses plain HTTP on a non-local host, so the API key travels unencrypted. Use https://.");
503
283
  }
504
- // TypeScript `private` is erased at runtime: `apiKey` is an ordinary enumerable
505
- // property, so `{...client}` copies it and `JSON.stringify({...client})` prints the
506
- // key in full. The existing masking covers `JSON.stringify(client)` and
507
- // `util.inspect(client)` — but spreading an object into a log record is the common
508
- // shape in structured logging, and it went straight past both. Make the key and the
509
- // prepared auth header non-enumerable so a spread cannot pick them up; they stay
510
- // readable inside the class, which is all the code needs.
511
284
  Object.defineProperty(this, "apiKey", { enumerable: false });
512
285
  Object.defineProperty(this, "headers", { enumerable: false });
513
286
  }
514
- /** A client whose key comes from `WONTOPOS_API_KEY` (or `WOS_API_KEY`) — keeps
515
- * keys out of source code. Node/Bun/Deno-with-env only. */
516
287
  static fromEnv(opts = {}) {
517
288
  const env = globalThis?.process?.env;
518
- // First env var with a NON-EMPTY value wins — an empty WONTOPOS_API_KEY must
519
- // not shadow a valid WOS_API_KEY (`??` only skips null/undefined, not "").
520
289
  const key = [env?.WONTOPOS_API_KEY, env?.WOS_API_KEY].find((v) => v && v.trim());
521
290
  if (!key)
522
291
  throw new Error("set WONTOPOS_API_KEY (or WOS_API_KEY) in the environment");
523
- // apiKey LAST so a stray apiKey in opts can't override the env key.
524
292
  return new Client({ ...opts, apiKey: key });
525
293
  }
526
- /** Never expose the key: `JSON.stringify(client)` gets the redacted view. */
527
294
  toJSON() {
528
295
  return { baseUrl: this.base, model: this.model, userId: this.defaultUser, apiKey: maskKey(this.apiKey) };
529
296
  }
530
- /** Never expose the key: Node's `console.log(client)` gets the redacted view. */
531
297
  [Symbol.for("nodejs.util.inspect.custom")]() {
532
298
  return `Client(${this.base}, model=${this.model}, userId=${this.defaultUser}, apiKey=${maskKey(this.apiKey)})`;
533
299
  }
534
- /** Resolve a call's store: the explicit userId, else the client default. */
535
300
  uid(userId) {
536
- // An OMITTED id means "use the client's default" — that is the documented shortcut.
537
- // An id that was PASSED but is blank is a different thing: the caller computed a
538
- // tenant id and got nothing. Falling back there writes one customer's memories into
539
- // whatever store this client defaults to, silently. Omission is a choice; a blank
540
- // string is a bug, and it should say so where it happened.
541
- //
542
- // The check is `typeof !== "string"`, not `!String(userId).trim()`. The old form
543
- // only caught a blank STRING, and the value a failed lookup actually produces in
544
- // JavaScript is `null` — `String(null)` is the truthy "null", so it sailed through
545
- // the guard and then `null || default` sent the write to the client's default
546
- // store. `0` (an integer primary key) did the same. Both are the exact outcome the
547
- // guard was written to stop, and both were still silent.
548
- //
549
- // `undefined` alone means "omitted", because that is what an absent argument is.
550
301
  if (userId !== undefined && (typeof userId !== "string" || !userId.trim())) {
551
302
  throw new Error(`userId must be a non-blank string; got ${JSON.stringify(userId)}. Omit it to use the client's ` +
552
303
  "default store, or pass a real store id — anything else would silently write into the default store.");
@@ -563,119 +314,67 @@ export class Client {
563
314
  model: this.model,
564
315
  userId: this.defaultUser,
565
316
  maxRetries: this.maxRetries,
566
- // Carry the transport across clones. Without this, `withModel(...)` and
567
- // friends would silently fall back to the global fetch and a proxy-bound
568
- // client would stop connecting the moment it was cloned.
569
317
  fetch: this.fetchImpl,
318
+ deadlineMs: this.deadlineMs,
319
+ signal: this.signal,
570
320
  ...overrides,
571
321
  });
572
322
  }
573
- /**
574
- * A client that uses `model` (sent as `X-WOS-Model`). Use it to set the default
575
- * (`new Client({ apiKey, model })`) or to override one call
576
- * (`client.withModel("tablet-1").recall(...)`).
577
- */
578
323
  withModel(model) {
579
324
  return this.clone({ model });
580
325
  }
581
- /** A client bound to `userId` as its default store (everything else kept). */
582
326
  withUser(userId) {
583
327
  return this.clone({ userId });
584
328
  }
585
- /** A client with a different per-request timeout in ms (everything else kept).
586
- * `mem.withTimeout(120_000).addBulk(bigBlob)` — this slow call only. */
329
+ withSignal(signal) {
330
+ return this.clone({ signal });
331
+ }
332
+ withDeadline(deadlineMs) {
333
+ return this.clone({ deadlineMs });
334
+ }
587
335
  withTimeout(timeoutMs) {
588
336
  return this.clone({ timeoutMs });
589
337
  }
590
- /** A client with a different retry budget (everything else kept). 0 disables retries. */
591
338
  withRetries(maxRetries) {
592
339
  return this.clone({ maxRetries });
593
340
  }
594
- // ----- write -----
595
- // `userId` is optional in every call below - omit it to use the client's default
596
- // store (set via `new Client({ apiKey, userId })` or `withUser`).
597
- /** Store one memory. `metadata` optional — e.g. `{ event_date: "2026-03-01" }` for when the
598
- * content actually happened. A plain date works (read as UTC midnight); a full RFC3339
599
- * timestamp also works; older examples here show the long form.
600
- * `metadata.speaker`: "me" = the assistant's own words, or a person's name (up to 50 per store).
601
- *
602
- * `opts.idempotencyKey`: see {@link WriteOptions} — pass one to make YOUR retry
603
- * of this exact write safe to repeat. */
604
341
  add(content, userId, metadata = {}, opts = {}) {
605
342
  const body = { user_id: this.uid(userId), content, metadata };
606
343
  if (opts.image !== undefined)
607
344
  body.image = normalizeImage(opts.image);
608
345
  return this.post("/api/v1/memory/store", body, opts.idempotencyKey);
609
346
  }
610
- /** Alias of `add` — store one memory. Same surface as the Python SDK's `store`. */
611
347
  store(content, userId, metadata = {}, opts = {}) {
612
348
  return this.add(content, userId, metadata, opts);
613
349
  }
614
- /** Store a conversation turn (user + assistant). Payload first, userId last — same shape as add/search. */
615
350
  addTurn(userMsg, assistantMsg, userId, opts = {}) {
616
351
  return this.post("/api/v1/memory/store-turn", { user_id: this.uid(userId), user_msg: userMsg, assistant_msg: assistantMsg }, opts.idempotencyKey);
617
352
  }
618
- /** Bulk-ingest a large blob of text in one call. For backfilling.
619
- * The call most worth an `opts.idempotencyKey`: a backfill that dies halfway and is
620
- * re-run would otherwise re-ingest and re-bill the whole blob. */
621
353
  addBulk(content, userId, category = "general", timestamp, opts = {}) {
622
354
  const body = { user_id: this.uid(userId), content, category };
623
355
  if (timestamp)
624
356
  body.timestamp = timestamp;
625
357
  return this.post("/api/v1/memory/bulk-store", body, opts.idempotencyKey);
626
358
  }
627
- /** Supersede an old memory with new content. Payload first, userId last — same shape as add/search. */
628
359
  update(oldMemoryId, newContent, userId, opts = {}) {
629
360
  return this.post("/api/v1/memory/supersede", { user_id: this.uid(userId), old_memory_id: oldMemoryId, new_content: newContent }, opts.idempotencyKey);
630
361
  }
631
- // ----- read -----
632
- /** Search a store's memories. Returns them most relevant first.
633
- *
634
- * `limit` bounds `memories`, not the returned array. On a model that keeps the
635
- * assistant's own words separate (Scroll 1.2+) those come back as well, so the
636
- * array can hold more than `limit`. They were retrieved and billed either way;
637
- * dropping them would only hide what you already paid for. Size a prompt window
638
- * on the array you get back, not on `limit`. `searchSelf()` hands the two back
639
- * apart. */
640
362
  async search(query, userId, limit = 10, opts = {}) {
641
- // Reserved fields win over ...opts: an app that forwards untrusted input as
642
- // opts must not be able to override the store (user_id), query, or limit.
643
363
  warnOnUnknownFilters(opts.filters);
644
- checkSearchLimit(limit);
364
+ checkCount(limit, "limit");
645
365
  const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
646
366
  return mergeResults(r);
647
367
  }
648
- /** Search a self-memory model (Scroll 1.2+): both fields from ONE call. Returns
649
- * `{ memories, self_memories }` — `memories` is what others said and general
650
- * memories, `self_memories` is the assistant's OWN words (stored with speaker
651
- * "me"), kept apart so whoever reads them never confuses who said what. On a model
652
- * that does not keep them apart, `self_memories` is `[]`. `userId` may be omitted. */
653
368
  async searchSelf(query, userId, limit = 10, opts = {}) {
654
369
  warnOnUnknownFilters(opts.filters);
655
- checkSearchLimit(limit);
370
+ checkCount(limit, "limit");
656
371
  const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
657
372
  return { memories: asRecords(r.memories), self_memories: asRecords(r.self_memories) };
658
373
  }
659
- /**
660
- * Search, and keep every field the answer came with.
661
- *
662
- * Same request as {@link Client.search} — reach for this one when the options make
663
- * the merged array an incomplete answer:
664
- *
665
- * const r = await mem.searchFull("the day we moved", "alice", 10,
666
- * { max_images: 3, verify: 2 });
667
- * r.images.length; // the photos, which `search` drops
668
- * r.verify_used; // how many re-ask passes actually ran (you are billed per pass)
669
- *
670
- * Both options need Tablet 2 or newer and are refused (403) on an older engine
671
- * rather than accepted and ignored.
672
- */
673
374
  async searchFull(query, userId, limit = 10, opts = {}) {
674
375
  warnOnUnknownFilters(opts.filters);
675
- checkSearchLimit(limit);
376
+ checkCount(limit, "limit");
676
377
  const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
677
- // Spread first so a field added later still arrives; the known ones are then
678
- // normalized, because a broken proxy can null any of them.
679
378
  return {
680
379
  ...r,
681
380
  memories: asRecords(r.memories),
@@ -683,74 +382,49 @@ export class Client {
683
382
  images: asRecords(r.images),
684
383
  };
685
384
  }
686
- /** One-call LLM context: short-term turns + long-term matches + surrounding context.
687
- * `form` ("memoir"/"archive", Scroll 1.2+) renders each long-term memory's time in
688
- * that form; `tz` is your UTC-offset hours for that rendering. */
689
- recall(query, userId, opts = {}) {
385
+ async recall(query, userId, opts = {}) {
690
386
  const body = { user_id: this.uid(userId), query };
691
- if (opts.limit !== undefined)
387
+ if (opts.limit !== undefined) {
388
+ checkCount(opts.limit, "limit");
692
389
  body.limit = opts.limit;
693
- if (opts.context_limit !== undefined)
390
+ }
391
+ if (opts.context_limit !== undefined) {
392
+ checkContextLimit(opts.context_limit);
694
393
  body.context_limit = opts.context_limit;
394
+ }
695
395
  return this.post("/api/v1/memory/recall", withForm(body, opts.form, opts.tz));
696
396
  }
697
- /** Run a built-in engram ("deep_recall" | "timeline" | "gather" | "equilibrium" |
698
- * "tone_stabilizer"; the service is the authority — an unknown name comes back with
699
- * the list it accepts). Returns the merged result.
700
- * `form`/`tz` render memory times (memoir/archive) on Scroll 1.2+, same as search/recall. */
701
397
  engram(name, query, userId, opts = {}) {
702
398
  return this.post("/api/v1/engram/run", withForm({ name, user_id: this.uid(userId), query }, opts.form, opts.tz));
703
399
  }
704
- /** Recent conversation turns (short-term memory). */
705
400
  async history(userId) {
706
401
  const r = await this.post("/api/v1/memory/history", { user_id: this.uid(userId) });
707
402
  return asRecords(r.turns);
708
403
  }
709
- /** Memory counts for a store: { total_memories, short_term_turns }. */
710
404
  stats(userId) {
711
405
  return this.post("/api/v1/memory/stats", { user_id: this.uid(userId) });
712
406
  }
713
- /** Fetch ONE memory by id — the text you stored, and its metadata.
714
- * The id is what `add`/`store` or `listMemories` returned. Same visibility as
715
- * `listMemories`: an id from another store, an internal record id, or an
716
- * invalidated memory rejects with `NotFoundError`. Pass `undefined` as `userId` for
717
- * the default store — it is positional here, not omittable as it is in Python. */
407
+ async usage(days = 7) {
408
+ if (!Number.isInteger(days) || days < 1 || days > 365) {
409
+ throw new Error(`days must be an integer between 1 and 365, got ${days}.`);
410
+ }
411
+ return this.request("GET", `/api/v1/won/usage?days=${days}`);
412
+ }
718
413
  get(userId, memoryId) {
719
- // Sync throw (same shape as `delete`), so a missing id fails loudly even
720
- // when the caller forgets to await.
721
414
  if (!memoryId || typeof memoryId !== "string") {
722
415
  throw new Error("memoryId is required — the id that add/store or listMemories returned.");
723
416
  }
724
417
  return this.post("/api/v1/memory/get", { user_id: this.uid(userId), memory_id: memoryId }).then((r) => asObj(r.memory));
725
418
  }
726
- /** List a store's stored memories — the text you stored, plus its metadata.
727
- * Paginated: pass the returned `next_cursor` back as `cursor` for the next
728
- * page; a `null` cursor means the last page. Use it to browse or export a store.
729
- *
730
- * let cursor: string | null = null;
731
- * const all: Memory[] = [];
732
- * do {
733
- * const page = await mem.listMemories(undefined, { cursor: cursor ?? undefined });
734
- * all.push(...page.memories);
735
- * cursor = page.next_cursor;
736
- * } while (cursor);
737
- */
738
419
  listMemories(userId, opts = {}) {
739
420
  const body = { user_id: this.uid(userId), limit: opts.limit ?? 100 };
740
421
  if (opts.cursor)
741
422
  body.cursor = opts.cursor;
742
423
  return this.post("/api/v1/memory/list", body);
743
424
  }
744
- /** Async-iterate every stored memory in a store, paging under the hood — no cursor
745
- * bookkeeping. The text you stored and its metadata only.
746
- *
747
- * for await (const m of mem.iterMemories()) console.log(m.id, m.content);
748
- */
749
425
  async *iterMemories(userId, opts = {}) {
750
426
  let cursor;
751
427
  const seen = new Set();
752
- // Backstop: the cursor-repeat guard catches a repeated cursor, but not a
753
- // server that mints a FRESH cursor every page forever — bound the walk.
754
428
  for (let page = 0; page < MAX_PAGES; page++) {
755
429
  const p = await this.listMemories(userId, { limit: opts.pageSize ?? 100, cursor });
756
430
  for (const m of asRecords(p.memories))
@@ -761,56 +435,21 @@ export class Client {
761
435
  seen.add(next);
762
436
  cursor = next;
763
437
  }
764
- throw new WosError(0, `stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
438
+ throw new Error(`stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
765
439
  `answer, not the whole store.`);
766
440
  }
767
- /** Collect ALL of a store's memories into an array (the text you stored, and its metadata). */
768
441
  async exportMemories(userId) {
769
442
  const out = [];
770
443
  for await (const m of this.iterMemories(userId))
771
444
  out.push(m);
772
445
  return out;
773
446
  }
774
- // ----- images (Tablet 2 and newer) -----
775
- /**
776
- * Fetch the bytes of an image memory — the picture the SERVICE holds.
777
- *
778
- * Not necessarily your upload, in size OR in format: an image whose long edge was
779
- * over 1568px was downscaled to 1568 on the way in and re-encoded — lossless
780
- * formats as WebP, so a PNG comes back as `image/webp`; JPEG stays JPEG — and that
781
- * smaller picture is what is stored and comes back here. Nothing on our side ever
782
- * uses more than 1568, so the extra pixels would be bytes nobody reads.
783
- * Keep your own copy for the full-resolution file.
784
- *
785
- * The type is sniffed from the bytes themselves rather than from whatever the file
786
- * was called, so name the file from `contentType` rather than from what you sent.
787
- * When the format changed, the response also carries `x-wos-image-converted-from`
788
- * naming what you uploaded. Rejects with `NotFoundError` when this memory has no
789
- * image, or when the service keeps no image bytes at all — it says "no" instead of
790
- * handing back something empty, so "a memory with no image" never looks the same as
791
- * "an image we lost".
792
- *
793
- * const { bytes, contentType } = await mem.getImage(undefined, id);
794
- * const ext = contentType.split("/")[1]; // "webp" for a downscaled PNG
795
- * await fs.writeFile(`image.${ext}`, bytes);
796
- */
797
447
  getImage(userId, memoryId) {
798
448
  if (!memoryId || typeof memoryId !== "string") {
799
449
  throw new Error("memoryId is required — the id that add/store or listImages returned.");
800
450
  }
801
451
  return this.requestBytes("/api/v1/memory/image", { user_id: this.uid(userId), memory_id: memoryId });
802
452
  }
803
- /**
804
- * Remove the PHOTO from a memory, keeping its text.
805
- *
806
- * Except when there is no text: an image stored without a caption *is* the memory, so
807
- * deleting the image deletes it. That is the one case worth checking before you
808
- * commit, which is what `preview` is for — it reports `memory_kept` and changes
809
- * nothing.
810
- *
811
- * const p = await mem.forgetImage(undefined, id, { preview: true });
812
- * if (p.memory_kept === false) { /* this would delete the whole memory *\/ }
813
- */
814
453
  forgetImage(userId, memoryId, opts = {}) {
815
454
  if (!memoryId || typeof memoryId !== "string") {
816
455
  throw new Error("memoryId is required — the id of the memory whose image you want removed.");
@@ -820,15 +459,6 @@ export class Client {
820
459
  body.preview = true;
821
460
  return this.request("DELETE", "/api/v1/memory/image", body);
822
461
  }
823
- /**
824
- * List a store's image memories, newest first, and count them.
825
- *
826
- * `count` is the TOTAL in the store, not the size of the page — so you can show
827
- * "142 images" without walking every page. Paging is by cursor, not offset: hand
828
- * `next_before` and `next_skip_ids` back as `before` / `skipIds`. The pair exists
829
- * because several images can share a timestamp, and a timestamp alone would either
830
- * repeat them or skip them.
831
- */
832
462
  listImages(userId, opts = {}) {
833
463
  const body = { user_id: this.uid(userId) };
834
464
  if (opts.limit !== undefined)
@@ -839,28 +469,16 @@ export class Client {
839
469
  body.skip_ids = opts.skipIds;
840
470
  return this.post("/api/v1/memory/images", body);
841
471
  }
842
- /** Async-iterate every image memory, paging under the hood. */
843
472
  async *iterImages(userId, opts = {}) {
844
473
  let before;
845
474
  let skipIds;
846
- // Same repeat-cursor guard as `iterMemories`. A server that hands back the
847
- // cursor it was just given would otherwise re-yield one page MAX_PAGES times,
848
- // and the caller reads those repeats as more images. The page cap alone bounds
849
- // the walk; it does not stop the duplicates.
850
475
  const seen = new Set();
851
476
  for (let page = 0; page < MAX_PAGES; page++) {
852
477
  const p = await this.listImages(userId, { limit: opts.pageSize, before, skipIds });
853
478
  for (const m of asRecords(p.images))
854
479
  yield m;
855
- // `return`, not `break`: the throw below is the MAX_PAGES backstop, and a
856
- // `break` fell straight into it — so the ordinary end of a walk raised
857
- // "the store did not end" on a store that had just ended. Python is safe
858
- // here by using for/else and Rust by carrying an `ended` flag; TypeScript
859
- // has neither, so the exit has to leave the function outright.
860
480
  if (!p.has_more || !p.next_before)
861
481
  return;
862
- // The cursor is the PAIR: several images can share a timestamp, so `before`
863
- // alone repeats across pages legitimately.
864
482
  const key = `${p.next_before}|${(Array.isArray(p.next_skip_ids) ? p.next_skip_ids : []).join(",")}`;
865
483
  if (seen.has(key))
866
484
  return;
@@ -868,52 +486,9 @@ export class Client {
868
486
  before = p.next_before;
869
487
  skipIds = Array.isArray(p.next_skip_ids) ? p.next_skip_ids : undefined;
870
488
  }
871
- throw new WosError(0, `stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
489
+ throw new Error(`stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
872
490
  `answer, not the whole store.`);
873
491
  }
874
- // ----- how much has this memory been edited -----
875
- /**
876
- * How much of this store has been altered since it was written.
877
- *
878
- * This one is aimed at the MODEL, not at you. An assistant leaning on its own memory
879
- * should be able to ask how far that memory has been edited underneath it — a store
880
- * nobody has touched reads differently from one where a third of the facts were
881
- * rewritten. Free, and it never reaches the retrieval path.
882
- *
883
- * Counts memories a transform touched (supersede, update, retract, image removed).
884
- * Deletions are NOT counted: a deleted memory leaves nothing to count. Neither are
885
- * the internal records derived from what you stored — nobody stored those directly,
886
- * so they do not belong in a ratio that answers "how much of MY memory changed".
887
- *
888
- * By default this returns COUNTS ONLY, and the answer is the same size for a store
889
- * of a hundred memories and a store of a hundred million. That is deliberate: a model
890
- * asks this mid-conversation, and an answer that grew with the store would be
891
- * unusable for exactly the customers who most need to ask.
892
- *
893
- * To see WHICH memories, ask for one side and page through it — at most 20 per call,
894
- * and never both sides in one response:
895
- *
896
- * ```ts
897
- * const { revised, unrevised, total } = await mem.revisions(); // numbers only
898
- *
899
- * let before: string | undefined, skipIds: string[] | undefined;
900
- * for (;;) {
901
- * const p = await mem.revisions(undefined, { include: "revised", before, skipIds });
902
- * for (const m of p.memories ?? []) console.log(m.content);
903
- * if (!p.has_more || !p.next_before) break;
904
- * before = p.next_before;
905
- * skipIds = p.next_skip_ids;
906
- * }
907
- * ```
908
- *
909
- * Pages are ordered by when each memory was STORED, not by when it was edited.
910
- * The response says so in `ordered_by`.
911
- *
912
- * Served from `/api/v1/won/*`, not `/api/v1/memory/*`. Won is the surface for calls
913
- * a model makes ABOUT its memory rather than calls an application makes WITH it, and
914
- * the address says so. The old path still answers, for clients published before
915
- * 2026-08-18, and both share one rate-limit budget.
916
- */
917
492
  revisions(userId, opts = {}) {
918
493
  const body = {};
919
494
  if (opts.include !== undefined)
@@ -924,35 +499,15 @@ export class Client {
924
499
  body.before = opts.before;
925
500
  if (opts.skipIds !== undefined)
926
501
  body.skip_ids = opts.skipIds;
927
- // `user_id` LAST, after the caller's fields — an `opts` spread that can land on top
928
- // of it is how a client ends up reading another store. Same order as `search`.
929
502
  body.user_id = this.uid(userId);
930
503
  return this.post("/api/v1/won/revisions", body);
931
504
  }
932
- /**
933
- * The full chain of edits behind one memory, oldest first.
934
- *
935
- * `is_current` marks the version in force. Use it to answer "what did this used to
936
- * say, and when did it change" — `revisions()` tells you HOW MUCH a store moved,
937
- * this tells you what happened to one fact.
938
- */
939
505
  lineage(userId, memoryId) {
940
506
  if (!memoryId || typeof memoryId !== "string") {
941
507
  throw new Error("memoryId is required — the memory whose history you want.");
942
508
  }
943
509
  return this.post("/api/v1/memory/lineage", { user_id: this.uid(userId), memory_id: memoryId });
944
510
  }
945
- /**
946
- * What one person said, newest first.
947
- *
948
- * `speaker` is the tag written at store time (`metadata.speaker`) — `"me"` for the
949
- * assistant's own words, otherwise a person's name. Same cursor paging as
950
- * `listImages`.
951
- *
952
- * `chunks` / `points_to_delete` report how many internal records a delete would
953
- * actually remove — usually more than `returned`, and worth showing to whoever is
954
- * about to confirm one.
955
- */
956
511
  bySpeaker(speaker, userId, opts = {}) {
957
512
  if (!speaker || typeof speaker !== "string" || speaker.trim() === "") {
958
513
  throw new Error('speaker is required — "me" for the assistant, or a person\'s name.');
@@ -966,161 +521,148 @@ export class Client {
966
521
  body.skip_ids = opts.skipIds;
967
522
  return this.post("/api/v1/memory/by-speaker", body);
968
523
  }
969
- /** Check connectivity AND that the API key works. Resolves `true`, or rejects — an
970
- * `AuthenticationError` (bad key), `PaymentRequiredError` (valid key but no card /
971
- * depleted balance), or `APIConnectionError` (unreachable). Makes one metered request. */
972
524
  async ping() {
973
525
  await this.request("GET", "/api/v1/memory/collections");
974
526
  return true;
975
527
  }
976
- // ----- models -----
977
- /** Available models: `[{ id, name, available, memory }, ...]`. Needs no API key. */
978
528
  async listModels() {
979
529
  const data = await this.request("GET", "/api/v1/models");
980
530
  return asRecords(data.models);
981
531
  }
982
- // ----- engrams -----
983
- /**
984
- * The engrams (and delivery forms) the selected model can actually run.
985
- *
986
- * Ask rather than hard-code: a name copied from the docs freezes a caller to the
987
- * catalogue as it was that day, and anything added later stays invisible. The
988
- * service is the authority. What
989
- * comes back depends on the model (delivery forms need Scroll 1.2+), so pass
990
- * `withModel()` if you want another model's catalogue.
991
- *
992
- * const { engrams, forms } = await mem.listEngrams();
993
- */
994
532
  async listEngrams() {
995
533
  const data = await this.request("GET", "/api/v1/engram");
996
534
  return {
997
535
  engrams: asRecords(data.engrams),
998
536
  forms: asRecords(data.forms),
999
- // Present when the selected model has no engram support — the service explains
1000
- // rather than returning a bare empty list.
1001
537
  note: typeof data.note === "string" ? data.note : undefined,
1002
538
  };
1003
539
  }
1004
- // ----- stores -----
1005
- /** Create a store — the `userId` you read and write under. Stores are explicit:
1006
- * a store must exist before you `add` to or `search` it, otherwise those calls
1007
- * return 404. Idempotent. Every account starts with a `default` store.
1008
- * Returns `{ user_id, status }` (`status` is `"created"` or `"exists"`). */
1009
540
  createStore(userId) {
1010
541
  return this.post("/api/v1/memory/collection", { user_id: this.uid(userId) });
1011
542
  }
1012
- /** List your stores: `[{ user_id, created_at }, ...]` (`default` first). */
1013
543
  async listStores() {
1014
544
  const r = await this.request("GET", "/api/v1/memory/collections");
1015
545
  return asRecords(r.collections);
1016
546
  }
1017
- /** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
1018
547
  deleteStore(userId) {
1019
- // Validate before warning. The warning helper lowercases the id, so a non-string
1020
- // reaches it and dies as "id.toLowerCase is not a function" — a TypeError from
1021
- // inside the SDK instead of a sentence about the id.
1022
548
  if (typeof userId !== "string" || !userId.trim())
1023
549
  throw new Error("userId is required (a non-blank string) — deleteStore never falls back to the default store.");
1024
- warnIfStoreIdCollapses(userId); // destructive: the collision warning belongs here too
550
+ warnIfStoreIdCollapses(userId);
1025
551
  return this.request("DELETE", "/api/v1/memory/collection", { user_id: userId });
1026
552
  }
1027
- // ----- speakers (who said it) -----
1028
- /** Register a person for this store. Speakers are explicit: register once, then
1029
- * store with `{ speaker }`. `"me"` (the assistant itself) never needs
1030
- * registration. A store registers up to 50 people to start. */
1031
553
  addSpeaker(speaker, userId) {
1032
554
  return this.post("/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
1033
555
  }
1034
- /** The store's registered people, each with its memory count. */
1035
556
  async listSpeakers(userId) {
1036
557
  const r = await this.request("GET", `/api/v1/memory/speakers?user_id=${encodeURIComponent(this.uid(userId))}`);
1037
- // The spread came second, so a present-but-null `speakers` overwrote the default
1038
- // and the caller got null typed as an array. Every other list route goes through
1039
- // asRecords; this one did not.
1040
558
  return { ...r, speakers: asRecords(r?.speakers) };
1041
559
  }
1042
- /** Unregister a person. Their memories stay; the name tag goes. */
1043
560
  removeSpeaker(speaker, userId) {
1044
561
  return this.request("DELETE", "/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
1045
562
  }
1046
- // ----- delete -----
1047
- /** Delete a single memory by id. Pass `undefined` as `userId` for the default
1048
- * store — it is positional here, not omittable as it is in Python. */
1049
563
  delete(userId, memoryId) {
1050
- // Guard: without a memory_id the API's forget endpoint means "delete the
1051
- // whole store". An undefined/"" slipping in here must never become a wipe.
1052
- //
1053
- // `.trim()` matters as much as the emptiness check: " " is truthy, so without it
1054
- // a blank string passes the guard and travels as memory_id. A server that trims it
1055
- // back to nothing reads the request as the whole-store form, which is the most
1056
- // destructive way for this SDK to be wrong.
1057
564
  if (typeof memoryId !== "string" || !memoryId.trim()) {
1058
565
  throw new Error("memoryId is required (non-blank). To delete every memory in a store, call deleteAll(userId) explicitly.");
1059
566
  }
1060
567
  return this.post("/api/v1/memory/forget", { user_id: this.uid(userId), memory_id: memoryId.trim() });
1061
568
  }
1062
- /** Delete ALL memories for a store (GDPR erase). `userId` is required on purpose -
1063
- * this is destructive, so it never falls back to the default store. */
1064
569
  deleteAll(userId) {
1065
570
  if (typeof userId !== "string" || !userId.trim())
1066
571
  throw new Error("userId is required (a non-blank string) for deleteAll — anything else would wipe the default store.");
1067
- // These two take the store id directly instead of going through uid(), so the
1068
- // collision warning — the one that says `Alice.Smith` and `alice_smith` are ONE
1069
- // store — never fired on the two calls that DESTROY data. Erasing the wrong
1070
- // tenant's memories is exactly the outcome that warning exists to prevent.
1071
572
  warnIfStoreIdCollapses(userId);
1072
573
  return this.post("/api/v1/memory/forget", { user_id: userId });
1073
574
  }
1074
- // ----- internal -----
1075
575
  post(path, body, idempotencyKey) {
1076
576
  return this.request("POST", path, body, idempotencyKey);
1077
577
  }
1078
- /**
1079
- * One request that answers with BYTES rather than JSON.
1080
- *
1081
- * Only `/memory/image` does this, and it is why this cannot go through `request()`:
1082
- * that path reads the body as text and insists the result parses to a JSON object.
1083
- * Feeding it a JPEG would throw "invalid JSON in response" — a confusing error for a
1084
- * call that actually succeeded.
1085
- *
1086
- * Errors still arrive as JSON, so those are handed back to the normal machinery: on
1087
- * a non-2xx we re-read the body as text and reuse `errorFor`, which keeps 404
1088
- * (`NotFoundError`) and 401 behaving the same as everywhere else.
1089
- */
578
+ deadlineAt() {
579
+ return this.deadlineMs === undefined ? undefined : Date.now() + this.deadlineMs;
580
+ }
581
+ beginAttempt(deadlineAt) {
582
+ let why = "timeout";
583
+ let budget = this.timeoutMs;
584
+ if (deadlineAt !== undefined) {
585
+ const left = deadlineAt - Date.now();
586
+ if (left <= 0)
587
+ throw new APIConnectionError(0, this.abortMessage("deadline"));
588
+ if (left < budget) {
589
+ budget = left;
590
+ why = "deadline";
591
+ }
592
+ }
593
+ const ctrl = new AbortController();
594
+ const timer = setTimeout(() => ctrl.abort(), budget);
595
+ const outer = this.signal;
596
+ const onCaller = () => {
597
+ why = "caller";
598
+ ctrl.abort();
599
+ };
600
+ if (outer) {
601
+ if (outer.aborted)
602
+ onCaller();
603
+ else
604
+ outer.addEventListener("abort", onCaller);
605
+ }
606
+ return {
607
+ signal: ctrl.signal,
608
+ clear: () => {
609
+ clearTimeout(timer);
610
+ outer?.removeEventListener("abort", onCaller);
611
+ },
612
+ why: () => why,
613
+ };
614
+ }
615
+ abortMessage(why) {
616
+ if (why === "caller")
617
+ return "cancelled — the caller's AbortSignal fired";
618
+ if (why === "deadline")
619
+ return `deadline of ${this.deadlineMs}ms exhausted`;
620
+ return `request timed out after ${this.timeoutMs}ms`;
621
+ }
622
+ async backoffSleep(ms, deadlineAt) {
623
+ if (deadlineAt !== undefined && ms > deadlineAt - Date.now()) {
624
+ throw new APIConnectionError(0, this.abortMessage("deadline"));
625
+ }
626
+ const outer = this.signal;
627
+ if (!outer) {
628
+ await new Promise((r) => setTimeout(r, ms));
629
+ return;
630
+ }
631
+ if (outer.aborted)
632
+ return;
633
+ await new Promise((resolve) => {
634
+ const done = () => {
635
+ clearTimeout(timer);
636
+ outer.removeEventListener("abort", done);
637
+ resolve();
638
+ };
639
+ const timer = setTimeout(done, ms);
640
+ outer.addEventListener("abort", done);
641
+ });
642
+ }
1090
643
  async requestBytes(path, body) {
1091
- // This route had no retry loop at all, while the module doc promised "every call
1092
- // retries transient failures … 429 always". Measured with maxRetries: 4 against a
1093
- // server answering 429 — stats() made five requests, getImage() made one. The
1094
- // largest and most rate-limit-prone call in the SDK was the one that gave up
1095
- // immediately. The loop lives inside the function because honouring `Retry-After`
1096
- // means still holding the response.
1097
644
  const attempts = this.maxRetries + 1;
645
+ const deadlineAt = this.deadlineAt();
1098
646
  for (let attempt = 0;; attempt++) {
1099
- const ctrl = new AbortController();
1100
- const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
647
+ const att = this.beginAttempt(deadlineAt);
1101
648
  let res;
1102
649
  try {
1103
650
  res = await this.fetchImpl(`${this.base}${path}`, {
1104
651
  method: "POST",
1105
652
  headers: this.headers,
1106
653
  body: JSON.stringify(body),
1107
- signal: ctrl.signal,
654
+ signal: att.signal,
1108
655
  redirect: "manual",
1109
656
  });
1110
657
  }
1111
658
  catch (e) {
1112
- clearTimeout(timer);
1113
- const timedOut = e?.name === "AbortError" || ctrl.signal.aborted;
1114
- // Same rule as `request()`: a connect-level failure never reached the server,
1115
- // so re-sending cannot double-process anything. The 429 loop below was added
1116
- // in 2.2.35 and this branch was not, which left the module doc's promise —
1117
- // "every call retries transient failures" — still false for `getImage` on
1118
- // exactly the failure a retry is for. A timeout is ambiguous and stays final.
659
+ att.clear();
660
+ const timedOut = e?.name === "AbortError" || att.signal.aborted;
1119
661
  if (!timedOut && isConnectFailure(e) && attempt + 1 < attempts) {
1120
- await new Promise((r) => setTimeout(r, this.backoffMs(attempt)));
662
+ await this.backoffSleep(this.backoffMs(attempt), deadlineAt);
1121
663
  continue;
1122
664
  }
1123
- throw new APIConnectionError(0, timedOut ? `request timed out after ${this.timeoutMs}ms` : `network error: ${e?.message ?? e}`);
665
+ throw new APIConnectionError(0, timedOut ? this.abortMessage(att.why()) : `network error: ${e?.message ?? e}`);
1124
666
  }
1125
667
  try {
1126
668
  this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
@@ -1132,8 +674,8 @@ export class Client {
1132
674
  if (RETRY_ALWAYS.has(res.status) && attempt + 1 < attempts) {
1133
675
  const retryAfter = res.headers.get("Retry-After");
1134
676
  void res.body?.cancel();
1135
- clearTimeout(timer);
1136
- await new Promise((r) => setTimeout(r, this.backoffMs(attempt, retryAfter)));
677
+ att.clear();
678
+ await this.backoffSleep(this.backoffMs(attempt, retryAfter), deadlineAt);
1137
679
  continue;
1138
680
  }
1139
681
  let msg = await this.readCapped(res).catch(() => "");
@@ -1146,48 +688,32 @@ export class Client {
1146
688
  msg = err;
1147
689
  }
1148
690
  catch {
1149
- /* keep raw text */
1150
691
  }
1151
692
  if (msg.length > MAX_ERR_MSG)
1152
693
  msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
1153
694
  throw errorFor(res.status, msg || `HTTP ${res.status}`);
1154
695
  }
1155
- // Translated, not raw. A timeout that fires while the body is streaming used to
1156
- // escape as `AbortError` — not a WosError, not an APIConnectionError — so a
1157
- // caller's `catch (e) { if (e instanceof WosError) … }` handled every JSON route
1158
- // and let this one through unclassified. The JSON path already translated it.
1159
696
  let buf;
1160
697
  try {
1161
698
  buf = await this.readCappedBytes(res);
1162
699
  }
1163
700
  catch (e) {
1164
- if (e?.name === "AbortError" || ctrl.signal.aborted) {
1165
- throw new APIConnectionError(0, `request timed out after ${this.timeoutMs}ms`);
701
+ if (e?.name === "AbortError" || att.signal.aborted) {
702
+ throw new APIConnectionError(0, this.abortMessage(att.why()));
1166
703
  }
1167
704
  throw e;
1168
705
  }
1169
706
  if (buf.byteLength === 0) {
1170
- // An empty 200 would otherwise read as "here is your image" and write a
1171
- // zero-byte file — indistinguishable from an image we lost.
1172
707
  throw new WosError(res.status, "empty image body — the service returned no bytes");
1173
708
  }
1174
709
  return { bytes: buf, contentType: res.headers.get("content-type") ?? "application/octet-stream" };
1175
710
  }
1176
711
  finally {
1177
- clearTimeout(timer);
712
+ att.clear();
1178
713
  }
1179
714
  }
1180
715
  }
1181
- /** Validate an idempotency key BEFORE the network. The API answers a malformed
1182
- * key with a 400, which on a retry path reads as "my write failed" when in fact
1183
- * it was never attempted — cheaper and clearer to reject it here. */
1184
716
  idemHeader(key) {
1185
- // `=== undefined` let `null` through: RegExp.test stringifies its argument, so
1186
- // `null` became the literal key "null" and passed the format check. A key read
1187
- // from JSON or a database row arrives as null, not undefined, and every write on
1188
- // that path then shared one key — the second onward returned the first response
1189
- // and stored nothing. Python guards with `is None`; Rust's Option makes it
1190
- // unrepresentable.
1191
717
  if (key === undefined || key === null)
1192
718
  return undefined;
1193
719
  if (!IDEMPOTENCY_KEY_RE.test(key)) {
@@ -1195,43 +721,29 @@ export class Client {
1195
721
  }
1196
722
  return { "Idempotency-Key": key };
1197
723
  }
1198
- /** Seconds→ms to sleep before retry `attempt` (0-based). Honors Retry-After. */
1199
724
  backoffMs(attempt, retryAfter) {
1200
725
  if (retryAfter) {
1201
726
  const s = Number(retryAfter);
1202
727
  if (Number.isFinite(s) && s >= 0)
1203
728
  return Math.min(30_000, s * 1000);
1204
- // HTTP-date form (RFC 9110), e.g. "Wed, 21 Oct 2015 07:28:00 GMT".
1205
729
  const t = Date.parse(retryAfter);
1206
730
  if (Number.isFinite(t))
1207
731
  return Math.min(30_000, Math.max(0, t - Date.now()));
1208
732
  }
1209
733
  return Math.min(8_000, 500 * 2 ** attempt) + Math.random() * 250;
1210
734
  }
1211
- /** Read the body with a hard size cap, so a broken/hostile endpoint can't
1212
- * make the process buffer gigabytes. */
1213
735
  async readCapped(res) {
1214
736
  return new TextDecoder().decode(await this.readCappedBytes(res));
1215
737
  }
1216
- /** The same cap, for a body that is not text.
1217
- *
1218
- * `arrayBuffer()` would buffer whatever arrives. The cap exists for a hostile or
1219
- * broken `baseUrl`, and an endpoint that returns megabytes is where that costs the
1220
- * most. One implementation, two callers, so they cannot drift. */
1221
738
  async readCappedBytes(res) {
1222
739
  const cl = res.headers.get("content-length");
1223
740
  if (cl && Number(cl) > MAX_RESPONSE_BYTES) {
1224
- throw new WosError(0, `response too large (${cl} bytes) — refusing to buffer it`);
741
+ throw new WosError(res.status, `response too large (${cl} bytes) — refusing to buffer it`);
1225
742
  }
1226
743
  if (!res.body) {
1227
- // `arrayBuffer()` has no ceiling, and only the content-length pre-check stands
1228
- // in front of it — which a server simply omits. Reachable through the documented
1229
- // `fetch` seam (test doubles, polyfills, wrappers that rebuild the Response),
1230
- // and the option's own doc promises "the size cap still applies". Measured: a
1231
- // body-less Response with no content-length buffered 73MB past the 64MB cap.
1232
744
  const buf = new Uint8Array(await res.arrayBuffer());
1233
745
  if (buf.byteLength > MAX_RESPONSE_BYTES) {
1234
- throw new WosError(0, `response too large (${buf.byteLength} bytes) — refusing it`);
746
+ throw new WosError(res.status, `response too large (${buf.byteLength} bytes) — refusing it`);
1235
747
  }
1236
748
  return buf;
1237
749
  }
@@ -1245,7 +757,7 @@ export class Client {
1245
757
  size += value.byteLength;
1246
758
  if (size > MAX_RESPONSE_BYTES) {
1247
759
  void reader.cancel();
1248
- throw new WosError(0, "response too large — refusing to buffer it");
760
+ throw new WosError(res.status, "response too large — refusing to buffer it");
1249
761
  }
1250
762
  parts.push(value);
1251
763
  }
@@ -1260,84 +772,67 @@ export class Client {
1260
772
  async request(method, path, body, idempotencyKey) {
1261
773
  const extraHeaders = this.idemHeader(idempotencyKey);
1262
774
  const attempts = this.maxRetries + 1;
1263
- // Debug logging shows the path WITHOUT its query string — a query can carry
1264
- // a store id (listSpeakers), and logs must never carry data.
1265
775
  const logPath = path.split("?")[0];
776
+ const deadlineAt = this.deadlineAt();
1266
777
  for (let attempt = 0; attempt < attempts; attempt++) {
1267
778
  const start = Date.now();
1268
- const ctrl = new AbortController();
1269
- const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
779
+ const att = this.beginAttempt(deadlineAt);
1270
780
  let res;
1271
781
  try {
1272
782
  res = await this.fetchImpl(`${this.base}${path}`, {
1273
783
  method,
1274
784
  headers: extraHeaders ? { ...this.headers, ...extraHeaders } : this.headers,
1275
785
  body: body === undefined ? undefined : JSON.stringify(body),
1276
- signal: ctrl.signal,
1277
- // Never follow a redirect: fetch would forward the API key to
1278
- // wherever a 3xx points. The API never legitimately redirects.
786
+ signal: att.signal,
1279
787
  redirect: "manual",
1280
788
  });
1281
789
  }
1282
790
  catch (e) {
1283
- clearTimeout(timer);
1284
- // Timeouts are ambiguous (the write may have landed) — don't retry those.
1285
- const timedOut = e?.name === "AbortError" || ctrl.signal.aborted;
1286
- // Other network errors retry only when a retry can't double-process a
1287
- // write: idempotent methods always; writes only for connect-level
1288
- // failures (the request never reached the server). A mid-stream drop on
1289
- // a POST may already have stored + billed server-side.
791
+ att.clear();
792
+ const timedOut = e?.name === "AbortError" || att.signal.aborted;
1290
793
  const safe = IDEMPOTENT_METHODS.has(method.toUpperCase()) || isConnectFailure(e);
1291
794
  if (!timedOut && safe && attempt + 1 < attempts) {
1292
795
  const delay = this.backoffMs(attempt);
1293
796
  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));
797
+ await this.backoffSleep(delay, deadlineAt);
1295
798
  continue;
1296
799
  }
1297
- throw new APIConnectionError(0, timedOut ? `request timed out after ${this.timeoutMs}ms` : `network error: ${e?.message ?? e}`);
800
+ throw new APIConnectionError(0, timedOut ? this.abortMessage(att.why()) : `network error: ${e?.message ?? e}`);
1298
801
  }
1299
802
  const retryable = RETRY_ALWAYS.has(res.status) ||
1300
803
  (RETRY_IF_IDEMPOTENT.has(res.status) && IDEMPOTENT_METHODS.has(method.toUpperCase()));
1301
804
  if (retryable && attempt + 1 < attempts) {
1302
- clearTimeout(timer);
805
+ att.clear();
1303
806
  const retryAfter = res.headers.get("Retry-After");
1304
807
  void res.body?.cancel();
1305
808
  const delay = this.backoffMs(attempt, retryAfter);
1306
809
  logDebug(`${method} ${logPath} -> ${res.status} — retrying in ${delay}ms (attempt ${attempt + 1}/${attempts})`);
1307
- await new Promise((r) => setTimeout(r, delay));
810
+ await this.backoffSleep(delay, deadlineAt);
1308
811
  continue;
1309
812
  }
1310
813
  if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
1311
- clearTimeout(timer);
1312
- void res.body?.cancel(); // release the stream — otherwise the connection leaks
814
+ att.clear();
815
+ void res.body?.cancel();
1313
816
  throw new WosError(res.status, "unexpected redirect — refused (the API key never follows a redirect). Check baseUrl: exact host, https://.");
1314
817
  }
1315
818
  this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
1316
- // Keep the abort timer alive THROUGH the body read: fetch resolves on
1317
- // headers, so a server that sends headers then trickles/hangs the body
1318
- // would otherwise stall forever. An abort mid-body surfaces as a timeout.
1319
819
  let text;
1320
820
  try {
1321
821
  text = await this.readCapped(res);
1322
822
  }
1323
823
  catch (e) {
1324
- if (e?.name === "AbortError" || ctrl.signal.aborted) {
1325
- throw new APIConnectionError(0, `request timed out after ${this.timeoutMs}ms`);
824
+ if (e?.name === "AbortError" || att.signal.aborted) {
825
+ throw new APIConnectionError(0, this.abortMessage(att.why()));
1326
826
  }
1327
827
  if (e instanceof WosError)
1328
- throw e; // the size cap — already the right error
1329
- // A drop while READING the body is a transport failure — surface it as
1330
- // APIConnectionError, not a raw fetch TypeError. Ambiguous, never retried.
828
+ throw e;
1331
829
  throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
1332
830
  }
1333
831
  finally {
1334
- clearTimeout(timer);
832
+ att.clear();
1335
833
  }
1336
834
  logDebug(`${method} ${logPath} -> ${res.status} in ${Date.now() - start}ms (attempt ${attempt + 1}/${attempts})`);
1337
835
  if (!res.ok) {
1338
- // Server returns either Anthropic-style envelope
1339
- // {"type":"error","error":{"type":...,"message":...,"request_id":...}}
1340
- // or simple {"error":"reason"}. Fall back to raw text.
1341
836
  let msg = text;
1342
837
  let requestId;
1343
838
  try {
@@ -1356,17 +851,11 @@ export class Client {
1356
851
  }
1357
852
  }
1358
853
  catch {
1359
- /* keep raw text */
1360
854
  }
1361
- // Cap the FINAL message (a string `error` field can be as huge as the body),
1362
- // so a hostile server can't turn our exception/log into a giant string.
1363
855
  if (typeof msg === "string" && msg.length > MAX_ERR_MSG)
1364
856
  msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
1365
857
  throw errorFor(res.status, msg, requestId);
1366
858
  }
1367
- // An empty body is only legal when the STATUS says there is no body. Accepting
1368
- // any empty 2xx would make an `add()` answered by a truncating proxy return `{}`,
1369
- // which reads as a successful write with no id. All three SDKs apply this rule.
1370
859
  if (!text) {
1371
860
  if (NO_BODY_STATUS.has(res.status))
1372
861
  return {};
@@ -1377,29 +866,19 @@ export class Client {
1377
866
  data = JSON.parse(text);
1378
867
  }
1379
868
  catch (e) {
1380
- // A 2xx with a corrupt body is a real failure — surface it, don't leak
1381
- // a raw SyntaxError (parity with the Rust SDK).
1382
869
  throw new WosError(res.status, `invalid JSON in response: ${e?.message ?? e}`);
1383
870
  }
1384
- // Enforce a JSON OBJECT: every method reads the result with `.field`, so a
1385
- // body that parses to null / a number / a string / an array (a broken or
1386
- // hostile server) must be a clean WosError, not a `null.memories` TypeError.
1387
871
  if (data === null || typeof data !== "object" || Array.isArray(data)) {
1388
872
  const got = data === null ? "null" : Array.isArray(data) ? "array" : typeof data;
1389
873
  throw new WosError(res.status, `expected a JSON object in the response, got ${got}`);
1390
874
  }
1391
- // Surface whether the write was stored or replayed. The server says so with
1392
- // `Idempotent-Replayed: true`, and it answers the question someone using an
1393
- // idempotency key is asking: did my retry write, or is this the first response
1394
- // coming back again?
1395
- if (res.headers.get("Idempotent-Replayed") === "true") {
875
+ if (res.headers.get("Idempotent-Replayed") === "true" && !("replayed" in data)) {
1396
876
  data.replayed = true;
1397
877
  }
1398
878
  return data;
1399
879
  }
1400
- throw new WosError(0, "retries exhausted"); // unreachable; keeps the compiler happy
880
+ throw new Error("retries exhausted");
1401
881
  }
1402
882
  }
1403
- /** Back-compat alias: older code used `WME`. */
1404
883
  export const WME = Client;
1405
884
  export default Client;