wontopos 2.2.34 → 2.2.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1582 @@
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
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ 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
+ exports._resetStoreIdWarnings = _resetStoreIdWarnings;
47
+ 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). */
52
+ const RUNTIME = (() => {
53
+ const p = globalThis?.process;
54
+ return p?.version ? ` (node/${p.version}; ${p.platform}-${p.arch})` : "";
55
+ })();
56
+ 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
+ function logDebug(msg) {
60
+ const env = globalThis?.process?.env;
61
+ if (typeof env?.WONTOPOS_LOG === "string" && env.WONTOPOS_LOG.toLowerCase() === "debug") {
62
+ console.error(`wontopos: ${msg}`);
63
+ }
64
+ }
65
+ 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
+ const DEFAULT_MODEL = "tablet-2";
75
+ /** 429 = rate-limited BEFORE processing → always safe to retry (no write, no bill). */
76
+ 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
+ const RETRY_IF_IDEMPOTENT = new Set([502, 503]);
81
+ const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
82
+ 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
+ const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
86
+ /** Model names travel in a header — only header-safe characters. */
87
+ 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
+ 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
+ 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
+ function normalizeStoreId(id) {
108
+ return id.toLowerCase().replace(/[^a-z0-9_]/g, "_");
109
+ }
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
+ const WARNED_STORE_IDS_MAX = 1024;
122
+ /** Same cap, same reason, for the unknown-filter warn set. */
123
+ const WARNED_FILTER_KEYS_MAX = 1024;
124
+ const warnedStoreIds = new Set();
125
+ function warnIfStoreIdCollapses(id) {
126
+ if (!id || warnedStoreIds.has(id))
127
+ return;
128
+ const normalized = normalizeStoreId(id);
129
+ if (normalized === id)
130
+ return;
131
+ warnedStoreIds.add(id);
132
+ while (warnedStoreIds.size > WARNED_STORE_IDS_MAX) {
133
+ const oldest = warnedStoreIds.values().next().value;
134
+ if (oldest === undefined)
135
+ break;
136
+ warnedStoreIds.delete(oldest);
137
+ }
138
+ console.warn(`wontopos: store id ${JSON.stringify(id)} is stored as ${JSON.stringify(normalized)} ` +
139
+ `(lowercased, and anything outside [a-z0-9_] becomes "_"). Ids that differ only by case or ` +
140
+ `punctuation share ONE store and therefore one set of memories — if these ids come from your ` +
141
+ `end users, normalize them yourself first so two people can never collide.`);
142
+ }
143
+ /** Test hook — the warn-once set is process-global by design. */
144
+ function _resetStoreIdWarnings() {
145
+ warnedStoreIds.clear();
146
+ }
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
+ 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
+ const MAX_PAGES = 20_000;
160
+ exports.SEARCH_LIMIT_MIN = 5;
161
+ exports.SEARCH_LIMIT_MAX = 20;
162
+ /** `recall`'s surrounding-context count. 0 to 20 inclusive; 0 attaches none. */
163
+ exports.CONTEXT_LIMIT_MIN = 0;
164
+ 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
+ function checkCount(limit, name) {
191
+ if (!Number.isInteger(limit) || limit < exports.SEARCH_LIMIT_MIN || limit > exports.SEARCH_LIMIT_MAX) {
192
+ throw new Error(`${name} must be an integer between ${exports.SEARCH_LIMIT_MIN} and ${exports.SEARCH_LIMIT_MAX}, got ${limit}. ` +
193
+ `Out of range is refused rather than adjusted, so a short answer always means ` +
194
+ `the store was short.`);
195
+ }
196
+ }
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
+ function checkContextLimit(n) {
202
+ if (!Number.isInteger(n) || n < exports.CONTEXT_LIMIT_MIN || n > exports.CONTEXT_LIMIT_MAX) {
203
+ throw new Error(`context_limit must be an integer between ${exports.CONTEXT_LIMIT_MIN} and ${exports.CONTEXT_LIMIT_MAX}, got ${n}.`);
204
+ }
205
+ }
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
+ function normalizeImage(image) {
219
+ if (!image || typeof image !== "object") {
220
+ throw new Error("image must be an object — { data: '<base64>' }.");
221
+ }
222
+ if (typeof image.data !== "string" || image.data.trim() === "") {
223
+ throw new Error("image.data is required — base64 of the image (a data: URL is fine).");
224
+ }
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
+ const raw = image.data.trim();
230
+ 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
+ const data = (comma === -1 ? raw : raw.slice(comma + 1)).replace(/\s+/g, "");
235
+ if (data === "")
236
+ throw new Error("image.data is empty after stripping its data: prefix.");
237
+ const out = { data };
238
+ if (image.reference !== undefined)
239
+ out.reference = image.reference;
240
+ if (image.taken_at !== undefined)
241
+ out.taken_at = image.taken_at;
242
+ return out;
243
+ }
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
+ const CONNECT_FAIL_CODES = new Set([
249
+ "ECONNREFUSED",
250
+ "ENOTFOUND",
251
+ "EAI_AGAIN",
252
+ "EHOSTUNREACH",
253
+ "ENETUNREACH",
254
+ "ENETDOWN",
255
+ "EADDRNOTAVAIL",
256
+ "UND_ERR_CONNECT_TIMEOUT",
257
+ ]);
258
+ /** Walk an error's `cause` chain (and AggregateError members — happy-eyeballs
259
+ * connects) looking for a connect-level failure code. */
260
+ function isConnectFailure(e) {
261
+ const stack = [e];
262
+ for (let steps = 0; stack.length && steps < 24; steps++) {
263
+ const cur = stack.pop();
264
+ if (!cur || typeof cur !== "object")
265
+ continue;
266
+ if (typeof cur.code === "string" && CONNECT_FAIL_CODES.has(cur.code))
267
+ return true;
268
+ if (cur.cause)
269
+ stack.push(cur.cause);
270
+ if (Array.isArray(cur.errors))
271
+ stack.push(...cur.errors.slice(0, 8));
272
+ }
273
+ return false;
274
+ }
275
+ /** `wos-abc...wxyz` — enough to tell keys apart, never enough to use. */
276
+ function maskKey(key) {
277
+ return key.length > 12 ? `${key.slice(0, 4)}...${key.slice(-4)}` : "***";
278
+ }
279
+ function parseRateLimit(h) {
280
+ const num = (name) => {
281
+ const v = h.get(name);
282
+ if (v == null || v.trim() === "")
283
+ return null; // Number("") is 0 — treat blank as absent
284
+ const n = Number(v);
285
+ return Number.isFinite(n) ? n : null;
286
+ };
287
+ const limit = num("X-RateLimit-Limit");
288
+ const remaining = num("X-RateLimit-Remaining");
289
+ const reset = num("X-RateLimit-Reset");
290
+ if (limit === null && remaining === null && reset === null)
291
+ return null;
292
+ return { limit, remaining, reset };
293
+ }
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
+ const asList = (x) => (Array.isArray(x) ? x : []);
299
+ 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
+ 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
+ function mergeResults(r) {
319
+ const main = asRecords(r?.memories);
320
+ const mine = asRecords(r?.self_memories);
321
+ if (mine.length === 0)
322
+ return main;
323
+ const seen = new Set(main.map((m) => m.id).filter((id) => typeof id === "string"));
324
+ return main.concat(mine.filter((m) => !(typeof m.id === "string" && seen.has(m.id))));
325
+ }
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
+ function withForm(body, form, tz) {
330
+ if (form !== undefined)
331
+ body.form = form;
332
+ if (tz !== undefined)
333
+ body.tz = tz;
334
+ return body;
335
+ }
336
+ class WosError extends Error {
337
+ status;
338
+ /** The server's id for the request when it sent one — include it when contacting support. */
339
+ requestId;
340
+ constructor(status, message, requestId) {
341
+ super(`[${status}] ${message}${requestId ? ` (request_id: ${requestId})` : ""}`);
342
+ this.name = new.target.name; // the concrete subclass name (RateLimitError, …)
343
+ this.status = status;
344
+ this.requestId = requestId;
345
+ }
346
+ }
347
+ 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
+ class APIConnectionError extends WosError {
352
+ }
353
+ exports.APIConnectionError = APIConnectionError;
354
+ /** 400 — the request was malformed (bad arguments). */
355
+ class BadRequestError extends WosError {
356
+ }
357
+ exports.BadRequestError = BadRequestError;
358
+ /** 401 — the API key is missing, wrong, or revoked. */
359
+ class AuthenticationError extends WosError {
360
+ }
361
+ exports.AuthenticationError = AuthenticationError;
362
+ /** 402 — no card on file or the balance is depleted. Top up to continue. */
363
+ class PaymentRequiredError extends WosError {
364
+ }
365
+ exports.PaymentRequiredError = PaymentRequiredError;
366
+ /** 403 — the key/model isn't allowed to do this. */
367
+ class PermissionDeniedError extends WosError {
368
+ }
369
+ exports.PermissionDeniedError = PermissionDeniedError;
370
+ /** 404 — the store or resource doesn't exist (create the store first). */
371
+ class NotFoundError extends WosError {
372
+ }
373
+ exports.NotFoundError = NotFoundError;
374
+ /** 409 — a concurrent write to the same store. Retry. */
375
+ class ConflictError extends WosError {
376
+ }
377
+ exports.ConflictError = ConflictError;
378
+ /** 429 — too many requests. Back off and retry (the client already retries these). */
379
+ class RateLimitError extends WosError {
380
+ }
381
+ 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
+ class ServerError extends WosError {
395
+ }
396
+ exports.ServerError = ServerError;
397
+ const STATUS_ERRORS = {
398
+ 400: BadRequestError,
399
+ 401: AuthenticationError,
400
+ 402: PaymentRequiredError,
401
+ 403: PermissionDeniedError,
402
+ 404: NotFoundError,
403
+ 409: ConflictError,
404
+ 429: RateLimitError,
405
+ };
406
+ /** Build the most specific WosError subclass for an HTTP status. */
407
+ // Internal factory — the typed error CLASSES are the public surface.
408
+ function errorFor(status, message, requestId) {
409
+ const Cls = status === 0
410
+ ? APIConnectionError
411
+ : STATUS_ERRORS[status] ?? (status >= 500 && status < 600 ? ServerError : WosError);
412
+ return new Cls(status, message, requestId);
413
+ }
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
+ const KNOWN_FILTER_KEYS = new Set([
418
+ "categories",
419
+ "event_from",
420
+ "event_to",
421
+ "time_from",
422
+ "time_to",
423
+ "min_importance",
424
+ ]);
425
+ const warnedFilterKeys = new Set();
426
+ function warnOnUnknownFilters(filters) {
427
+ if (!filters || typeof filters !== "object" || Array.isArray(filters))
428
+ return;
429
+ for (const k of Object.keys(filters)) {
430
+ if (KNOWN_FILTER_KEYS.has(k) || warnedFilterKeys.has(k))
431
+ continue;
432
+ 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
+ while (warnedFilterKeys.size > WARNED_FILTER_KEYS_MAX) {
436
+ const oldest = warnedFilterKeys.values().next().value;
437
+ if (oldest === undefined)
438
+ break;
439
+ warnedFilterKeys.delete(oldest);
440
+ }
441
+ console.warn(`wontopos: unknown search filter ${JSON.stringify(k)} — the API drops keys it does not know, ` +
442
+ `so this filter has NO effect and the search is wider than you think. ` +
443
+ `Known keys: ${[...KNOWN_FILTER_KEYS].join(", ")}.`);
444
+ }
445
+ }
446
+ /** Test hook — the warn-once set is process-global by design. */
447
+ function _resetFilterWarnings() {
448
+ warnedFilterKeys.clear();
449
+ }
450
+ const DEFAULT_USER = "default";
451
+ /** Wontopos memory client. Store and recall memories, isolated per `userId`. */
452
+ class Client {
453
+ base;
454
+ headers;
455
+ timeoutMs;
456
+ apiKey;
457
+ model;
458
+ maxRetries;
459
+ deadlineMs;
460
+ 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
+ fetchImpl;
464
+ /** The store every call uses unless one passes `userId`. */
465
+ defaultUser;
466
+ _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
+ get rateLimit() {
471
+ return this._rateLimit;
472
+ }
473
+ 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
+ const key = (opts?.apiKey ?? "").trim();
477
+ if (!key)
478
+ throw new Error("apiKey is required");
479
+ if (/\s/.test(key))
480
+ 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
+ if (/[\x00-\x1f\x7f]/.test(key))
486
+ throw new Error("apiKey contains a control character - check for a stray byte or paste error");
487
+ this.apiKey = key;
488
+ 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
+ const tm = opts.timeoutMs;
493
+ this.timeoutMs = typeof tm === "number" && Number.isFinite(tm) && tm > 0 ? tm : 30_000;
494
+ this.model = opts.model ?? DEFAULT_MODEL;
495
+ if (this.model && !MODEL_RE.test(this.model)) {
496
+ throw new Error(`invalid model name: ${JSON.stringify(this.model)} (letters, digits, '.', '_', '-' only)`);
497
+ }
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.
502
+ const mr = opts.maxRetries ?? opts.retries;
503
+ this.maxRetries = typeof mr === "number" && Number.isFinite(mr) ? Math.max(0, Math.floor(mr)) : 2;
504
+ 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
+ this.deadlineMs = typeof dl === "number" && Number.isFinite(dl) && dl > 0 ? dl : undefined;
508
+ 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
+ const f = opts.fetch;
513
+ if (f !== undefined && typeof f !== "function") {
514
+ throw new Error("fetch must be a function (a fetch-compatible transport)");
515
+ }
516
+ this.fetchImpl = f ?? (globalThis.fetch ? globalThis.fetch.bind(globalThis) : undefined);
517
+ if (typeof this.fetchImpl !== "function") {
518
+ throw new Error("no fetch available in this runtime — pass one as `fetch` (Node 18+ has it built in; older Node needs undici)");
519
+ }
520
+ this.headers = {
521
+ "X-API-Key": this.apiKey,
522
+ "Content-Type": "application/json",
523
+ // Browsers silently drop User-Agent (forbidden header) — harmless.
524
+ "User-Agent": USER_AGENT,
525
+ };
526
+ if (this.model)
527
+ 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
+ let host;
536
+ 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
+ host = new URL(this.base).hostname.toLowerCase().replace(/^\[|\]$/g, "");
543
+ }
544
+ catch {
545
+ host = ""; // unparseable → not loopback → warn, which is the safe direction
546
+ }
547
+ // Scheme compare is case-insensitive — `HTTP://` connects in plaintext too.
548
+ if (/^http:\/\//i.test(this.base) && !LOOPBACK_HOSTS.has(host)) {
549
+ console.warn("wontopos: baseUrl uses plain HTTP on a non-local host, so the API key travels unencrypted. Use https://.");
550
+ }
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
+ Object.defineProperty(this, "apiKey", { enumerable: false });
559
+ Object.defineProperty(this, "headers", { enumerable: false });
560
+ }
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
+ static fromEnv(opts = {}) {
564
+ 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
+ const key = [env?.WONTOPOS_API_KEY, env?.WOS_API_KEY].find((v) => v && v.trim());
568
+ if (!key)
569
+ 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
+ return new Client({ ...opts, apiKey: key });
572
+ }
573
+ /** Never expose the key: `JSON.stringify(client)` gets the redacted view. */
574
+ toJSON() {
575
+ return { baseUrl: this.base, model: this.model, userId: this.defaultUser, apiKey: maskKey(this.apiKey) };
576
+ }
577
+ /** Never expose the key: Node's `console.log(client)` gets the redacted view. */
578
+ [Symbol.for("nodejs.util.inspect.custom")]() {
579
+ return `Client(${this.base}, model=${this.model}, userId=${this.defaultUser}, apiKey=${maskKey(this.apiKey)})`;
580
+ }
581
+ /** Resolve a call's store: the explicit userId, else the client default. */
582
+ 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;
602
+ warnIfStoreIdCollapses(id);
603
+ return id;
604
+ }
605
+ clone(overrides) {
606
+ return new Client({
607
+ apiKey: this.apiKey,
608
+ baseUrl: this.base,
609
+ timeoutMs: this.timeoutMs,
610
+ model: this.model,
611
+ userId: this.defaultUser,
612
+ 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
+ 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
+ deadlineMs: this.deadlineMs,
621
+ signal: this.signal,
622
+ ...overrides,
623
+ });
624
+ }
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
+ withModel(model) {
631
+ return this.clone({ model });
632
+ }
633
+ /** A client bound to `userId` as its default store (everything else kept). */
634
+ withUser(userId) {
635
+ return this.clone({ userId });
636
+ }
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
+ withSignal(signal) {
645
+ return this.clone({ signal });
646
+ }
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
+ withDeadline(deadlineMs) {
651
+ return this.clone({ deadlineMs });
652
+ }
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
+ withTimeout(timeoutMs) {
656
+ return this.clone({ timeoutMs });
657
+ }
658
+ /** A client with a different retry budget (everything else kept). 0 disables retries. */
659
+ withRetries(maxRetries) {
660
+ return this.clone({ maxRetries });
661
+ }
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
+ add(content, userId, metadata = {}, opts = {}) {
673
+ const body = { user_id: this.uid(userId), content, metadata };
674
+ if (opts.image !== undefined)
675
+ body.image = normalizeImage(opts.image);
676
+ return this.post("/api/v1/memory/store", body, opts.idempotencyKey);
677
+ }
678
+ /** Alias of `add` — store one memory. Same surface as the Python SDK's `store`. */
679
+ store(content, userId, metadata = {}, opts = {}) {
680
+ return this.add(content, userId, metadata, opts);
681
+ }
682
+ /** Store a conversation turn (user + assistant). Payload first, userId last — same shape as add/search. */
683
+ addTurn(userMsg, assistantMsg, userId, opts = {}) {
684
+ return this.post("/api/v1/memory/store-turn", { user_id: this.uid(userId), user_msg: userMsg, assistant_msg: assistantMsg }, opts.idempotencyKey);
685
+ }
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
+ addBulk(content, userId, category = "general", timestamp, opts = {}) {
690
+ const body = { user_id: this.uid(userId), content, category };
691
+ if (timestamp)
692
+ body.timestamp = timestamp;
693
+ return this.post("/api/v1/memory/bulk-store", body, opts.idempotencyKey);
694
+ }
695
+ /** Supersede an old memory with new content. Payload first, userId last — same shape as add/search. */
696
+ update(oldMemoryId, newContent, userId, opts = {}) {
697
+ return this.post("/api/v1/memory/supersede", { user_id: this.uid(userId), old_memory_id: oldMemoryId, new_content: newContent }, opts.idempotencyKey);
698
+ }
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
+ 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.
711
+ warnOnUnknownFilters(opts.filters);
712
+ checkCount(limit, "limit");
713
+ const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
714
+ return mergeResults(r);
715
+ }
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
+ async searchSelf(query, userId, limit = 10, opts = {}) {
722
+ warnOnUnknownFilters(opts.filters);
723
+ checkCount(limit, "limit");
724
+ const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
725
+ return { memories: asRecords(r.memories), self_memories: asRecords(r.self_memories) };
726
+ }
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
+ async searchFull(query, userId, limit = 10, opts = {}) {
742
+ warnOnUnknownFilters(opts.filters);
743
+ 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.
747
+ return {
748
+ ...r,
749
+ memories: asRecords(r.memories),
750
+ self_memories: asRecords(r.self_memories),
751
+ images: asRecords(r.images),
752
+ };
753
+ }
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
+ async recall(query, userId, opts = {}) {
762
+ const body = { user_id: this.uid(userId), query };
763
+ if (opts.limit !== undefined) {
764
+ checkCount(opts.limit, "limit");
765
+ body.limit = opts.limit;
766
+ }
767
+ if (opts.context_limit !== undefined) {
768
+ checkContextLimit(opts.context_limit);
769
+ body.context_limit = opts.context_limit;
770
+ }
771
+ return this.post("/api/v1/memory/recall", withForm(body, opts.form, opts.tz));
772
+ }
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
+ engram(name, query, userId, opts = {}) {
778
+ return this.post("/api/v1/engram/run", withForm({ name, user_id: this.uid(userId), query }, opts.form, opts.tz));
779
+ }
780
+ /** Recent conversation turns (short-term memory). */
781
+ async history(userId) {
782
+ const r = await this.post("/api/v1/memory/history", { user_id: this.uid(userId) });
783
+ return asRecords(r.turns);
784
+ }
785
+ /** Memory counts for a store: { total_memories, short_term_turns }. */
786
+ stats(userId) {
787
+ return this.post("/api/v1/memory/stats", { user_id: this.uid(userId) });
788
+ }
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. */
794
+ 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") {
798
+ throw new Error("memoryId is required — the id that add/store or listMemories returned.");
799
+ }
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
+ */
814
+ listMemories(userId, opts = {}) {
815
+ const body = { user_id: this.uid(userId), limit: opts.limit ?? 100 };
816
+ if (opts.cursor)
817
+ body.cursor = opts.cursor;
818
+ return this.post("/api/v1/memory/list", body);
819
+ }
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
+ async *iterMemories(userId, opts = {}) {
826
+ let cursor;
827
+ 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
+ for (let page = 0; page < MAX_PAGES; page++) {
831
+ const p = await this.listMemories(userId, { limit: opts.pageSize ?? 100, cursor });
832
+ for (const m of asRecords(p.memories))
833
+ yield m;
834
+ const next = p.next_cursor ?? undefined;
835
+ if (!next || seen.has(next))
836
+ return;
837
+ seen.add(next);
838
+ cursor = next;
839
+ }
840
+ throw new Error(`stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
841
+ `answer, not the whole store.`);
842
+ }
843
+ /** Collect ALL of a store's memories into an array (the text you stored, and its metadata). */
844
+ async exportMemories(userId) {
845
+ const out = [];
846
+ for await (const m of this.iterMemories(userId))
847
+ out.push(m);
848
+ return out;
849
+ }
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
+ getImage(userId, memoryId) {
874
+ if (!memoryId || typeof memoryId !== "string") {
875
+ throw new Error("memoryId is required — the id that add/store or listImages returned.");
876
+ }
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
+ */
890
+ forgetImage(userId, memoryId, opts = {}) {
891
+ if (!memoryId || typeof memoryId !== "string") {
892
+ throw new Error("memoryId is required — the id of the memory whose image you want removed.");
893
+ }
894
+ const body = { user_id: this.uid(userId), memory_id: memoryId };
895
+ if (opts.preview)
896
+ body.preview = true;
897
+ return this.request("DELETE", "/api/v1/memory/image", body);
898
+ }
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
+ listImages(userId, opts = {}) {
909
+ const body = { user_id: this.uid(userId) };
910
+ if (opts.limit !== undefined)
911
+ body.limit = opts.limit;
912
+ if (opts.before !== undefined)
913
+ body.before = opts.before;
914
+ if (opts.skipIds !== undefined)
915
+ body.skip_ids = opts.skipIds;
916
+ return this.post("/api/v1/memory/images", body);
917
+ }
918
+ /** Async-iterate every image memory, paging under the hood. */
919
+ async *iterImages(userId, opts = {}) {
920
+ let before;
921
+ 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
+ const seen = new Set();
927
+ for (let page = 0; page < MAX_PAGES; page++) {
928
+ const p = await this.listImages(userId, { limit: opts.pageSize, before, skipIds });
929
+ for (const m of asRecords(p.images))
930
+ 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
+ if (!p.has_more || !p.next_before)
937
+ return;
938
+ // The cursor is the PAIR: several images can share a timestamp, so `before`
939
+ // alone repeats across pages legitimately.
940
+ const key = `${p.next_before}|${(Array.isArray(p.next_skip_ids) ? p.next_skip_ids : []).join(",")}`;
941
+ if (seen.has(key))
942
+ return;
943
+ seen.add(key);
944
+ before = p.next_before;
945
+ skipIds = Array.isArray(p.next_skip_ids) ? p.next_skip_ids : undefined;
946
+ }
947
+ throw new Error(`stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
948
+ `answer, not the whole store.`);
949
+ }
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
+ revisions(userId, opts = {}) {
994
+ const body = {};
995
+ if (opts.include !== undefined)
996
+ body.include = opts.include;
997
+ if (opts.limit !== undefined)
998
+ body.limit = opts.limit;
999
+ if (opts.before !== undefined)
1000
+ body.before = opts.before;
1001
+ if (opts.skipIds !== undefined)
1002
+ 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
+ body.user_id = this.uid(userId);
1006
+ return this.post("/api/v1/won/revisions", body);
1007
+ }
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
+ lineage(userId, memoryId) {
1016
+ if (!memoryId || typeof memoryId !== "string") {
1017
+ throw new Error("memoryId is required — the memory whose history you want.");
1018
+ }
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
+ */
1032
+ bySpeaker(speaker, userId, opts = {}) {
1033
+ if (!speaker || typeof speaker !== "string" || speaker.trim() === "") {
1034
+ throw new Error('speaker is required — "me" for the assistant, or a person\'s name.');
1035
+ }
1036
+ const body = { user_id: this.uid(userId), speaker: speaker.trim() };
1037
+ if (opts.limit !== undefined)
1038
+ body.limit = opts.limit;
1039
+ if (opts.before !== undefined)
1040
+ body.before = opts.before;
1041
+ if (opts.skipIds !== undefined)
1042
+ body.skip_ids = opts.skipIds;
1043
+ return this.post("/api/v1/memory/by-speaker", body);
1044
+ }
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
+ async ping() {
1049
+ await this.request("GET", "/api/v1/memory/collections");
1050
+ return true;
1051
+ }
1052
+ // ----- models -----
1053
+ /** Available models: `[{ id, name, available, memory }, ...]`. Needs no API key. */
1054
+ async listModels() {
1055
+ const data = await this.request("GET", "/api/v1/models");
1056
+ return asRecords(data.models);
1057
+ }
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
+ async listEngrams() {
1071
+ const data = await this.request("GET", "/api/v1/engram");
1072
+ return {
1073
+ engrams: asRecords(data.engrams),
1074
+ 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
+ note: typeof data.note === "string" ? data.note : undefined,
1078
+ };
1079
+ }
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
+ createStore(userId) {
1086
+ return this.post("/api/v1/memory/collection", { user_id: this.uid(userId) });
1087
+ }
1088
+ /** List your stores: `[{ user_id, created_at }, ...]` (`default` first). */
1089
+ async listStores() {
1090
+ const r = await this.request("GET", "/api/v1/memory/collections");
1091
+ return asRecords(r.collections);
1092
+ }
1093
+ /** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
1094
+ 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
+ if (typeof userId !== "string" || !userId.trim())
1099
+ 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
1101
+ return this.request("DELETE", "/api/v1/memory/collection", { user_id: userId });
1102
+ }
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
+ addSpeaker(speaker, userId) {
1108
+ return this.post("/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
1109
+ }
1110
+ /** The store's registered people, each with its memory count. */
1111
+ async listSpeakers(userId) {
1112
+ 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
+ return { ...r, speakers: asRecords(r?.speakers) };
1117
+ }
1118
+ /** Unregister a person. Their memories stay; the name tag goes. */
1119
+ removeSpeaker(speaker, userId) {
1120
+ return this.request("DELETE", "/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
1121
+ }
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
+ 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
+ if (typeof memoryId !== "string" || !memoryId.trim()) {
1134
+ throw new Error("memoryId is required (non-blank). To delete every memory in a store, call deleteAll(userId) explicitly.");
1135
+ }
1136
+ return this.post("/api/v1/memory/forget", { user_id: this.uid(userId), memory_id: memoryId.trim() });
1137
+ }
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
+ deleteAll(userId) {
1141
+ if (typeof userId !== "string" || !userId.trim())
1142
+ 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
+ warnIfStoreIdCollapses(userId);
1148
+ return this.post("/api/v1/memory/forget", { user_id: userId });
1149
+ }
1150
+ // ----- internal -----
1151
+ post(path, body, idempotencyKey) {
1152
+ return this.request("POST", path, body, idempotencyKey);
1153
+ }
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
+ deadlineAt() {
1170
+ return this.deadlineMs === undefined ? undefined : Date.now() + this.deadlineMs;
1171
+ }
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
+ beginAttempt(deadlineAt) {
1179
+ let why = "timeout";
1180
+ let budget = this.timeoutMs;
1181
+ if (deadlineAt !== undefined) {
1182
+ 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
+ if (left <= 0)
1190
+ throw new APIConnectionError(0, this.abortMessage("deadline"));
1191
+ if (left < budget) {
1192
+ budget = left;
1193
+ why = "deadline";
1194
+ }
1195
+ }
1196
+ const ctrl = new AbortController();
1197
+ const timer = setTimeout(() => ctrl.abort(), budget);
1198
+ const outer = this.signal;
1199
+ const onCaller = () => {
1200
+ why = "caller";
1201
+ ctrl.abort();
1202
+ };
1203
+ if (outer) {
1204
+ if (outer.aborted)
1205
+ 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
+ else
1210
+ outer.addEventListener("abort", onCaller);
1211
+ }
1212
+ return {
1213
+ signal: ctrl.signal,
1214
+ clear: () => {
1215
+ clearTimeout(timer);
1216
+ outer?.removeEventListener("abort", onCaller);
1217
+ },
1218
+ why: () => why,
1219
+ };
1220
+ }
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
+ abortMessage(why) {
1225
+ if (why === "caller")
1226
+ return "cancelled — the caller's AbortSignal fired";
1227
+ if (why === "deadline")
1228
+ return `deadline of ${this.deadlineMs}ms exhausted`;
1229
+ return `request timed out after ${this.timeoutMs}ms`;
1230
+ }
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
+ async backoffSleep(ms, deadlineAt) {
1241
+ if (deadlineAt !== undefined && ms > deadlineAt - Date.now()) {
1242
+ throw new APIConnectionError(0, this.abortMessage("deadline"));
1243
+ }
1244
+ const outer = this.signal;
1245
+ if (!outer) {
1246
+ await new Promise((r) => setTimeout(r, ms));
1247
+ return;
1248
+ }
1249
+ if (outer.aborted)
1250
+ return;
1251
+ await new Promise((resolve) => {
1252
+ const done = () => {
1253
+ clearTimeout(timer);
1254
+ outer.removeEventListener("abort", done);
1255
+ resolve();
1256
+ };
1257
+ const timer = setTimeout(done, ms);
1258
+ outer.addEventListener("abort", done);
1259
+ });
1260
+ }
1261
+ 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
+ const attempts = this.maxRetries + 1;
1269
+ const deadlineAt = this.deadlineAt();
1270
+ for (let attempt = 0;; attempt++) {
1271
+ const att = this.beginAttempt(deadlineAt);
1272
+ let res;
1273
+ try {
1274
+ res = await this.fetchImpl(`${this.base}${path}`, {
1275
+ method: "POST",
1276
+ headers: this.headers,
1277
+ body: JSON.stringify(body),
1278
+ signal: att.signal,
1279
+ redirect: "manual",
1280
+ });
1281
+ }
1282
+ catch (e) {
1283
+ att.clear();
1284
+ 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
+ if (!timedOut && isConnectFailure(e) && attempt + 1 < attempts) {
1291
+ await this.backoffSleep(this.backoffMs(attempt), deadlineAt);
1292
+ continue;
1293
+ }
1294
+ throw new APIConnectionError(0, timedOut ? this.abortMessage(att.why()) : `network error: ${e?.message ?? e}`);
1295
+ }
1296
+ try {
1297
+ this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
1298
+ if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
1299
+ void res.body?.cancel();
1300
+ throw errorFor(res.status, "the API answered with a redirect; refusing to follow it");
1301
+ }
1302
+ if (!res.ok) {
1303
+ if (RETRY_ALWAYS.has(res.status) && attempt + 1 < attempts) {
1304
+ const retryAfter = res.headers.get("Retry-After");
1305
+ void res.body?.cancel();
1306
+ att.clear();
1307
+ await this.backoffSleep(this.backoffMs(attempt, retryAfter), deadlineAt);
1308
+ continue;
1309
+ }
1310
+ let msg = await this.readCapped(res).catch(() => "");
1311
+ try {
1312
+ const data = JSON.parse(msg);
1313
+ const err = data?.error;
1314
+ if (err && typeof err === "object")
1315
+ msg = err.message ?? err.type ?? msg;
1316
+ else if (typeof err === "string")
1317
+ msg = err;
1318
+ }
1319
+ catch {
1320
+ /* keep raw text */
1321
+ }
1322
+ if (msg.length > MAX_ERR_MSG)
1323
+ msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
1324
+ throw errorFor(res.status, msg || `HTTP ${res.status}`);
1325
+ }
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
+ let buf;
1331
+ try {
1332
+ buf = await this.readCappedBytes(res);
1333
+ }
1334
+ catch (e) {
1335
+ if (e?.name === "AbortError" || att.signal.aborted) {
1336
+ throw new APIConnectionError(0, `request timed out after ${this.timeoutMs}ms`);
1337
+ }
1338
+ throw e;
1339
+ }
1340
+ 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
+ throw new WosError(res.status, "empty image body — the service returned no bytes");
1344
+ }
1345
+ return { bytes: buf, contentType: res.headers.get("content-type") ?? "application/octet-stream" };
1346
+ }
1347
+ finally {
1348
+ att.clear();
1349
+ }
1350
+ }
1351
+ }
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
+ 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
+ if (key === undefined || key === null)
1363
+ return undefined;
1364
+ if (!IDEMPOTENCY_KEY_RE.test(key)) {
1365
+ throw new Error(`invalid idempotencyKey: ${JSON.stringify(key)} — 1-128 chars of [A-Za-z0-9._:-]`);
1366
+ }
1367
+ return { "Idempotency-Key": key };
1368
+ }
1369
+ /** Seconds→ms to sleep before retry `attempt` (0-based). Honors Retry-After. */
1370
+ backoffMs(attempt, retryAfter) {
1371
+ if (retryAfter) {
1372
+ const s = Number(retryAfter);
1373
+ if (Number.isFinite(s) && s >= 0)
1374
+ return Math.min(30_000, s * 1000);
1375
+ // HTTP-date form (RFC 9110), e.g. "Wed, 21 Oct 2015 07:28:00 GMT".
1376
+ const t = Date.parse(retryAfter);
1377
+ if (Number.isFinite(t))
1378
+ return Math.min(30_000, Math.max(0, t - Date.now()));
1379
+ }
1380
+ return Math.min(8_000, 500 * 2 ** attempt) + Math.random() * 250;
1381
+ }
1382
+ /** Read the body with a hard size cap, so a broken/hostile endpoint can't
1383
+ * make the process buffer gigabytes. */
1384
+ async readCapped(res) {
1385
+ return new TextDecoder().decode(await this.readCappedBytes(res));
1386
+ }
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
+ async readCappedBytes(res) {
1393
+ const cl = res.headers.get("content-length");
1394
+ if (cl && Number(cl) > MAX_RESPONSE_BYTES) {
1395
+ throw new WosError(res.status, `response too large (${cl} bytes) — refusing to buffer it`);
1396
+ }
1397
+ 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
+ const buf = new Uint8Array(await res.arrayBuffer());
1404
+ if (buf.byteLength > MAX_RESPONSE_BYTES) {
1405
+ throw new WosError(res.status, `response too large (${buf.byteLength} bytes) — refusing it`);
1406
+ }
1407
+ return buf;
1408
+ }
1409
+ const reader = res.body.getReader();
1410
+ const parts = [];
1411
+ let size = 0;
1412
+ for (;;) {
1413
+ const { done, value } = await reader.read();
1414
+ if (done)
1415
+ break;
1416
+ size += value.byteLength;
1417
+ if (size > MAX_RESPONSE_BYTES) {
1418
+ void reader.cancel();
1419
+ throw new WosError(res.status, "response too large — refusing to buffer it");
1420
+ }
1421
+ parts.push(value);
1422
+ }
1423
+ const buf = new Uint8Array(size);
1424
+ let off = 0;
1425
+ for (const p of parts) {
1426
+ buf.set(p, off);
1427
+ off += p.byteLength;
1428
+ }
1429
+ return buf;
1430
+ }
1431
+ async request(method, path, body, idempotencyKey) {
1432
+ const extraHeaders = this.idemHeader(idempotencyKey);
1433
+ 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
+ const logPath = path.split("?")[0];
1437
+ const deadlineAt = this.deadlineAt();
1438
+ for (let attempt = 0; attempt < attempts; attempt++) {
1439
+ const start = Date.now();
1440
+ const att = this.beginAttempt(deadlineAt);
1441
+ let res;
1442
+ try {
1443
+ res = await this.fetchImpl(`${this.base}${path}`, {
1444
+ method,
1445
+ headers: extraHeaders ? { ...this.headers, ...extraHeaders } : this.headers,
1446
+ body: body === undefined ? undefined : JSON.stringify(body),
1447
+ 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
+ redirect: "manual",
1451
+ });
1452
+ }
1453
+ catch (e) {
1454
+ att.clear();
1455
+ // Timeouts are ambiguous (the write may have landed) — don't retry those.
1456
+ 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
+ const safe = IDEMPOTENT_METHODS.has(method.toUpperCase()) || isConnectFailure(e);
1462
+ if (!timedOut && safe && attempt + 1 < attempts) {
1463
+ const delay = this.backoffMs(attempt);
1464
+ logDebug(`${method} ${logPath}: ${e?.code ?? e?.name ?? "network error"} — retrying in ${delay}ms (attempt ${attempt + 1}/${attempts})`);
1465
+ await this.backoffSleep(delay, deadlineAt);
1466
+ continue;
1467
+ }
1468
+ throw new APIConnectionError(0, timedOut ? this.abortMessage(att.why()) : `network error: ${e?.message ?? e}`);
1469
+ }
1470
+ const retryable = RETRY_ALWAYS.has(res.status) ||
1471
+ (RETRY_IF_IDEMPOTENT.has(res.status) && IDEMPOTENT_METHODS.has(method.toUpperCase()));
1472
+ if (retryable && attempt + 1 < attempts) {
1473
+ att.clear();
1474
+ const retryAfter = res.headers.get("Retry-After");
1475
+ void res.body?.cancel();
1476
+ const delay = this.backoffMs(attempt, retryAfter);
1477
+ logDebug(`${method} ${logPath} -> ${res.status} — retrying in ${delay}ms (attempt ${attempt + 1}/${attempts})`);
1478
+ await this.backoffSleep(delay, deadlineAt);
1479
+ continue;
1480
+ }
1481
+ if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
1482
+ att.clear();
1483
+ void res.body?.cancel(); // release the stream — otherwise the connection leaks
1484
+ throw new WosError(res.status, "unexpected redirect — refused (the API key never follows a redirect). Check baseUrl: exact host, https://.");
1485
+ }
1486
+ 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
+ let text;
1491
+ try {
1492
+ text = await this.readCapped(res);
1493
+ }
1494
+ catch (e) {
1495
+ if (e?.name === "AbortError" || att.signal.aborted) {
1496
+ throw new APIConnectionError(0, `request timed out after ${this.timeoutMs}ms`);
1497
+ }
1498
+ 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.
1502
+ throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
1503
+ }
1504
+ finally {
1505
+ att.clear();
1506
+ }
1507
+ logDebug(`${method} ${logPath} -> ${res.status} in ${Date.now() - start}ms (attempt ${attempt + 1}/${attempts})`);
1508
+ 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
+ let msg = text;
1513
+ let requestId;
1514
+ try {
1515
+ const data = JSON.parse(text);
1516
+ if (data && typeof data === "object") {
1517
+ const err = data.error;
1518
+ if (err && typeof err === "object") {
1519
+ msg = err.message ?? err.type ?? msg;
1520
+ if (typeof err.request_id === "string")
1521
+ requestId = err.request_id;
1522
+ }
1523
+ else if (typeof err === "string")
1524
+ msg = err;
1525
+ else if (typeof data.message === "string")
1526
+ msg = data.message;
1527
+ }
1528
+ }
1529
+ catch {
1530
+ /* keep raw text */
1531
+ }
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
+ if (typeof msg === "string" && msg.length > MAX_ERR_MSG)
1535
+ msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
1536
+ throw errorFor(res.status, msg, requestId);
1537
+ }
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
+ if (!text) {
1542
+ if (NO_BODY_STATUS.has(res.status))
1543
+ return {};
1544
+ throw new WosError(res.status, "empty response body — expected a JSON object");
1545
+ }
1546
+ let data;
1547
+ try {
1548
+ data = JSON.parse(text);
1549
+ }
1550
+ 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
+ throw new WosError(res.status, `invalid JSON in response: ${e?.message ?? e}`);
1554
+ }
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
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
1559
+ const got = data === null ? "null" : Array.isArray(data) ? "array" : typeof data;
1560
+ throw new WosError(res.status, `expected a JSON object in the response, got ${got}`);
1561
+ }
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
+ if (res.headers.get("Idempotent-Replayed") === "true" && !("replayed" in data)) {
1572
+ data.replayed = true;
1573
+ }
1574
+ return data;
1575
+ }
1576
+ throw new Error("retries exhausted"); // unreachable; keeps the compiler happy
1577
+ }
1578
+ }
1579
+ exports.Client = Client;
1580
+ /** Back-compat alias: older code used `WME`. */
1581
+ exports.WME = Client;
1582
+ exports.default = Client;