wontopos 2.2.38 → 2.2.40

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
@@ -80,12 +80,12 @@ const hits = await mem.search("...the conversation so far...", "alice", 10,
80
80
 
81
81
  Built in, no configuration needed:
82
82
 
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
83
+ - **Automatic retries** — 429 always, and 408 / 502 / 503 / 504 or a connection error
84
+ only when a retry cannot double-process a write. The writes and the searches are
85
+ POSTs, and any of those four may have been returned *after* the write already
86
+ landed, so those get 429 and connect-level failures only. The reads and the
87
87
  deletes that address a whole store — `listStores`, `listSpeakers`, `deleteStore`,
88
- `removeSpeaker`, `forgetImage` — are GET or DELETE and do retry a 502. Twice, with
88
+ `removeSpeaker`, `forgetImage` — are GET or DELETE and do retry all four. Twice, with
89
89
  exponential backoff + jitter, honoring the server's `Retry-After`. Tune with
90
90
  `new Client({ maxRetries })`; `0` disables.
91
91
  - **Redirects refused** — the API key never follows a 3xx to another host.
@@ -106,9 +106,7 @@ Built in, no configuration needed:
106
106
  ### Behind a corporate proxy
107
107
 
108
108
  Node's global `fetch` (undici) ignores `HTTP_PROXY` / `HTTPS_PROXY`, so behind an
109
- egress proxy this SDK could not connect at all — while the Python and Rust SDKs went
110
- through, because `requests` and `reqwest` both read those variables. Pass a
111
- proxy-aware `fetch` and the three behave the same:
109
+ egress proxy no request leaves at all. Pass a proxy-aware `fetch` and they go through:
112
110
 
113
111
  This one needs `npm i undici` — Node's global `fetch` is built on undici, but the
114
112
  package itself is not installed for you.
@@ -211,7 +209,7 @@ data to fix it.
211
209
  ## Changelog
212
210
 
213
211
  The three clients release in lockstep — same version, same surface, same day. Patch
214
- releases are additive. Four inside 2.2 were not, deliberately and each with its
212
+ releases are additive. Six inside 2.2 were not, deliberately and each with its
215
213
  reason; the changelog lists them.
216
214
 
217
215
  See [CHANGELOG.md](https://github.com/wontopos/wontopos-node/blob/main/CHANGELOG.md).
@@ -26,8 +26,8 @@
26
26
  * and searching call no LLM.
27
27
  *
28
28
  * Reliability: every call retries transient failures with exponential backoff +
29
- * jitter, honoring `Retry-After` — 429 always; 502/503 and connection errors only
30
- * when a retry can never double-process a write (idempotent calls, or a failure
29
+ * jitter, honoring `Retry-After` — 429 always; 408/502/503/504 and connection errors
30
+ * only when a retry can never double-process a write (idempotent calls, or a failure
31
31
  * at connect time). Tune with `maxRetries` (0 disables) and `timeoutMs`, or per
32
32
  * call site via the `withTimeout()` / `withRetries()` clones.
33
33
  *
@@ -74,10 +74,11 @@ export declare class PaymentRequiredError extends WosError {
74
74
  /** 403 — the key/model isn't allowed to do this. */
75
75
  export declare class PermissionDeniedError extends WosError {
76
76
  }
77
- /** 404 — the store or resource doesn't exist (create the store first). */
77
+ /** 404 — the store or resource doesn't exist. */
78
78
  export declare class NotFoundError extends WosError {
79
79
  }
80
- /** 409 — a concurrent write to the same store. Retry. */
80
+ /** 409 — another write to this store was in flight (nothing was stored; retry), or the
81
+ * store id collides with an existing store's (permanent). */
81
82
  export declare class ConflictError extends WosError {
82
83
  }
83
84
  /** 429 — too many requests. Back off and retry (the client already retries these). */
@@ -86,8 +87,8 @@ export declare class RateLimitError extends WosError {
86
87
  /**
87
88
  * 5xx — the service failed.
88
89
  *
89
- * `502` / `503` are transient: the client already retries them for calls where a
90
- * retry cannot double-process a write, and retrying yourself is reasonable.
90
+ * `502` / `503` / `504` are transient: the client already retries them for calls
91
+ * where a retry cannot double-process a write, and retrying yourself is reasonable.
91
92
  *
92
93
  * `501` is NOT transient. It means the engine behind the model you selected does
93
94
  * not implement that endpoint at all (the error carries `model` and `endpoint`).
@@ -159,6 +160,8 @@ export interface StatusResult {
159
160
  /**
160
161
  * An image attached to a memory, passed to `add`/`store` via `opts.image`.
161
162
  *
163
+ * Both edges must be 700px or more; a smaller image is refused (400).
164
+ *
162
165
  * What the service keeps is NOT your original. An image whose long edge is over
163
166
  * 1568px is downscaled to 1568 before anything else happens, and that smaller
164
167
  * picture is what gets stored, and handed back by `getImage`. Nothing on our side
@@ -291,8 +294,8 @@ export interface LineageResult {
291
294
  export interface SpeakerPage {
292
295
  speaker?: string;
293
296
  memories: Memory[];
294
- /** How many underlying records a delete would actually remove, beyond what was returned. */
295
297
  chunks?: number;
298
+ /** The count to show before anyone confirms a delete of this speaker's memories. */
296
299
  points_to_delete?: number;
297
300
  returned?: number;
298
301
  has_more?: boolean;
@@ -307,20 +310,17 @@ export interface UpdateResult {
307
310
  status?: string;
308
311
  [key: string]: unknown;
309
312
  }
310
- /** `searchFull` → everything one search answered with, not just the merged memories.
311
- *
312
- * `search` returns the memories and nothing else, which is the right answer for
313
- * almost every call. Two options make it the wrong one: `max_images` asks for
314
- * photos, which arrive in their own field, and `verify` is reported on by
315
- * `verify_used`. Merging away both means paying for a
316
- * search you shaped and never seeing what came of it. */
313
+ /** `searchFull` → everything one search answered with. `search` returns every memory
314
+ * as one array; this keeps the fields apart and adds `verify_used`, the report on
315
+ * the `verify` option. */
317
316
  export interface SearchResult {
318
317
  /** What others said, and general memories. */
319
318
  memories: Memory[];
320
319
  /** The assistant's own words (speaker "me"); `[]` on a model that does not keep
321
320
  * them apart. */
322
321
  self_memories: Memory[];
323
- /** Image memories, when `max_images` asked for any; `[]` otherwise. */
322
+ /** Image memories the answer carried (one by default on an image-capable model,
323
+ * up to `max_images`); `[]` when there were none. */
324
324
  images: Memory[];
325
325
  /** Re-ask passes actually performed. Present only when `verify` was sent; lower
326
326
  * than you asked for means the store had nothing further to add. */
@@ -428,21 +428,27 @@ export interface EngramCatalog {
428
428
  engrams: EngramInfo[];
429
429
  forms: EngramInfo[];
430
430
  note?: string;
431
+ /** Anything else the reply carried. Responses widen; the runtime keeps those fields
432
+ * and this is what lets a typed caller read them. */
433
+ [key: string]: unknown;
431
434
  }
432
435
  /** `createStore` / `deleteStore` → `{ user_id, status }`. */
433
436
  export interface StoreOpResult {
434
- /** The store id the API actually used — NOT necessarily the one you sent.
435
- * Ids are normalized (see the `note`), so compare this against your input. */
437
+ /** The store id. `createStore` answers with the id as you sent it. */
436
438
  user_id?: string;
439
+ /** The normalized form the store is filed under, when it differs from `user_id`. */
440
+ canonical_id?: string;
437
441
  status?: string;
438
- /** Present when the API changed your id, explaining how. Read it: two of your
439
- * end users can land in one store if their ids differ only by punctuation. */
442
+ /** Present when the API filed your id under a normalized form, explaining how. */
440
443
  note?: string;
441
444
  [key: string]: unknown;
442
445
  }
443
446
  export interface StoreInfo {
447
+ /** The id the store was created with — pass it back as `userId`. */
444
448
  user_id: string;
445
449
  created_at: string;
450
+ /** The normalized form the store is filed under, when it differs from `user_id`. */
451
+ canonical_id?: string;
446
452
  [key: string]: unknown;
447
453
  }
448
454
  /** `addSpeaker` / `removeSpeaker` → `{ user_id, speaker, status }`. */
@@ -532,11 +538,15 @@ export interface SearchOptions {
532
538
  * than answering with no images.
533
539
  */
534
540
  max_images?: number;
541
+ /** Delivery form, "memoir" or "archive" (Scroll 1.2+): how each memory's time is
542
+ * rendered. The service refuses an unknown one (400). */
543
+ form?: string;
544
+ /** Your UTC offset in hours, for rendering times in `form`. */
545
+ tz?: number;
535
546
  /**
536
547
  * Fields the service accepts that this version does not know about, merged into the
537
- * request as written. Every other key is refused, so a typo cannot reach the wire —
538
- * `verfy: 3` used to be sent, dropped by the service, and answered normally, leaving
539
- * a caller billed for one pass believing they had bought three.
548
+ * request as written. Every other key is refused, so a typo cannot reach the wire.
549
+ * The store, the query and the count always win over a copy in here.
540
550
  */
541
551
  extra?: Record<string, unknown>;
542
552
  }
@@ -548,9 +558,9 @@ export interface SearchOptions {
548
558
  * arrives with a different body. Use it when a retry is your own (a job that died and
549
559
  * was re-run, a queue that redelivers). The SDK retries a write on exactly one
550
560
  * status: 429, which the service answers before it processes anything, so nothing
551
- * was stored. It never retries a write on 502 / 503 or a dropped body, where the
552
- * request may already have been stored and billed — without a key it cannot know
553
- * whether that first attempt landed.
561
+ * was stored. It never retries a write on 408 / 502 / 503 / 504 or a dropped body,
562
+ * where the first attempt may already have landed — without a key the client cannot
563
+ * know whether it did.
554
564
  *
555
565
  * The key must be UNIQUE PER LOGICAL WRITE — derive it from the thing being stored
556
566
  * (`` `import:${row.id}` ``), never a constant, or the second write replays the first
@@ -582,8 +592,8 @@ export interface ClientOptions {
582
592
  * to the account's built-in `default` store. */
583
593
  userId?: string;
584
594
  /** How many times to retry transient failures before throwing — 429 always;
585
- * 502/503 and connection errors only when a retry can never double-process a
586
- * write. 0 disables retries (default 2). */
595
+ * 408/502/503/504 and connection errors only when a retry can never
596
+ * double-process a write. 0 disables retries (default 2). */
587
597
  maxRetries?: number;
588
598
  /** Alias of `maxRetries`. The Python SDK calls this option `retries`, and these
589
599
  * SDKs are published in lockstep as "the same surface" — so code ported between
@@ -611,10 +621,8 @@ export interface ClientOptions {
611
621
  *
612
622
  * This is the seam for anything the runtime cannot express through options.
613
623
  * The one that matters in practice is a corporate egress proxy: Node's global
614
- * fetch (undici) IGNORES `HTTP_PROXY` / `HTTPS_PROXY`, so behind such a proxy
615
- * this SDK could not connect at all — while the Python and Rust SDKs went
616
- * through, because `requests` and `reqwest` both read those variables. Pass a
617
- * proxy-aware fetch and the three behave the same again:
624
+ * fetch (undici) IGNORES `HTTP_PROXY` / `HTTPS_PROXY`, so behind such a proxy no
625
+ * request leaves at all. Pass a proxy-aware fetch and they go through:
618
626
  *
619
627
  * import { ProxyAgent } from "undici";
620
628
  * const agent = new ProxyAgent(process.env.HTTPS_PROXY!);
@@ -696,7 +704,7 @@ export declare class Client {
696
704
  addTurn(userMsg: string, assistantMsg: string, userId?: string, opts?: WriteOptions): Promise<StatusResult>;
697
705
  /** Bulk-ingest a large blob of text in one call. For backfilling.
698
706
  * The call most worth an `opts.idempotencyKey`: a backfill that dies halfway and is
699
- * re-run would otherwise re-ingest and re-bill the whole blob. */
707
+ * re-run would otherwise ingest the whole blob a second time. */
700
708
  addBulk(content: string, userId?: string, category?: string, timestamp?: string, opts?: WriteOptions): Promise<StatusResult>;
701
709
  /** Supersede an old memory with new content. Payload first, userId last — same shape as add/search. */
702
710
  update(oldMemoryId: string, newContent: string, userId?: string, opts?: WriteOptions): Promise<UpdateResult>;
@@ -707,18 +715,18 @@ export declare class Client {
707
715
  * a call that passes no count is unaffected. Before 2.2.35 the count was sent on
708
716
  * unchecked.
709
717
  *
710
- * `limit` bounds `memories`, not the returned array. On a model that keeps the
711
- * assistant's own words separate (Scroll 1.2+) those come back as well, so the
712
- * array can hold more than `limit`. They were retrieved and billed either way;
713
- * dropping them would only hide what you already paid for. Size a prompt window
714
- * on the array you get back, not on `limit`. `searchSelf()` hands the two back
715
- * apart. */
718
+ * `limit` bounds `memories`, not the returned array. The assistant's own words
719
+ * (Scroll 1.2+) and image memories (Tablet 2+, one unless `max_images` says
720
+ * otherwise) come back in it as well, so it can hold more than `limit`. They are
721
+ * billed either way. Size a prompt window on the array you get back, not on
722
+ * `limit`. `searchFull()` hands the fields back apart. */
716
723
  search(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<Memory[]>;
717
724
  /** Search a self-memory model (Scroll 1.2+): both fields from ONE call. Returns
718
725
  * `{ memories, self_memories }` — `memories` is what others said and general
719
726
  * memories, `self_memories` is the assistant's OWN words (stored with speaker
720
727
  * "me"), kept apart so whoever reads them never confuses who said what. On a model
721
- * that does not keep them apart, `self_memories` is `[]`. `userId` may be omitted. */
728
+ * that does not keep them apart, `self_memories` is `[]`. Image memories are not
729
+ * included; `search` and `searchFull` carry them. `userId` may be omitted. */
722
730
  searchSelf(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<SelfSearchResult>;
723
731
  /**
724
732
  * Search, and keep every field the answer came with.
@@ -728,7 +736,7 @@ export declare class Client {
728
736
  *
729
737
  * const r = await mem.searchFull("the day we moved", "alice", 10,
730
738
  * { max_images: 3, verify: 2 });
731
- * r.images.length; // the photos, which `search` drops
739
+ * r.images.length; // the photos, apart from the text memories
732
740
  * r.verify_used; // how many re-ask passes actually ran (you are billed per pass)
733
741
  *
734
742
  * Both options need Tablet 2 or newer and are refused (403) on an older engine
@@ -812,6 +820,11 @@ export declare class Client {
812
820
  }): AsyncGenerator<Memory>;
813
821
  /** Collect ALL of a store's memories into an array (the text you stored, and its metadata). */
814
822
  exportMemories(userId?: string): Promise<Memory[]>;
823
+ /** Collect ALL of a store's image memories into an array — the image-side pair of
824
+ * `exportMemories`. */
825
+ exportImages(userId?: string, opts?: {
826
+ pageSize?: number;
827
+ }): Promise<Memory[]>;
815
828
  /**
816
829
  * Fetch the bytes of an image memory — the picture the SERVICE holds.
817
830
  *
@@ -937,9 +950,8 @@ export declare class Client {
937
950
  * assistant's own words, otherwise a person's name. Same cursor paging as
938
951
  * `listImages`.
939
952
  *
940
- * `chunks` / `points_to_delete` report how many internal records a delete would
941
- * actually remove — usually more than `returned`, and worth showing to whoever is
942
- * about to confirm one.
953
+ * `points_to_delete` is the count to show before anyone confirms a delete of this
954
+ * speaker's memories.
943
955
  */
944
956
  bySpeaker(speaker: string, userId?: string, opts?: {
945
957
  limit?: number;
@@ -969,7 +981,8 @@ export declare class Client {
969
981
  * return 404. Idempotent. Every account starts with a `default` store.
970
982
  * Returns `{ user_id, status }` (`status` is `"created"` or `"exists"`). */
971
983
  createStore(userId?: string): Promise<StoreOpResult>;
972
- /** List your stores: `[{ user_id, created_at }, ...]` (`default` first). */
984
+ /** List your stores: `[{ user_id, created_at, canonical_id? }, ...]` (`default`
985
+ * first). Each `user_id` is the id the store was created with. */
973
986
  listStores(): Promise<StoreInfo[]>;
974
987
  /** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
975
988
  deleteStore(userId: string): Promise<StoreOpResult>;
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WME = exports.Client = exports.ServerError = exports.RateLimitError = exports.ConflictError = exports.NotFoundError = exports.PermissionDeniedError = exports.PaymentRequiredError = exports.AuthenticationError = exports.BadRequestError = exports.APIConnectionError = exports.WosError = exports.CONTEXT_LIMIT_MAX = exports.CONTEXT_LIMIT_MIN = exports.SEARCH_LIMIT_MAX = exports.SEARCH_LIMIT_MIN = void 0;
4
4
  exports._resetStoreIdWarnings = _resetStoreIdWarnings;
5
5
  exports._resetFilterWarnings = _resetFilterWarnings;
6
- const VERSION = "2.2.38";
6
+ const VERSION = "2.2.40";
7
7
  const RUNTIME = (() => {
8
8
  const p = globalThis?.process;
9
9
  return p?.version ? ` (node/${p.version}; ${p.platform}-${p.arch})` : "";
@@ -18,7 +18,7 @@ function logDebug(msg) {
18
18
  const DEFAULT_BASE_URL = "https://api.wontopos.com";
19
19
  const DEFAULT_MODEL = "tablet-2";
20
20
  const RETRY_ALWAYS = new Set([429]);
21
- const RETRY_IF_IDEMPOTENT = new Set([502, 503]);
21
+ const RETRY_IF_IDEMPOTENT = new Set([408, 502, 503, 504]);
22
22
  const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
23
23
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
24
24
  const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
@@ -40,8 +40,9 @@ const warnedStoreIds = new Set();
40
40
  function warnIfStoreIdCollapses(id) {
41
41
  if (!id || warnedStoreIds.has(id))
42
42
  return;
43
+ const valid = STORE_ID_FORMAT.test(id);
43
44
  const normalized = normalizeStoreId(id);
44
- if (normalized === id)
45
+ if (valid && normalized === id)
45
46
  return;
46
47
  warnedStoreIds.add(id);
47
48
  while (warnedStoreIds.size > WARNED_STORE_IDS_MAX) {
@@ -50,10 +51,27 @@ function warnIfStoreIdCollapses(id) {
50
51
  break;
51
52
  warnedStoreIds.delete(oldest);
52
53
  }
53
- console.warn(`wontopos: store id ${JSON.stringify(id)} is stored as ${JSON.stringify(normalized)} ` +
54
- `(lowercased, and anything outside [a-z0-9_] becomes "_"). Ids that differ only by case or ` +
55
- `punctuation share ONE store and therefore one set of memories — if these ids come from your ` +
56
- `end users, normalize them yourself first so two people can never collide.`);
54
+ if (!valid) {
55
+ console.warn(`wontopos: store id ${JSON.stringify(id)} is not a valid store id: use 1-64 ASCII letters, ` +
56
+ `digits, ".", "_" and "-", starting with a letter or digit. Creating it is refused (400).`);
57
+ return;
58
+ }
59
+ console.warn(`wontopos: store id ${JSON.stringify(id)} normalizes to ${JSON.stringify(normalized)}. ` +
60
+ `Ids that differ only by case name this same store; one that differs only by punctuation ` +
61
+ `cannot be created beside it (409) and is not found when used (404). If these ids come from ` +
62
+ `your end users, normalize them yourself first so two people never compete for one name.`);
63
+ }
64
+ const STORE_ID_FORMAT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
65
+ const NOT_METADATA = new Set(["userid", "storeid", "idempotencykey"]);
66
+ function checkMetadataKeys(metadata) {
67
+ if (metadata == null || typeof metadata !== "object")
68
+ return;
69
+ for (const k of Object.keys(metadata)) {
70
+ if (NOT_METADATA.has(k.toLowerCase().replace(/[\s_.-]/g, ""))) {
71
+ throw new Error(`${JSON.stringify(k)} is not a metadata field: pass the store as userId and an ` +
72
+ `idempotency key as opts.idempotencyKey. Nothing was sent.`);
73
+ }
74
+ }
57
75
  }
58
76
  function _resetStoreIdWarnings() {
59
77
  warnedStoreIds.clear();
@@ -141,13 +159,24 @@ function parseRateLimit(h) {
141
159
  const asList = (x) => (Array.isArray(x) ? x : []);
142
160
  const asObj = (x) => (x != null && typeof x === "object" && !Array.isArray(x) ? x : {});
143
161
  const asRecords = (x) => asList(x).filter((m) => m != null && typeof m === "object" && !Array.isArray(m));
162
+ const memoryFromGet = (r) => {
163
+ const o = asObj(r);
164
+ if (o.memory != null && typeof o.memory === "object" && !Array.isArray(o.memory))
165
+ return o.memory;
166
+ return typeof o.id === "string" ? o : {};
167
+ };
144
168
  function mergeResults(r) {
145
- const main = asRecords(r?.memories);
146
- const mine = asRecords(r?.self_memories);
147
- if (mine.length === 0)
148
- return main;
149
- const seen = new Set(main.map((m) => m.id).filter((id) => typeof id === "string"));
150
- return main.concat(mine.filter((m) => !(typeof m.id === "string" && seen.has(m.id))));
169
+ const out = asRecords(r?.memories);
170
+ const seen = new Set(out.map((m) => m.id).filter((id) => typeof id === "string"));
171
+ for (const m of [...asRecords(r?.self_memories), ...asRecords(r?.images)]) {
172
+ if (typeof m.id === "string") {
173
+ if (seen.has(m.id))
174
+ continue;
175
+ seen.add(m.id);
176
+ }
177
+ out.push(m);
178
+ }
179
+ return out;
151
180
  }
152
181
  function withForm(body, form, tz) {
153
182
  if (form !== undefined)
@@ -219,10 +248,11 @@ const KNOWN_FILTER_KEYS = new Set([
219
248
  ]);
220
249
  const warnedFilterKeys = new Set();
221
250
  const KNOWN_SEARCH_KEYS = new Set([
222
- "cache_control", "speaker", "filters", "verify", "max_images", "extra",
251
+ "cache_control", "speaker", "filters", "verify", "max_images", "form", "tz", "extra",
223
252
  ]);
224
253
  const RESERVED_SEARCH_KEYS = new Set(["user_id", "query", "max_results"]);
225
254
  const KNOWN_RECALL_KEYS = new Set(["form", "tz", "limit", "context_limit"]);
255
+ const KNOWN_ENGRAM_KEYS = new Set(["form", "tz"]);
226
256
  function checkSearchOpts(opts) {
227
257
  checkOpts(opts, KNOWN_SEARCH_KEYS, "search");
228
258
  }
@@ -237,8 +267,7 @@ function checkOpts(opts, known, what) {
237
267
  const near = [...known].find((n) => n !== "extra" && editWithin(k, n, 2));
238
268
  throw new Error(`unknown ${what} option ${JSON.stringify(k)}` +
239
269
  (near ? ` — did you mean ${JSON.stringify(near)}?` : "") +
240
- `. The service drops keys it does not know and answers anyway, so this would ` +
241
- `have looked like it worked.` +
270
+ `. It would have had no effect, and the call would have looked like it worked.` +
242
271
  (known.has("extra")
243
272
  ? ` Pass it under \`extra\` if the service accepts it and this client does not know it yet.`
244
273
  : ""));
@@ -407,25 +436,26 @@ class Client {
407
436
  withRetries(maxRetries) {
408
437
  return this.clone({ maxRetries });
409
438
  }
410
- add(content, userId, metadata = {}, opts = {}) {
439
+ async add(content, userId, metadata = {}, opts = {}) {
440
+ checkMetadataKeys(metadata);
411
441
  const body = { user_id: this.uid(userId), content, metadata };
412
442
  if (opts.image !== undefined)
413
443
  body.image = normalizeImage(opts.image);
414
444
  return this.post("/api/v1/memory/store", body, opts.idempotencyKey);
415
445
  }
416
- store(content, userId, metadata = {}, opts = {}) {
446
+ async store(content, userId, metadata = {}, opts = {}) {
417
447
  return this.add(content, userId, metadata, opts);
418
448
  }
419
- addTurn(userMsg, assistantMsg, userId, opts = {}) {
449
+ async addTurn(userMsg, assistantMsg, userId, opts = {}) {
420
450
  return this.post("/api/v1/memory/store-turn", { user_id: this.uid(userId), user_msg: userMsg, assistant_msg: assistantMsg }, opts.idempotencyKey);
421
451
  }
422
- addBulk(content, userId, category = "general", timestamp, opts = {}) {
452
+ async addBulk(content, userId, category = "general", timestamp, opts = {}) {
423
453
  const body = { user_id: this.uid(userId), content, category };
424
454
  if (timestamp)
425
455
  body.timestamp = timestamp;
426
456
  return this.post("/api/v1/memory/bulk-store", body, opts.idempotencyKey);
427
457
  }
428
- update(oldMemoryId, newContent, userId, opts = {}) {
458
+ async update(oldMemoryId, newContent, userId, opts = {}) {
429
459
  return this.post("/api/v1/memory/supersede", { user_id: this.uid(userId), old_memory_id: oldMemoryId, new_content: newContent }, opts.idempotencyKey);
430
460
  }
431
461
  async search(query, userId, limit = 10, opts = {}) {
@@ -470,14 +500,15 @@ class Client {
470
500
  }
471
501
  return this.post("/api/v1/memory/recall", withForm(body, opts.form, opts.tz));
472
502
  }
473
- engram(name, query, userId, opts = {}) {
503
+ async engram(name, query, userId, opts = {}) {
504
+ checkOpts(opts, KNOWN_ENGRAM_KEYS, "engram");
474
505
  return this.post("/api/v1/engram/run", withForm({ name, user_id: this.uid(userId), query }, opts.form, opts.tz));
475
506
  }
476
507
  async history(userId) {
477
508
  const r = await this.post("/api/v1/memory/history", { user_id: this.uid(userId) });
478
509
  return asRecords(r.turns);
479
510
  }
480
- stats(userId) {
511
+ async stats(userId) {
481
512
  return this.post("/api/v1/memory/stats", { user_id: this.uid(userId) });
482
513
  }
483
514
  async usage(days = 7) {
@@ -486,13 +517,13 @@ class Client {
486
517
  }
487
518
  return this.request("GET", `/api/v1/won/usage?days=${days}`);
488
519
  }
489
- get(userId, memoryId) {
520
+ async get(userId, memoryId) {
490
521
  if (typeof memoryId !== "string" || !memoryId.trim()) {
491
522
  throw new Error("memoryId is required — the id that add/store or listMemories returned.");
492
523
  }
493
- return this.post("/api/v1/memory/get", { user_id: this.uid(userId), memory_id: memoryId.trim() }).then((r) => asObj(r.memory));
524
+ return this.post("/api/v1/memory/get", { user_id: this.uid(userId), memory_id: memoryId.trim() }).then(memoryFromGet);
494
525
  }
495
- listMemories(userId, opts = {}) {
526
+ async listMemories(userId, opts = {}) {
496
527
  const body = { user_id: this.uid(userId), limit: opts.limit ?? 100 };
497
528
  if (opts.cursor)
498
529
  body.cursor = opts.cursor;
@@ -520,13 +551,19 @@ class Client {
520
551
  out.push(m);
521
552
  return out;
522
553
  }
523
- getImage(userId, memoryId) {
554
+ async exportImages(userId, opts = {}) {
555
+ const out = [];
556
+ for await (const m of this.iterImages(userId, opts))
557
+ out.push(m);
558
+ return out;
559
+ }
560
+ async getImage(userId, memoryId) {
524
561
  if (typeof memoryId !== "string" || !memoryId.trim()) {
525
562
  throw new Error("memoryId is required — the id that add/store or listImages returned.");
526
563
  }
527
564
  return this.requestBytes("/api/v1/memory/image", { user_id: this.uid(userId), memory_id: memoryId.trim() });
528
565
  }
529
- forgetImage(userId, memoryId, opts = {}) {
566
+ async forgetImage(userId, memoryId, opts = {}) {
530
567
  if (typeof memoryId !== "string" || !memoryId.trim()) {
531
568
  throw new Error("memoryId is required — the id of the memory whose image you want removed.");
532
569
  }
@@ -535,7 +572,7 @@ class Client {
535
572
  body.preview = true;
536
573
  return this.request("DELETE", "/api/v1/memory/image", body);
537
574
  }
538
- listImages(userId, opts = {}) {
575
+ async listImages(userId, opts = {}) {
539
576
  const body = { user_id: this.uid(userId) };
540
577
  if (opts.limit !== undefined)
541
578
  body.limit = opts.limit;
@@ -565,7 +602,7 @@ class Client {
565
602
  throw new Error(`stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
566
603
  `answer, not the whole store.`);
567
604
  }
568
- revisions(userId, opts = {}) {
605
+ async revisions(userId, opts = {}) {
569
606
  const body = {};
570
607
  if (opts.include !== undefined)
571
608
  body.include = opts.include;
@@ -578,13 +615,13 @@ class Client {
578
615
  body.user_id = this.uid(userId);
579
616
  return this.post("/api/v1/won/revisions", body);
580
617
  }
581
- lineage(userId, memoryId) {
618
+ async lineage(userId, memoryId) {
582
619
  if (typeof memoryId !== "string" || !memoryId.trim()) {
583
620
  throw new Error("memoryId is required — the memory whose history you want.");
584
621
  }
585
622
  return this.post("/api/v1/memory/lineage", { user_id: this.uid(userId), memory_id: memoryId.trim() });
586
623
  }
587
- bySpeaker(speaker, userId, opts = {}) {
624
+ async bySpeaker(speaker, userId, opts = {}) {
588
625
  if (!speaker || typeof speaker !== "string" || speaker.trim() === "") {
589
626
  throw new Error('speaker is required — "me" for the assistant, or a person\'s name.');
590
627
  }
@@ -608,41 +645,42 @@ class Client {
608
645
  async listEngrams() {
609
646
  const data = await this.request("GET", "/api/v1/engram");
610
647
  return {
648
+ ...data,
611
649
  engrams: asRecords(data.engrams),
612
650
  forms: asRecords(data.forms),
613
651
  note: typeof data.note === "string" ? data.note : undefined,
614
652
  };
615
653
  }
616
- createStore(userId) {
654
+ async createStore(userId) {
617
655
  return this.post("/api/v1/memory/collection", { user_id: this.uid(userId) });
618
656
  }
619
657
  async listStores() {
620
658
  const r = await this.request("GET", "/api/v1/memory/collections");
621
659
  return asRecords(r.collections);
622
660
  }
623
- deleteStore(userId) {
661
+ async deleteStore(userId) {
624
662
  if (typeof userId !== "string" || !userId.trim())
625
663
  throw new Error("userId is required (a non-blank string) — deleteStore never falls back to the default store.");
626
664
  warnIfStoreIdCollapses(userId);
627
665
  return this.request("DELETE", "/api/v1/memory/collection", { user_id: userId });
628
666
  }
629
- addSpeaker(speaker, userId) {
667
+ async addSpeaker(speaker, userId) {
630
668
  return this.post("/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
631
669
  }
632
670
  async listSpeakers(userId) {
633
671
  const r = await this.request("GET", `/api/v1/memory/speakers?user_id=${encodeURIComponent(this.uid(userId))}`);
634
672
  return { ...r, speakers: asRecords(r?.speakers) };
635
673
  }
636
- removeSpeaker(speaker, userId) {
674
+ async removeSpeaker(speaker, userId) {
637
675
  return this.request("DELETE", "/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
638
676
  }
639
- delete(userId, memoryId) {
677
+ async delete(userId, memoryId) {
640
678
  if (typeof memoryId !== "string" || !memoryId.trim()) {
641
679
  throw new Error("memoryId is required (non-blank). To delete every memory in a store, call deleteAll(userId) explicitly.");
642
680
  }
643
681
  return this.post("/api/v1/memory/forget", { user_id: this.uid(userId), memory_id: memoryId.trim() });
644
682
  }
645
- deleteAll(userId) {
683
+ async deleteAll(userId) {
646
684
  if (typeof userId !== "string" || !userId.trim())
647
685
  throw new Error("userId is required (a non-blank string) for deleteAll — anything else would wipe the default store.");
648
686
  warnIfStoreIdCollapses(userId);
@@ -777,7 +815,9 @@ class Client {
777
815
  if (e?.name === "AbortError" || att.signal.aborted) {
778
816
  throw new APIConnectionError(0, this.abortMessage(att.why()));
779
817
  }
780
- throw e;
818
+ if (e instanceof WosError)
819
+ throw e;
820
+ throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
781
821
  }
782
822
  if (buf.byteLength === 0) {
783
823
  throw new WosError(res.status, "empty image body — the service returned no bytes");
@@ -910,7 +950,14 @@ class Client {
910
950
  }
911
951
  if (e instanceof WosError)
912
952
  throw e;
913
- throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
953
+ if (!(IDEMPOTENT_METHODS.has(method.toUpperCase()) && attempt + 1 < attempts)) {
954
+ throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
955
+ }
956
+ att.clear();
957
+ const delay = this.backoffMs(attempt);
958
+ logDebug(`${method} ${logPath} — body dropped mid-stream, retrying in ${delay}ms (attempt ${attempt + 1}/${attempts})`);
959
+ await this.backoffSleep(delay, deadlineAt);
960
+ continue;
914
961
  }
915
962
  finally {
916
963
  att.clear();
@@ -26,8 +26,8 @@
26
26
  * and searching call no LLM.
27
27
  *
28
28
  * Reliability: every call retries transient failures with exponential backoff +
29
- * jitter, honoring `Retry-After` — 429 always; 502/503 and connection errors only
30
- * when a retry can never double-process a write (idempotent calls, or a failure
29
+ * jitter, honoring `Retry-After` — 429 always; 408/502/503/504 and connection errors
30
+ * only when a retry can never double-process a write (idempotent calls, or a failure
31
31
  * at connect time). Tune with `maxRetries` (0 disables) and `timeoutMs`, or per
32
32
  * call site via the `withTimeout()` / `withRetries()` clones.
33
33
  *
@@ -74,10 +74,11 @@ export declare class PaymentRequiredError extends WosError {
74
74
  /** 403 — the key/model isn't allowed to do this. */
75
75
  export declare class PermissionDeniedError extends WosError {
76
76
  }
77
- /** 404 — the store or resource doesn't exist (create the store first). */
77
+ /** 404 — the store or resource doesn't exist. */
78
78
  export declare class NotFoundError extends WosError {
79
79
  }
80
- /** 409 — a concurrent write to the same store. Retry. */
80
+ /** 409 — another write to this store was in flight (nothing was stored; retry), or the
81
+ * store id collides with an existing store's (permanent). */
81
82
  export declare class ConflictError extends WosError {
82
83
  }
83
84
  /** 429 — too many requests. Back off and retry (the client already retries these). */
@@ -86,8 +87,8 @@ export declare class RateLimitError extends WosError {
86
87
  /**
87
88
  * 5xx — the service failed.
88
89
  *
89
- * `502` / `503` are transient: the client already retries them for calls where a
90
- * retry cannot double-process a write, and retrying yourself is reasonable.
90
+ * `502` / `503` / `504` are transient: the client already retries them for calls
91
+ * where a retry cannot double-process a write, and retrying yourself is reasonable.
91
92
  *
92
93
  * `501` is NOT transient. It means the engine behind the model you selected does
93
94
  * not implement that endpoint at all (the error carries `model` and `endpoint`).
@@ -159,6 +160,8 @@ export interface StatusResult {
159
160
  /**
160
161
  * An image attached to a memory, passed to `add`/`store` via `opts.image`.
161
162
  *
163
+ * Both edges must be 700px or more; a smaller image is refused (400).
164
+ *
162
165
  * What the service keeps is NOT your original. An image whose long edge is over
163
166
  * 1568px is downscaled to 1568 before anything else happens, and that smaller
164
167
  * picture is what gets stored, and handed back by `getImage`. Nothing on our side
@@ -291,8 +294,8 @@ export interface LineageResult {
291
294
  export interface SpeakerPage {
292
295
  speaker?: string;
293
296
  memories: Memory[];
294
- /** How many underlying records a delete would actually remove, beyond what was returned. */
295
297
  chunks?: number;
298
+ /** The count to show before anyone confirms a delete of this speaker's memories. */
296
299
  points_to_delete?: number;
297
300
  returned?: number;
298
301
  has_more?: boolean;
@@ -307,20 +310,17 @@ export interface UpdateResult {
307
310
  status?: string;
308
311
  [key: string]: unknown;
309
312
  }
310
- /** `searchFull` → everything one search answered with, not just the merged memories.
311
- *
312
- * `search` returns the memories and nothing else, which is the right answer for
313
- * almost every call. Two options make it the wrong one: `max_images` asks for
314
- * photos, which arrive in their own field, and `verify` is reported on by
315
- * `verify_used`. Merging away both means paying for a
316
- * search you shaped and never seeing what came of it. */
313
+ /** `searchFull` → everything one search answered with. `search` returns every memory
314
+ * as one array; this keeps the fields apart and adds `verify_used`, the report on
315
+ * the `verify` option. */
317
316
  export interface SearchResult {
318
317
  /** What others said, and general memories. */
319
318
  memories: Memory[];
320
319
  /** The assistant's own words (speaker "me"); `[]` on a model that does not keep
321
320
  * them apart. */
322
321
  self_memories: Memory[];
323
- /** Image memories, when `max_images` asked for any; `[]` otherwise. */
322
+ /** Image memories the answer carried (one by default on an image-capable model,
323
+ * up to `max_images`); `[]` when there were none. */
324
324
  images: Memory[];
325
325
  /** Re-ask passes actually performed. Present only when `verify` was sent; lower
326
326
  * than you asked for means the store had nothing further to add. */
@@ -428,21 +428,27 @@ export interface EngramCatalog {
428
428
  engrams: EngramInfo[];
429
429
  forms: EngramInfo[];
430
430
  note?: string;
431
+ /** Anything else the reply carried. Responses widen; the runtime keeps those fields
432
+ * and this is what lets a typed caller read them. */
433
+ [key: string]: unknown;
431
434
  }
432
435
  /** `createStore` / `deleteStore` → `{ user_id, status }`. */
433
436
  export interface StoreOpResult {
434
- /** The store id the API actually used — NOT necessarily the one you sent.
435
- * Ids are normalized (see the `note`), so compare this against your input. */
437
+ /** The store id. `createStore` answers with the id as you sent it. */
436
438
  user_id?: string;
439
+ /** The normalized form the store is filed under, when it differs from `user_id`. */
440
+ canonical_id?: string;
437
441
  status?: string;
438
- /** Present when the API changed your id, explaining how. Read it: two of your
439
- * end users can land in one store if their ids differ only by punctuation. */
442
+ /** Present when the API filed your id under a normalized form, explaining how. */
440
443
  note?: string;
441
444
  [key: string]: unknown;
442
445
  }
443
446
  export interface StoreInfo {
447
+ /** The id the store was created with — pass it back as `userId`. */
444
448
  user_id: string;
445
449
  created_at: string;
450
+ /** The normalized form the store is filed under, when it differs from `user_id`. */
451
+ canonical_id?: string;
446
452
  [key: string]: unknown;
447
453
  }
448
454
  /** `addSpeaker` / `removeSpeaker` → `{ user_id, speaker, status }`. */
@@ -532,11 +538,15 @@ export interface SearchOptions {
532
538
  * than answering with no images.
533
539
  */
534
540
  max_images?: number;
541
+ /** Delivery form, "memoir" or "archive" (Scroll 1.2+): how each memory's time is
542
+ * rendered. The service refuses an unknown one (400). */
543
+ form?: string;
544
+ /** Your UTC offset in hours, for rendering times in `form`. */
545
+ tz?: number;
535
546
  /**
536
547
  * Fields the service accepts that this version does not know about, merged into the
537
- * request as written. Every other key is refused, so a typo cannot reach the wire —
538
- * `verfy: 3` used to be sent, dropped by the service, and answered normally, leaving
539
- * a caller billed for one pass believing they had bought three.
548
+ * request as written. Every other key is refused, so a typo cannot reach the wire.
549
+ * The store, the query and the count always win over a copy in here.
540
550
  */
541
551
  extra?: Record<string, unknown>;
542
552
  }
@@ -548,9 +558,9 @@ export interface SearchOptions {
548
558
  * arrives with a different body. Use it when a retry is your own (a job that died and
549
559
  * was re-run, a queue that redelivers). The SDK retries a write on exactly one
550
560
  * status: 429, which the service answers before it processes anything, so nothing
551
- * was stored. It never retries a write on 502 / 503 or a dropped body, where the
552
- * request may already have been stored and billed — without a key it cannot know
553
- * whether that first attempt landed.
561
+ * was stored. It never retries a write on 408 / 502 / 503 / 504 or a dropped body,
562
+ * where the first attempt may already have landed — without a key the client cannot
563
+ * know whether it did.
554
564
  *
555
565
  * The key must be UNIQUE PER LOGICAL WRITE — derive it from the thing being stored
556
566
  * (`` `import:${row.id}` ``), never a constant, or the second write replays the first
@@ -582,8 +592,8 @@ export interface ClientOptions {
582
592
  * to the account's built-in `default` store. */
583
593
  userId?: string;
584
594
  /** How many times to retry transient failures before throwing — 429 always;
585
- * 502/503 and connection errors only when a retry can never double-process a
586
- * write. 0 disables retries (default 2). */
595
+ * 408/502/503/504 and connection errors only when a retry can never
596
+ * double-process a write. 0 disables retries (default 2). */
587
597
  maxRetries?: number;
588
598
  /** Alias of `maxRetries`. The Python SDK calls this option `retries`, and these
589
599
  * SDKs are published in lockstep as "the same surface" — so code ported between
@@ -611,10 +621,8 @@ export interface ClientOptions {
611
621
  *
612
622
  * This is the seam for anything the runtime cannot express through options.
613
623
  * The one that matters in practice is a corporate egress proxy: Node's global
614
- * fetch (undici) IGNORES `HTTP_PROXY` / `HTTPS_PROXY`, so behind such a proxy
615
- * this SDK could not connect at all — while the Python and Rust SDKs went
616
- * through, because `requests` and `reqwest` both read those variables. Pass a
617
- * proxy-aware fetch and the three behave the same again:
624
+ * fetch (undici) IGNORES `HTTP_PROXY` / `HTTPS_PROXY`, so behind such a proxy no
625
+ * request leaves at all. Pass a proxy-aware fetch and they go through:
618
626
  *
619
627
  * import { ProxyAgent } from "undici";
620
628
  * const agent = new ProxyAgent(process.env.HTTPS_PROXY!);
@@ -696,7 +704,7 @@ export declare class Client {
696
704
  addTurn(userMsg: string, assistantMsg: string, userId?: string, opts?: WriteOptions): Promise<StatusResult>;
697
705
  /** Bulk-ingest a large blob of text in one call. For backfilling.
698
706
  * The call most worth an `opts.idempotencyKey`: a backfill that dies halfway and is
699
- * re-run would otherwise re-ingest and re-bill the whole blob. */
707
+ * re-run would otherwise ingest the whole blob a second time. */
700
708
  addBulk(content: string, userId?: string, category?: string, timestamp?: string, opts?: WriteOptions): Promise<StatusResult>;
701
709
  /** Supersede an old memory with new content. Payload first, userId last — same shape as add/search. */
702
710
  update(oldMemoryId: string, newContent: string, userId?: string, opts?: WriteOptions): Promise<UpdateResult>;
@@ -707,18 +715,18 @@ export declare class Client {
707
715
  * a call that passes no count is unaffected. Before 2.2.35 the count was sent on
708
716
  * unchecked.
709
717
  *
710
- * `limit` bounds `memories`, not the returned array. On a model that keeps the
711
- * assistant's own words separate (Scroll 1.2+) those come back as well, so the
712
- * array can hold more than `limit`. They were retrieved and billed either way;
713
- * dropping them would only hide what you already paid for. Size a prompt window
714
- * on the array you get back, not on `limit`. `searchSelf()` hands the two back
715
- * apart. */
718
+ * `limit` bounds `memories`, not the returned array. The assistant's own words
719
+ * (Scroll 1.2+) and image memories (Tablet 2+, one unless `max_images` says
720
+ * otherwise) come back in it as well, so it can hold more than `limit`. They are
721
+ * billed either way. Size a prompt window on the array you get back, not on
722
+ * `limit`. `searchFull()` hands the fields back apart. */
716
723
  search(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<Memory[]>;
717
724
  /** Search a self-memory model (Scroll 1.2+): both fields from ONE call. Returns
718
725
  * `{ memories, self_memories }` — `memories` is what others said and general
719
726
  * memories, `self_memories` is the assistant's OWN words (stored with speaker
720
727
  * "me"), kept apart so whoever reads them never confuses who said what. On a model
721
- * that does not keep them apart, `self_memories` is `[]`. `userId` may be omitted. */
728
+ * that does not keep them apart, `self_memories` is `[]`. Image memories are not
729
+ * included; `search` and `searchFull` carry them. `userId` may be omitted. */
722
730
  searchSelf(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<SelfSearchResult>;
723
731
  /**
724
732
  * Search, and keep every field the answer came with.
@@ -728,7 +736,7 @@ export declare class Client {
728
736
  *
729
737
  * const r = await mem.searchFull("the day we moved", "alice", 10,
730
738
  * { max_images: 3, verify: 2 });
731
- * r.images.length; // the photos, which `search` drops
739
+ * r.images.length; // the photos, apart from the text memories
732
740
  * r.verify_used; // how many re-ask passes actually ran (you are billed per pass)
733
741
  *
734
742
  * Both options need Tablet 2 or newer and are refused (403) on an older engine
@@ -812,6 +820,11 @@ export declare class Client {
812
820
  }): AsyncGenerator<Memory>;
813
821
  /** Collect ALL of a store's memories into an array (the text you stored, and its metadata). */
814
822
  exportMemories(userId?: string): Promise<Memory[]>;
823
+ /** Collect ALL of a store's image memories into an array — the image-side pair of
824
+ * `exportMemories`. */
825
+ exportImages(userId?: string, opts?: {
826
+ pageSize?: number;
827
+ }): Promise<Memory[]>;
815
828
  /**
816
829
  * Fetch the bytes of an image memory — the picture the SERVICE holds.
817
830
  *
@@ -937,9 +950,8 @@ export declare class Client {
937
950
  * assistant's own words, otherwise a person's name. Same cursor paging as
938
951
  * `listImages`.
939
952
  *
940
- * `chunks` / `points_to_delete` report how many internal records a delete would
941
- * actually remove — usually more than `returned`, and worth showing to whoever is
942
- * about to confirm one.
953
+ * `points_to_delete` is the count to show before anyone confirms a delete of this
954
+ * speaker's memories.
943
955
  */
944
956
  bySpeaker(speaker: string, userId?: string, opts?: {
945
957
  limit?: number;
@@ -969,7 +981,8 @@ export declare class Client {
969
981
  * return 404. Idempotent. Every account starts with a `default` store.
970
982
  * Returns `{ user_id, status }` (`status` is `"created"` or `"exists"`). */
971
983
  createStore(userId?: string): Promise<StoreOpResult>;
972
- /** List your stores: `[{ user_id, created_at }, ...]` (`default` first). */
984
+ /** List your stores: `[{ user_id, created_at, canonical_id? }, ...]` (`default`
985
+ * first). Each `user_id` is the id the store was created with. */
973
986
  listStores(): Promise<StoreInfo[]>;
974
987
  /** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
975
988
  deleteStore(userId: string): Promise<StoreOpResult>;
package/dist/wontopos.js CHANGED
@@ -1,4 +1,4 @@
1
- const VERSION = "2.2.38";
1
+ const VERSION = "2.2.40";
2
2
  const RUNTIME = (() => {
3
3
  const p = globalThis?.process;
4
4
  return p?.version ? ` (node/${p.version}; ${p.platform}-${p.arch})` : "";
@@ -13,7 +13,7 @@ function logDebug(msg) {
13
13
  const DEFAULT_BASE_URL = "https://api.wontopos.com";
14
14
  const DEFAULT_MODEL = "tablet-2";
15
15
  const RETRY_ALWAYS = new Set([429]);
16
- const RETRY_IF_IDEMPOTENT = new Set([502, 503]);
16
+ const RETRY_IF_IDEMPOTENT = new Set([408, 502, 503, 504]);
17
17
  const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
18
18
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
19
19
  const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
@@ -35,8 +35,9 @@ const warnedStoreIds = new Set();
35
35
  function warnIfStoreIdCollapses(id) {
36
36
  if (!id || warnedStoreIds.has(id))
37
37
  return;
38
+ const valid = STORE_ID_FORMAT.test(id);
38
39
  const normalized = normalizeStoreId(id);
39
- if (normalized === id)
40
+ if (valid && normalized === id)
40
41
  return;
41
42
  warnedStoreIds.add(id);
42
43
  while (warnedStoreIds.size > WARNED_STORE_IDS_MAX) {
@@ -45,10 +46,27 @@ function warnIfStoreIdCollapses(id) {
45
46
  break;
46
47
  warnedStoreIds.delete(oldest);
47
48
  }
48
- console.warn(`wontopos: store id ${JSON.stringify(id)} is stored as ${JSON.stringify(normalized)} ` +
49
- `(lowercased, and anything outside [a-z0-9_] becomes "_"). Ids that differ only by case or ` +
50
- `punctuation share ONE store and therefore one set of memories — if these ids come from your ` +
51
- `end users, normalize them yourself first so two people can never collide.`);
49
+ if (!valid) {
50
+ console.warn(`wontopos: store id ${JSON.stringify(id)} is not a valid store id: use 1-64 ASCII letters, ` +
51
+ `digits, ".", "_" and "-", starting with a letter or digit. Creating it is refused (400).`);
52
+ return;
53
+ }
54
+ console.warn(`wontopos: store id ${JSON.stringify(id)} normalizes to ${JSON.stringify(normalized)}. ` +
55
+ `Ids that differ only by case name this same store; one that differs only by punctuation ` +
56
+ `cannot be created beside it (409) and is not found when used (404). If these ids come from ` +
57
+ `your end users, normalize them yourself first so two people never compete for one name.`);
58
+ }
59
+ const STORE_ID_FORMAT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
60
+ const NOT_METADATA = new Set(["userid", "storeid", "idempotencykey"]);
61
+ function checkMetadataKeys(metadata) {
62
+ if (metadata == null || typeof metadata !== "object")
63
+ return;
64
+ for (const k of Object.keys(metadata)) {
65
+ if (NOT_METADATA.has(k.toLowerCase().replace(/[\s_.-]/g, ""))) {
66
+ throw new Error(`${JSON.stringify(k)} is not a metadata field: pass the store as userId and an ` +
67
+ `idempotency key as opts.idempotencyKey. Nothing was sent.`);
68
+ }
69
+ }
52
70
  }
53
71
  export function _resetStoreIdWarnings() {
54
72
  warnedStoreIds.clear();
@@ -136,13 +154,24 @@ function parseRateLimit(h) {
136
154
  const asList = (x) => (Array.isArray(x) ? x : []);
137
155
  const asObj = (x) => (x != null && typeof x === "object" && !Array.isArray(x) ? x : {});
138
156
  const asRecords = (x) => asList(x).filter((m) => m != null && typeof m === "object" && !Array.isArray(m));
157
+ const memoryFromGet = (r) => {
158
+ const o = asObj(r);
159
+ if (o.memory != null && typeof o.memory === "object" && !Array.isArray(o.memory))
160
+ return o.memory;
161
+ return typeof o.id === "string" ? o : {};
162
+ };
139
163
  function mergeResults(r) {
140
- const main = asRecords(r?.memories);
141
- const mine = asRecords(r?.self_memories);
142
- if (mine.length === 0)
143
- return main;
144
- const seen = new Set(main.map((m) => m.id).filter((id) => typeof id === "string"));
145
- return main.concat(mine.filter((m) => !(typeof m.id === "string" && seen.has(m.id))));
164
+ const out = asRecords(r?.memories);
165
+ const seen = new Set(out.map((m) => m.id).filter((id) => typeof id === "string"));
166
+ for (const m of [...asRecords(r?.self_memories), ...asRecords(r?.images)]) {
167
+ if (typeof m.id === "string") {
168
+ if (seen.has(m.id))
169
+ continue;
170
+ seen.add(m.id);
171
+ }
172
+ out.push(m);
173
+ }
174
+ return out;
146
175
  }
147
176
  function withForm(body, form, tz) {
148
177
  if (form !== undefined)
@@ -204,10 +233,11 @@ const KNOWN_FILTER_KEYS = new Set([
204
233
  ]);
205
234
  const warnedFilterKeys = new Set();
206
235
  const KNOWN_SEARCH_KEYS = new Set([
207
- "cache_control", "speaker", "filters", "verify", "max_images", "extra",
236
+ "cache_control", "speaker", "filters", "verify", "max_images", "form", "tz", "extra",
208
237
  ]);
209
238
  const RESERVED_SEARCH_KEYS = new Set(["user_id", "query", "max_results"]);
210
239
  const KNOWN_RECALL_KEYS = new Set(["form", "tz", "limit", "context_limit"]);
240
+ const KNOWN_ENGRAM_KEYS = new Set(["form", "tz"]);
211
241
  function checkSearchOpts(opts) {
212
242
  checkOpts(opts, KNOWN_SEARCH_KEYS, "search");
213
243
  }
@@ -222,8 +252,7 @@ function checkOpts(opts, known, what) {
222
252
  const near = [...known].find((n) => n !== "extra" && editWithin(k, n, 2));
223
253
  throw new Error(`unknown ${what} option ${JSON.stringify(k)}` +
224
254
  (near ? ` — did you mean ${JSON.stringify(near)}?` : "") +
225
- `. The service drops keys it does not know and answers anyway, so this would ` +
226
- `have looked like it worked.` +
255
+ `. It would have had no effect, and the call would have looked like it worked.` +
227
256
  (known.has("extra")
228
257
  ? ` Pass it under \`extra\` if the service accepts it and this client does not know it yet.`
229
258
  : ""));
@@ -392,25 +421,26 @@ export class Client {
392
421
  withRetries(maxRetries) {
393
422
  return this.clone({ maxRetries });
394
423
  }
395
- add(content, userId, metadata = {}, opts = {}) {
424
+ async add(content, userId, metadata = {}, opts = {}) {
425
+ checkMetadataKeys(metadata);
396
426
  const body = { user_id: this.uid(userId), content, metadata };
397
427
  if (opts.image !== undefined)
398
428
  body.image = normalizeImage(opts.image);
399
429
  return this.post("/api/v1/memory/store", body, opts.idempotencyKey);
400
430
  }
401
- store(content, userId, metadata = {}, opts = {}) {
431
+ async store(content, userId, metadata = {}, opts = {}) {
402
432
  return this.add(content, userId, metadata, opts);
403
433
  }
404
- addTurn(userMsg, assistantMsg, userId, opts = {}) {
434
+ async addTurn(userMsg, assistantMsg, userId, opts = {}) {
405
435
  return this.post("/api/v1/memory/store-turn", { user_id: this.uid(userId), user_msg: userMsg, assistant_msg: assistantMsg }, opts.idempotencyKey);
406
436
  }
407
- addBulk(content, userId, category = "general", timestamp, opts = {}) {
437
+ async addBulk(content, userId, category = "general", timestamp, opts = {}) {
408
438
  const body = { user_id: this.uid(userId), content, category };
409
439
  if (timestamp)
410
440
  body.timestamp = timestamp;
411
441
  return this.post("/api/v1/memory/bulk-store", body, opts.idempotencyKey);
412
442
  }
413
- update(oldMemoryId, newContent, userId, opts = {}) {
443
+ async update(oldMemoryId, newContent, userId, opts = {}) {
414
444
  return this.post("/api/v1/memory/supersede", { user_id: this.uid(userId), old_memory_id: oldMemoryId, new_content: newContent }, opts.idempotencyKey);
415
445
  }
416
446
  async search(query, userId, limit = 10, opts = {}) {
@@ -455,14 +485,15 @@ export class Client {
455
485
  }
456
486
  return this.post("/api/v1/memory/recall", withForm(body, opts.form, opts.tz));
457
487
  }
458
- engram(name, query, userId, opts = {}) {
488
+ async engram(name, query, userId, opts = {}) {
489
+ checkOpts(opts, KNOWN_ENGRAM_KEYS, "engram");
459
490
  return this.post("/api/v1/engram/run", withForm({ name, user_id: this.uid(userId), query }, opts.form, opts.tz));
460
491
  }
461
492
  async history(userId) {
462
493
  const r = await this.post("/api/v1/memory/history", { user_id: this.uid(userId) });
463
494
  return asRecords(r.turns);
464
495
  }
465
- stats(userId) {
496
+ async stats(userId) {
466
497
  return this.post("/api/v1/memory/stats", { user_id: this.uid(userId) });
467
498
  }
468
499
  async usage(days = 7) {
@@ -471,13 +502,13 @@ export class Client {
471
502
  }
472
503
  return this.request("GET", `/api/v1/won/usage?days=${days}`);
473
504
  }
474
- get(userId, memoryId) {
505
+ async get(userId, memoryId) {
475
506
  if (typeof memoryId !== "string" || !memoryId.trim()) {
476
507
  throw new Error("memoryId is required — the id that add/store or listMemories returned.");
477
508
  }
478
- return this.post("/api/v1/memory/get", { user_id: this.uid(userId), memory_id: memoryId.trim() }).then((r) => asObj(r.memory));
509
+ return this.post("/api/v1/memory/get", { user_id: this.uid(userId), memory_id: memoryId.trim() }).then(memoryFromGet);
479
510
  }
480
- listMemories(userId, opts = {}) {
511
+ async listMemories(userId, opts = {}) {
481
512
  const body = { user_id: this.uid(userId), limit: opts.limit ?? 100 };
482
513
  if (opts.cursor)
483
514
  body.cursor = opts.cursor;
@@ -505,13 +536,19 @@ export class Client {
505
536
  out.push(m);
506
537
  return out;
507
538
  }
508
- getImage(userId, memoryId) {
539
+ async exportImages(userId, opts = {}) {
540
+ const out = [];
541
+ for await (const m of this.iterImages(userId, opts))
542
+ out.push(m);
543
+ return out;
544
+ }
545
+ async getImage(userId, memoryId) {
509
546
  if (typeof memoryId !== "string" || !memoryId.trim()) {
510
547
  throw new Error("memoryId is required — the id that add/store or listImages returned.");
511
548
  }
512
549
  return this.requestBytes("/api/v1/memory/image", { user_id: this.uid(userId), memory_id: memoryId.trim() });
513
550
  }
514
- forgetImage(userId, memoryId, opts = {}) {
551
+ async forgetImage(userId, memoryId, opts = {}) {
515
552
  if (typeof memoryId !== "string" || !memoryId.trim()) {
516
553
  throw new Error("memoryId is required — the id of the memory whose image you want removed.");
517
554
  }
@@ -520,7 +557,7 @@ export class Client {
520
557
  body.preview = true;
521
558
  return this.request("DELETE", "/api/v1/memory/image", body);
522
559
  }
523
- listImages(userId, opts = {}) {
560
+ async listImages(userId, opts = {}) {
524
561
  const body = { user_id: this.uid(userId) };
525
562
  if (opts.limit !== undefined)
526
563
  body.limit = opts.limit;
@@ -550,7 +587,7 @@ export class Client {
550
587
  throw new Error(`stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
551
588
  `answer, not the whole store.`);
552
589
  }
553
- revisions(userId, opts = {}) {
590
+ async revisions(userId, opts = {}) {
554
591
  const body = {};
555
592
  if (opts.include !== undefined)
556
593
  body.include = opts.include;
@@ -563,13 +600,13 @@ export class Client {
563
600
  body.user_id = this.uid(userId);
564
601
  return this.post("/api/v1/won/revisions", body);
565
602
  }
566
- lineage(userId, memoryId) {
603
+ async lineage(userId, memoryId) {
567
604
  if (typeof memoryId !== "string" || !memoryId.trim()) {
568
605
  throw new Error("memoryId is required — the memory whose history you want.");
569
606
  }
570
607
  return this.post("/api/v1/memory/lineage", { user_id: this.uid(userId), memory_id: memoryId.trim() });
571
608
  }
572
- bySpeaker(speaker, userId, opts = {}) {
609
+ async bySpeaker(speaker, userId, opts = {}) {
573
610
  if (!speaker || typeof speaker !== "string" || speaker.trim() === "") {
574
611
  throw new Error('speaker is required — "me" for the assistant, or a person\'s name.');
575
612
  }
@@ -593,41 +630,42 @@ export class Client {
593
630
  async listEngrams() {
594
631
  const data = await this.request("GET", "/api/v1/engram");
595
632
  return {
633
+ ...data,
596
634
  engrams: asRecords(data.engrams),
597
635
  forms: asRecords(data.forms),
598
636
  note: typeof data.note === "string" ? data.note : undefined,
599
637
  };
600
638
  }
601
- createStore(userId) {
639
+ async createStore(userId) {
602
640
  return this.post("/api/v1/memory/collection", { user_id: this.uid(userId) });
603
641
  }
604
642
  async listStores() {
605
643
  const r = await this.request("GET", "/api/v1/memory/collections");
606
644
  return asRecords(r.collections);
607
645
  }
608
- deleteStore(userId) {
646
+ async deleteStore(userId) {
609
647
  if (typeof userId !== "string" || !userId.trim())
610
648
  throw new Error("userId is required (a non-blank string) — deleteStore never falls back to the default store.");
611
649
  warnIfStoreIdCollapses(userId);
612
650
  return this.request("DELETE", "/api/v1/memory/collection", { user_id: userId });
613
651
  }
614
- addSpeaker(speaker, userId) {
652
+ async addSpeaker(speaker, userId) {
615
653
  return this.post("/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
616
654
  }
617
655
  async listSpeakers(userId) {
618
656
  const r = await this.request("GET", `/api/v1/memory/speakers?user_id=${encodeURIComponent(this.uid(userId))}`);
619
657
  return { ...r, speakers: asRecords(r?.speakers) };
620
658
  }
621
- removeSpeaker(speaker, userId) {
659
+ async removeSpeaker(speaker, userId) {
622
660
  return this.request("DELETE", "/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
623
661
  }
624
- delete(userId, memoryId) {
662
+ async delete(userId, memoryId) {
625
663
  if (typeof memoryId !== "string" || !memoryId.trim()) {
626
664
  throw new Error("memoryId is required (non-blank). To delete every memory in a store, call deleteAll(userId) explicitly.");
627
665
  }
628
666
  return this.post("/api/v1/memory/forget", { user_id: this.uid(userId), memory_id: memoryId.trim() });
629
667
  }
630
- deleteAll(userId) {
668
+ async deleteAll(userId) {
631
669
  if (typeof userId !== "string" || !userId.trim())
632
670
  throw new Error("userId is required (a non-blank string) for deleteAll — anything else would wipe the default store.");
633
671
  warnIfStoreIdCollapses(userId);
@@ -762,7 +800,9 @@ export class Client {
762
800
  if (e?.name === "AbortError" || att.signal.aborted) {
763
801
  throw new APIConnectionError(0, this.abortMessage(att.why()));
764
802
  }
765
- throw e;
803
+ if (e instanceof WosError)
804
+ throw e;
805
+ throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
766
806
  }
767
807
  if (buf.byteLength === 0) {
768
808
  throw new WosError(res.status, "empty image body — the service returned no bytes");
@@ -895,7 +935,14 @@ export class Client {
895
935
  }
896
936
  if (e instanceof WosError)
897
937
  throw e;
898
- throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
938
+ if (!(IDEMPOTENT_METHODS.has(method.toUpperCase()) && attempt + 1 < attempts)) {
939
+ throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
940
+ }
941
+ att.clear();
942
+ const delay = this.backoffMs(attempt);
943
+ logDebug(`${method} ${logPath} — body dropped mid-stream, retrying in ${delay}ms (attempt ${attempt + 1}/${attempts})`);
944
+ await this.backoffSleep(delay, deadlineAt);
945
+ continue;
899
946
  }
900
947
  finally {
901
948
  att.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wontopos",
3
- "version": "2.2.38",
3
+ "version": "2.2.40",
4
4
  "description": "Wontopos \u2014 long-term memory for AI agents. Identical recall in every language, ~100\u00d7 lower LLM bill.",
5
5
  "keywords": [
6
6
  "memory",