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