wontopos 2.2.34 → 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/README.md CHANGED
@@ -23,15 +23,23 @@ const ctx = await mem.recall("what does alice drink?", "alice");
23
23
 
24
24
  ## Why
25
25
 
26
- - **Pure semantic retrieval** — no keyword/BM25. Identical recall in every language (Korean · Japanese · Chinese · English).
27
- - **No LLM in the loop** — `store` / `search` / `recall` never call a language model. You pay embeddings, not generation.
26
+ - **The same in every language** — identical recall whichever language a memory was written in (Korean · Japanese · Chinese · English).
27
+ - **No LLM in the loop** — `store` / `search` / `recall` never call a language model. You pay retrieval, not generation.
28
28
  - **Bounded retrieval** — `recall()` returns a small, fixed-size slice (~1,200 tokens) regardless of how much you've stored.
29
29
 
30
30
  ## Methods
31
31
 
32
32
  `add` · `addTurn` · `addBulk` · `update` · `search` · `searchFull` · `recall` · `history` · `stats` · `get` · `listMemories` · `delete` · `deleteAll`
33
33
 
34
- Every method takes the store id as its second argument — `mem.add("...", "alice")`. Omit it to use the client's default store. A store is one isolated memory space per end-user, agent, or topic. WHO said each memory inside a store is the `speaker` tag below — storing the assistant's own words never needs a separate id.
34
+ Where the store id goes depends on whether the method takes a payload. A method that
35
+ writes or searches puts the payload first and the store second — `mem.add("...", "alice")`,
36
+ `mem.search("...", "alice")`. A method that only addresses a store takes it first —
37
+ `mem.listMemories("alice")`, `mem.stats("alice")`, `mem.delete("alice", memoryId)`.
38
+ Most of them let you leave it out for the client's default store. Four take it
39
+ positionally and cannot — `get`, `delete`, `lineage` and `forgetImage` want an explicit
40
+ `undefined` there, because a second required argument follows it. Python omits it in
41
+ all four; this is the one place the three clients differ in shape rather than meaning.
42
+ Let your editor confirm the order: every signature is typed. A store is one isolated memory space per end-user, agent, or topic. WHO said each memory inside a store is the `speaker` tag below — storing the assistant's own words never needs a separate id.
35
43
 
36
44
  ## Who said it (speakers)
37
45
 
@@ -72,7 +80,12 @@ const hits = await mem.search("...the conversation so far...", "alice", 10,
72
80
 
73
81
  Built in, no configuration needed:
74
82
 
75
- - **Automatic retries** — 429 / 502 / 503 and connection errors retry twice with
83
+ - **Automatic retries** — 429 always, and 502 / 503 or a connection error only when a
84
+ retry cannot double-process a write. The writes and the searches are POSTs, and a
85
+ 502 on one of those may have been returned *after* the service already stored and
86
+ billed it, so those get 429 and connect-level failures only. The reads and the
87
+ deletes that address a whole store — `listStores`, `listSpeakers`, `deleteStore`,
88
+ `removeSpeaker`, `forgetImage` — are GET or DELETE and do retry a 502. Twice, with
76
89
  exponential backoff + jitter, honoring the server's `Retry-After`. Tune with
77
90
  `new Client({ maxRetries })`; `0` disables.
78
91
  - **Redirects refused** — the API key never follows a 3xx to another host.
@@ -189,7 +202,60 @@ data to fix it.
189
202
 
190
203
  The three clients release in lockstep — same version, same surface, same day.
191
204
  Patch releases are additive: nothing is removed or reordered within a minor line.
192
-
205
+ Two have bent that, deliberately and named at the top of their entry — 2.2.34 moved the
206
+ default engine, and 2.2.35 gives `search` the count range `recall` has always had. A
207
+ rule you can bend without saying so is not a rule, so both are stated rather than left
208
+ for a reader to hit.
209
+
210
+ - **2.2.35** — fixes, and **two behaviour changes**, both listed first because a patch
211
+ release is not the place to find one by surprise.
212
+
213
+ ⚠️ **`search`'s count is now 5–20, and out of range is refused rather than
214
+ adjusted.** `recall` has carried that range from the start; search had no contract
215
+ anywhere, so the clients sent whatever they were given and the service capped at 50
216
+ with no floor. `search(q, limit=50)` and `search(q, limit=3)` both worked on 2.2.34
217
+ and now raise. The default is still 10, so a call that passes no count is unaffected.
218
+ Asking for 20 and silently getting 10 reads as "that is all there is", which is why
219
+ this refuses instead of clamping.
220
+
221
+ ⚠️ **A Rust `limit` of 0 is no longer rewritten to 10.** It was, and the caller whose
222
+ prompt budget computed zero was handed ten memories and the bill for them. It is now
223
+ refused under the range above rather than quietly changed to something nobody asked
224
+ for.
225
+
226
+ The fixes: a `null` idempotency key no longer becomes the literal key `"null"`, which
227
+ had made every write on that path share one key so the second onward stored nothing
228
+ (TypeScript). The async client no longer reads an explicit `retries=2` as "the caller
229
+ said nothing" (Python). The image read is actually streamed, so the 64MB cap bounds a
230
+ compressed body instead of measuring bytes already in memory (Python async), and that
231
+ route's ERROR body is capped too (Rust). `iter_images` stops on a repeated cursor the
232
+ way `iter_memories` always did (Python). `iterImages` no longer throws on the ordinary
233
+ end of a walk (TypeScript). `get_image` retries a 429 and a connect-level failure like
234
+ every other call — it had none at all — in the **sync Python client and TypeScript**;
235
+ the async Python client and the Rust crate still make a single attempt there, and say
236
+ so at the method. An empty or control-character API key is refused at the call site
237
+ rather than at the network in **Python and Rust**; TypeScript still refuses only empty
238
+ and whitespace. And a page walk that reaches its ceiling now raises instead of
239
+ returning a truncated list that looks complete.
240
+ - **2.2.34** — the default engine is `tablet-2`: same token price as `tablet-1`, and
241
+ the one that serves images and re-ask. Pin the old one with
242
+ `model="tablet-1"` for the previous behaviour exactly. (The engine's own default
243
+ count differs between the two, but these SDKs always send one, so a call through
244
+ them is unaffected.) New `search_full` /
245
+ `searchFull` keeps every field the answer came with — `search` was merging away
246
+ the photos and `verify_used`, both of which you were billed for. Also: results arrive
247
+ best-first — take the order as given. `similarity` is a raw closeness score, and
248
+ sorting by it makes results worse.
249
+ - **2.2.33** — `get_image` can return a different FORMAT than you uploaded: the
250
+ service re-encodes on downscale, so a large PNG comes back as WebP. Take the file
251
+ extension from the response Content-Type, not from what you sent.
252
+ - **2.2.32** — `get_image` returns the picture the SERVICE holds, not your original.
253
+ An image whose long edge is over 1568px is downscaled on the way in, and that
254
+ smaller picture is what is stored and handed back. Keep your own copy if you need
255
+ the full file.
256
+ - **2.2.31** — images, by-speaker, lineage and revisions are SDK methods in all three
257
+ clients; `search` and `recall` take `verify`, `max_images` and recall's `limit` /
258
+ `context_limit`.
193
259
  - **2.2.30** — documentation only: this changelog, which had not been updated
194
260
  in the shipped package since 2.2.10.
195
261
  - **2.2.29** — a store id that was PASSED but unusable no longer becomes the
@@ -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
@@ -39,6 +42,8 @@
39
42
  */
40
43
  /** Test hook — the warn-once set is process-global by design. */
41
44
  export declare function _resetStoreIdWarnings(): void;
45
+ export declare const SEARCH_LIMIT_MIN = 5;
46
+ export declare const SEARCH_LIMIT_MAX = 20;
42
47
  /** Quota from a response's `X-RateLimit-*` headers, or `null` if absent. */
43
48
  export interface RateLimit {
44
49
  limit: number | null;
@@ -104,7 +109,7 @@ export interface Memory {
104
109
  * re-sorting by `similarity` overrides the ranking and makes results worse. Take the
105
110
  * list in the order given. There is no `score` field. */
106
111
  similarity?: number;
107
- /** Learned importance weight of the memory. */
112
+ /** Importance weight the service assigns to the memory. */
108
113
  importance?: number;
109
114
  /** Month bucket the memory belongs to (e.g. "2026-07"). Omitted when temporal fields are stripped. */
110
115
  time_bucket?: string;
@@ -128,11 +133,11 @@ export interface StoreResult {
128
133
  /**
129
134
  * Present when `status` is `"duplicate"`: the id of the memory this write collided with.
130
135
  *
131
- * The match is by MEANING, not by exact text — a genuinely new fact that only varies a
132
- * detail of one already stored ("allergic to shellfish" next to "allergic to peanuts")
133
- * lands here too. So a duplicate is not always a harmless no-op: read this id, and
134
- * either merge the two into one sentence or `update()` the existing memory. Without it
135
- * a dropped write gave the caller nothing to act on.
136
+ * A genuinely new fact that only varies a detail of one already stored ("no meetings
137
+ * before 10am" next to "no meetings on Fridays") can land here too, so a duplicate is
138
+ * not always a harmless no-op: read this id, and either store one sentence that states
139
+ * both or `update()` the existing memory with the combined statement. Without it a
140
+ * dropped write gave the caller nothing to act on.
136
141
  */
137
142
  duplicate_of?: string;
138
143
  /** Present when the store was a duplicate and something (e.g. a speaker tag) was dropped. */
@@ -153,9 +158,9 @@ export interface StatusResult {
153
158
  *
154
159
  * What the service keeps is NOT your original. An image whose long edge is over
155
160
  * 1568px is downscaled to 1568 before anything else happens, and that smaller
156
- * picture is what gets embedded, stored, and handed back by `getImage`. Nothing in
157
- * the retrieval path ever looks above 1568 — the embedder and the reader both
158
- * shrink to it — so the extra pixels would be bytes nobody reads. This is a memory
161
+ * picture is what gets stored, and handed back by `getImage`. Nothing on our side
162
+ * ever uses more than 1568, so the extra pixels would be bytes nobody reads. This
163
+ * is a memory
159
164
  * service, not a photo host: the picture it holds has the resolution of a memory.
160
165
  *
161
166
  * It can also come back in a different FORMAT. Downscaling means re-encoding, and
@@ -302,14 +307,15 @@ export interface UpdateResult {
302
307
  /** `searchFull` → everything one search answered with, not just the merged memories.
303
308
  *
304
309
  * `search` returns the memories and nothing else, which is the right answer for
305
- * almost every call. Two options make it the wrong one: `max_images` asks for a lane
306
- * that is delivered SEPARATELY (Tablet 2 splits photo results from text), and
307
- * `verify` is reported on by `verify_used`. Merging away both means paying for a
310
+ * almost every call. Two options make it the wrong one: `max_images` asks for
311
+ * photos, which arrive in their own field, and `verify` is reported on by
312
+ * `verify_used`. Merging away both means paying for a
308
313
  * search you shaped and never seeing what came of it. */
309
314
  export interface SearchResult {
310
- /** The main lane — what others said / general memories. */
315
+ /** What others said, and general memories. */
311
316
  memories: Memory[];
312
- /** The assistant's own words (speaker "me"); `[]` on a model without a self lane. */
317
+ /** The assistant's own words (speaker "me"); `[]` on a model that does not keep
318
+ * them apart. */
313
319
  self_memories: Memory[];
314
320
  /** Image memories, when `max_images` asked for any; `[]` otherwise. */
315
321
  images: Memory[];
@@ -355,7 +361,7 @@ export interface StatsResult {
355
361
  short_term_turns?: number;
356
362
  [key: string]: unknown;
357
363
  }
358
- /** `listMemories` → one page of a store's raw memories (original text + metadata, no vector). */
364
+ /** `listMemories` → one page of a store's raw memories (the text you stored, and its metadata). */
359
365
  export interface MemoryPage {
360
366
  memories: Memory[];
361
367
  count: number;
@@ -363,9 +369,9 @@ export interface MemoryPage {
363
369
  next_cursor: string | null;
364
370
  [key: string]: unknown;
365
371
  }
366
- /** `searchSelf` result: the main lane plus the assistant's own words, split. */
372
+ /** `searchSelf` result: general memories and the assistant's own, kept apart. */
367
373
  export interface SelfSearchResult {
368
- /** Main lane — what others said / general memories. */
374
+ /** What others said, and general memories. */
369
375
  memories: Memory[];
370
376
  /** The assistant's own words (stored with speaker "me"); `[]` on non-self models. */
371
377
  self_memories: Memory[];
@@ -428,10 +434,11 @@ export interface SpeakersList {
428
434
  [key: string]: unknown;
429
435
  }
430
436
  /**
431
- * Narrow a search to part of a store — applied BEFORE ranking, so the results are
432
- * the best matches *within* the filter, not a filtered top-N.
437
+ * Narrow a search to part of a store. The filter chooses what is searched, not what
438
+ * is kept afterwards — so a narrow filter still returns your full `limit` when that
439
+ * many matches sit inside it.
433
440
  *
434
- * Retrieval itself stays purely semantic (no keyword matching), so these behave
441
+ * Retrieval behaves the same in every language, so these behave
435
442
  * identically in every language. Unlisted keys are dropped by the API rather than
436
443
  * rejected, so a typo silently widens the search — spell them exactly.
437
444
  */
@@ -465,7 +472,7 @@ export interface SearchOptions {
465
472
  /**
466
473
  * Re-ask passes, 0–3. After the first retrieval the service asks the engine again up
467
474
  * to this many times, each time telling it what was already returned so it skips
468
- * those and reaches further back. Retrieval stays purely semantic — no LLM at any
475
+ * those and reaches further back. No LLM runs at any
469
476
  * value. Stops early when a pass finds nothing new; `verify_used` in the response
470
477
  * says how many actually ran.
471
478
  *
@@ -495,8 +502,11 @@ export interface SearchOptions {
495
502
  * `idempotencyKey` makes repeating THIS EXACT write safe: the API replays the first
496
503
  * response instead of storing again, for 10 minutes, and answers 422 if the same key
497
504
  * arrives with a different body. Use it when a retry is your own (a job that died and
498
- * was re-run, a queue that redelivers) — the SDK never retries writes by itself,
499
- * because without a key it cannot know whether the first attempt landed.
505
+ * was re-run, a queue that redelivers). The SDK retries a write on exactly one
506
+ * status: 429, which the service answers before it processes anything, so nothing
507
+ * was stored. It never retries a write on 502 / 503 or a dropped body, where the
508
+ * request may already have been stored and billed — without a key it cannot know
509
+ * whether that first attempt landed.
500
510
  *
501
511
  * The key must be UNIQUE PER LOGICAL WRITE — derive it from the thing being stored
502
512
  * (`` `import:${row.id}` ``), never a constant, or the second write replays the first
@@ -610,36 +620,36 @@ export declare class Client {
610
620
  store(content: string, userId?: string, metadata?: Record<string, unknown>, opts?: WriteOptions): Promise<StoreResult>;
611
621
  /** Store a conversation turn (user + assistant). Payload first, userId last — same shape as add/search. */
612
622
  addTurn(userMsg: string, assistantMsg: string, userId?: string, opts?: WriteOptions): Promise<StatusResult>;
613
- /** Bulk-ingest a large blob of text (chunked + embedded server-side). For backfilling.
623
+ /** Bulk-ingest a large blob of text in one call. For backfilling.
614
624
  * The call most worth an `opts.idempotencyKey`: a backfill that dies halfway and is
615
- * re-run would otherwise re-chunk and re-bill the whole blob. */
625
+ * re-run would otherwise re-ingest and re-bill the whole blob. */
616
626
  addBulk(content: string, userId?: string, category?: string, timestamp?: string, opts?: WriteOptions): Promise<StatusResult>;
617
627
  /** Supersede an old memory with new content. Payload first, userId last — same shape as add/search. */
618
628
  update(oldMemoryId: string, newContent: string, userId?: string, opts?: WriteOptions): Promise<UpdateResult>;
619
- /** Semantic search over a store's memories. Returns memories, most relevant first.
620
- *
621
- * `limit` bounds the MAIN lane, not the returned array. On a self-lane model
622
- * (Scroll 1.2+) the service answers in two disjoint lanes — the assistant's own
623
- * words come back separately — and both are returned here, so the array can hold
624
- * more than `limit`. They were retrieved and billed either way; dropping them would
625
- * only hide what you already paid for. Size a prompt window on the array you get
626
- * back, not on `limit`. `searchSelf()` hands the lanes back apart. */
629
+ /** Search a store's memories. Returns them most relevant first.
630
+ *
631
+ * `limit` bounds `memories`, not the returned array. On a model that keeps the
632
+ * assistant's own words separate (Scroll 1.2+) those come back as well, so the
633
+ * array can hold more than `limit`. They were retrieved and billed either way;
634
+ * dropping them would only hide what you already paid for. Size a prompt window
635
+ * on the array you get back, not on `limit`. `searchSelf()` hands the two back
636
+ * apart. */
627
637
  search(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<Memory[]>;
628
- /** Search a self-memory model (Scroll 1.2+): both lanes from ONE call. Returns
629
- * `{ memories, self_memories }` — `memories` is the main lane (what others said /
630
- * general memories), `self_memories` is the assistant's OWN words (stored with
631
- * speaker "me"), kept separate so the reader never confuses who said what. On a model
632
- * without a self lane, `self_memories` is `[]`. `userId` optional (default store). */
638
+ /** Search a self-memory model (Scroll 1.2+): both fields from ONE call. Returns
639
+ * `{ memories, self_memories }` — `memories` is what others said and general
640
+ * memories, `self_memories` is the assistant's OWN words (stored with speaker
641
+ * "me"), kept apart so whoever reads them never confuses who said what. On a model
642
+ * that does not keep them apart, `self_memories` is `[]`. `userId` may be omitted. */
633
643
  searchSelf(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<SelfSearchResult>;
634
644
  /**
635
- * Search, and keep every lane the answer came in.
645
+ * Search, and keep every field the answer came with.
636
646
  *
637
647
  * Same request as {@link Client.search} — reach for this one when the options make
638
648
  * the merged array an incomplete answer:
639
649
  *
640
650
  * const r = await mem.searchFull("the day we moved", "alice", 10,
641
651
  * { max_images: 3, verify: 2 });
642
- * r.images.length; // the photo lane, which `search` drops
652
+ * r.images.length; // the photos, which `search` drops
643
653
  * r.verify_used; // how many re-ask passes actually ran (you are billed per pass)
644
654
  *
645
655
  * Both options need Tablet 2 or newer and are refused (403) on an older engine
@@ -661,11 +671,13 @@ export declare class Client {
661
671
  * did not ask for.
662
672
  */
663
673
  limit?: number;
664
- /** Chunks of surrounding context attached around the best match, 0–20. Default 10;
674
+ /** How much surrounding context is attached around the best match, 0–20. Default 10;
665
675
  * 0 attaches none. Same model floor as `limit`. */
666
676
  context_limit?: number;
667
677
  }): Promise<RecallResult>;
668
- /** Run a built-in engram ("deep_recall" | "timeline" | "gather"). Returns the merged result.
678
+ /** Run a built-in engram ("deep_recall" | "timeline" | "gather" | "equilibrium" |
679
+ * "tone_stabilizer"; the service is the authority — an unknown name comes back with
680
+ * the list it accepts). Returns the merged result.
669
681
  * `form`/`tz` render memory times (memoir/archive) on Scroll 1.2+, same as search/recall. */
670
682
  engram(name: string, query: string, userId?: string, opts?: {
671
683
  form?: string;
@@ -675,13 +687,14 @@ export declare class Client {
675
687
  history(userId?: string): Promise<HistoryTurn[]>;
676
688
  /** Memory counts for a store: { total_memories, short_term_turns }. */
677
689
  stats(userId?: string): Promise<StatsResult>;
678
- /** Fetch ONE memory by id — the original text + metadata, never the vector.
690
+ /** Fetch ONE memory by id — the text you stored, and its metadata.
679
691
  * The id is what `add`/`store` or `listMemories` returned. Same visibility as
680
- * `listMemories`: an id from another store, a decomposed atom sentence, or an
681
- * invalidated memory rejects with `NotFoundError`. `userId` optional (default store). */
692
+ * `listMemories`: an id from another store, an internal record id, or an
693
+ * invalidated memory rejects with `NotFoundError`. Pass `undefined` as `userId` for
694
+ * the default store — it is positional here, not omittable as it is in Python. */
682
695
  get(userId: string | undefined, memoryId: string): Promise<Memory>;
683
- /** List a store's stored memories — the original text plus metadata, never the
684
- * vector. Paginated: pass the returned `next_cursor` back as `cursor` for the next
696
+ /** List a store's stored memories — the text you stored, plus its metadata.
697
+ * Paginated: pass the returned `next_cursor` back as `cursor` for the next
685
698
  * page; a `null` cursor means the last page. Use it to browse or export a store.
686
699
  *
687
700
  * let cursor: string | null = null;
@@ -697,14 +710,14 @@ export declare class Client {
697
710
  cursor?: string;
698
711
  }): Promise<MemoryPage>;
699
712
  /** Async-iterate every stored memory in a store, paging under the hood — no cursor
700
- * bookkeeping. Original text + metadata only, never vectors.
713
+ * bookkeeping. The text you stored and its metadata only.
701
714
  *
702
715
  * for await (const m of mem.iterMemories()) console.log(m.id, m.content);
703
716
  */
704
717
  iterMemories(userId?: string, opts?: {
705
718
  pageSize?: number;
706
719
  }): AsyncGenerator<Memory>;
707
- /** Collect ALL of a store's memories into an array (original text + metadata, no vectors). */
720
+ /** Collect ALL of a store's memories into an array (the text you stored, and its metadata). */
708
721
  exportMemories(userId?: string): Promise<Memory[]>;
709
722
  /**
710
723
  * Fetch the bytes of an image memory — the picture the SERVICE holds.
@@ -712,8 +725,8 @@ export declare class Client {
712
725
  * Not necessarily your upload, in size OR in format: an image whose long edge was
713
726
  * over 1568px was downscaled to 1568 on the way in and re-encoded — lossless
714
727
  * formats as WebP, so a PNG comes back as `image/webp`; JPEG stays JPEG — and that
715
- * smaller picture is what was embedded, stored and comes back here. Nothing in the
716
- * retrieval path looks above 1568, so the extra pixels would be bytes nobody reads.
728
+ * smaller picture is what is stored and comes back here. Nothing on our side ever
729
+ * uses more than 1568, so the extra pixels would be bytes nobody reads.
717
730
  * Keep your own copy for the full-resolution file.
718
731
  *
719
732
  * The type is sniffed from the bytes themselves rather than from whatever the file
@@ -831,9 +844,9 @@ export declare class Client {
831
844
  * assistant's own words, otherwise a person's name. Same cursor paging as
832
845
  * `listImages`.
833
846
  *
834
- * `chunks` / `points_to_delete` report the sentence-level fragments behind these
835
- * memories, which is what a delete would actually remove — usually larger than
836
- * `returned`, and worth showing before anyone confirms one.
847
+ * `chunks` / `points_to_delete` report how many internal records a delete would
848
+ * actually remove — usually more than `returned`, and worth showing to whoever is
849
+ * about to confirm one.
837
850
  */
838
851
  bySpeaker(speaker: string, userId?: string, opts?: {
839
852
  limit?: number;
@@ -875,7 +888,8 @@ export declare class Client {
875
888
  listSpeakers(userId?: string): Promise<SpeakersList>;
876
889
  /** Unregister a person. Their memories stay; the name tag goes. */
877
890
  removeSpeaker(speaker: string, userId?: string): Promise<SpeakerOpResult>;
878
- /** Delete a single memory by id. `userId` optional (uses the default store). */
891
+ /** Delete a single memory by id. Pass `undefined` as `userId` for the default
892
+ * store — it is positional here, not omittable as it is in Python. */
879
893
  delete(userId: string | undefined, memoryId: string): Promise<StatusResult>;
880
894
  /** Delete ALL memories for a store (GDPR erase). `userId` is required on purpose -
881
895
  * this is destructive, so it never falls back to the default store. */
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.34";
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). */
@@ -57,11 +60,12 @@ function logDebug(msg) {
57
60
  const DEFAULT_BASE_URL = "https://api.wontopos.com";
58
61
  /** The engine every call uses unless the caller names another.
59
62
  *
60
- * Tablet 2 costs the same per token as Tablet 1 and is the only released line that
61
- * can serve images, re-ask passes (`verify`), and the self lane, so a caller who
62
- * names nothing gets the engine that can answer the most. All models read the same
63
- * memory, so switching is a header, not a migration. Pin an older one explicitly
64
- * with `new Client({ apiKey, model: "tablet-1" })` or `withModel("tablet-1")`. */
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")`. */
65
69
  const DEFAULT_MODEL = "tablet-2";
66
70
  /** 429 = rate-limited BEFORE processing → always safe to retry (no write, no bill). */
67
71
  const RETRY_ALWAYS = new Set([429]);
@@ -139,15 +143,44 @@ export function _resetStoreIdWarnings() {
139
143
  * answer with a JSON object — see the empty-body check in `request`. */
140
144
  const NO_BODY_STATUS = new Set([204, 205, 304]);
141
145
  /** Paging backstop: bound the walk so a fresh-cursor-forever server can't loop us. */
142
- 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
+ }
143
176
  /**
144
177
  * Put an image into the shape the API expects, and fail loudly on the parts we can
145
178
  * check from here.
146
179
  *
147
- * Deliberately NOT checked: the byte ceiling. It is a server setting (`/health`
148
- * reports it as `memory.images.max_bytes`), so a number baked in here would drift the
149
- * first time that config changes and would reject an image the service would have
150
- * 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
151
184
  * prefix — browsers and file pickers hand you `data:image/jpeg;base64,…`, the API
152
185
  * wants the part after the comma, and the difference is invisible until the write
153
186
  * fails with "not a readable image".
@@ -241,21 +274,18 @@ const asObj = (x) => (x != null && typeof x === "object" && !Array.isArray(x) ?
241
274
  // user code. Drop elements that aren't objects and keep the valid records, so one
242
275
  // bad element never poisons the batch (and never destroys it).
243
276
  const asRecords = (x) => asList(x).filter((m) => m != null && typeof m === "object" && !Array.isArray(m));
244
- // Attach a delivery-form override (memoir/archive) + tz to a body, the way search
245
- // passes them; on Scroll 1.2+ this renders each returned memory's time in that form.
246
- /** Every memory a search returned — main lane and self lane together.
277
+ /** Every memory a search returned, from both fields, as one array.
247
278
  *
248
- * On a self-lane model the service splits the result: the assistant's OWN words
249
- * come back in `self_memories` and are REMOVED from `memories`, so the two never
250
- * double-count. `search()` read only `memories`, so on Scroll 1.2 an assistant
251
- * turn stored with `addTurn` was silently missing from search while the same
252
- * query on tablet-1 returned it — upgrading the model made search return LESS,
253
- * 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.
254
284
  *
255
- * Both lanes, de-duplicated by id, each memory keeping its `speaker` so the
256
- * caller can still tell who said what. Callers who want the lanes kept apart use
257
- * `searchSelf()`, which exists for exactly that. */
258
- 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) {
259
289
  const main = asRecords(r?.memories);
260
290
  const mine = asRecords(r?.self_memories);
261
291
  if (mine.length === 0)
@@ -263,6 +293,9 @@ function mergeLanes(r) {
263
293
  const seen = new Set(main.map((m) => m.id).filter((id) => typeof id === "string"));
264
294
  return main.concat(mine.filter((m) => !(typeof m.id === "string" && seen.has(m.id))));
265
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). */
266
299
  function withForm(body, form, tz) {
267
300
  if (form !== undefined)
268
301
  body.form = form;
@@ -403,6 +436,12 @@ export class Client {
403
436
  throw new Error("apiKey is required");
404
437
  if (/\s/.test(key))
405
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");
406
445
  this.apiKey = key;
407
446
  this.base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
408
447
  // Coerce a non-finite or non-positive timeout back to the default — NaN
@@ -448,7 +487,12 @@ export class Client {
448
487
  // (Python already used urlsplit here; this was the copy that did not.)
449
488
  let host;
450
489
  try {
451
- 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, "");
452
496
  }
453
497
  catch {
454
498
  host = ""; // unparseable → not loopback → warn, which is the safe direction
@@ -571,9 +615,9 @@ export class Client {
571
615
  addTurn(userMsg, assistantMsg, userId, opts = {}) {
572
616
  return this.post("/api/v1/memory/store-turn", { user_id: this.uid(userId), user_msg: userMsg, assistant_msg: assistantMsg }, opts.idempotencyKey);
573
617
  }
574
- /** 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.
575
619
  * The call most worth an `opts.idempotencyKey`: a backfill that dies halfway and is
576
- * 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. */
577
621
  addBulk(content, userId, category = "general", timestamp, opts = {}) {
578
622
  const body = { user_id: this.uid(userId), content, category };
579
623
  if (timestamp)
@@ -585,40 +629,42 @@ export class Client {
585
629
  return this.post("/api/v1/memory/supersede", { user_id: this.uid(userId), old_memory_id: oldMemoryId, new_content: newContent }, opts.idempotencyKey);
586
630
  }
587
631
  // ----- read -----
588
- /** Semantic search over a store's memories. Returns memories, most relevant first.
632
+ /** Search a store's memories. Returns them most relevant first.
589
633
  *
590
- * `limit` bounds the MAIN lane, not the returned array. On a self-lane model
591
- * (Scroll 1.2+) the service answers in two disjoint lanes — the assistant's own
592
- * words come back separately — and both are returned here, so the array can hold
593
- * more than `limit`. They were retrieved and billed either way; dropping them would
594
- * only hide what you already paid for. Size a prompt window on the array you get
595
- * 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. */
596
640
  async search(query, userId, limit = 10, opts = {}) {
597
641
  // Reserved fields win over ...opts: an app that forwards untrusted input as
598
642
  // opts must not be able to override the store (user_id), query, or limit.
599
643
  warnOnUnknownFilters(opts.filters);
644
+ checkSearchLimit(limit);
600
645
  const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
601
- return mergeLanes(r);
646
+ return mergeResults(r);
602
647
  }
603
- /** Search a self-memory model (Scroll 1.2+): both lanes from ONE call. Returns
604
- * `{ memories, self_memories }` — `memories` is the main lane (what others said /
605
- * general memories), `self_memories` is the assistant's OWN words (stored with
606
- * speaker "me"), kept separate so the reader never confuses who said what. On a model
607
- * 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. */
608
653
  async searchSelf(query, userId, limit = 10, opts = {}) {
609
654
  warnOnUnknownFilters(opts.filters);
655
+ checkSearchLimit(limit);
610
656
  const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
611
657
  return { memories: asRecords(r.memories), self_memories: asRecords(r.self_memories) };
612
658
  }
613
659
  /**
614
- * Search, and keep every lane the answer came in.
660
+ * Search, and keep every field the answer came with.
615
661
  *
616
662
  * Same request as {@link Client.search} — reach for this one when the options make
617
663
  * the merged array an incomplete answer:
618
664
  *
619
665
  * const r = await mem.searchFull("the day we moved", "alice", 10,
620
666
  * { max_images: 3, verify: 2 });
621
- * r.images.length; // the photo lane, which `search` drops
667
+ * r.images.length; // the photos, which `search` drops
622
668
  * r.verify_used; // how many re-ask passes actually ran (you are billed per pass)
623
669
  *
624
670
  * Both options need Tablet 2 or newer and are refused (403) on an older engine
@@ -626,8 +672,9 @@ export class Client {
626
672
  */
627
673
  async searchFull(query, userId, limit = 10, opts = {}) {
628
674
  warnOnUnknownFilters(opts.filters);
675
+ checkSearchLimit(limit);
629
676
  const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
630
- // Spread first so a lane added later still arrives; the known ones are then
677
+ // Spread first so a field added later still arrives; the known ones are then
631
678
  // normalized, because a broken proxy can null any of them.
632
679
  return {
633
680
  ...r,
@@ -647,7 +694,9 @@ export class Client {
647
694
  body.context_limit = opts.context_limit;
648
695
  return this.post("/api/v1/memory/recall", withForm(body, opts.form, opts.tz));
649
696
  }
650
- /** 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.
651
700
  * `form`/`tz` render memory times (memoir/archive) on Scroll 1.2+, same as search/recall. */
652
701
  engram(name, query, userId, opts = {}) {
653
702
  return this.post("/api/v1/engram/run", withForm({ name, user_id: this.uid(userId), query }, opts.form, opts.tz));
@@ -661,10 +710,11 @@ export class Client {
661
710
  stats(userId) {
662
711
  return this.post("/api/v1/memory/stats", { user_id: this.uid(userId) });
663
712
  }
664
- /** 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.
665
714
  * The id is what `add`/`store` or `listMemories` returned. Same visibility as
666
- * `listMemories`: an id from another store, a decomposed atom sentence, or an
667
- * 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. */
668
718
  get(userId, memoryId) {
669
719
  // Sync throw (same shape as `delete`), so a missing id fails loudly even
670
720
  // when the caller forgets to await.
@@ -673,8 +723,8 @@ export class Client {
673
723
  }
674
724
  return this.post("/api/v1/memory/get", { user_id: this.uid(userId), memory_id: memoryId }).then((r) => asObj(r.memory));
675
725
  }
676
- /** List a store's stored memories — the original text plus metadata, never the
677
- * 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
678
728
  * page; a `null` cursor means the last page. Use it to browse or export a store.
679
729
  *
680
730
  * let cursor: string | null = null;
@@ -692,7 +742,7 @@ export class Client {
692
742
  return this.post("/api/v1/memory/list", body);
693
743
  }
694
744
  /** Async-iterate every stored memory in a store, paging under the hood — no cursor
695
- * bookkeeping. Original text + metadata only, never vectors.
745
+ * bookkeeping. The text you stored and its metadata only.
696
746
  *
697
747
  * for await (const m of mem.iterMemories()) console.log(m.id, m.content);
698
748
  */
@@ -707,12 +757,14 @@ export class Client {
707
757
  yield m;
708
758
  const next = p.next_cursor ?? undefined;
709
759
  if (!next || seen.has(next))
710
- break;
760
+ return;
711
761
  seen.add(next);
712
762
  cursor = next;
713
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.`);
714
766
  }
715
- /** 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). */
716
768
  async exportMemories(userId) {
717
769
  const out = [];
718
770
  for await (const m of this.iterMemories(userId))
@@ -726,8 +778,8 @@ export class Client {
726
778
  * Not necessarily your upload, in size OR in format: an image whose long edge was
727
779
  * over 1568px was downscaled to 1568 on the way in and re-encoded — lossless
728
780
  * formats as WebP, so a PNG comes back as `image/webp`; JPEG stays JPEG — and that
729
- * smaller picture is what was embedded, stored and comes back here. Nothing in the
730
- * 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.
731
783
  * Keep your own copy for the full-resolution file.
732
784
  *
733
785
  * The type is sniffed from the bytes themselves rather than from whatever the file
@@ -800,17 +852,24 @@ export class Client {
800
852
  const p = await this.listImages(userId, { limit: opts.pageSize, before, skipIds });
801
853
  for (const m of asRecords(p.images))
802
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.
803
860
  if (!p.has_more || !p.next_before)
804
- break;
861
+ return;
805
862
  // The cursor is the PAIR: several images can share a timestamp, so `before`
806
863
  // alone repeats across pages legitimately.
807
864
  const key = `${p.next_before}|${(Array.isArray(p.next_skip_ids) ? p.next_skip_ids : []).join(",")}`;
808
865
  if (seen.has(key))
809
- break;
866
+ return;
810
867
  seen.add(key);
811
868
  before = p.next_before;
812
869
  skipIds = Array.isArray(p.next_skip_ids) ? p.next_skip_ids : undefined;
813
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.`);
814
873
  }
815
874
  // ----- how much has this memory been edited -----
816
875
  /**
@@ -890,9 +949,9 @@ export class Client {
890
949
  * assistant's own words, otherwise a person's name. Same cursor paging as
891
950
  * `listImages`.
892
951
  *
893
- * `chunks` / `points_to_delete` report the sentence-level fragments behind these
894
- * memories, which is what a delete would actually remove — usually larger than
895
- * `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.
896
955
  */
897
956
  bySpeaker(speaker, userId, opts = {}) {
898
957
  if (!speaker || typeof speaker !== "string" || speaker.trim() === "") {
@@ -975,14 +1034,18 @@ export class Client {
975
1034
  /** The store's registered people, each with its memory count. */
976
1035
  async listSpeakers(userId) {
977
1036
  const r = await this.request("GET", `/api/v1/memory/speakers?user_id=${encodeURIComponent(this.uid(userId))}`);
978
- 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) };
979
1041
  }
980
1042
  /** Unregister a person. Their memories stay; the name tag goes. */
981
1043
  removeSpeaker(speaker, userId) {
982
1044
  return this.request("DELETE", "/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
983
1045
  }
984
1046
  // ----- delete -----
985
- /** 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. */
986
1049
  delete(userId, memoryId) {
987
1050
  // Guard: without a memory_id the API's forget endpoint means "delete the
988
1051
  // whole store". An undefined/"" slipping in here must never become a wipe.
@@ -1025,65 +1088,107 @@ export class Client {
1025
1088
  * (`NotFoundError`) and 401 behaving the same as everywhere else.
1026
1089
  */
1027
1090
  async requestBytes(path, body) {
1028
- const ctrl = new AbortController();
1029
- const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
1030
- let res;
1031
- try {
1032
- res = await this.fetchImpl(`${this.base}${path}`, {
1033
- method: "POST",
1034
- headers: this.headers,
1035
- body: JSON.stringify(body),
1036
- signal: ctrl.signal,
1037
- redirect: "manual",
1038
- });
1039
- }
1040
- catch (e) {
1041
- clearTimeout(timer);
1042
- if (e?.name === "AbortError" || ctrl.signal.aborted) {
1043
- 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
+ });
1044
1110
  }
1045
- throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
1046
- }
1047
- try {
1048
- this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
1049
- if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
1050
- void res.body?.cancel();
1051
- 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}`);
1052
1124
  }
1053
- if (!res.ok) {
1054
- 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;
1055
1160
  try {
1056
- const data = JSON.parse(msg);
1057
- const err = data?.error;
1058
- if (err && typeof err === "object")
1059
- msg = err.message ?? err.type ?? msg;
1060
- else if (typeof err === "string")
1061
- msg = err;
1161
+ buf = await this.readCappedBytes(res);
1062
1162
  }
1063
- catch {
1064
- /* 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;
1065
1168
  }
1066
- if (msg.length > MAX_ERR_MSG)
1067
- msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
1068
- 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" };
1069
1175
  }
1070
- const buf = await this.readCappedBytes(res);
1071
- if (buf.byteLength === 0) {
1072
- // An empty 200 would otherwise read as "here is your image" and write a
1073
- // zero-byte file — indistinguishable from an image we lost.
1074
- throw new WosError(res.status, "empty image body — the service returned no bytes");
1176
+ finally {
1177
+ clearTimeout(timer);
1075
1178
  }
1076
- return { bytes: buf, contentType: res.headers.get("content-type") ?? "application/octet-stream" };
1077
- }
1078
- finally {
1079
- clearTimeout(timer);
1080
1179
  }
1081
1180
  }
1082
1181
  /** Validate an idempotency key BEFORE the network. The API answers a malformed
1083
1182
  * key with a 400, which on a retry path reads as "my write failed" when in fact
1084
1183
  * it was never attempted — cheaper and clearer to reject it here. */
1085
1184
  idemHeader(key) {
1086
- 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)
1087
1192
  return undefined;
1088
1193
  if (!IDEMPOTENCY_KEY_RE.test(key)) {
1089
1194
  throw new Error(`invalid idempotencyKey: ${JSON.stringify(key)} — 1-128 chars of [A-Za-z0-9._:-]`);
@@ -1118,8 +1223,18 @@ export class Client {
1118
1223
  if (cl && Number(cl) > MAX_RESPONSE_BYTES) {
1119
1224
  throw new WosError(0, `response too large (${cl} bytes) — refusing to buffer it`);
1120
1225
  }
1121
- if (!res.body)
1122
- 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
+ }
1123
1238
  const reader = res.body.getReader();
1124
1239
  const parts = [];
1125
1240
  let size = 0;
package/package.json CHANGED
@@ -1,16 +1,13 @@
1
1
  {
2
2
  "name": "wontopos",
3
- "version": "2.2.34",
4
- "description": "Wontopos \u2014 long-term memory for AI agents. Pure semantic retrieval, identical recall in every language, ~100\u00d7 lower LLM bill.",
5
- "repository": "github:wontopos/wontopos-typescript",
3
+ "version": "2.2.35",
4
+ "description": "Wontopos — long-term memory for AI agents. Identical recall in every language, ~100× lower LLM bill.",
6
5
  "keywords": [
7
6
  "memory",
8
7
  "ai",
9
8
  "agent",
10
9
  "llm",
11
- "vector",
12
- "embedding",
13
- "semantic",
10
+ "multilingual",
14
11
  "rag",
15
12
  "context"
16
13
  ],
@@ -39,7 +36,7 @@
39
36
  "scripts": {
40
37
  "build": "tsc",
41
38
  "test": "npm run build && node --test tests/client.test.mjs",
42
- "prepublishOnly": "npm run build"
39
+ "prepublishOnly": "npm run build && node scripts/pre-publish.mjs"
43
40
  },
44
41
  "devDependencies": {
45
42
  "typescript": "^5.4.0"