wontopos 2.2.39 → 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
@@ -209,7 +209,7 @@ data to fix it.
209
209
  ## Changelog
210
210
 
211
211
  The three clients release in lockstep — same version, same surface, same day. Patch
212
- releases are additive. Five 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
213
213
  reason; the changelog lists them.
214
214
 
215
215
  See [CHANGELOG.md](https://github.com/wontopos/wontopos-node/blob/main/CHANGELOG.md).
@@ -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). */
@@ -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. */
@@ -434,18 +434,21 @@ export interface EngramCatalog {
434
434
  }
435
435
  /** `createStore` / `deleteStore` → `{ user_id, status }`. */
436
436
  export interface StoreOpResult {
437
- /** The store id the API actually used — NOT necessarily the one you sent.
438
- * 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. */
439
438
  user_id?: string;
439
+ /** The normalized form the store is filed under, when it differs from `user_id`. */
440
+ canonical_id?: string;
440
441
  status?: string;
441
- /** Present when the API changed your id, explaining how. Read it: two of your
442
- * 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. */
443
443
  note?: string;
444
444
  [key: string]: unknown;
445
445
  }
446
446
  export interface StoreInfo {
447
+ /** The id the store was created with — pass it back as `userId`. */
447
448
  user_id: string;
448
449
  created_at: string;
450
+ /** The normalized form the store is filed under, when it differs from `user_id`. */
451
+ canonical_id?: string;
449
452
  [key: string]: unknown;
450
453
  }
451
454
  /** `addSpeaker` / `removeSpeaker` → `{ user_id, speaker, status }`. */
@@ -535,11 +538,15 @@ export interface SearchOptions {
535
538
  * than answering with no images.
536
539
  */
537
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;
538
546
  /**
539
547
  * Fields the service accepts that this version does not know about, merged into the
540
- * request as written. Every other key is refused, so a typo cannot reach the wire —
541
- * `verfy: 3` used to be sent, dropped by the service, and answered normally, leaving
542
- * 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.
543
550
  */
544
551
  extra?: Record<string, unknown>;
545
552
  }
@@ -708,18 +715,18 @@ export declare class Client {
708
715
  * a call that passes no count is unaffected. Before 2.2.35 the count was sent on
709
716
  * unchecked.
710
717
  *
711
- * `limit` bounds `memories`, not the returned array. On a model that keeps the
712
- * assistant's own words separate (Scroll 1.2+) those come back as well, so the
713
- * array can hold more than `limit`. They were retrieved and billed either way;
714
- * dropping them would only hide what you already paid for. Size a prompt window
715
- * on the array you get back, not on `limit`. `searchSelf()` hands the two back
716
- * 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. */
717
723
  search(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<Memory[]>;
718
724
  /** Search a self-memory model (Scroll 1.2+): both fields from ONE call. Returns
719
725
  * `{ memories, self_memories }` — `memories` is what others said and general
720
726
  * memories, `self_memories` is the assistant's OWN words (stored with speaker
721
727
  * "me"), kept apart so whoever reads them never confuses who said what. On a model
722
- * 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. */
723
730
  searchSelf(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<SelfSearchResult>;
724
731
  /**
725
732
  * Search, and keep every field the answer came with.
@@ -729,7 +736,7 @@ export declare class Client {
729
736
  *
730
737
  * const r = await mem.searchFull("the day we moved", "alice", 10,
731
738
  * { max_images: 3, verify: 2 });
732
- * r.images.length; // the photos, which `search` drops
739
+ * r.images.length; // the photos, apart from the text memories
733
740
  * r.verify_used; // how many re-ask passes actually ran (you are billed per pass)
734
741
  *
735
742
  * Both options need Tablet 2 or newer and are refused (403) on an older engine
@@ -943,9 +950,8 @@ export declare class Client {
943
950
  * assistant's own words, otherwise a person's name. Same cursor paging as
944
951
  * `listImages`.
945
952
  *
946
- * `chunks` / `points_to_delete` report how many internal records a delete would
947
- * actually remove — usually more than `returned`, and worth showing to whoever is
948
- * about to confirm one.
953
+ * `points_to_delete` is the count to show before anyone confirms a delete of this
954
+ * speaker's memories.
949
955
  */
950
956
  bySpeaker(speaker: string, userId?: string, opts?: {
951
957
  limit?: number;
@@ -975,7 +981,8 @@ export declare class Client {
975
981
  * return 404. Idempotent. Every account starts with a `default` store.
976
982
  * Returns `{ user_id, status }` (`status` is `"created"` or `"exists"`). */
977
983
  createStore(userId?: string): Promise<StoreOpResult>;
978
- /** 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. */
979
986
  listStores(): Promise<StoreInfo[]>;
980
987
  /** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
981
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.39";
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})` : "";
@@ -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,7 +248,7 @@ 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"]);
@@ -408,6 +437,7 @@ class Client {
408
437
  return this.clone({ maxRetries });
409
438
  }
410
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);
@@ -491,7 +521,7 @@ class Client {
491
521
  if (typeof memoryId !== "string" || !memoryId.trim()) {
492
522
  throw new Error("memoryId is required — the id that add/store or listMemories returned.");
493
523
  }
494
- 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);
495
525
  }
496
526
  async listMemories(userId, opts = {}) {
497
527
  const body = { user_id: this.uid(userId), limit: opts.limit ?? 100 };
@@ -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). */
@@ -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. */
@@ -434,18 +434,21 @@ export interface EngramCatalog {
434
434
  }
435
435
  /** `createStore` / `deleteStore` → `{ user_id, status }`. */
436
436
  export interface StoreOpResult {
437
- /** The store id the API actually used — NOT necessarily the one you sent.
438
- * 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. */
439
438
  user_id?: string;
439
+ /** The normalized form the store is filed under, when it differs from `user_id`. */
440
+ canonical_id?: string;
440
441
  status?: string;
441
- /** Present when the API changed your id, explaining how. Read it: two of your
442
- * 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. */
443
443
  note?: string;
444
444
  [key: string]: unknown;
445
445
  }
446
446
  export interface StoreInfo {
447
+ /** The id the store was created with — pass it back as `userId`. */
447
448
  user_id: string;
448
449
  created_at: string;
450
+ /** The normalized form the store is filed under, when it differs from `user_id`. */
451
+ canonical_id?: string;
449
452
  [key: string]: unknown;
450
453
  }
451
454
  /** `addSpeaker` / `removeSpeaker` → `{ user_id, speaker, status }`. */
@@ -535,11 +538,15 @@ export interface SearchOptions {
535
538
  * than answering with no images.
536
539
  */
537
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;
538
546
  /**
539
547
  * Fields the service accepts that this version does not know about, merged into the
540
- * request as written. Every other key is refused, so a typo cannot reach the wire —
541
- * `verfy: 3` used to be sent, dropped by the service, and answered normally, leaving
542
- * 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.
543
550
  */
544
551
  extra?: Record<string, unknown>;
545
552
  }
@@ -708,18 +715,18 @@ export declare class Client {
708
715
  * a call that passes no count is unaffected. Before 2.2.35 the count was sent on
709
716
  * unchecked.
710
717
  *
711
- * `limit` bounds `memories`, not the returned array. On a model that keeps the
712
- * assistant's own words separate (Scroll 1.2+) those come back as well, so the
713
- * array can hold more than `limit`. They were retrieved and billed either way;
714
- * dropping them would only hide what you already paid for. Size a prompt window
715
- * on the array you get back, not on `limit`. `searchSelf()` hands the two back
716
- * 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. */
717
723
  search(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<Memory[]>;
718
724
  /** Search a self-memory model (Scroll 1.2+): both fields from ONE call. Returns
719
725
  * `{ memories, self_memories }` — `memories` is what others said and general
720
726
  * memories, `self_memories` is the assistant's OWN words (stored with speaker
721
727
  * "me"), kept apart so whoever reads them never confuses who said what. On a model
722
- * 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. */
723
730
  searchSelf(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<SelfSearchResult>;
724
731
  /**
725
732
  * Search, and keep every field the answer came with.
@@ -729,7 +736,7 @@ export declare class Client {
729
736
  *
730
737
  * const r = await mem.searchFull("the day we moved", "alice", 10,
731
738
  * { max_images: 3, verify: 2 });
732
- * r.images.length; // the photos, which `search` drops
739
+ * r.images.length; // the photos, apart from the text memories
733
740
  * r.verify_used; // how many re-ask passes actually ran (you are billed per pass)
734
741
  *
735
742
  * Both options need Tablet 2 or newer and are refused (403) on an older engine
@@ -943,9 +950,8 @@ export declare class Client {
943
950
  * assistant's own words, otherwise a person's name. Same cursor paging as
944
951
  * `listImages`.
945
952
  *
946
- * `chunks` / `points_to_delete` report how many internal records a delete would
947
- * actually remove — usually more than `returned`, and worth showing to whoever is
948
- * about to confirm one.
953
+ * `points_to_delete` is the count to show before anyone confirms a delete of this
954
+ * speaker's memories.
949
955
  */
950
956
  bySpeaker(speaker: string, userId?: string, opts?: {
951
957
  limit?: number;
@@ -975,7 +981,8 @@ export declare class Client {
975
981
  * return 404. Idempotent. Every account starts with a `default` store.
976
982
  * Returns `{ user_id, status }` (`status` is `"created"` or `"exists"`). */
977
983
  createStore(userId?: string): Promise<StoreOpResult>;
978
- /** 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. */
979
986
  listStores(): Promise<StoreInfo[]>;
980
987
  /** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
981
988
  deleteStore(userId: string): Promise<StoreOpResult>;
package/dist/wontopos.js CHANGED
@@ -1,4 +1,4 @@
1
- const VERSION = "2.2.39";
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})` : "";
@@ -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,7 +233,7 @@ 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"]);
@@ -393,6 +422,7 @@ export class Client {
393
422
  return this.clone({ maxRetries });
394
423
  }
395
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);
@@ -476,7 +506,7 @@ export class Client {
476
506
  if (typeof memoryId !== "string" || !memoryId.trim()) {
477
507
  throw new Error("memoryId is required — the id that add/store or listMemories returned.");
478
508
  }
479
- 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);
480
510
  }
481
511
  async listMemories(userId, opts = {}) {
482
512
  const body = { user_id: this.uid(userId), limit: opts.limit ?? 100 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wontopos",
3
- "version": "2.2.39",
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",