wontopos 2.2.32 → 2.2.34

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
@@ -4,10 +4,13 @@
4
4
  npm install wontopos
5
5
  ```
6
6
 
7
+ Get an API key in the [console](https://wontopos.com). Keys look like `wos-live-...`;
8
+ the client also reads `WONTOPOS_API_KEY` from the environment.
9
+
7
10
  ```ts
8
11
  import { Client } from "wontopos";
9
12
 
10
- const mem = new Client({ apiKey: "wos-..." });
13
+ const mem = new Client({ apiKey: "wos-live-..." });
11
14
 
12
15
  // Each end-user / agent / topic gets its own store — create it once.
13
16
  // (A `default` store already exists, so you can skip this and omit the id.)
@@ -20,15 +23,15 @@ const ctx = await mem.recall("what does alice drink?", "alice");
20
23
 
21
24
  ## Why
22
25
 
23
- - **Pure semantic retrieval** — no keyword/BM25. Identical recall in every language (한국어 · 日本語 · 中文 · English).
26
+ - **Pure semantic retrieval** — no keyword/BM25. Identical recall in every language (Korean · Japanese · Chinese · English).
24
27
  - **No LLM in the loop** — `store` / `search` / `recall` never call a language model. You pay embeddings, not generation.
25
28
  - **Bounded retrieval** — `recall()` returns a small, fixed-size slice (~1,200 tokens) regardless of how much you've stored.
26
29
 
27
30
  ## Methods
28
31
 
29
- `add` · `addTurn` · `addBulk` · `update` · `search` · `recall` · `history` · `stats` · `get` · `listMemories` · `delete` · `deleteAll`
32
+ `add` · `addTurn` · `addBulk` · `update` · `search` · `searchFull` · `recall` · `history` · `stats` · `get` · `listMemories` · `delete` · `deleteAll`
30
33
 
31
- All methods take `{ userId }` — it names the **store**: one isolated memory space per end-user, agent, or topic, then per account. WHO said each memory inside a store is the `speaker` tag below — storing the assistant's own words never needs a separate id.
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.
32
35
 
33
36
  ## Who said it (speakers)
34
37
 
@@ -87,6 +90,9 @@ egress proxy this SDK could not connect at all — while the Python and Rust SDK
87
90
  through, because `requests` and `reqwest` both read those variables. Pass a
88
91
  proxy-aware `fetch` and the three behave the same:
89
92
 
93
+ This one needs `npm i undici` — Node's global `fetch` is built on undici, but the
94
+ package itself is not installed for you.
95
+
90
96
  ```ts
91
97
  import { ProxyAgent } from "undici";
92
98
 
@@ -126,10 +132,17 @@ update. `search` options are typed too (`SearchOptions`: `cache_control`,
126
132
  `speaker`, plus pass-through).
127
133
 
128
134
  ```ts
135
+ import { type Memory } from "wontopos";
136
+
129
137
  const hits: Memory[] = await mem.search("what did Bob say?", "alice", 10, { speaker: "Bob" });
130
138
  console.log(hits[0].speaker, hits[0].time_bucket); // typed: string | undefined
131
139
  ```
132
140
 
141
+ `hits` arrives best-first — take it in the order given. `similarity` is a raw
142
+ closeness score, not the ranking key: what produces the order is internal and is
143
+ not returned, so sorting by `similarity` overrides the ranking and makes results
144
+ worse. There is no `score` field.
145
+
133
146
  ## Errors
134
147
 
135
148
  Any non-2xx response throws `WosError` with `status`, `message`, and — when the
@@ -162,8 +175,7 @@ const mem = new Client({ apiKey: "...", baseUrl: "https://wos.your-host.com" });
162
175
 
163
176
  ## Reporting a bug
164
177
 
165
- Found something wrong, or something that looks unsafe? Tell us — one person reads
166
- every report.
178
+ Found something wrong, or something that looks unsafe? Tell us — every report gets read.
167
179
 
168
180
  - Bugs: <https://wontopos.com/contact?topic=bug>
169
181
  - Security: <https://wontopos.com/contact?topic=security> (also published at
@@ -84,8 +84,8 @@ export declare class RateLimitError extends WosError {
84
84
  * `501` is NOT transient. It means the engine behind the model you selected does
85
85
  * not implement that endpoint at all (the error carries `model` and `endpoint`).
86
86
  * Retrying can never succeed — pick a model that supports it (`listModels`) or
87
- * drop the call. This class used to say "safe to retry" for the whole 5xx range,
88
- * which sent callers into a loop that could not end.
87
+ * drop the call. Treating the whole 5xx range as retryable sends a caller into a
88
+ * loop that cannot end.
89
89
  */
90
90
  export declare class ServerError extends WosError {
91
91
  }
@@ -99,7 +99,10 @@ export interface Memory {
99
99
  content?: string;
100
100
  /** Cognitive category, e.g. "general". */
101
101
  category?: string;
102
- /** Semantic relevance to the query — higher is closer. This IS the relevance score; there is no `score` field. */
102
+ /** Raw closeness to the query — higher is closer. NOT the ranking key: results already
103
+ * arrive best-first, and what produces that order is internal and not returned, so
104
+ * re-sorting by `similarity` overrides the ranking and makes results worse. Take the
105
+ * list in the order given. There is no `score` field. */
103
106
  similarity?: number;
104
107
  /** Learned importance weight of the memory. */
105
108
  importance?: number;
@@ -155,6 +158,13 @@ export interface StatusResult {
155
158
  * shrink to it — so the extra pixels would be bytes nobody reads. This is a memory
156
159
  * service, not a photo host: the picture it holds has the resolution of a memory.
157
160
  *
161
+ * It can also come back in a different FORMAT. Downscaling means re-encoding, and
162
+ * we write lossless formats as WebP — send a PNG or a GIF over 1568px and `getImage`
163
+ * hands back `image/webp`; JPEG stays JPEG. So do not name the file from the
164
+ * extension you uploaded: read `contentType`. Nothing is re-encoded when the image
165
+ * already fits within 1568px, and a re-encode that would make the file BIGGER is
166
+ * thrown away and your bytes kept as they were.
167
+ *
158
168
  * Keep your own copy if you need the full-resolution file. Either archive it
159
169
  * yourself, or put its URL in `reference` — we store that string and never open it.
160
170
  *
@@ -180,7 +190,8 @@ export interface ImageBytes {
180
190
  * The image as stored. Write it to a file, or wrap it in a Blob to show it.
181
191
  *
182
192
  * This is the service's copy, not necessarily your upload: anything over 1568px
183
- * on its long edge was downscaled on the way in (see `ImageInput`).
193
+ * on its long edge was downscaled on the way in, and re-encoded to WebP unless it
194
+ * was a JPEG (see `ImageInput`). Take the file extension from `contentType`.
184
195
  */
185
196
  bytes: Uint8Array;
186
197
  /** Sniffed from the BYTES, not from whatever the upload was named — e.g. `image/jpeg`. */
@@ -223,7 +234,7 @@ export interface RevisionsResult {
223
234
  revised?: number;
224
235
  /** Memories nothing has touched since they were written. Always `total - revised`. */
225
236
  unrevised?: number;
226
- /** Every memory in the store. Atoms — the fragments search uses internally — are not counted. */
237
+ /** Every memory in the store. Internal records nobody stored directly are not counted. */
227
238
  total?: number;
228
239
  /** What `revised` counts, spelled out by the service. */
229
240
  counts?: string;
@@ -272,7 +283,7 @@ export interface LineageResult {
272
283
  export interface SpeakerPage {
273
284
  speaker?: string;
274
285
  memories: Memory[];
275
- /** Sentence-level fragments behind these memories — what a delete would actually remove. */
286
+ /** How many underlying records a delete would actually remove, beyond what was returned. */
276
287
  chunks?: number;
277
288
  points_to_delete?: number;
278
289
  returned?: number;
@@ -288,6 +299,25 @@ export interface UpdateResult {
288
299
  status?: string;
289
300
  [key: string]: unknown;
290
301
  }
302
+ /** `searchFull` → everything one search answered with, not just the merged memories.
303
+ *
304
+ * `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
308
+ * search you shaped and never seeing what came of it. */
309
+ export interface SearchResult {
310
+ /** The main lane — what others said / general memories. */
311
+ memories: Memory[];
312
+ /** The assistant's own words (speaker "me"); `[]` on a model without a self lane. */
313
+ self_memories: Memory[];
314
+ /** Image memories, when `max_images` asked for any; `[]` otherwise. */
315
+ images: Memory[];
316
+ /** Re-ask passes actually performed. Present only when `verify` was sent; lower
317
+ * than you asked for means the store had nothing further to add. */
318
+ verify_used?: number;
319
+ [key: string]: unknown;
320
+ }
291
321
  export interface RecallResult {
292
322
  short_term: {
293
323
  turns: unknown[];
@@ -444,7 +474,7 @@ export interface SearchOptions {
444
474
  * that need several distinct memories from far apart in the history; it does little
445
475
  * on a single-fact lookup.
446
476
  *
447
- * ⚠️Requires a re-ask-capable model. Older ones REFUSE the call (403) rather than
477
+ * Requires a re-ask-capable model. Older ones REFUSE the call (403) rather than
448
478
  * quietly charging you for passes that never happened.
449
479
  */
450
480
  verify?: number;
@@ -453,7 +483,7 @@ export interface SearchOptions {
453
483
  * Out of range is refused, not clamped — silently cutting 5 to 1 would leave you
454
484
  * believing you got five.
455
485
  *
456
- * ⚠️Requires an image-capable model, and is refused (403) on one without it rather
486
+ * Requires an image-capable model, and is refused (403) on one without it rather
457
487
  * than answering with no images.
458
488
  */
459
489
  max_images?: number;
@@ -570,8 +600,7 @@ export declare class Client {
570
600
  withRetries(maxRetries: number): Client;
571
601
  /** Store one memory. `metadata` optional — e.g. `{ event_date: "2026-03-01" }` for when the
572
602
  * content actually happened. A plain date works (read as UTC midnight); a full RFC3339
573
- * timestamp also works. The API used to demand the full form and answered a bare date with
574
- * an opaque 400, which is why older examples here show the long one.
603
+ * timestamp also works; older examples here show the long form.
575
604
  * `metadata.speaker`: "me" = the assistant's own words, or a person's name (up to 50 per store).
576
605
  *
577
606
  * `opts.idempotencyKey`: see {@link WriteOptions} — pass one to make YOUR retry
@@ -602,6 +631,21 @@ export declare class Client {
602
631
  * speaker "me"), kept separate so the reader never confuses who said what. On a model
603
632
  * without a self lane, `self_memories` is `[]`. `userId` optional (default store). */
604
633
  searchSelf(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<SelfSearchResult>;
634
+ /**
635
+ * Search, and keep every lane the answer came in.
636
+ *
637
+ * Same request as {@link Client.search} — reach for this one when the options make
638
+ * the merged array an incomplete answer:
639
+ *
640
+ * const r = await mem.searchFull("the day we moved", "alice", 10,
641
+ * { max_images: 3, verify: 2 });
642
+ * r.images.length; // the photo lane, which `search` drops
643
+ * r.verify_used; // how many re-ask passes actually ran (you are billed per pass)
644
+ *
645
+ * Both options need Tablet 2 or newer and are refused (403) on an older engine
646
+ * rather than accepted and ignored.
647
+ */
648
+ searchFull(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<SearchResult>;
605
649
  /** One-call LLM context: short-term turns + long-term matches + surrounding context.
606
650
  * `form` ("memoir"/"archive", Scroll 1.2+) renders each long-term memory's time in
607
651
  * that form; `tz` is your UTC-offset hours for that rendering. */
@@ -612,7 +656,7 @@ export declare class Client {
612
656
  * Long-term memories to recall, 5–20. Default 10. Out of range is refused, not
613
657
  * clamped — asking for 20 and silently getting 10 reads as "that is all there is".
614
658
  *
615
- * ⚠️Needs a limit-aware model. An older one recalls a fixed ten no matter what you
659
+ * Needs a limit-aware model. An older one recalls a fixed ten no matter what you
616
660
  * send, so the API refuses the call (403) rather than answering with a number you
617
661
  * did not ask for.
618
662
  */
@@ -665,18 +709,24 @@ export declare class Client {
665
709
  /**
666
710
  * Fetch the bytes of an image memory — the picture the SERVICE holds.
667
711
  *
668
- * Not necessarily your upload: an image whose long edge was over 1568px was
669
- * downscaled to 1568 on the way in, and that is what was embedded, stored and
670
- * comes back here. Nothing in the retrieval path looks above 1568, so the extra
671
- * pixels would be bytes nobody reads. Keep your own copy for the full-resolution
672
- * file. The type is sniffed from the bytes themselves rather
673
- * than from whatever the file was called. Rejects with `NotFoundError` when this
674
- * memory has no image, or when the service was not configured to keep originals —
675
- * it says "no" instead of handing back something empty, so "a memory with no image"
676
- * never looks the same as "an image we lost".
712
+ * Not necessarily your upload, in size OR in format: an image whose long edge was
713
+ * over 1568px was downscaled to 1568 on the way in and re-encoded — lossless
714
+ * 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.
717
+ * Keep your own copy for the full-resolution file.
718
+ *
719
+ * The type is sniffed from the bytes themselves rather than from whatever the file
720
+ * was called, so name the file from `contentType` rather than from what you sent.
721
+ * When the format changed, the response also carries `x-wos-image-converted-from`
722
+ * naming what you uploaded. Rejects with `NotFoundError` when this memory has no
723
+ * image, or when the service keeps no image bytes at all — it says "no" instead of
724
+ * handing back something empty, so "a memory with no image" never looks the same as
725
+ * "an image we lost".
677
726
  *
678
727
  * const { bytes, contentType } = await mem.getImage(undefined, id);
679
- * await fs.writeFile("image.jpg", bytes);
728
+ * const ext = contentType.split("/")[1]; // "webp" for a downscaled PNG
729
+ * await fs.writeFile(`image.${ext}`, bytes);
680
730
  */
681
731
  getImage(userId: string | undefined, memoryId: string): Promise<ImageBytes>;
682
732
  /**
@@ -721,10 +771,10 @@ export declare class Client {
721
771
  *
722
772
  * Counts memories a transform touched (supersede, update, retract, image removed).
723
773
  * Deletions are NOT counted: a deleted memory leaves nothing to count. Neither are
724
- * atoms, the sentence fragments search uses internally — nobody stored one, so they
725
- * do not belong in a ratio that answers "how much of MY memory changed".
774
+ * the internal records derived from what you stored — nobody stored those directly,
775
+ * so they do not belong in a ratio that answers "how much of MY memory changed".
726
776
  *
727
- * ★By default this returns COUNTS ONLY, and the answer is the same size for a store
777
+ * By default this returns COUNTS ONLY, and the answer is the same size for a store
728
778
  * of a hundred memories and a store of a hundred million. That is deliberate: a model
729
779
  * asks this mid-conversation, and an answer that grew with the store would be
730
780
  * unusable for exactly the customers who most need to ask.
@@ -745,10 +795,10 @@ export declare class Client {
745
795
  * }
746
796
  * ```
747
797
  *
748
- * ⚠️Pages are ordered by when each memory was STORED, not by when it was edited.
798
+ * Pages are ordered by when each memory was STORED, not by when it was edited.
749
799
  * The response says so in `ordered_by`.
750
800
  *
751
- * ★Served from `/api/v1/won/*`, not `/api/v1/memory/*`. Won is the surface for calls
801
+ * Served from `/api/v1/won/*`, not `/api/v1/memory/*`. Won is the surface for calls
752
802
  * a model makes ABOUT its memory rather than calls an application makes WITH it, and
753
803
  * the address says so. The old path still answers, for clients published before
754
804
  * 2026-08-18, and both share one rate-limit budget.
@@ -799,10 +849,9 @@ export declare class Client {
799
849
  /**
800
850
  * The engrams (and delivery forms) the selected model can actually run.
801
851
  *
802
- * `engram()` could always RUN one, but there was no way to ASK what exists — so a
803
- * caller had to hard-code names from the docs, and every engram we shipped after
804
- * their code was written stayed invisible to them. The MCP server hit exactly this
805
- * and stopped hard-coding for the same reason. The service is the authority: what
852
+ * Ask rather than hard-code: a name copied from the docs freezes a caller to the
853
+ * catalogue as it was that day, and anything added later stays invisible. The
854
+ * service is the authority. What
806
855
  * comes back depends on the model (delivery forms need Scroll 1.2+), so pass
807
856
  * `withModel()` if you want another model's catalogue.
808
857
  *
@@ -856,10 +905,9 @@ export declare class Client {
856
905
  private readCapped;
857
906
  /** The same cap, for a body that is not text.
858
907
  *
859
- * `getImage` used to call `arrayBuffer()` here, which buffers whatever arrives. The
860
- * cap exists for a hostile or broken `baseUrl`, and a photo endpoint is exactly where
861
- * that shows up — the JSON paths were guarded while the one path that returns
862
- * megabytes was not. One implementation, two callers, so they cannot drift. */
908
+ * `arrayBuffer()` would buffer whatever arrives. The cap exists for a hostile or
909
+ * broken `baseUrl`, and an endpoint that returns megabytes is where that costs the
910
+ * most. One implementation, two callers, so they cannot drift. */
863
911
  private readCappedBytes;
864
912
  private request;
865
913
  }
package/dist/wontopos.js CHANGED
@@ -37,7 +37,7 @@
37
37
  * runtime's (never disabled by this SDK; Node 18+ floors TLS at 1.2). Prefer
38
38
  * `Client.fromEnv()` over keys in source code.
39
39
  */
40
- const VERSION = "2.2.32";
40
+ const VERSION = "2.2.34";
41
41
  /** Runtime info helps support debug a report ("node 18 on Windows...") —
42
42
  * platform only, never anything identifying. Browsers have no `process` (and
43
43
  * silently drop the UA header anyway). */
@@ -55,7 +55,14 @@ function logDebug(msg) {
55
55
  }
56
56
  }
57
57
  const DEFAULT_BASE_URL = "https://api.wontopos.com";
58
- const DEFAULT_MODEL = "tablet-1";
58
+ /** The engine every call uses unless the caller names another.
59
+ *
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")`. */
65
+ const DEFAULT_MODEL = "tablet-2";
59
66
  /** 429 = rate-limited BEFORE processing → always safe to retry (no write, no bill). */
60
67
  const RETRY_ALWAYS = new Set([429]);
61
68
  /** 502/503 are ambiguous for a write (gateway may return them AFTER the backend
@@ -96,8 +103,9 @@ function normalizeStoreId(id) {
96
103
  * Bounded on purpose. The warning fires on ids that fold, and the shape it exists
97
104
  * to catch is an email — which this SDK's own documented pattern ("one store per
98
105
  * 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`
106
+ * never released, even when the client is. A server that forwards 50,000 distinct user
107
+ * ids would grow this to 50,000 entries, leaking in exactly the case the warning
108
+ * exists to catch. A `Set`
101
109
  * keeps insertion order, so the oldest entry is the one evicted; dropping the
102
110
  * oldest rather than refusing new ones means a collision that first shows up late
103
111
  * still gets its one warning. */
@@ -151,16 +159,15 @@ function normalizeImage(image) {
151
159
  if (typeof image.data !== "string" || image.data.trim() === "") {
152
160
  throw new Error("image.data is required — base64 of the image (a data: URL is fine).");
153
161
  }
154
- // 🔴**다듬고 나서 접두어를 본다.** 예전엔 원본에 바로 `startsWith("data:")` 를
155
- // 걸어서, 앞에 공백이 하나만 있어도(` data:image/png;base64,…`) 접두어를 못 떼고
156
- // **`data:image/png;base64,` 문자열까지 통째로 base64 로** 보냈다. 엔진은
157
- // "이미지를 읽을 수 없다"며 400 을 내고, 부르는 쪽은 왜인지 모른다.
162
+ // Trim before looking for the prefix. Checked against the raw string, one leading
163
+ // space (` data:image/png;base64,…`) hides the prefix, and the literal
164
+ // `data:image/png;base64,` travels as part of the base64. The engine answers 400
165
+ // "image could not be read" and the caller has no way to tell why.
158
166
  const raw = image.data.trim();
159
167
  const comma = raw.startsWith("data:") ? raw.indexOf(",") : -1;
160
- // 🔴그리고 **가운데 공백까지** 없앤다. `base64` 명령과 `openssl base64` 는 76자마다
161
- // 줄을 바꾼다 — 파일을 그렇게 만들어 붙여넣는 건 흔한 길인데, 양끝만 다듬으면
162
- // 그 줄바꿈이 그대로 payload 에 남는다. base64 알파벳에 공백은 없으므로
163
- // 전부 빼는 게 안전하다.
168
+ // Strip whitespace in the middle as well. `base64` and `openssl base64` wrap at 76
169
+ // columns, and trimming only the ends leaves those line breaks in the payload. The
170
+ // base64 alphabet contains no whitespace, so removing all of it is safe.
164
171
  const data = (comma === -1 ? raw : raw.slice(comma + 1)).replace(/\s+/g, "");
165
172
  if (data === "")
166
173
  throw new Error("image.data is empty after stripping its data: prefix.");
@@ -309,8 +316,8 @@ export class RateLimitError extends WosError {
309
316
  * `501` is NOT transient. It means the engine behind the model you selected does
310
317
  * not implement that endpoint at all (the error carries `model` and `endpoint`).
311
318
  * 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.
319
+ * drop the call. Treating the whole 5xx range as retryable sends a caller into a
320
+ * loop that cannot end.
314
321
  */
315
322
  export class ServerError extends WosError {
316
323
  }
@@ -545,8 +552,7 @@ export class Client {
545
552
  // store (set via `new Client({ apiKey, userId })` or `withUser`).
546
553
  /** Store one memory. `metadata` optional — e.g. `{ event_date: "2026-03-01" }` for when the
547
554
  * 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.
555
+ * timestamp also works; older examples here show the long form.
550
556
  * `metadata.speaker`: "me" = the assistant's own words, or a person's name (up to 50 per store).
551
557
  *
552
558
  * `opts.idempotencyKey`: see {@link WriteOptions} — pass one to make YOUR retry
@@ -604,6 +610,32 @@ export class Client {
604
610
  const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
605
611
  return { memories: asRecords(r.memories), self_memories: asRecords(r.self_memories) };
606
612
  }
613
+ /**
614
+ * Search, and keep every lane the answer came in.
615
+ *
616
+ * Same request as {@link Client.search} — reach for this one when the options make
617
+ * the merged array an incomplete answer:
618
+ *
619
+ * const r = await mem.searchFull("the day we moved", "alice", 10,
620
+ * { max_images: 3, verify: 2 });
621
+ * r.images.length; // the photo lane, which `search` drops
622
+ * r.verify_used; // how many re-ask passes actually ran (you are billed per pass)
623
+ *
624
+ * Both options need Tablet 2 or newer and are refused (403) on an older engine
625
+ * rather than accepted and ignored.
626
+ */
627
+ async searchFull(query, userId, limit = 10, opts = {}) {
628
+ warnOnUnknownFilters(opts.filters);
629
+ 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
631
+ // normalized, because a broken proxy can null any of them.
632
+ return {
633
+ ...r,
634
+ memories: asRecords(r.memories),
635
+ self_memories: asRecords(r.self_memories),
636
+ images: asRecords(r.images),
637
+ };
638
+ }
607
639
  /** One-call LLM context: short-term turns + long-term matches + surrounding context.
608
640
  * `form` ("memoir"/"archive", Scroll 1.2+) renders each long-term memory's time in
609
641
  * that form; `tz` is your UTC-offset hours for that rendering. */
@@ -691,18 +723,24 @@ export class Client {
691
723
  /**
692
724
  * Fetch the bytes of an image memory — the picture the SERVICE holds.
693
725
  *
694
- * Not necessarily your upload: an image whose long edge was over 1568px was
695
- * downscaled to 1568 on the way in, and that is what was embedded, stored and
696
- * comes back here. Nothing in the retrieval path looks above 1568, so the extra
697
- * pixels would be bytes nobody reads. Keep your own copy for the full-resolution
698
- * file. The type is sniffed from the bytes themselves rather
699
- * than from whatever the file was called. Rejects with `NotFoundError` when this
700
- * memory has no image, or when the service was not configured to keep originals —
701
- * it says "no" instead of handing back something empty, so "a memory with no image"
702
- * never looks the same as "an image we lost".
726
+ * Not necessarily your upload, in size OR in format: an image whose long edge was
727
+ * over 1568px was downscaled to 1568 on the way in and re-encoded — lossless
728
+ * 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.
731
+ * Keep your own copy for the full-resolution file.
732
+ *
733
+ * The type is sniffed from the bytes themselves rather than from whatever the file
734
+ * was called, so name the file from `contentType` rather than from what you sent.
735
+ * When the format changed, the response also carries `x-wos-image-converted-from`
736
+ * naming what you uploaded. Rejects with `NotFoundError` when this memory has no
737
+ * image, or when the service keeps no image bytes at all — it says "no" instead of
738
+ * handing back something empty, so "a memory with no image" never looks the same as
739
+ * "an image we lost".
703
740
  *
704
741
  * const { bytes, contentType } = await mem.getImage(undefined, id);
705
- * await fs.writeFile("image.jpg", bytes);
742
+ * const ext = contentType.split("/")[1]; // "webp" for a downscaled PNG
743
+ * await fs.writeFile(`image.${ext}`, bytes);
706
744
  */
707
745
  getImage(userId, memoryId) {
708
746
  if (!memoryId || typeof memoryId !== "string") {
@@ -753,12 +791,23 @@ export class Client {
753
791
  async *iterImages(userId, opts = {}) {
754
792
  let before;
755
793
  let skipIds;
794
+ // Same repeat-cursor guard as `iterMemories`. A server that hands back the
795
+ // cursor it was just given would otherwise re-yield one page MAX_PAGES times,
796
+ // and the caller reads those repeats as more images. The page cap alone bounds
797
+ // the walk; it does not stop the duplicates.
798
+ const seen = new Set();
756
799
  for (let page = 0; page < MAX_PAGES; page++) {
757
800
  const p = await this.listImages(userId, { limit: opts.pageSize, before, skipIds });
758
801
  for (const m of asRecords(p.images))
759
802
  yield m;
760
803
  if (!p.has_more || !p.next_before)
761
804
  break;
805
+ // The cursor is the PAIR: several images can share a timestamp, so `before`
806
+ // alone repeats across pages legitimately.
807
+ const key = `${p.next_before}|${(Array.isArray(p.next_skip_ids) ? p.next_skip_ids : []).join(",")}`;
808
+ if (seen.has(key))
809
+ break;
810
+ seen.add(key);
762
811
  before = p.next_before;
763
812
  skipIds = Array.isArray(p.next_skip_ids) ? p.next_skip_ids : undefined;
764
813
  }
@@ -774,10 +823,10 @@ export class Client {
774
823
  *
775
824
  * Counts memories a transform touched (supersede, update, retract, image removed).
776
825
  * Deletions are NOT counted: a deleted memory leaves nothing to count. Neither are
777
- * atoms, the sentence fragments search uses internally — nobody stored one, so they
778
- * do not belong in a ratio that answers "how much of MY memory changed".
826
+ * the internal records derived from what you stored — nobody stored those directly,
827
+ * so they do not belong in a ratio that answers "how much of MY memory changed".
779
828
  *
780
- * ★By default this returns COUNTS ONLY, and the answer is the same size for a store
829
+ * By default this returns COUNTS ONLY, and the answer is the same size for a store
781
830
  * of a hundred memories and a store of a hundred million. That is deliberate: a model
782
831
  * asks this mid-conversation, and an answer that grew with the store would be
783
832
  * unusable for exactly the customers who most need to ask.
@@ -798,10 +847,10 @@ export class Client {
798
847
  * }
799
848
  * ```
800
849
  *
801
- * ⚠️Pages are ordered by when each memory was STORED, not by when it was edited.
850
+ * Pages are ordered by when each memory was STORED, not by when it was edited.
802
851
  * The response says so in `ordered_by`.
803
852
  *
804
- * ★Served from `/api/v1/won/*`, not `/api/v1/memory/*`. Won is the surface for calls
853
+ * Served from `/api/v1/won/*`, not `/api/v1/memory/*`. Won is the surface for calls
805
854
  * a model makes ABOUT its memory rather than calls an application makes WITH it, and
806
855
  * the address says so. The old path still answers, for clients published before
807
856
  * 2026-08-18, and both share one rate-limit budget.
@@ -875,10 +924,9 @@ export class Client {
875
924
  /**
876
925
  * The engrams (and delivery forms) the selected model can actually run.
877
926
  *
878
- * `engram()` could always RUN one, but there was no way to ASK what exists — so a
879
- * caller had to hard-code names from the docs, and every engram we shipped after
880
- * their code was written stayed invisible to them. The MCP server hit exactly this
881
- * and stopped hard-coding for the same reason. The service is the authority: what
927
+ * Ask rather than hard-code: a name copied from the docs freezes a caller to the
928
+ * catalogue as it was that day, and anything added later stays invisible. The
929
+ * service is the authority. What
882
930
  * comes back depends on the model (delivery forms need Scroll 1.2+), so pass
883
931
  * `withModel()` if you want another model's catalogue.
884
932
  *
@@ -909,9 +957,9 @@ export class Client {
909
957
  }
910
958
  /** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
911
959
  deleteStore(userId) {
912
- // Validate BEFORE warning. The warning helper lowercases the id, so a non-string
913
- // used to die there as "id.toLowerCase is not a function" — the caller got a
914
- // TypeError from our internals instead of being told what was wrong with the id.
960
+ // Validate before warning. The warning helper lowercases the id, so a non-string
961
+ // reaches it and dies as "id.toLowerCase is not a function" — a TypeError from
962
+ // inside the SDK instead of a sentence about the id.
915
963
  if (typeof userId !== "string" || !userId.trim())
916
964
  throw new Error("userId is required (a non-blank string) — deleteStore never falls back to the default store.");
917
965
  warnIfStoreIdCollapses(userId); // destructive: the collision warning belongs here too
@@ -939,11 +987,10 @@ export class Client {
939
987
  // Guard: without a memory_id the API's forget endpoint means "delete the
940
988
  // whole store". An undefined/"" slipping in here must never become a wipe.
941
989
  //
942
- // `.trim()` matters as much as the emptiness check: " " is truthy, so it used to
943
- // pass this guard and travel as memory_id. A server that trims it back to nothing
944
- // reads the request as the whole-store form — the single most destructive way for
945
- // this SDK to be wrong. `deleteAll` below already trimmed; the more dangerous path
946
- // was the one that did not.
990
+ // `.trim()` matters as much as the emptiness check: " " is truthy, so without it
991
+ // a blank string passes the guard and travels as memory_id. A server that trims it
992
+ // back to nothing reads the request as the whole-store form, which is the most
993
+ // destructive way for this SDK to be wrong.
947
994
  if (typeof memoryId !== "string" || !memoryId.trim()) {
948
995
  throw new Error("memoryId is required (non-blank). To delete every memory in a store, call deleteAll(userId) explicitly.");
949
996
  }
@@ -1063,10 +1110,9 @@ export class Client {
1063
1110
  }
1064
1111
  /** The same cap, for a body that is not text.
1065
1112
  *
1066
- * `getImage` used to call `arrayBuffer()` here, which buffers whatever arrives. The
1067
- * cap exists for a hostile or broken `baseUrl`, and a photo endpoint is exactly where
1068
- * that shows up — the JSON paths were guarded while the one path that returns
1069
- * megabytes was not. One implementation, two callers, so they cannot drift. */
1113
+ * `arrayBuffer()` would buffer whatever arrives. The cap exists for a hostile or
1114
+ * broken `baseUrl`, and an endpoint that returns megabytes is where that costs the
1115
+ * most. One implementation, two callers, so they cannot drift. */
1070
1116
  async readCappedBytes(res) {
1071
1117
  const cl = res.headers.get("content-length");
1072
1118
  if (cl && Number(cl) > MAX_RESPONSE_BYTES) {
@@ -1203,10 +1249,9 @@ export class Client {
1203
1249
  msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
1204
1250
  throw errorFor(res.status, msg, requestId);
1205
1251
  }
1206
- // An empty body is only legal when the STATUS says there is no body. This
1207
- // used to accept any empty 2xx, so an `add()` answered by a truncating proxy
1208
- // returned `{}` and read as a successful write with no id — while the Python
1209
- // and Rust SDKs raised on the same response. Same rule in all three now.
1252
+ // An empty body is only legal when the STATUS says there is no body. Accepting
1253
+ // any empty 2xx would make an `add()` answered by a truncating proxy return `{}`,
1254
+ // which reads as a successful write with no id. All three SDKs apply this rule.
1210
1255
  if (!text) {
1211
1256
  if (NO_BODY_STATUS.has(res.status))
1212
1257
  return {};
@@ -1228,10 +1273,10 @@ export class Client {
1228
1273
  const got = data === null ? "null" : Array.isArray(data) ? "array" : typeof data;
1229
1274
  throw new WosError(res.status, `expected a JSON object in the response, got ${got}`);
1230
1275
  }
1231
- // 멱등키를 쓴 쓰기가 **저장된 것인지 재생된 것인지**를 호출자가 알아야 한다.
1232
- // 서버는 `Idempotent-Replayed: true` 로 말해주는데 지금까지 SDK 가 삼켰다 —
1233
- // 멱등키를 쓰는 사람이 가장 알고 싶은 사실이 그것이다(재시도가 실제로
1234
- // 저장을 일으켰나, 아니면 첫 응답이 되풀이된 것인가).
1276
+ // Surface whether the write was stored or replayed. The server says so with
1277
+ // `Idempotent-Replayed: true`, and it answers the question someone using an
1278
+ // idempotency key is asking: did my retry write, or is this the first response
1279
+ // coming back again?
1235
1280
  if (res.headers.get("Idempotent-Replayed") === "true") {
1236
1281
  data.replayed = true;
1237
1282
  }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "wontopos",
3
- "version": "2.2.32",
4
- "description": "Wontopos — long-term memory for AI agents. Pure semantic retrieval, identical recall in every language, ~100× lower LLM bill.",
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",
5
6
  "keywords": [
6
7
  "memory",
7
8
  "ai",
@@ -37,7 +38,7 @@
37
38
  },
38
39
  "scripts": {
39
40
  "build": "tsc",
40
- "test": "npm run build && node --test test/client.test.mjs",
41
+ "test": "npm run build && node --test tests/client.test.mjs",
41
42
  "prepublishOnly": "npm run build"
42
43
  },
43
44
  "devDependencies": {