wontopos 2.2.33 → 2.2.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/wontopos.js CHANGED
@@ -15,12 +15,15 @@
15
15
  * so with no `userId` anywhere the zero-setup path just works.
16
16
  *
17
17
  * The API key picks *which memory* (your account); `model` picks *which engine*
18
- * reads it. All models share one memory, so you can store
19
- * with one and recall with another. Set a default in the constructor, or override
20
- * a single call with `mem.withModel("tablet-1").recall(...)`.
18
+ * reads it. Models on the shared pool read the same memory, so you can store with
19
+ * one and recall with another; `listModels()` reports each model's `memory` as
20
+ * `"shared"` or `"isolated"`, and an isolated one starts empty. Set a default in
21
+ * the constructor, or override a single call with
22
+ * `mem.withModel("tablet-1").recall(...)`.
21
23
  *
22
- * Pure semantic retrieval — no keyword/BM25 matching, so it works the SAME in every
23
- * language. Storing and searching call no LLM; you only pay for embeddings.
24
+ * Recall quality does not depend on which language a memory was written in: a
25
+ * memory stored in one language is found by a question asked in another. Storing
26
+ * and searching call no LLM.
24
27
  *
25
28
  * Reliability: every call retries transient failures with exponential backoff +
26
29
  * jitter, honoring `Retry-After` — 429 always; 502/503 and connection errors only
@@ -37,7 +40,7 @@
37
40
  * runtime's (never disabled by this SDK; Node 18+ floors TLS at 1.2). Prefer
38
41
  * `Client.fromEnv()` over keys in source code.
39
42
  */
40
- const VERSION = "2.2.33";
43
+ const VERSION = "2.2.35";
41
44
  /** Runtime info helps support debug a report ("node 18 on Windows...") —
42
45
  * platform only, never anything identifying. Browsers have no `process` (and
43
46
  * silently drop the UA header anyway). */
@@ -55,7 +58,15 @@ function logDebug(msg) {
55
58
  }
56
59
  }
57
60
  const DEFAULT_BASE_URL = "https://api.wontopos.com";
58
- const DEFAULT_MODEL = "tablet-1";
61
+ /** The engine every call uses unless the caller names another.
62
+ *
63
+ * Tablet 2 costs the same per token as Tablet 1 and is the one that
64
+ * serves images, re-ask passes (`verify`), and `self_memories`, so a caller who
65
+ * names nothing gets the engine that can answer the most. Models on the shared
66
+ * pool read the same memory, so switching between them is a header, not a
67
+ * migration. Pin an older one explicitly with
68
+ * `new Client({ apiKey, model: "tablet-1" })` or `withModel("tablet-1")`. */
69
+ const DEFAULT_MODEL = "tablet-2";
59
70
  /** 429 = rate-limited BEFORE processing → always safe to retry (no write, no bill). */
60
71
  const RETRY_ALWAYS = new Set([429]);
61
72
  /** 502/503 are ambiguous for a write (gateway may return them AFTER the backend
@@ -96,8 +107,9 @@ function normalizeStoreId(id) {
96
107
  * Bounded on purpose. The warning fires on ids that fold, and the shape it exists
97
108
  * to catch is an email — which this SDK's own documented pattern ("one store per
98
109
  * end user") turns into one entry PER USER, held for the life of the process and
99
- * never released, even when the client is. A server passing 50,000 user ids grew
100
- * this to 50,000 entries, a leak in exactly the case being warned about. A `Set`
110
+ * never released, even when the client is. A server that forwards 50,000 distinct user
111
+ * ids would grow this to 50,000 entries, leaking in exactly the case the warning
112
+ * exists to catch. A `Set`
101
113
  * keeps insertion order, so the oldest entry is the one evicted; dropping the
102
114
  * oldest rather than refusing new ones means a collision that first shows up late
103
115
  * still gets its one warning. */
@@ -131,15 +143,44 @@ export function _resetStoreIdWarnings() {
131
143
  * answer with a JSON object — see the empty-body check in `request`. */
132
144
  const NO_BODY_STATUS = new Set([204, 205, 304]);
133
145
  /** Paging backstop: bound the walk so a fresh-cursor-forever server can't loop us. */
134
- const MAX_PAGES = 1_000_000;
146
+ // A page walk has to stop somewhere, and 1,000,000 pages was not a stop: at 100 per
147
+ // page that is 100 million memories, so a server minting a fresh cursor every time
148
+ // would spend hours and a million billed requests before it fired. 20,000 pages is two
149
+ // million memories — past any real store, reached in minutes.
150
+ //
151
+ // ★And it has to be LOUD. Falling out of the loop yielded a truncated list that looks
152
+ // exactly like a complete one; the caller writes it to a file believing it is the
153
+ // whole store. Hitting this now throws.
154
+ const MAX_PAGES = 20_000;
155
+ export const SEARCH_LIMIT_MIN = 5;
156
+ export const SEARCH_LIMIT_MAX = 20;
157
+ /** `search`'s count, refused out of range rather than quietly adjusted.
158
+ *
159
+ * `recall` has carried this contract from the start — 5 to 20, and the service
160
+ * refuses anything else, because asking for 20 and silently getting 10 reads as
161
+ * "that is all there is". Search had no contract at all: the three clients sent
162
+ * whatever they were given, the MCP server allowed 1 to 60, and the service quietly
163
+ * capped at 50 with no floor. Four surfaces, four answers, and the caller could not
164
+ * tell which one they got.
165
+ *
166
+ * Refusing rather than clamping is the same decision as 2.2.35's, where a Rust
167
+ * `limit` of 0 had been rewritten to 10 and the caller was handed ten memories they
168
+ * had not asked for, and the bill for them. */
169
+ function checkSearchLimit(limit) {
170
+ if (!Number.isInteger(limit) || limit < SEARCH_LIMIT_MIN || limit > SEARCH_LIMIT_MAX) {
171
+ throw new WosError(0, `limit must be an integer between ${SEARCH_LIMIT_MIN} and ${SEARCH_LIMIT_MAX}, got ${limit}. ` +
172
+ `Out of range is refused rather than adjusted, so a short answer always means ` +
173
+ `the store was short.`);
174
+ }
175
+ }
135
176
  /**
136
177
  * Put an image into the shape the API expects, and fail loudly on the parts we can
137
178
  * check from here.
138
179
  *
139
- * Deliberately NOT checked: the byte ceiling. It is a server setting (`/health`
140
- * reports it as `memory.images.max_bytes`), so a number baked in here would drift the
141
- * first time that config changes and would reject an image the service would have
142
- * taken. What we do check is what cannot change: an empty payload, and a `data:` URL
180
+ * Deliberately NOT checked: the byte ceiling. It is a service setting, so a number
181
+ * baked in here would drift the first time the service is reconfigured and would
182
+ * reject an image the service would have taken.
183
+ * What we do check is what cannot change: an empty payload, and a `data:` URL
143
184
  * prefix — browsers and file pickers hand you `data:image/jpeg;base64,…`, the API
144
185
  * wants the part after the comma, and the difference is invisible until the write
145
186
  * fails with "not a readable image".
@@ -151,16 +192,15 @@ function normalizeImage(image) {
151
192
  if (typeof image.data !== "string" || image.data.trim() === "") {
152
193
  throw new Error("image.data is required — base64 of the image (a data: URL is fine).");
153
194
  }
154
- // 🔴**다듬고 나서 접두어를 본다.** 예전엔 원본에 바로 `startsWith("data:")` 를
155
- // 걸어서, 앞에 공백이 하나만 있어도(` data:image/png;base64,…`) 접두어를 못 떼고
156
- // **`data:image/png;base64,` 문자열까지 통째로 base64 로** 보냈다. 엔진은
157
- // "이미지를 읽을 수 없다"며 400 을 내고, 부르는 쪽은 왜인지 모른다.
195
+ // Trim before looking for the prefix. Checked against the raw string, one leading
196
+ // space (` data:image/png;base64,…`) hides the prefix, and the literal
197
+ // `data:image/png;base64,` travels as part of the base64. The engine answers 400
198
+ // "image could not be read" and the caller has no way to tell why.
158
199
  const raw = image.data.trim();
159
200
  const comma = raw.startsWith("data:") ? raw.indexOf(",") : -1;
160
- // 🔴그리고 **가운데 공백까지** 없앤다. `base64` 명령과 `openssl base64` 는 76자마다
161
- // 줄을 바꾼다 — 파일을 그렇게 만들어 붙여넣는 건 흔한 길인데, 양끝만 다듬으면
162
- // 그 줄바꿈이 그대로 payload 에 남는다. base64 알파벳에 공백은 없으므로
163
- // 전부 빼는 게 안전하다.
201
+ // Strip whitespace in the middle as well. `base64` and `openssl base64` wrap at 76
202
+ // columns, and trimming only the ends leaves those line breaks in the payload. The
203
+ // base64 alphabet contains no whitespace, so removing all of it is safe.
164
204
  const data = (comma === -1 ? raw : raw.slice(comma + 1)).replace(/\s+/g, "");
165
205
  if (data === "")
166
206
  throw new Error("image.data is empty after stripping its data: prefix.");
@@ -234,21 +274,18 @@ const asObj = (x) => (x != null && typeof x === "object" && !Array.isArray(x) ?
234
274
  // user code. Drop elements that aren't objects and keep the valid records, so one
235
275
  // bad element never poisons the batch (and never destroys it).
236
276
  const asRecords = (x) => asList(x).filter((m) => m != null && typeof m === "object" && !Array.isArray(m));
237
- // Attach a delivery-form override (memoir/archive) + tz to a body, the way search
238
- // passes them; on Scroll 1.2+ this renders each returned memory's time in that form.
239
- /** Every memory a search returned — main lane and self lane together.
277
+ /** Every memory a search returned, from both fields, as one array.
240
278
  *
241
- * On a self-lane model the service splits the result: the assistant's OWN words
242
- * come back in `self_memories` and are REMOVED from `memories`, so the two never
243
- * double-count. `search()` read only `memories`, so on Scroll 1.2 an assistant
244
- * turn stored with `addTurn` was silently missing from search while the same
245
- * query on tablet-1 returned it — upgrading the model made search return LESS,
246
- * and the dropped memories were already on the wire and already paid for.
279
+ * Some models answer with the assistant's own words in `self_memories`, not
280
+ * repeated in `memories`. `search()` used to return `memories` alone, so an
281
+ * assistant turn stored with `addTurn` was missing from its results on those
282
+ * models while the same query returned it on others — upgrading made search
283
+ * return LESS, and what went missing had already been retrieved and paid for.
247
284
  *
248
- * Both lanes, de-duplicated by id, each memory keeping its `speaker` so the
249
- * caller can still tell who said what. Callers who want the lanes kept apart use
250
- * `searchSelf()`, which exists for exactly that. */
251
- function mergeLanes(r) {
285
+ * Both fields are returned here, de-duplicated by id, each memory keeping its
286
+ * `speaker` so a caller can still tell who said what. Callers who want them kept
287
+ * apart use `searchSelf()`, which is what that method is for. */
288
+ function mergeResults(r) {
252
289
  const main = asRecords(r?.memories);
253
290
  const mine = asRecords(r?.self_memories);
254
291
  if (mine.length === 0)
@@ -256,6 +293,9 @@ function mergeLanes(r) {
256
293
  const seen = new Set(main.map((m) => m.id).filter((id) => typeof id === "string"));
257
294
  return main.concat(mine.filter((m) => !(typeof m.id === "string" && seen.has(m.id))));
258
295
  }
296
+ /** Attach a delivery-form override ("memoir"/"archive") + tz to a request body, the
297
+ * way search passes them; on Scroll 1.2+ this renders each returned memory's time in
298
+ * that form. The server validates the form (400 on an unknown one). */
259
299
  function withForm(body, form, tz) {
260
300
  if (form !== undefined)
261
301
  body.form = form;
@@ -309,8 +349,8 @@ export class RateLimitError extends WosError {
309
349
  * `501` is NOT transient. It means the engine behind the model you selected does
310
350
  * not implement that endpoint at all (the error carries `model` and `endpoint`).
311
351
  * Retrying can never succeed — pick a model that supports it (`listModels`) or
312
- * drop the call. This class used to say "safe to retry" for the whole 5xx range,
313
- * which sent callers into a loop that could not end.
352
+ * drop the call. Treating the whole 5xx range as retryable sends a caller into a
353
+ * loop that cannot end.
314
354
  */
315
355
  export class ServerError extends WosError {
316
356
  }
@@ -396,6 +436,12 @@ export class Client {
396
436
  throw new Error("apiKey is required");
397
437
  if (/\s/.test(key))
398
438
  throw new Error("apiKey contains whitespace - check for a stray newline or paste error");
439
+ // `\s` does not cover NUL, 0x01 or DEL, and those travel into the header and fail
440
+ // deep inside fetch as the mystery 401 this check exists to prevent. Python has
441
+ // `_HEADER_CTL_RE` and Rust tests `is_control`; this was the client that did not,
442
+ // while all three READMEs announced the guard without naming a language.
443
+ if (/[\x00-\x1f\x7f]/.test(key))
444
+ throw new Error("apiKey contains a control character - check for a stray byte or paste error");
399
445
  this.apiKey = key;
400
446
  this.base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
401
447
  // Coerce a non-finite or non-positive timeout back to the default — NaN
@@ -441,7 +487,12 @@ export class Client {
441
487
  // (Python already used urlsplit here; this was the copy that did not.)
442
488
  let host;
443
489
  try {
444
- host = new URL(this.base).hostname.toLowerCase();
490
+ // `URL.hostname` keeps the brackets on an IPv6 literal — `http://[::1]:8080`
491
+ // gives back "[::1]", which was never in LOOPBACK_HOSTS, so a self-hosted
492
+ // client on the v6 loopback was told its key was travelling in cleartext when
493
+ // it was not. Python's urlsplit strips them and Rust strips them by hand; this
494
+ // was the copy that did not. Strip them here so all three agree.
495
+ host = new URL(this.base).hostname.toLowerCase().replace(/^\[|\]$/g, "");
445
496
  }
446
497
  catch {
447
498
  host = ""; // unparseable → not loopback → warn, which is the safe direction
@@ -545,8 +596,7 @@ export class Client {
545
596
  // store (set via `new Client({ apiKey, userId })` or `withUser`).
546
597
  /** Store one memory. `metadata` optional — e.g. `{ event_date: "2026-03-01" }` for when the
547
598
  * content actually happened. A plain date works (read as UTC midnight); a full RFC3339
548
- * timestamp also works. The API used to demand the full form and answered a bare date with
549
- * an opaque 400, which is why older examples here show the long one.
599
+ * timestamp also works; older examples here show the long form.
550
600
  * `metadata.speaker`: "me" = the assistant's own words, or a person's name (up to 50 per store).
551
601
  *
552
602
  * `opts.idempotencyKey`: see {@link WriteOptions} — pass one to make YOUR retry
@@ -565,9 +615,9 @@ export class Client {
565
615
  addTurn(userMsg, assistantMsg, userId, opts = {}) {
566
616
  return this.post("/api/v1/memory/store-turn", { user_id: this.uid(userId), user_msg: userMsg, assistant_msg: assistantMsg }, opts.idempotencyKey);
567
617
  }
568
- /** Bulk-ingest a large blob of text (chunked + embedded server-side). For backfilling.
618
+ /** Bulk-ingest a large blob of text in one call. For backfilling.
569
619
  * The call most worth an `opts.idempotencyKey`: a backfill that dies halfway and is
570
- * re-run would otherwise re-chunk and re-bill the whole blob. */
620
+ * re-run would otherwise re-ingest and re-bill the whole blob. */
571
621
  addBulk(content, userId, category = "general", timestamp, opts = {}) {
572
622
  const body = { user_id: this.uid(userId), content, category };
573
623
  if (timestamp)
@@ -579,31 +629,60 @@ export class Client {
579
629
  return this.post("/api/v1/memory/supersede", { user_id: this.uid(userId), old_memory_id: oldMemoryId, new_content: newContent }, opts.idempotencyKey);
580
630
  }
581
631
  // ----- read -----
582
- /** Semantic search over a store's memories. Returns memories, most relevant first.
632
+ /** Search a store's memories. Returns them most relevant first.
583
633
  *
584
- * `limit` bounds the MAIN lane, not the returned array. On a self-lane model
585
- * (Scroll 1.2+) the service answers in two disjoint lanes — the assistant's own
586
- * words come back separately — and both are returned here, so the array can hold
587
- * more than `limit`. They were retrieved and billed either way; dropping them would
588
- * only hide what you already paid for. Size a prompt window on the array you get
589
- * back, not on `limit`. `searchSelf()` hands the lanes back apart. */
634
+ * `limit` bounds `memories`, not the returned array. On a model that keeps the
635
+ * assistant's own words separate (Scroll 1.2+) those come back as well, so the
636
+ * array can hold more than `limit`. They were retrieved and billed either way;
637
+ * dropping them would only hide what you already paid for. Size a prompt window
638
+ * on the array you get back, not on `limit`. `searchSelf()` hands the two back
639
+ * apart. */
590
640
  async search(query, userId, limit = 10, opts = {}) {
591
641
  // Reserved fields win over ...opts: an app that forwards untrusted input as
592
642
  // opts must not be able to override the store (user_id), query, or limit.
593
643
  warnOnUnknownFilters(opts.filters);
644
+ checkSearchLimit(limit);
594
645
  const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
595
- return mergeLanes(r);
646
+ return mergeResults(r);
596
647
  }
597
- /** Search a self-memory model (Scroll 1.2+): both lanes from ONE call. Returns
598
- * `{ memories, self_memories }` — `memories` is the main lane (what others said /
599
- * general memories), `self_memories` is the assistant's OWN words (stored with
600
- * speaker "me"), kept separate so the reader never confuses who said what. On a model
601
- * without a self lane, `self_memories` is `[]`. `userId` optional (default store). */
648
+ /** Search a self-memory model (Scroll 1.2+): both fields from ONE call. Returns
649
+ * `{ memories, self_memories }` — `memories` is what others said and general
650
+ * memories, `self_memories` is the assistant's OWN words (stored with speaker
651
+ * "me"), kept apart so whoever reads them never confuses who said what. On a model
652
+ * that does not keep them apart, `self_memories` is `[]`. `userId` may be omitted. */
602
653
  async searchSelf(query, userId, limit = 10, opts = {}) {
603
654
  warnOnUnknownFilters(opts.filters);
655
+ checkSearchLimit(limit);
604
656
  const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
605
657
  return { memories: asRecords(r.memories), self_memories: asRecords(r.self_memories) };
606
658
  }
659
+ /**
660
+ * Search, and keep every field the answer came with.
661
+ *
662
+ * Same request as {@link Client.search} — reach for this one when the options make
663
+ * the merged array an incomplete answer:
664
+ *
665
+ * const r = await mem.searchFull("the day we moved", "alice", 10,
666
+ * { max_images: 3, verify: 2 });
667
+ * r.images.length; // the photos, which `search` drops
668
+ * r.verify_used; // how many re-ask passes actually ran (you are billed per pass)
669
+ *
670
+ * Both options need Tablet 2 or newer and are refused (403) on an older engine
671
+ * rather than accepted and ignored.
672
+ */
673
+ async searchFull(query, userId, limit = 10, opts = {}) {
674
+ warnOnUnknownFilters(opts.filters);
675
+ checkSearchLimit(limit);
676
+ const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
677
+ // Spread first so a field added later still arrives; the known ones are then
678
+ // normalized, because a broken proxy can null any of them.
679
+ return {
680
+ ...r,
681
+ memories: asRecords(r.memories),
682
+ self_memories: asRecords(r.self_memories),
683
+ images: asRecords(r.images),
684
+ };
685
+ }
607
686
  /** One-call LLM context: short-term turns + long-term matches + surrounding context.
608
687
  * `form` ("memoir"/"archive", Scroll 1.2+) renders each long-term memory's time in
609
688
  * that form; `tz` is your UTC-offset hours for that rendering. */
@@ -615,7 +694,9 @@ export class Client {
615
694
  body.context_limit = opts.context_limit;
616
695
  return this.post("/api/v1/memory/recall", withForm(body, opts.form, opts.tz));
617
696
  }
618
- /** Run a built-in engram ("deep_recall" | "timeline" | "gather"). Returns the merged result.
697
+ /** Run a built-in engram ("deep_recall" | "timeline" | "gather" | "equilibrium" |
698
+ * "tone_stabilizer"; the service is the authority — an unknown name comes back with
699
+ * the list it accepts). Returns the merged result.
619
700
  * `form`/`tz` render memory times (memoir/archive) on Scroll 1.2+, same as search/recall. */
620
701
  engram(name, query, userId, opts = {}) {
621
702
  return this.post("/api/v1/engram/run", withForm({ name, user_id: this.uid(userId), query }, opts.form, opts.tz));
@@ -629,10 +710,11 @@ export class Client {
629
710
  stats(userId) {
630
711
  return this.post("/api/v1/memory/stats", { user_id: this.uid(userId) });
631
712
  }
632
- /** Fetch ONE memory by id — the original text + metadata, never the vector.
713
+ /** Fetch ONE memory by id — the text you stored, and its metadata.
633
714
  * The id is what `add`/`store` or `listMemories` returned. Same visibility as
634
- * `listMemories`: an id from another store, a decomposed atom sentence, or an
635
- * invalidated memory rejects with `NotFoundError`. `userId` optional (default store). */
715
+ * `listMemories`: an id from another store, an internal record id, or an
716
+ * invalidated memory rejects with `NotFoundError`. Pass `undefined` as `userId` for
717
+ * the default store — it is positional here, not omittable as it is in Python. */
636
718
  get(userId, memoryId) {
637
719
  // Sync throw (same shape as `delete`), so a missing id fails loudly even
638
720
  // when the caller forgets to await.
@@ -641,8 +723,8 @@ export class Client {
641
723
  }
642
724
  return this.post("/api/v1/memory/get", { user_id: this.uid(userId), memory_id: memoryId }).then((r) => asObj(r.memory));
643
725
  }
644
- /** List a store's stored memories — the original text plus metadata, never the
645
- * vector. Paginated: pass the returned `next_cursor` back as `cursor` for the next
726
+ /** List a store's stored memories — the text you stored, plus its metadata.
727
+ * Paginated: pass the returned `next_cursor` back as `cursor` for the next
646
728
  * page; a `null` cursor means the last page. Use it to browse or export a store.
647
729
  *
648
730
  * let cursor: string | null = null;
@@ -660,7 +742,7 @@ export class Client {
660
742
  return this.post("/api/v1/memory/list", body);
661
743
  }
662
744
  /** Async-iterate every stored memory in a store, paging under the hood — no cursor
663
- * bookkeeping. Original text + metadata only, never vectors.
745
+ * bookkeeping. The text you stored and its metadata only.
664
746
  *
665
747
  * for await (const m of mem.iterMemories()) console.log(m.id, m.content);
666
748
  */
@@ -675,12 +757,14 @@ export class Client {
675
757
  yield m;
676
758
  const next = p.next_cursor ?? undefined;
677
759
  if (!next || seen.has(next))
678
- break;
760
+ return;
679
761
  seen.add(next);
680
762
  cursor = next;
681
763
  }
764
+ throw new WosError(0, `stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
765
+ `answer, not the whole store.`);
682
766
  }
683
- /** Collect ALL of a store's memories into an array (original text + metadata, no vectors). */
767
+ /** Collect ALL of a store's memories into an array (the text you stored, and its metadata). */
684
768
  async exportMemories(userId) {
685
769
  const out = [];
686
770
  for await (const m of this.iterMemories(userId))
@@ -694,8 +778,8 @@ export class Client {
694
778
  * Not necessarily your upload, in size OR in format: an image whose long edge was
695
779
  * over 1568px was downscaled to 1568 on the way in and re-encoded — lossless
696
780
  * formats as WebP, so a PNG comes back as `image/webp`; JPEG stays JPEG — and that
697
- * smaller picture is what was embedded, stored and comes back here. Nothing in the
698
- * retrieval path looks above 1568, so the extra pixels would be bytes nobody reads.
781
+ * smaller picture is what is stored and comes back here. Nothing on our side ever
782
+ * uses more than 1568, so the extra pixels would be bytes nobody reads.
699
783
  * Keep your own copy for the full-resolution file.
700
784
  *
701
785
  * The type is sniffed from the bytes themselves rather than from whatever the file
@@ -759,15 +843,33 @@ export class Client {
759
843
  async *iterImages(userId, opts = {}) {
760
844
  let before;
761
845
  let skipIds;
846
+ // Same repeat-cursor guard as `iterMemories`. A server that hands back the
847
+ // cursor it was just given would otherwise re-yield one page MAX_PAGES times,
848
+ // and the caller reads those repeats as more images. The page cap alone bounds
849
+ // the walk; it does not stop the duplicates.
850
+ const seen = new Set();
762
851
  for (let page = 0; page < MAX_PAGES; page++) {
763
852
  const p = await this.listImages(userId, { limit: opts.pageSize, before, skipIds });
764
853
  for (const m of asRecords(p.images))
765
854
  yield m;
855
+ // `return`, not `break`: the throw below is the MAX_PAGES backstop, and a
856
+ // `break` fell straight into it — so the ordinary end of a walk raised
857
+ // "the store did not end" on a store that had just ended. Python is safe
858
+ // here by using for/else and Rust by carrying an `ended` flag; TypeScript
859
+ // has neither, so the exit has to leave the function outright.
766
860
  if (!p.has_more || !p.next_before)
767
- break;
861
+ return;
862
+ // The cursor is the PAIR: several images can share a timestamp, so `before`
863
+ // alone repeats across pages legitimately.
864
+ const key = `${p.next_before}|${(Array.isArray(p.next_skip_ids) ? p.next_skip_ids : []).join(",")}`;
865
+ if (seen.has(key))
866
+ return;
867
+ seen.add(key);
768
868
  before = p.next_before;
769
869
  skipIds = Array.isArray(p.next_skip_ids) ? p.next_skip_ids : undefined;
770
870
  }
871
+ throw new WosError(0, `stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
872
+ `answer, not the whole store.`);
771
873
  }
772
874
  // ----- how much has this memory been edited -----
773
875
  /**
@@ -780,10 +882,10 @@ export class Client {
780
882
  *
781
883
  * Counts memories a transform touched (supersede, update, retract, image removed).
782
884
  * Deletions are NOT counted: a deleted memory leaves nothing to count. Neither are
783
- * atoms, the sentence fragments search uses internally — nobody stored one, so they
784
- * do not belong in a ratio that answers "how much of MY memory changed".
885
+ * the internal records derived from what you stored — nobody stored those directly,
886
+ * so they do not belong in a ratio that answers "how much of MY memory changed".
785
887
  *
786
- * ★By default this returns COUNTS ONLY, and the answer is the same size for a store
888
+ * By default this returns COUNTS ONLY, and the answer is the same size for a store
787
889
  * of a hundred memories and a store of a hundred million. That is deliberate: a model
788
890
  * asks this mid-conversation, and an answer that grew with the store would be
789
891
  * unusable for exactly the customers who most need to ask.
@@ -804,10 +906,10 @@ export class Client {
804
906
  * }
805
907
  * ```
806
908
  *
807
- * ⚠️Pages are ordered by when each memory was STORED, not by when it was edited.
909
+ * Pages are ordered by when each memory was STORED, not by when it was edited.
808
910
  * The response says so in `ordered_by`.
809
911
  *
810
- * ★Served from `/api/v1/won/*`, not `/api/v1/memory/*`. Won is the surface for calls
912
+ * Served from `/api/v1/won/*`, not `/api/v1/memory/*`. Won is the surface for calls
811
913
  * a model makes ABOUT its memory rather than calls an application makes WITH it, and
812
914
  * the address says so. The old path still answers, for clients published before
813
915
  * 2026-08-18, and both share one rate-limit budget.
@@ -847,9 +949,9 @@ export class Client {
847
949
  * assistant's own words, otherwise a person's name. Same cursor paging as
848
950
  * `listImages`.
849
951
  *
850
- * `chunks` / `points_to_delete` report the sentence-level fragments behind these
851
- * memories, which is what a delete would actually remove — usually larger than
852
- * `returned`, and worth showing before anyone confirms one.
952
+ * `chunks` / `points_to_delete` report how many internal records a delete would
953
+ * actually remove — usually more than `returned`, and worth showing to whoever is
954
+ * about to confirm one.
853
955
  */
854
956
  bySpeaker(speaker, userId, opts = {}) {
855
957
  if (!speaker || typeof speaker !== "string" || speaker.trim() === "") {
@@ -881,10 +983,9 @@ export class Client {
881
983
  /**
882
984
  * The engrams (and delivery forms) the selected model can actually run.
883
985
  *
884
- * `engram()` could always RUN one, but there was no way to ASK what exists — so a
885
- * caller had to hard-code names from the docs, and every engram we shipped after
886
- * their code was written stayed invisible to them. The MCP server hit exactly this
887
- * and stopped hard-coding for the same reason. The service is the authority: what
986
+ * Ask rather than hard-code: a name copied from the docs freezes a caller to the
987
+ * catalogue as it was that day, and anything added later stays invisible. The
988
+ * service is the authority. What
888
989
  * comes back depends on the model (delivery forms need Scroll 1.2+), so pass
889
990
  * `withModel()` if you want another model's catalogue.
890
991
  *
@@ -915,9 +1016,9 @@ export class Client {
915
1016
  }
916
1017
  /** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
917
1018
  deleteStore(userId) {
918
- // Validate BEFORE warning. The warning helper lowercases the id, so a non-string
919
- // used to die there as "id.toLowerCase is not a function" — the caller got a
920
- // TypeError from our internals instead of being told what was wrong with the id.
1019
+ // Validate before warning. The warning helper lowercases the id, so a non-string
1020
+ // reaches it and dies as "id.toLowerCase is not a function" — a TypeError from
1021
+ // inside the SDK instead of a sentence about the id.
921
1022
  if (typeof userId !== "string" || !userId.trim())
922
1023
  throw new Error("userId is required (a non-blank string) — deleteStore never falls back to the default store.");
923
1024
  warnIfStoreIdCollapses(userId); // destructive: the collision warning belongs here too
@@ -933,23 +1034,26 @@ export class Client {
933
1034
  /** The store's registered people, each with its memory count. */
934
1035
  async listSpeakers(userId) {
935
1036
  const r = await this.request("GET", `/api/v1/memory/speakers?user_id=${encodeURIComponent(this.uid(userId))}`);
936
- return { speakers: [], ...r };
1037
+ // The spread came second, so a present-but-null `speakers` overwrote the default
1038
+ // and the caller got null typed as an array. Every other list route goes through
1039
+ // asRecords; this one did not.
1040
+ return { ...r, speakers: asRecords(r?.speakers) };
937
1041
  }
938
1042
  /** Unregister a person. Their memories stay; the name tag goes. */
939
1043
  removeSpeaker(speaker, userId) {
940
1044
  return this.request("DELETE", "/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
941
1045
  }
942
1046
  // ----- delete -----
943
- /** Delete a single memory by id. `userId` optional (uses the default store). */
1047
+ /** Delete a single memory by id. Pass `undefined` as `userId` for the default
1048
+ * store — it is positional here, not omittable as it is in Python. */
944
1049
  delete(userId, memoryId) {
945
1050
  // Guard: without a memory_id the API's forget endpoint means "delete the
946
1051
  // whole store". An undefined/"" slipping in here must never become a wipe.
947
1052
  //
948
- // `.trim()` matters as much as the emptiness check: " " is truthy, so it used to
949
- // pass this guard and travel as memory_id. A server that trims it back to nothing
950
- // reads the request as the whole-store form — the single most destructive way for
951
- // this SDK to be wrong. `deleteAll` below already trimmed; the more dangerous path
952
- // was the one that did not.
1053
+ // `.trim()` matters as much as the emptiness check: " " is truthy, so without it
1054
+ // a blank string passes the guard and travels as memory_id. A server that trims it
1055
+ // back to nothing reads the request as the whole-store form, which is the most
1056
+ // destructive way for this SDK to be wrong.
953
1057
  if (typeof memoryId !== "string" || !memoryId.trim()) {
954
1058
  throw new Error("memoryId is required (non-blank). To delete every memory in a store, call deleteAll(userId) explicitly.");
955
1059
  }
@@ -984,65 +1088,107 @@ export class Client {
984
1088
  * (`NotFoundError`) and 401 behaving the same as everywhere else.
985
1089
  */
986
1090
  async requestBytes(path, body) {
987
- const ctrl = new AbortController();
988
- const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
989
- let res;
990
- try {
991
- res = await this.fetchImpl(`${this.base}${path}`, {
992
- method: "POST",
993
- headers: this.headers,
994
- body: JSON.stringify(body),
995
- signal: ctrl.signal,
996
- redirect: "manual",
997
- });
998
- }
999
- catch (e) {
1000
- clearTimeout(timer);
1001
- if (e?.name === "AbortError" || ctrl.signal.aborted) {
1002
- throw new APIConnectionError(0, `request timed out after ${this.timeoutMs}ms`);
1091
+ // This route had no retry loop at all, while the module doc promised "every call
1092
+ // retries transient failures … 429 always". Measured with maxRetries: 4 against a
1093
+ // server answering 429 — stats() made five requests, getImage() made one. The
1094
+ // largest and most rate-limit-prone call in the SDK was the one that gave up
1095
+ // immediately. The loop lives inside the function because honouring `Retry-After`
1096
+ // means still holding the response.
1097
+ const attempts = this.maxRetries + 1;
1098
+ for (let attempt = 0;; attempt++) {
1099
+ const ctrl = new AbortController();
1100
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
1101
+ let res;
1102
+ try {
1103
+ res = await this.fetchImpl(`${this.base}${path}`, {
1104
+ method: "POST",
1105
+ headers: this.headers,
1106
+ body: JSON.stringify(body),
1107
+ signal: ctrl.signal,
1108
+ redirect: "manual",
1109
+ });
1003
1110
  }
1004
- throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
1005
- }
1006
- try {
1007
- this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
1008
- if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
1009
- void res.body?.cancel();
1010
- throw errorFor(res.status, "the API answered with a redirect; refusing to follow it");
1111
+ catch (e) {
1112
+ clearTimeout(timer);
1113
+ const timedOut = e?.name === "AbortError" || ctrl.signal.aborted;
1114
+ // Same rule as `request()`: a connect-level failure never reached the server,
1115
+ // so re-sending cannot double-process anything. The 429 loop below was added
1116
+ // in 2.2.35 and this branch was not, which left the module doc's promise —
1117
+ // "every call retries transient failures" — still false for `getImage` on
1118
+ // exactly the failure a retry is for. A timeout is ambiguous and stays final.
1119
+ if (!timedOut && isConnectFailure(e) && attempt + 1 < attempts) {
1120
+ await new Promise((r) => setTimeout(r, this.backoffMs(attempt)));
1121
+ continue;
1122
+ }
1123
+ throw new APIConnectionError(0, timedOut ? `request timed out after ${this.timeoutMs}ms` : `network error: ${e?.message ?? e}`);
1011
1124
  }
1012
- if (!res.ok) {
1013
- let msg = await this.readCapped(res).catch(() => "");
1125
+ try {
1126
+ this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
1127
+ if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
1128
+ void res.body?.cancel();
1129
+ throw errorFor(res.status, "the API answered with a redirect; refusing to follow it");
1130
+ }
1131
+ if (!res.ok) {
1132
+ if (RETRY_ALWAYS.has(res.status) && attempt + 1 < attempts) {
1133
+ const retryAfter = res.headers.get("Retry-After");
1134
+ void res.body?.cancel();
1135
+ clearTimeout(timer);
1136
+ await new Promise((r) => setTimeout(r, this.backoffMs(attempt, retryAfter)));
1137
+ continue;
1138
+ }
1139
+ let msg = await this.readCapped(res).catch(() => "");
1140
+ try {
1141
+ const data = JSON.parse(msg);
1142
+ const err = data?.error;
1143
+ if (err && typeof err === "object")
1144
+ msg = err.message ?? err.type ?? msg;
1145
+ else if (typeof err === "string")
1146
+ msg = err;
1147
+ }
1148
+ catch {
1149
+ /* keep raw text */
1150
+ }
1151
+ if (msg.length > MAX_ERR_MSG)
1152
+ msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
1153
+ throw errorFor(res.status, msg || `HTTP ${res.status}`);
1154
+ }
1155
+ // Translated, not raw. A timeout that fires while the body is streaming used to
1156
+ // escape as `AbortError` — not a WosError, not an APIConnectionError — so a
1157
+ // caller's `catch (e) { if (e instanceof WosError) … }` handled every JSON route
1158
+ // and let this one through unclassified. The JSON path already translated it.
1159
+ let buf;
1014
1160
  try {
1015
- const data = JSON.parse(msg);
1016
- const err = data?.error;
1017
- if (err && typeof err === "object")
1018
- msg = err.message ?? err.type ?? msg;
1019
- else if (typeof err === "string")
1020
- msg = err;
1161
+ buf = await this.readCappedBytes(res);
1021
1162
  }
1022
- catch {
1023
- /* keep raw text */
1163
+ catch (e) {
1164
+ if (e?.name === "AbortError" || ctrl.signal.aborted) {
1165
+ throw new APIConnectionError(0, `request timed out after ${this.timeoutMs}ms`);
1166
+ }
1167
+ throw e;
1024
1168
  }
1025
- if (msg.length > MAX_ERR_MSG)
1026
- msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
1027
- throw errorFor(res.status, msg || `HTTP ${res.status}`);
1169
+ if (buf.byteLength === 0) {
1170
+ // An empty 200 would otherwise read as "here is your image" and write a
1171
+ // zero-byte file — indistinguishable from an image we lost.
1172
+ throw new WosError(res.status, "empty image body — the service returned no bytes");
1173
+ }
1174
+ return { bytes: buf, contentType: res.headers.get("content-type") ?? "application/octet-stream" };
1028
1175
  }
1029
- const buf = await this.readCappedBytes(res);
1030
- if (buf.byteLength === 0) {
1031
- // An empty 200 would otherwise read as "here is your image" and write a
1032
- // zero-byte file — indistinguishable from an image we lost.
1033
- throw new WosError(res.status, "empty image body — the service returned no bytes");
1176
+ finally {
1177
+ clearTimeout(timer);
1034
1178
  }
1035
- return { bytes: buf, contentType: res.headers.get("content-type") ?? "application/octet-stream" };
1036
- }
1037
- finally {
1038
- clearTimeout(timer);
1039
1179
  }
1040
1180
  }
1041
1181
  /** Validate an idempotency key BEFORE the network. The API answers a malformed
1042
1182
  * key with a 400, which on a retry path reads as "my write failed" when in fact
1043
1183
  * it was never attempted — cheaper and clearer to reject it here. */
1044
1184
  idemHeader(key) {
1045
- if (key === undefined)
1185
+ // `=== undefined` let `null` through: RegExp.test stringifies its argument, so
1186
+ // `null` became the literal key "null" and passed the format check. A key read
1187
+ // from JSON or a database row arrives as null, not undefined, and every write on
1188
+ // that path then shared one key — the second onward returned the first response
1189
+ // and stored nothing. Python guards with `is None`; Rust's Option makes it
1190
+ // unrepresentable.
1191
+ if (key === undefined || key === null)
1046
1192
  return undefined;
1047
1193
  if (!IDEMPOTENCY_KEY_RE.test(key)) {
1048
1194
  throw new Error(`invalid idempotencyKey: ${JSON.stringify(key)} — 1-128 chars of [A-Za-z0-9._:-]`);
@@ -1069,17 +1215,26 @@ export class Client {
1069
1215
  }
1070
1216
  /** The same cap, for a body that is not text.
1071
1217
  *
1072
- * `getImage` used to call `arrayBuffer()` here, which buffers whatever arrives. The
1073
- * cap exists for a hostile or broken `baseUrl`, and a photo endpoint is exactly where
1074
- * that shows up — the JSON paths were guarded while the one path that returns
1075
- * megabytes was not. One implementation, two callers, so they cannot drift. */
1218
+ * `arrayBuffer()` would buffer whatever arrives. The cap exists for a hostile or
1219
+ * broken `baseUrl`, and an endpoint that returns megabytes is where that costs the
1220
+ * most. One implementation, two callers, so they cannot drift. */
1076
1221
  async readCappedBytes(res) {
1077
1222
  const cl = res.headers.get("content-length");
1078
1223
  if (cl && Number(cl) > MAX_RESPONSE_BYTES) {
1079
1224
  throw new WosError(0, `response too large (${cl} bytes) — refusing to buffer it`);
1080
1225
  }
1081
- if (!res.body)
1082
- return new Uint8Array(await res.arrayBuffer());
1226
+ if (!res.body) {
1227
+ // `arrayBuffer()` has no ceiling, and only the content-length pre-check stands
1228
+ // in front of it — which a server simply omits. Reachable through the documented
1229
+ // `fetch` seam (test doubles, polyfills, wrappers that rebuild the Response),
1230
+ // and the option's own doc promises "the size cap still applies". Measured: a
1231
+ // body-less Response with no content-length buffered 73MB past the 64MB cap.
1232
+ const buf = new Uint8Array(await res.arrayBuffer());
1233
+ if (buf.byteLength > MAX_RESPONSE_BYTES) {
1234
+ throw new WosError(0, `response too large (${buf.byteLength} bytes) — refusing it`);
1235
+ }
1236
+ return buf;
1237
+ }
1083
1238
  const reader = res.body.getReader();
1084
1239
  const parts = [];
1085
1240
  let size = 0;
@@ -1209,10 +1364,9 @@ export class Client {
1209
1364
  msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
1210
1365
  throw errorFor(res.status, msg, requestId);
1211
1366
  }
1212
- // An empty body is only legal when the STATUS says there is no body. This
1213
- // used to accept any empty 2xx, so an `add()` answered by a truncating proxy
1214
- // returned `{}` and read as a successful write with no id — while the Python
1215
- // and Rust SDKs raised on the same response. Same rule in all three now.
1367
+ // An empty body is only legal when the STATUS says there is no body. Accepting
1368
+ // any empty 2xx would make an `add()` answered by a truncating proxy return `{}`,
1369
+ // which reads as a successful write with no id. All three SDKs apply this rule.
1216
1370
  if (!text) {
1217
1371
  if (NO_BODY_STATUS.has(res.status))
1218
1372
  return {};
@@ -1234,10 +1388,10 @@ export class Client {
1234
1388
  const got = data === null ? "null" : Array.isArray(data) ? "array" : typeof data;
1235
1389
  throw new WosError(res.status, `expected a JSON object in the response, got ${got}`);
1236
1390
  }
1237
- // 멱등키를 쓴 쓰기가 **저장된 것인지 재생된 것인지**를 호출자가 알아야 한다.
1238
- // 서버는 `Idempotent-Replayed: true` 로 말해주는데 지금까지 SDK 가 삼켰다 —
1239
- // 멱등키를 쓰는 사람이 가장 알고 싶은 사실이 그것이다(재시도가 실제로
1240
- // 저장을 일으켰나, 아니면 첫 응답이 되풀이된 것인가).
1391
+ // Surface whether the write was stored or replayed. The server says so with
1392
+ // `Idempotent-Replayed: true`, and it answers the question someone using an
1393
+ // idempotency key is asking: did my retry write, or is this the first response
1394
+ // coming back again?
1241
1395
  if (res.headers.get("Idempotent-Replayed") === "true") {
1242
1396
  data.replayed = true;
1243
1397
  }