wontopos 2.2.36 → 2.2.38

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