wontopos 2.2.29 → 2.2.31

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
@@ -160,22 +160,43 @@ const mem = new Client({ apiKey: "...", baseUrl: "https://wos.your-host.com" });
160
160
  - Homepage: <https://wontopos.com>
161
161
  - API reference: <https://wontopos.com/en/why> (Developers tab)
162
162
 
163
+ ## Reporting a bug
164
+
165
+ Found something wrong, or something that looks unsafe? Tell us — one person reads
166
+ every report.
167
+
168
+ - Bugs: <https://wontopos.com/contact?topic=bug>
169
+ - Security: <https://wontopos.com/contact?topic=security> (also published at
170
+ [`/.well-known/security.txt`](https://wontopos.com/.well-known/security.txt))
171
+
172
+ Include the SDK version (`the version in package.json`) and the language. If it involves a store id or a
173
+ memory, describe the shape rather than pasting the contents — we do not need your
174
+ data to fix it.
175
+
163
176
  ## Changelog
164
177
 
165
- - **2.2.10** — `Memory` fix: the relevance field is `similarity` (not `score`,
166
- which was never in the response); added typed `importance`, `category`,
167
- `is_superseded`, `superseded_by`, `created_at`, `event_date`.
168
- - **2.2.9** — typed responses for every method (index-signature friendly) +
169
- typed `SearchOptions`; security round 2: 64MB response cap, key/model-name
170
- hygiene, `Client.fromEnv()`.
171
- - **2.2.8** — reliability + hardening: automatic retries (429/502/503 + connection
172
- errors, backoff + jitter, honors `Retry-After`), redirects refused, key masked in
173
- `toJSON`/inspect, `WosError.requestId`, guard against `delete()` without
174
- `memoryId`, plain-HTTP warning, `User-Agent`.
175
- - **2.2.7** — speakers: `addSpeaker` / `listSpeakers` / `removeSpeaker`.
176
- - **2.2.6** — docs: `userId` = store, clarified everywhere.
177
- - **2.2.5** — docs: speakers + recall caching sections.
178
- - **2.2.4** — Python/TypeScript/Rust converge on one version; all three now
179
- release in lockstep (same version, same surface). Patch releases are additive.
178
+ The three clients release in lockstep — same version, same surface, same day.
179
+ Patch releases are additive: nothing is removed or reordered within a minor line.
180
+
181
+ - **2.2.30** — documentation only: this changelog, which had not been updated
182
+ in the shipped package since 2.2.10.
183
+ - **2.2.29** — a store id that was PASSED but unusable no longer becomes the
184
+ default store. 2.2.28 caught a blank string; the value a failed tenant lookup
185
+ actually produces in JavaScript is `null` — and `String(null)` is the truthy
186
+ `"null"`, so it walked past the check and `null || default` sent the write to
187
+ the client's default store. `0` did the same. Anything but a non-blank string
188
+ now throws, on `deleteStore` / `deleteAll` too. Omitting the argument still
189
+ means "use the default". Also: `deleteStore` validates before it warns, so a
190
+ non-string no longer surfaces as a `TypeError` from inside the SDK.
191
+ - **2.2.28** — audit: the API key no longer survives an object spread (`private`
192
+ is erased at runtime, so `{...client}` copied it), a whitespace `memoryId` can
193
+ no longer read as a whole-store delete, and a blank store id throws instead of
194
+ silently using the default.
195
+ - **2.2.11–2.2.27** — additive fixes and hardening across all three clients.
196
+ - **2.2.10** — `Memory` fix: the relevance field is `similarity` (not `score`);
197
+ added typed `importance`, `category`, `is_superseded`, `superseded_by`,
198
+ `created_at`, `event_date`.
199
+ - **2.2.4–2.2.9** — one version across Python, TypeScript and Rust, released in
200
+ lockstep; retries, redirect refusal, response cap, key masking, speakers.
180
201
 
181
202
  License: MIT.
@@ -145,6 +145,129 @@ export interface StatusResult {
145
145
  status?: string;
146
146
  [key: string]: unknown;
147
147
  }
148
+ /**
149
+ * An image attached to a memory, passed to `add`/`store` via `opts.image`.
150
+ *
151
+ * The bytes are used ONCE, to embed the image, and are not kept. If you want the
152
+ * original back later, either let the service keep it (then `getImage` returns it) or
153
+ * put your own URL in `reference` — we store that string and never open it.
154
+ */
155
+ export interface ImageInput {
156
+ /** base64 of the image. A `data:image/...;base64,` prefix is accepted and stripped. */
157
+ data: string;
158
+ /** Where YOUR copy of the original lives. Stored as-is; the service never fetches it. */
159
+ reference?: string;
160
+ /**
161
+ * When the image was TAKEN (RFC3339), if you know it — usually from EXIF.
162
+ *
163
+ * Worth passing: an image knows a moment that its caption does not. This fills
164
+ * `metadata.event_date` when that is empty, so "the day we moved" sorts by when it
165
+ * happened rather than by when it was uploaded.
166
+ */
167
+ taken_at?: string;
168
+ }
169
+ /** `getImage` → the original bytes plus what they actually are. */
170
+ export interface ImageBytes {
171
+ /** The raw image. Write it to a file, or wrap it in a Blob to show it. */
172
+ bytes: Uint8Array;
173
+ /** Sniffed from the BYTES, not from whatever the upload was named — e.g. `image/jpeg`. */
174
+ contentType: string;
175
+ }
176
+ /** `forgetImage` → what happened, or (with `preview`) what would happen. */
177
+ export interface ImageDeleteResult {
178
+ /** `"image_deleted"`, or `"preview"` when nothing was touched. */
179
+ status?: string;
180
+ memory_id?: string;
181
+ /**
182
+ * Whether the MEMORY survives losing its image.
183
+ *
184
+ * A captioned image keeps its text and only loses the image. An image stored with no
185
+ * caption IS the memory, so deleting the image deletes the memory — and the service
186
+ * says so here rather than silently taking more than you asked for. Call with
187
+ * `preview: true` first if that distinction matters to you.
188
+ */
189
+ memory_kept?: boolean;
190
+ /** Plain-language note about the above, when there is something to say. */
191
+ note?: string | null;
192
+ /** Only on a preview. */
193
+ would_delete_image?: boolean;
194
+ [key: string]: unknown;
195
+ }
196
+ /** `listImages` → one page of image memories, newest first. */
197
+ export interface ImagePage {
198
+ images: Memory[];
199
+ /** TOTAL images in the store, not the size of this page. */
200
+ count?: number;
201
+ has_more?: boolean;
202
+ /** Hand these two back as `before` / `skipIds` to get the next page. */
203
+ next_before?: string | null;
204
+ next_skip_ids?: string[];
205
+ [key: string]: unknown;
206
+ }
207
+ /** `revisions` → how much of this store has been edited since it was written. */
208
+ export interface RevisionsResult {
209
+ /** Memories a transform has touched (supersede, update, retract, image removed). */
210
+ revised?: number;
211
+ /** Memories nothing has touched since they were written. Always `total - revised`. */
212
+ unrevised?: number;
213
+ /** Every memory in the store. Atoms — the fragments search uses internally — are not counted. */
214
+ total?: number;
215
+ /** What `revised` counts, spelled out by the service. */
216
+ counts?: string;
217
+ /** What it does NOT count — deletions leave nothing to count. */
218
+ excludes?: string;
219
+ /** Which side was paged, echoed back. Only present when you asked for a page. */
220
+ include?: "revised" | "unrevised";
221
+ /** TOTAL memories behind this page, not the size of the page. Only with `include`. */
222
+ matched?: number;
223
+ /** One page, at most 20. **Absent unless you asked for it** — see `revisions()`. */
224
+ memories?: Memory[];
225
+ has_more?: boolean;
226
+ /** Hand these two back as `before` / `skipIds` to get the next page. */
227
+ next_before?: string | null;
228
+ next_skip_ids?: string[];
229
+ /** Which order the page is in — by when each memory was STORED, not when it was edited. */
230
+ ordered_by?: string;
231
+ [key: string]: unknown;
232
+ }
233
+ /** One link in a memory's supersede chain. */
234
+ export interface LineageStep {
235
+ memory_id?: string;
236
+ content?: string;
237
+ speaker?: string | null;
238
+ created_at?: string | null;
239
+ /** When this link was superseded (RFC3339), or null while it is still current. */
240
+ changed_at?: string | null;
241
+ /** What happened here — e.g. how the replacement related to this version. */
242
+ action?: string | null;
243
+ confidence?: number | null;
244
+ superseded_by?: string | null;
245
+ /** True for the one version that is still in force. */
246
+ is_current?: boolean;
247
+ [key: string]: unknown;
248
+ }
249
+ /** `lineage` → the full chain of edits behind one memory, oldest first. */
250
+ export interface LineageResult {
251
+ memory_id?: string;
252
+ chain: LineageStep[];
253
+ count?: number;
254
+ /** True when the chain was longer than the service would walk. */
255
+ truncated?: boolean;
256
+ [key: string]: unknown;
257
+ }
258
+ /** `bySpeaker` → one page of what a given person said. */
259
+ export interface SpeakerPage {
260
+ speaker?: string;
261
+ memories: Memory[];
262
+ /** Sentence-level fragments behind these memories — what a delete would actually remove. */
263
+ chunks?: number;
264
+ points_to_delete?: number;
265
+ returned?: number;
266
+ has_more?: boolean;
267
+ next_before?: string | null;
268
+ next_skip_ids?: string[];
269
+ [key: string]: unknown;
270
+ }
148
271
  /** `update` (supersede) → old and new memory ids. */
149
272
  export interface UpdateResult {
150
273
  old_memory_id?: string;
@@ -296,6 +419,31 @@ export interface SearchOptions {
296
419
  speaker?: string;
297
420
  /** Restrict the search to part of the store — see {@link SearchFilters}. */
298
421
  filters?: SearchFilters;
422
+ /**
423
+ * Re-ask passes, 0–3. After the first retrieval the service asks the engine again up
424
+ * to this many times, each time telling it what was already returned so it skips
425
+ * those and reaches further back. Retrieval stays purely semantic — no LLM at any
426
+ * value. Stops early when a pass finds nothing new; `verify_used` in the response
427
+ * says how many actually ran.
428
+ *
429
+ * Each pass is another engine call and can add up to `limit` more memories, so it
430
+ * costs more — you are billed for what is delivered. Default 0. Worth it on questions
431
+ * that need several distinct memories from far apart in the history; it does little
432
+ * on a single-fact lookup.
433
+ *
434
+ * ⚠️Requires a re-ask-capable model. Older ones REFUSE the call (403) rather than
435
+ * quietly charging you for passes that never happened.
436
+ */
437
+ verify?: number;
438
+ /**
439
+ * How many image memories the answer may carry, 0–5. Default 1; 0 asks for none.
440
+ * Out of range is refused, not clamped — silently cutting 5 to 1 would leave you
441
+ * believing you got five.
442
+ *
443
+ * ⚠️Requires an image-capable model, and is refused (403) on one without it rather
444
+ * than answering with no images.
445
+ */
446
+ max_images?: number;
299
447
  [key: string]: unknown;
300
448
  }
301
449
  /**
@@ -316,6 +464,14 @@ export interface SearchOptions {
316
464
  */
317
465
  export interface WriteOptions {
318
466
  idempotencyKey?: string;
467
+ /**
468
+ * An image to store alongside the text (Tablet 2 and newer).
469
+ *
470
+ * `content` may be empty when you pass one — then the image IS the memory and is
471
+ * searchable on its own. Older models have no image channel and will reject the
472
+ * write rather than store the caption and quietly drop the image.
473
+ */
474
+ image?: ImageInput;
319
475
  }
320
476
  export interface ClientOptions {
321
477
  apiKey: string;
@@ -439,6 +595,18 @@ export declare class Client {
439
595
  recall(query: string, userId?: string, opts?: {
440
596
  form?: string;
441
597
  tz?: number;
598
+ /**
599
+ * Long-term memories to recall, 5–20. Default 10. Out of range is refused, not
600
+ * clamped — asking for 20 and silently getting 10 reads as "that is all there is".
601
+ *
602
+ * ⚠️Needs a limit-aware model. An older one recalls a fixed ten no matter what you
603
+ * send, so the API refuses the call (403) rather than answering with a number you
604
+ * did not ask for.
605
+ */
606
+ limit?: number;
607
+ /** Chunks of surrounding context attached around the best match, 0–20. Default 10;
608
+ * 0 attaches none. Same model floor as `limit`. */
609
+ context_limit?: number;
442
610
  }): Promise<RecallResult>;
443
611
  /** Run a built-in engram ("deep_recall" | "timeline" | "gather"). Returns the merged result.
444
612
  * `form`/`tz` render memory times (memoir/archive) on Scroll 1.2+, same as search/recall. */
@@ -481,6 +649,130 @@ export declare class Client {
481
649
  }): AsyncGenerator<Memory>;
482
650
  /** Collect ALL of a store's memories into an array (original text + metadata, no vectors). */
483
651
  exportMemories(userId?: string): Promise<Memory[]>;
652
+ /**
653
+ * Fetch the ORIGINAL bytes of an image memory.
654
+ *
655
+ * Returns what was uploaded, with the type sniffed from the bytes themselves rather
656
+ * than from whatever the file was called. Rejects with `NotFoundError` when this
657
+ * memory has no image, or when the service was not configured to keep originals —
658
+ * it says "no" instead of handing back something empty, so "a memory with no image"
659
+ * never looks the same as "an image we lost".
660
+ *
661
+ * const { bytes, contentType } = await mem.getImage(undefined, id);
662
+ * await fs.writeFile("image.jpg", bytes);
663
+ */
664
+ getImage(userId: string | undefined, memoryId: string): Promise<ImageBytes>;
665
+ /**
666
+ * Remove the PHOTO from a memory, keeping its text.
667
+ *
668
+ * Except when there is no text: an image stored without a caption *is* the memory, so
669
+ * deleting the image deletes it. That is the one case worth checking before you
670
+ * commit, which is what `preview` is for — it reports `memory_kept` and changes
671
+ * nothing.
672
+ *
673
+ * const p = await mem.forgetImage(undefined, id, { preview: true });
674
+ * if (p.memory_kept === false) { /* this would delete the whole memory *\/ }
675
+ */
676
+ forgetImage(userId: string | undefined, memoryId: string, opts?: {
677
+ preview?: boolean;
678
+ }): Promise<ImageDeleteResult>;
679
+ /**
680
+ * List a store's image memories, newest first, and count them.
681
+ *
682
+ * `count` is the TOTAL in the store, not the size of the page — so you can show
683
+ * "142 images" without walking every page. Paging is by cursor, not offset: hand
684
+ * `next_before` and `next_skip_ids` back as `before` / `skipIds`. The pair exists
685
+ * because several images can share a timestamp, and a timestamp alone would either
686
+ * repeat them or skip them.
687
+ */
688
+ listImages(userId?: string, opts?: {
689
+ limit?: number;
690
+ before?: string;
691
+ skipIds?: string[];
692
+ }): Promise<ImagePage>;
693
+ /** Async-iterate every image memory, paging under the hood. */
694
+ iterImages(userId?: string, opts?: {
695
+ pageSize?: number;
696
+ }): AsyncGenerator<Memory>;
697
+ /**
698
+ * How much of this store has been altered since it was written.
699
+ *
700
+ * This one is aimed at the MODEL, not at you. An assistant leaning on its own memory
701
+ * should be able to ask how far that memory has been edited underneath it — a store
702
+ * nobody has touched reads differently from one where a third of the facts were
703
+ * rewritten. Free, and it never reaches the retrieval path.
704
+ *
705
+ * Counts memories a transform touched (supersede, update, retract, image removed).
706
+ * Deletions are NOT counted: a deleted memory leaves nothing to count. Neither are
707
+ * atoms, the sentence fragments search uses internally — nobody stored one, so they
708
+ * do not belong in a ratio that answers "how much of MY memory changed".
709
+ *
710
+ * ★By default this returns COUNTS ONLY, and the answer is the same size for a store
711
+ * of a hundred memories and a store of a hundred million. That is deliberate: a model
712
+ * asks this mid-conversation, and an answer that grew with the store would be
713
+ * unusable for exactly the customers who most need to ask.
714
+ *
715
+ * To see WHICH memories, ask for one side and page through it — at most 20 per call,
716
+ * and never both sides in one response:
717
+ *
718
+ * ```ts
719
+ * const { revised, unrevised, total } = await mem.revisions(); // numbers only
720
+ *
721
+ * let before: string | undefined, skipIds: string[] | undefined;
722
+ * for (;;) {
723
+ * const p = await mem.revisions(undefined, { include: "revised", before, skipIds });
724
+ * for (const m of p.memories ?? []) console.log(m.content);
725
+ * if (!p.has_more || !p.next_before) break;
726
+ * before = p.next_before;
727
+ * skipIds = p.next_skip_ids;
728
+ * }
729
+ * ```
730
+ *
731
+ * ⚠️Pages are ordered by when each memory was STORED, not by when it was edited.
732
+ * The response says so in `ordered_by`.
733
+ *
734
+ * ★Served from `/api/v1/won/*`, not `/api/v1/memory/*`. Won is the surface for calls
735
+ * a model makes ABOUT its memory rather than calls an application makes WITH it, and
736
+ * the address says so. The old path still answers, for clients published before
737
+ * 2026-08-18, and both share one rate-limit budget.
738
+ */
739
+ revisions(userId?: string, opts?: {
740
+ /**
741
+ * Omit for counts only. Set it to ALSO get one page of the memories behind that
742
+ * number. One side per call — there is no way to ask for both lists at once.
743
+ */
744
+ include?: "revised" | "unrevised";
745
+ /** Memories per page. 20 is both the default and the ceiling; more is rejected. */
746
+ limit?: number;
747
+ /** Cursor: `next_before` from the previous page. */
748
+ before?: string;
749
+ /** Cursor: `next_skip_ids` from the previous page. Hand back what you were given. */
750
+ skipIds?: string[];
751
+ }): Promise<RevisionsResult>;
752
+ /**
753
+ * The full chain of edits behind one memory, oldest first.
754
+ *
755
+ * `is_current` marks the version in force. Use it to answer "what did this used to
756
+ * say, and when did it change" — `revisions()` tells you HOW MUCH a store moved,
757
+ * this tells you what happened to one fact.
758
+ */
759
+ lineage(userId: string | undefined, memoryId: string): Promise<LineageResult>;
760
+ /**
761
+ * What one person said, newest first.
762
+ *
763
+ * `speaker` is the tag written at store time (`metadata.speaker`) — `"me"` for the
764
+ * assistant's own words, otherwise a person's name. Same cursor paging as
765
+ * `listImages`.
766
+ *
767
+ * `chunks` / `points_to_delete` report the sentence-level fragments behind these
768
+ * memories, which is what a delete would actually remove — usually larger than
769
+ * `returned`, and worth showing before anyone confirms one.
770
+ */
771
+ bySpeaker(speaker: string, userId?: string, opts?: {
772
+ limit?: number;
773
+ before?: string;
774
+ skipIds?: string[];
775
+ }): Promise<SpeakerPage>;
484
776
  /** Check connectivity AND that the API key works. Resolves `true`, or rejects — an
485
777
  * `AuthenticationError` (bad key), `PaymentRequiredError` (valid key but no card /
486
778
  * depleted balance), or `APIConnectionError` (unreachable). Makes one metered request. */
@@ -523,6 +815,19 @@ export declare class Client {
523
815
  * this is destructive, so it never falls back to the default store. */
524
816
  deleteAll(userId: string): Promise<StatusResult>;
525
817
  private post;
818
+ /**
819
+ * One request that answers with BYTES rather than JSON.
820
+ *
821
+ * Only `/memory/image` does this, and it is why this cannot go through `request()`:
822
+ * that path reads the body as text and insists the result parses to a JSON object.
823
+ * Feeding it a JPEG would throw "invalid JSON in response" — a confusing error for a
824
+ * call that actually succeeded.
825
+ *
826
+ * Errors still arrive as JSON, so those are handed back to the normal machinery: on
827
+ * a non-2xx we re-read the body as text and reuse `errorFor`, which keeps 404
828
+ * (`NotFoundError`) and 401 behaving the same as everywhere else.
829
+ */
830
+ private requestBytes;
526
831
  /** Validate an idempotency key BEFORE the network. The API answers a malformed
527
832
  * key with a 400, which on a retry path reads as "my write failed" when in fact
528
833
  * it was never attempted — cheaper and clearer to reject it here. */
@@ -532,6 +837,13 @@ export declare class Client {
532
837
  /** Read the body with a hard size cap, so a broken/hostile endpoint can't
533
838
  * make the process buffer gigabytes. */
534
839
  private readCapped;
840
+ /** The same cap, for a body that is not text.
841
+ *
842
+ * `getImage` used to call `arrayBuffer()` here, which buffers whatever arrives. The
843
+ * cap exists for a hostile or broken `baseUrl`, and a photo endpoint is exactly where
844
+ * that shows up — the JSON paths were guarded while the one path that returns
845
+ * megabytes was not. One implementation, two callers, so they cannot drift. */
846
+ private readCappedBytes;
535
847
  private request;
536
848
  }
537
849
  /** Back-compat alias: older code used `WME`. */
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.29";
40
+ const VERSION = "2.2.31";
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). */
@@ -132,6 +132,45 @@ export function _resetStoreIdWarnings() {
132
132
  const NO_BODY_STATUS = new Set([204, 205, 304]);
133
133
  /** Paging backstop: bound the walk so a fresh-cursor-forever server can't loop us. */
134
134
  const MAX_PAGES = 1_000_000;
135
+ /**
136
+ * Put an image into the shape the API expects, and fail loudly on the parts we can
137
+ * check from here.
138
+ *
139
+ * Deliberately NOT checked: the byte ceiling. It is a server setting (`/health`
140
+ * reports it as `memory.images.max_bytes`), so a number baked in here would drift the
141
+ * first time that config changes and would reject an image the service would have
142
+ * taken. What we do check is what cannot change: an empty payload, and a `data:` URL
143
+ * prefix — browsers and file pickers hand you `data:image/jpeg;base64,…`, the API
144
+ * wants the part after the comma, and the difference is invisible until the write
145
+ * fails with "not a readable image".
146
+ */
147
+ function normalizeImage(image) {
148
+ if (!image || typeof image !== "object") {
149
+ throw new Error("image must be an object — { data: '<base64>' }.");
150
+ }
151
+ if (typeof image.data !== "string" || image.data.trim() === "") {
152
+ throw new Error("image.data is required — base64 of the image (a data: URL is fine).");
153
+ }
154
+ // 🔴**다듬고 나서 접두어를 본다.** 예전엔 원본에 바로 `startsWith("data:")` 를
155
+ // 걸어서, 앞에 공백이 하나만 있어도(` data:image/png;base64,…`) 접두어를 못 떼고
156
+ // **`data:image/png;base64,` 문자열까지 통째로 base64 로** 보냈다. 엔진은
157
+ // "이미지를 읽을 수 없다"며 400 을 내고, 부르는 쪽은 왜인지 모른다.
158
+ const raw = image.data.trim();
159
+ const comma = raw.startsWith("data:") ? raw.indexOf(",") : -1;
160
+ // 🔴그리고 **가운데 공백까지** 없앤다. `base64` 명령과 `openssl base64` 는 76자마다
161
+ // 줄을 바꾼다 — 파일을 그렇게 만들어 붙여넣는 건 흔한 길인데, 양끝만 다듬으면
162
+ // 그 줄바꿈이 그대로 payload 에 남는다. base64 알파벳에 공백은 없으므로
163
+ // 전부 빼는 게 안전하다.
164
+ const data = (comma === -1 ? raw : raw.slice(comma + 1)).replace(/\s+/g, "");
165
+ if (data === "")
166
+ throw new Error("image.data is empty after stripping its data: prefix.");
167
+ const out = { data };
168
+ if (image.reference !== undefined)
169
+ out.reference = image.reference;
170
+ if (image.taken_at !== undefined)
171
+ out.taken_at = image.taken_at;
172
+ return out;
173
+ }
135
174
  /** Error codes that only occur while ESTABLISHING a connection — the request
136
175
  * never reached the server, so a retry can't double-process a write. Mid-stream
137
176
  * codes (ECONNRESET, EPIPE, UND_ERR_SOCKET, ETIMEDOUT) are ambiguous: the server
@@ -513,7 +552,10 @@ export class Client {
513
552
  * `opts.idempotencyKey`: see {@link WriteOptions} — pass one to make YOUR retry
514
553
  * of this exact write safe to repeat. */
515
554
  add(content, userId, metadata = {}, opts = {}) {
516
- return this.post("/api/v1/memory/store", { user_id: this.uid(userId), content, metadata }, opts.idempotencyKey);
555
+ const body = { user_id: this.uid(userId), content, metadata };
556
+ if (opts.image !== undefined)
557
+ body.image = normalizeImage(opts.image);
558
+ return this.post("/api/v1/memory/store", body, opts.idempotencyKey);
517
559
  }
518
560
  /** Alias of `add` — store one memory. Same surface as the Python SDK's `store`. */
519
561
  store(content, userId, metadata = {}, opts = {}) {
@@ -566,7 +608,12 @@ export class Client {
566
608
  * `form` ("memoir"/"archive", Scroll 1.2+) renders each long-term memory's time in
567
609
  * that form; `tz` is your UTC-offset hours for that rendering. */
568
610
  recall(query, userId, opts = {}) {
569
- return this.post("/api/v1/memory/recall", withForm({ user_id: this.uid(userId), query }, opts.form, opts.tz));
611
+ const body = { user_id: this.uid(userId), query };
612
+ if (opts.limit !== undefined)
613
+ body.limit = opts.limit;
614
+ if (opts.context_limit !== undefined)
615
+ body.context_limit = opts.context_limit;
616
+ return this.post("/api/v1/memory/recall", withForm(body, opts.form, opts.tz));
570
617
  }
571
618
  /** Run a built-in engram ("deep_recall" | "timeline" | "gather"). Returns the merged result.
572
619
  * `form`/`tz` render memory times (memoir/archive) on Scroll 1.2+, same as search/recall. */
@@ -640,6 +687,173 @@ export class Client {
640
687
  out.push(m);
641
688
  return out;
642
689
  }
690
+ // ----- images (Tablet 2 and newer) -----
691
+ /**
692
+ * Fetch the ORIGINAL bytes of an image memory.
693
+ *
694
+ * Returns what was uploaded, with the type sniffed from the bytes themselves rather
695
+ * than from whatever the file was called. Rejects with `NotFoundError` when this
696
+ * memory has no image, or when the service was not configured to keep originals —
697
+ * it says "no" instead of handing back something empty, so "a memory with no image"
698
+ * never looks the same as "an image we lost".
699
+ *
700
+ * const { bytes, contentType } = await mem.getImage(undefined, id);
701
+ * await fs.writeFile("image.jpg", bytes);
702
+ */
703
+ getImage(userId, memoryId) {
704
+ if (!memoryId || typeof memoryId !== "string") {
705
+ throw new Error("memoryId is required — the id that add/store or listImages returned.");
706
+ }
707
+ return this.requestBytes("/api/v1/memory/image", { user_id: this.uid(userId), memory_id: memoryId });
708
+ }
709
+ /**
710
+ * Remove the PHOTO from a memory, keeping its text.
711
+ *
712
+ * Except when there is no text: an image stored without a caption *is* the memory, so
713
+ * deleting the image deletes it. That is the one case worth checking before you
714
+ * commit, which is what `preview` is for — it reports `memory_kept` and changes
715
+ * nothing.
716
+ *
717
+ * const p = await mem.forgetImage(undefined, id, { preview: true });
718
+ * if (p.memory_kept === false) { /* this would delete the whole memory *\/ }
719
+ */
720
+ forgetImage(userId, memoryId, opts = {}) {
721
+ if (!memoryId || typeof memoryId !== "string") {
722
+ throw new Error("memoryId is required — the id of the memory whose image you want removed.");
723
+ }
724
+ const body = { user_id: this.uid(userId), memory_id: memoryId };
725
+ if (opts.preview)
726
+ body.preview = true;
727
+ return this.request("DELETE", "/api/v1/memory/image", body);
728
+ }
729
+ /**
730
+ * List a store's image memories, newest first, and count them.
731
+ *
732
+ * `count` is the TOTAL in the store, not the size of the page — so you can show
733
+ * "142 images" without walking every page. Paging is by cursor, not offset: hand
734
+ * `next_before` and `next_skip_ids` back as `before` / `skipIds`. The pair exists
735
+ * because several images can share a timestamp, and a timestamp alone would either
736
+ * repeat them or skip them.
737
+ */
738
+ listImages(userId, opts = {}) {
739
+ const body = { user_id: this.uid(userId) };
740
+ if (opts.limit !== undefined)
741
+ body.limit = opts.limit;
742
+ if (opts.before !== undefined)
743
+ body.before = opts.before;
744
+ if (opts.skipIds !== undefined)
745
+ body.skip_ids = opts.skipIds;
746
+ return this.post("/api/v1/memory/images", body);
747
+ }
748
+ /** Async-iterate every image memory, paging under the hood. */
749
+ async *iterImages(userId, opts = {}) {
750
+ let before;
751
+ let skipIds;
752
+ for (let page = 0; page < MAX_PAGES; page++) {
753
+ const p = await this.listImages(userId, { limit: opts.pageSize, before, skipIds });
754
+ for (const m of asRecords(p.images))
755
+ yield m;
756
+ if (!p.has_more || !p.next_before)
757
+ break;
758
+ before = p.next_before;
759
+ skipIds = Array.isArray(p.next_skip_ids) ? p.next_skip_ids : undefined;
760
+ }
761
+ }
762
+ // ----- how much has this memory been edited -----
763
+ /**
764
+ * How much of this store has been altered since it was written.
765
+ *
766
+ * This one is aimed at the MODEL, not at you. An assistant leaning on its own memory
767
+ * should be able to ask how far that memory has been edited underneath it — a store
768
+ * nobody has touched reads differently from one where a third of the facts were
769
+ * rewritten. Free, and it never reaches the retrieval path.
770
+ *
771
+ * Counts memories a transform touched (supersede, update, retract, image removed).
772
+ * Deletions are NOT counted: a deleted memory leaves nothing to count. Neither are
773
+ * atoms, the sentence fragments search uses internally — nobody stored one, so they
774
+ * do not belong in a ratio that answers "how much of MY memory changed".
775
+ *
776
+ * ★By default this returns COUNTS ONLY, and the answer is the same size for a store
777
+ * of a hundred memories and a store of a hundred million. That is deliberate: a model
778
+ * asks this mid-conversation, and an answer that grew with the store would be
779
+ * unusable for exactly the customers who most need to ask.
780
+ *
781
+ * To see WHICH memories, ask for one side and page through it — at most 20 per call,
782
+ * and never both sides in one response:
783
+ *
784
+ * ```ts
785
+ * const { revised, unrevised, total } = await mem.revisions(); // numbers only
786
+ *
787
+ * let before: string | undefined, skipIds: string[] | undefined;
788
+ * for (;;) {
789
+ * const p = await mem.revisions(undefined, { include: "revised", before, skipIds });
790
+ * for (const m of p.memories ?? []) console.log(m.content);
791
+ * if (!p.has_more || !p.next_before) break;
792
+ * before = p.next_before;
793
+ * skipIds = p.next_skip_ids;
794
+ * }
795
+ * ```
796
+ *
797
+ * ⚠️Pages are ordered by when each memory was STORED, not by when it was edited.
798
+ * The response says so in `ordered_by`.
799
+ *
800
+ * ★Served from `/api/v1/won/*`, not `/api/v1/memory/*`. Won is the surface for calls
801
+ * a model makes ABOUT its memory rather than calls an application makes WITH it, and
802
+ * the address says so. The old path still answers, for clients published before
803
+ * 2026-08-18, and both share one rate-limit budget.
804
+ */
805
+ revisions(userId, opts = {}) {
806
+ const body = {};
807
+ if (opts.include !== undefined)
808
+ body.include = opts.include;
809
+ if (opts.limit !== undefined)
810
+ body.limit = opts.limit;
811
+ if (opts.before !== undefined)
812
+ body.before = opts.before;
813
+ if (opts.skipIds !== undefined)
814
+ body.skip_ids = opts.skipIds;
815
+ // `user_id` LAST, after the caller's fields — an `opts` spread that can land on top
816
+ // of it is how a client ends up reading another store. Same order as `search`.
817
+ body.user_id = this.uid(userId);
818
+ return this.post("/api/v1/won/revisions", body);
819
+ }
820
+ /**
821
+ * The full chain of edits behind one memory, oldest first.
822
+ *
823
+ * `is_current` marks the version in force. Use it to answer "what did this used to
824
+ * say, and when did it change" — `revisions()` tells you HOW MUCH a store moved,
825
+ * this tells you what happened to one fact.
826
+ */
827
+ lineage(userId, memoryId) {
828
+ if (!memoryId || typeof memoryId !== "string") {
829
+ throw new Error("memoryId is required — the memory whose history you want.");
830
+ }
831
+ return this.post("/api/v1/memory/lineage", { user_id: this.uid(userId), memory_id: memoryId });
832
+ }
833
+ /**
834
+ * What one person said, newest first.
835
+ *
836
+ * `speaker` is the tag written at store time (`metadata.speaker`) — `"me"` for the
837
+ * assistant's own words, otherwise a person's name. Same cursor paging as
838
+ * `listImages`.
839
+ *
840
+ * `chunks` / `points_to_delete` report the sentence-level fragments behind these
841
+ * memories, which is what a delete would actually remove — usually larger than
842
+ * `returned`, and worth showing before anyone confirms one.
843
+ */
844
+ bySpeaker(speaker, userId, opts = {}) {
845
+ if (!speaker || typeof speaker !== "string" || speaker.trim() === "") {
846
+ throw new Error('speaker is required — "me" for the assistant, or a person\'s name.');
847
+ }
848
+ const body = { user_id: this.uid(userId), speaker: speaker.trim() };
849
+ if (opts.limit !== undefined)
850
+ body.limit = opts.limit;
851
+ if (opts.before !== undefined)
852
+ body.before = opts.before;
853
+ if (opts.skipIds !== undefined)
854
+ body.skip_ids = opts.skipIds;
855
+ return this.post("/api/v1/memory/by-speaker", body);
856
+ }
643
857
  /** Check connectivity AND that the API key works. Resolves `true`, or rejects — an
644
858
  * `AuthenticationError` (bad key), `PaymentRequiredError` (valid key but no card /
645
859
  * depleted balance), or `APIConnectionError` (unreachable). Makes one metered request. */
@@ -747,6 +961,73 @@ export class Client {
747
961
  post(path, body, idempotencyKey) {
748
962
  return this.request("POST", path, body, idempotencyKey);
749
963
  }
964
+ /**
965
+ * One request that answers with BYTES rather than JSON.
966
+ *
967
+ * Only `/memory/image` does this, and it is why this cannot go through `request()`:
968
+ * that path reads the body as text and insists the result parses to a JSON object.
969
+ * Feeding it a JPEG would throw "invalid JSON in response" — a confusing error for a
970
+ * call that actually succeeded.
971
+ *
972
+ * Errors still arrive as JSON, so those are handed back to the normal machinery: on
973
+ * a non-2xx we re-read the body as text and reuse `errorFor`, which keeps 404
974
+ * (`NotFoundError`) and 401 behaving the same as everywhere else.
975
+ */
976
+ async requestBytes(path, body) {
977
+ const ctrl = new AbortController();
978
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
979
+ let res;
980
+ try {
981
+ res = await this.fetchImpl(`${this.base}${path}`, {
982
+ method: "POST",
983
+ headers: this.headers,
984
+ body: JSON.stringify(body),
985
+ signal: ctrl.signal,
986
+ redirect: "manual",
987
+ });
988
+ }
989
+ catch (e) {
990
+ clearTimeout(timer);
991
+ if (e?.name === "AbortError" || ctrl.signal.aborted) {
992
+ throw new APIConnectionError(0, `request timed out after ${this.timeoutMs}ms`);
993
+ }
994
+ throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
995
+ }
996
+ try {
997
+ this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
998
+ if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
999
+ void res.body?.cancel();
1000
+ throw errorFor(res.status, "the API answered with a redirect; refusing to follow it");
1001
+ }
1002
+ if (!res.ok) {
1003
+ let msg = await this.readCapped(res).catch(() => "");
1004
+ try {
1005
+ const data = JSON.parse(msg);
1006
+ const err = data?.error;
1007
+ if (err && typeof err === "object")
1008
+ msg = err.message ?? err.type ?? msg;
1009
+ else if (typeof err === "string")
1010
+ msg = err;
1011
+ }
1012
+ catch {
1013
+ /* keep raw text */
1014
+ }
1015
+ if (msg.length > MAX_ERR_MSG)
1016
+ msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
1017
+ throw errorFor(res.status, msg || `HTTP ${res.status}`);
1018
+ }
1019
+ const buf = await this.readCappedBytes(res);
1020
+ if (buf.byteLength === 0) {
1021
+ // An empty 200 would otherwise read as "here is your image" and write a
1022
+ // zero-byte file — indistinguishable from an image we lost.
1023
+ throw new WosError(res.status, "empty image body — the service returned no bytes");
1024
+ }
1025
+ return { bytes: buf, contentType: res.headers.get("content-type") ?? "application/octet-stream" };
1026
+ }
1027
+ finally {
1028
+ clearTimeout(timer);
1029
+ }
1030
+ }
750
1031
  /** Validate an idempotency key BEFORE the network. The API answers a malformed
751
1032
  * key with a 400, which on a retry path reads as "my write failed" when in fact
752
1033
  * it was never attempted — cheaper and clearer to reject it here. */
@@ -774,12 +1055,21 @@ export class Client {
774
1055
  /** Read the body with a hard size cap, so a broken/hostile endpoint can't
775
1056
  * make the process buffer gigabytes. */
776
1057
  async readCapped(res) {
1058
+ return new TextDecoder().decode(await this.readCappedBytes(res));
1059
+ }
1060
+ /** The same cap, for a body that is not text.
1061
+ *
1062
+ * `getImage` used to call `arrayBuffer()` here, which buffers whatever arrives. The
1063
+ * cap exists for a hostile or broken `baseUrl`, and a photo endpoint is exactly where
1064
+ * that shows up — the JSON paths were guarded while the one path that returns
1065
+ * megabytes was not. One implementation, two callers, so they cannot drift. */
1066
+ async readCappedBytes(res) {
777
1067
  const cl = res.headers.get("content-length");
778
1068
  if (cl && Number(cl) > MAX_RESPONSE_BYTES) {
779
1069
  throw new WosError(0, `response too large (${cl} bytes) — refusing to buffer it`);
780
1070
  }
781
1071
  if (!res.body)
782
- return res.text();
1072
+ return new Uint8Array(await res.arrayBuffer());
783
1073
  const reader = res.body.getReader();
784
1074
  const parts = [];
785
1075
  let size = 0;
@@ -800,7 +1090,7 @@ export class Client {
800
1090
  buf.set(p, off);
801
1091
  off += p.byteLength;
802
1092
  }
803
- return new TextDecoder().decode(buf);
1093
+ return buf;
804
1094
  }
805
1095
  async request(method, path, body, idempotencyKey) {
806
1096
  const extraHeaders = this.idemHeader(idempotencyKey);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wontopos",
3
- "version": "2.2.29",
3
+ "version": "2.2.31",
4
4
  "description": "Wontopos — long-term memory for AI agents. Pure semantic retrieval, identical recall in every language, ~100× lower LLM bill.",
5
5
  "keywords": [
6
6
  "memory",
@@ -42,5 +42,8 @@
42
42
  },
43
43
  "devDependencies": {
44
44
  "typescript": "^5.4.0"
45
+ },
46
+ "bugs": {
47
+ "url": "https://wontopos.com/contact?topic=bug"
45
48
  }
46
49
  }