wontopos 2.2.46 → 2.2.47

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
@@ -29,7 +29,42 @@ const ctx = await mem.recall("what does alice drink?", "alice");
29
29
 
30
30
  ## Methods
31
31
 
32
- `add` · `addTurn` · `addBulk` · `update` · `search` · `searchFull` · `recall` · `history` · `stats` · `get` · `listMemories` · `delete` · `deleteAll`
32
+ | Method | What it does |
33
+ |---|---|
34
+ | **Write** | |
35
+ | `add(content, userId?, metadata?, opts?)` | Store one memory. Alias `store`. `opts.image` attaches a photo. |
36
+ | `addTurn(userMsg, assistantMsg, userId?)` | Store one exchange of a conversation. |
37
+ | `addBulk(content, userId?, category?, timestamp?)` | Backfill a long history in one call. |
38
+ | `update(oldMemoryId, newContent, userId?)` | Replace a fact that changed. The old version stays in its history. |
39
+ | **Read** | |
40
+ | `recall(query, userId?, opts?)` | The recent turns, the memories that match and the conversation around the best one, in one call. |
41
+ | `search(query, userId?, limit?, opts?)` | The memories that match, best first. |
42
+ | `searchFull(...)` | The same search with each part kept apart, `images` and `verify_used` included. |
43
+ | `searchSelf(...)` | The assistant's own words, kept apart from what others said. |
44
+ | `history(userId?)` | The recent conversation. |
45
+ | `get(userId, memoryId)` | One memory by id. |
46
+ | `listMemories` · `iterMemories` · `exportMemories` | Every memory in a store: a page at a time, as a stream, or all at once. |
47
+ | `lineage(userId, memoryId)` | The edits that followed one memory. |
48
+ | `revisions(userId?, opts?)` | How many of a store's memories were edited, and which. |
49
+ | `stats(userId?)` | Counts for a store. |
50
+ | `engram(name, query, userId?)` · `listEngrams()` | Built-in retrieval routines, and the ones a model can run. |
51
+ | **Photos** | |
52
+ | `listImages` · `iterImages` · `exportImages` | A store's image memories, newest first. |
53
+ | `getImage(userId, memoryId)` | The bytes of one image. |
54
+ | `forgetImage(userId, memoryId, { preview })` | Remove the photo from a memory. `preview: true` first says whether the memory itself stays. |
55
+ | **People** | |
56
+ | `addSpeaker` · `listSpeakers` · `removeSpeaker` | Register, list and unregister the people a store hears from. |
57
+ | `bySpeaker(speaker, userId?)` | What one person said, newest first. |
58
+ | **Stores** | |
59
+ | `createStore` · `listStores` · `deleteStore` | One isolated memory space per end-user, agent or topic. |
60
+ | `delete(userId, memoryId)` · `deleteAll(userId)` | Delete one memory, or every memory in a store. |
61
+ | **Account** | |
62
+ | `listModels()` | The models you can use, and what each can do. |
63
+ | `usage(days?)` | What this key, its workspace and its stores have spent. Free. |
64
+ | `ping()` | Check the key and the connection. |
65
+ | **Client** | |
66
+ | `withModel` · `withUser` · `withDeadline` · `withTimeout` · `withRetries` · `withSignal` | A copy of the client with another model, store, budget, timeout, retry count or cancel signal. |
67
+ | `Client.fromEnv()` · `rateLimit` | A client with the key from the environment; the last call's rate-limit numbers. |
33
68
 
34
69
  Where the store id goes depends on whether the method takes a payload. A method that
35
70
  writes or searches puts the payload first and the store second — `mem.add("...", "alice")`,
@@ -41,6 +76,29 @@ an explicit `undefined` there, because a second required argument follows it. Py
41
76
  omits it in all five, and Rust takes it positionally (`None` for the default store, where there is one).
42
77
  Let your editor confirm the order: every signature is typed. A store is one isolated memory space per end-user, agent, or topic. WHO said each memory inside a store is the `speaker` tag below — storing the assistant's own words never needs a separate id.
43
78
 
79
+ ## Putting recall into a prompt
80
+
81
+ ```ts
82
+ const ctx = await mem.recall(userMessage, "alice");
83
+
84
+ const system = [
85
+ "What you remember about this user, most relevant first:",
86
+ ...ctx.long_term.memories.map((m) => `- ${m.content}`),
87
+ "",
88
+ "Around the most relevant memory:",
89
+ ...ctx.context.around_top_memory,
90
+ "",
91
+ "The conversation so far:",
92
+ ...ctx.short_term.turns.map((t) => `${t.role}: ${t.content}`),
93
+ ].join("\n");
94
+
95
+ // Call your language model with `system` and the user's message, then store the exchange:
96
+ await mem.addTurn(userMessage, reply, "alice");
97
+ ```
98
+
99
+ Each row of `around_top_memory` starts with `[before] `, `[match] ` or `[after] `: where it
100
+ sits next to the best memory. Keep every list in the order it arrives.
101
+
44
102
  ## Who said it (speakers)
45
103
 
46
104
  Every memory can carry a speaker: `"me"` for the assistant's own words, or a
@@ -60,6 +118,40 @@ await mem.search("what did Bob say about deadlines?", "alice", 10, { speaker: "B
60
118
  up to 50 people to start (a limit we plan to raise); `"me"` never needs
61
119
  registration and never counts against it.
62
120
 
121
+ ## Photos
122
+
123
+ On a model whose `listModels()` capabilities include `images`, a memory can carry a photo.
124
+ The caption is still required.
125
+
126
+ ```ts
127
+ import { readFileSync, writeFileSync } from "node:fs";
128
+
129
+ const data = readFileSync("beach.jpg").toString("base64");
130
+ const stored = await mem.add("At the beach with Bob", "alice", {}, { image: { data, taken_at: "2026-08-14" } });
131
+
132
+ const { bytes, contentType } = await mem.getImage("alice", stored.id!);
133
+ writeFileSync(`beach.${contentType.split("/")[1]}`, bytes);
134
+ ```
135
+
136
+ Both edges must be 700px or more, the long edge at most 2500px and the file at most 7MB;
137
+ anything else is refused (400) with the reason. A long edge over 1568px is stored at
138
+ 1568px, so keep your own copy of the original. `search` returns image memories after
139
+ the others, and `listImages` pages through them newest first. `forgetImage` removes the
140
+ photo, and it can remove the whole memory with it: call it with `preview: true` first,
141
+ and `memory_kept` says whether the memory stays.
142
+
143
+ ## Export, edit history and usage
144
+
145
+ - **Export:** `exportMemories(userId)` returns every memory in a store, the text you
146
+ stored and its metadata; `exportImages(userId)` every image memory. For a large store,
147
+ `iterMemories` and `iterImages` stream them a page at a time.
148
+ - **Edit history:** `update` replaces a fact that changed. `lineage(userId, memoryId)` lists
149
+ the versions from the id you pass forward, oldest first, and marks the one in force with
150
+ `is_current`; pass the first version's id to see the whole history. `revisions()` counts
151
+ how many of a store's memories were edited.
152
+ - **Usage:** `usage(days)` reports this key's spend, the workspace's spend over the window,
153
+ the stores that spent the most, and the balance. It is free, and works at a zero balance.
154
+
63
155
  ## Recall caching
64
156
 
65
157
  Opt in per search and a repeated query reuses the previous result at 10% of the
@@ -85,12 +177,22 @@ Built in, no configuration needed:
85
177
 
86
178
  - **Automatic retries** — 429 and a 409 for a write already in flight always, and 408 /
87
179
  502 / 503 / 504 or a connection error only when a retry cannot double-process a write.
88
- A write may already have landed when any of those four comes back, so a POST write gets
89
- 429, that 409 and connect-level failures only. The calls that are safe to send again
90
- retry all four: the reads sent as POST (`search`, `recall`, `get`, `listMemories`,
91
- `stats`, ...) and the GET and DELETE calls (`ping`, `listStores`, `listSpeakers`,
92
- `listModels`, `listEngrams`, `usage`, `deleteStore`, `removeSpeaker`, `forgetImage`). Twice, with exponential backoff + jitter, honoring the server's
93
- `Retry-After` up to 30s. Tune with `new Client({ maxRetries })`; `0` disables.
180
+ The calls that are safe to send again retry all four: the reads sent as POST (`search`,
181
+ `recall`, `get`, `listMemories`, `stats`, ...), the GET and DELETE calls (`ping`,
182
+ `listStores`, `listSpeakers`, `listModels`, `listEngrams`, `usage`, `deleteStore`,
183
+ `removeSpeaker`, `forgetImage`), and the memory writes (`add`, `addTurn`, `addBulk`,
184
+ `update`). Those carry an idempotency key, so the service answers a repeat with the
185
+ first attempt's result instead of storing again. The other writes sent as POST
186
+ (`createStore`, `addSpeaker`, `delete`, `deleteAll`) get 429, that 409 and
187
+ connect-level failures only. Twice, with exponential backoff + jitter, honoring the
188
+ server's `Retry-After` up to 30s. Tune with `new Client({ maxRetries })`; `0` disables.
189
+ - **Your own retries** — pass `idempotencyKey` when the retry is yours (a job that is
190
+ re-run, a queue that redelivers). The service keeps a key for 24 hours and answers a
191
+ repeat with the first response, marked `replayed: true`. When the first attempt did
192
+ not come back with an answer, so whether it was applied is not known, a repeat
193
+ is refused with a `ConflictError` (409) rather than stored twice: check before sending
194
+ it again under a new key. Derive the key from what you store
195
+ (`` `import:${row.id}` ``), never a constant.
94
196
  - **Redirects refused** — the API key never follows a 3xx to another host.
95
197
  - **Timeouts** — 30s per attempt by default (`timeoutMs`), and a total budget for
96
198
  the whole call across every retry with `deadlineMs` / `withDeadline(ms)`. At the
@@ -131,8 +233,13 @@ timeouts, redirect refusal and the response cap all still apply.
131
233
 
132
234
  ## Security
133
235
 
134
- Built in, none of it configurable off:
236
+ Built in:
135
237
 
238
+ - **No key in a browser page** — anyone who opens a page can read a key in it, and with
239
+ it every memory in its workspace. A client in a browser page that would send the key to
240
+ the service is refused: call Wontopos from your server, or point `baseUrl` at a proxy of
241
+ your own that holds the key. `dangerouslyAllowBrowser: true` turns the refusal off, for
242
+ a local demo with a key you will revoke.
136
243
  - **Redirects refused** — a 3xx is an error, so the key never follows one to
137
244
  another host.
138
245
  - **Response size cap** — anything over 64MB is refused instead of buffered.
@@ -188,7 +295,8 @@ try {
188
295
 
189
296
  A retired model answers `410`, thrown as `GoneError`: retrying cannot succeed, so name a
190
297
  live model (`listModels()` lists them, with `retires_at` on one that is scheduled to
191
- retire). `deleteStore` still works under a retired model.
298
+ retire). Before the day, the client warns once with the date the model you use retires.
299
+ `deleteStore` still works under a retired model.
192
300
 
193
301
  ## A different API host
194
302
 
@@ -219,7 +327,7 @@ data to fix it.
219
327
  ## Changelog
220
328
 
221
329
  The three clients release in lockstep — same version, same surface, same day. Patch
222
- releases are additive. Nine inside 2.2 were not, deliberately and each with its
330
+ releases are additive. Ten inside 2.2 were not, deliberately and each with its
223
331
  reason; the changelog lists them.
224
332
 
225
333
  See [CHANGELOG.md](https://github.com/wontopos/wontopos-node/blob/main/CHANGELOG.md).
@@ -28,9 +28,10 @@
28
28
  * Reliability: every call retries transient failures with exponential backoff +
29
29
  * jitter, honoring `Retry-After` up to 30s — 429 and a 409 that says another write
30
30
  * to the store was in flight, always; 408/502/503/504 and connection errors only when
31
- * a retry can never double-process a write (reads, the DELETE calls, or a failure at
32
- * connect time). Tune with `maxRetries` (0 disables) and `timeoutMs`, or per call site via
33
- * the `withTimeout()` / `withRetries()` clones.
31
+ * a retry can never double-process a write (reads, the DELETE calls, the memory writes,
32
+ * which carry an idempotency key, or a failure at connect time). Tune with `maxRetries`
33
+ * (0 disables) and `timeoutMs`, or per call site via the `withTimeout()` /
34
+ * `withRetries()` clones.
34
35
  *
35
36
  * Debugging: set `WONTOPOS_LOG=debug` to log method/path/status/timing/retries
36
37
  * to stderr — never memory content, request bodies, or the API key.
@@ -48,6 +49,8 @@ export declare const SEARCH_LIMIT_MAX = 20;
48
49
  /** `recall`'s surrounding-context count. 0 to 20 inclusive; 0 attaches none. */
49
50
  export declare const CONTEXT_LIMIT_MIN = 0;
50
51
  export declare const CONTEXT_LIMIT_MAX = 20;
52
+ /** @deprecated Test hook. The warn-once set is process-global by design. */
53
+ export declare function _resetRetirementWarnings(): void;
51
54
  /** Quota from a response's `X-RateLimit-*` headers, or `null` if absent. */
52
55
  export interface RateLimit {
53
56
  limit: number | null;
@@ -100,8 +103,11 @@ export declare class PermissionDeniedError extends WosError {
100
103
  /** 404 — the store or resource doesn't exist. */
101
104
  export declare class NotFoundError extends WosError {
102
105
  }
103
- /** 409: another write to this store was in flight (retried automatically; nothing was
104
- * stored), or the store id collides with an existing one (not retried). */
106
+ /** 409: another write to this store was in flight, or the first request under this
107
+ * write's idempotency key is still running (both retried automatically; nothing was
108
+ * stored); the store id collides with an existing one (not retried); or the first
109
+ * request under the key did not come back with an answer, so whether it was stored is
110
+ * not known (not retried: check before sending it again under a new key). */
105
111
  export declare class ConflictError extends WosError {
106
112
  /** The existing store id this one collides with. Set only on a collision, which
107
113
  * retrying cannot fix. */
@@ -184,7 +190,9 @@ export interface StoreResult {
184
190
  note?: string;
185
191
  /** `true` when this response was REPLAYED from a previous request with the same
186
192
  * `idempotencyKey` — nothing new was stored. Absent on a fresh write. Read it to
187
- * tell "my retry landed" from "my retry was a no-op". */
193
+ * tell "my retry landed" from "my retry was a no-op". This client sets it only for
194
+ * a key you passed, and then the earlier request can also be an attempt of this same
195
+ * call that the client sent again after an answer that left the outcome open. */
188
196
  replayed?: boolean;
189
197
  [key: string]: unknown;
190
198
  }
@@ -371,17 +379,25 @@ export interface SearchResult {
371
379
  [key: string]: unknown;
372
380
  }
373
381
  export interface RecallResult {
382
+ /** The recent conversation, oldest first. */
374
383
  short_term: {
375
- turns: unknown[];
384
+ turns: HistoryTurn[];
376
385
  count: number;
386
+ [key: string]: unknown;
377
387
  };
388
+ /** The stored memories that match the query, best first. */
378
389
  long_term: {
379
390
  memories: Memory[];
380
391
  count: number;
392
+ [key: string]: unknown;
381
393
  };
394
+ /** The conversation around the best memory, in the order it was said. Each row is
395
+ * text that starts with where it sits next to that memory: `[before] `, `[match] `
396
+ * or `[after] `. Empty when there is no best memory or `context_limit` is 0. */
382
397
  context: {
383
- around_top_memory: unknown[];
398
+ around_top_memory: string[];
384
399
  count: number;
400
+ [key: string]: unknown;
385
401
  };
386
402
  [key: string]: unknown;
387
403
  }
@@ -396,7 +412,7 @@ export interface EngramResult {
396
412
  usage?: Usage;
397
413
  [key: string]: unknown;
398
414
  }
399
- /** One turn of the short-term window, as `history()` returns it. */
415
+ /** One turn of the short-term window, as `history()` and `recall()` return it. */
400
416
  export interface HistoryTurn {
401
417
  /** `"user"` or `"assistant"`. */
402
418
  role?: string;
@@ -636,25 +652,24 @@ export interface SearchOptions {
636
652
  /**
637
653
  * Per-call options for a write.
638
654
  *
639
- * `idempotencyKey` makes repeating THIS EXACT write safe: the API replays the first
640
- * response instead of storing again, for up to 10 minutes, and answers 422 if the
641
- * same key arrives with a different body. Use it when a retry is your own (a job that
642
- * died and was re-run, a queue that redelivers). The SDK retries a write on 429, and
643
- * on a 409 that says another write to the store was in flight: the service answers
644
- * both before it processes anything, so nothing was stored. It never retries a write
645
- * on 408 / 502 / 503 / 504 or a dropped body, where the write may already have been
646
- * applied — without a key the client cannot know whether it was.
655
+ * Every memory write (`add`, `addTurn`, `addBulk`, `update`) carries an idempotency key:
656
+ * `idempotencyKey` when you pass one, otherwise one this client makes for the call. With
657
+ * it the client retries the write on 408 / 502 / 503 / 504 and on a dropped connection,
658
+ * as it does a read: the service answers a repeat with the first attempt's result instead
659
+ * of storing again, and one that arrives while the first is still running with a 409 to
660
+ * wait, which is retried as well.
647
661
  *
648
- * The key covers a retry sent after the first attempt finished. A retry that overlaps
649
- * a first attempt still running can run twice, so wait for the first to fail before
650
- * re-sending.
662
+ * Pass your own key when the retry is yours: a job that died and was re-run, a queue
663
+ * that redelivers. The service keeps a key for 24 hours and answers a repeat with the
664
+ * first successful response, marked `replayed: true`; the same key with a different body
665
+ * is refused (422). A first attempt that failed is not kept, so a repeat of it runs,
666
+ * unless it did not come back with an answer, so whether it was applied is not known:
667
+ * then the repeat is refused with a `ConflictError` (409) instead of storing it twice.
668
+ * Check before sending it again under a new key.
651
669
  *
652
670
  * The key must be UNIQUE PER LOGICAL WRITE — derive it from the thing being stored
653
671
  * (`` `import:${row.id}` ``), never a constant, or the second write replays the first
654
672
  * and is silently lost. Format: 1-128 chars of `[A-Za-z0-9._:-]`, checked locally.
655
- *
656
- * The window is best-effort and can be shorter than 10 minutes; it is not a durable
657
- * de-duplication record.
658
673
  */
659
674
  export interface WriteOptions {
660
675
  idempotencyKey?: string;
@@ -782,6 +797,12 @@ export interface ClientOptions {
782
797
  * (`res.redirected`) is refused. A fetch that sends the request to another URL
783
798
  * on purpose, such as a proxy that rewrites it, is fine. */
784
799
  fetch?: FetchLike;
800
+ /** Run in a browser page anyway. Off by default: an API key in a page is readable by
801
+ * everyone who opens it, and with it every memory in its workspace, so a client in a
802
+ * page that would send the key to the service itself is refused. A `baseUrl` of your
803
+ * own, a proxy that holds the real key, is not refused. Turn this on only for a local
804
+ * demo with a key you will revoke. */
805
+ dangerouslyAllowBrowser?: boolean;
785
806
  }
786
807
  /** Wontopos memory client. Store and recall memories, isolated per `userId`. */
787
808
  export declare class Client {
@@ -803,6 +824,8 @@ export declare class Client {
803
824
  private readonly pageRelative;
804
825
  /** Whether the caller supplied `fetch`. */
805
826
  private readonly customFetch;
827
+ /** Whether the caller asked to run in a browser page anyway. */
828
+ private readonly allowBrowser;
806
829
  /** The store every call uses unless one passes `userId`. */
807
830
  private readonly defaultUser;
808
831
  private _rateLimit;
@@ -1022,10 +1045,10 @@ export declare class Client {
1022
1045
  */
1023
1046
  getImage(userId: string | undefined, memoryId: string): Promise<ImageBytes>;
1024
1047
  /**
1025
- * Remove the PHOTO from a memory, keeping its text.
1048
+ * Remove the PHOTO from a memory. It can take the whole memory with it.
1026
1049
  *
1027
- * `preview: true` reports what would happen, including `memory_kept`, and changes
1028
- * nothing. Retried like `deleteStore`; without `preview`, a 404 after an ambiguous
1050
+ * `preview: true` reports what would happen, including `memory_kept` (`false` when
1051
+ * the memory itself would go), and changes nothing. Retried like `deleteStore`; without `preview`, a 404 after an ambiguous
1029
1052
  * failure carries the same note.
1030
1053
  *
1031
1054
  * const p = await mem.forgetImage(undefined, id, { preview: true });
@@ -1182,6 +1205,11 @@ export declare class Client {
1182
1205
  * this is destructive, so it never falls back to the default store. */
1183
1206
  deleteAll(userId: string): Promise<StatusResult>;
1184
1207
  private post;
1208
+ /** A memory write. It always carries an `Idempotency-Key`, the caller's or one made
1209
+ * for this call, so an answer that leaves the outcome open (408/502/503/504, a
1210
+ * dropped connection) can be retried: the service answers the retry with the first
1211
+ * attempt's result instead of storing again. */
1212
+ private write;
1185
1213
  /** A DELETE that removes something: a 404 after an ambiguous failure says an earlier
1186
1214
  * attempt may already have removed it. */
1187
1215
  private remove;
@@ -1223,7 +1251,8 @@ export declare class Client {
1223
1251
  * Retried: 429, and a 409 whose error carries `retry_after_ms` (another write to the
1224
1252
  * store was in flight; nothing was stored), on every method; 408/502/503/504 and
1225
1253
  * dropped connections on a call that only reads (an idempotent method, or a POST
1226
- * that only reads); connect failures on every method. A retry whose wait is over the
1254
+ * that only reads) or a write that carries an idempotency key; connect failures on
1255
+ * every method. A retry whose wait is over the
1227
1256
  * cap, or does not fit the deadline, is not made: the call fails with the error of
1228
1257
  * the response it has. So does a retry the deadline leaves no time to send, and the
1229
1258
  * retry of a call that only reads that the deadline cuts short. Any other retry cut
@@ -2,8 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WME = exports.Client = exports.ServerError = exports.RateLimitError = exports.GoneError = 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
+ exports._resetRetirementWarnings = _resetRetirementWarnings;
5
6
  exports._resetFilterWarnings = _resetFilterWarnings;
6
- const VERSION = "2.2.46";
7
+ const VERSION = "2.2.47";
7
8
  const RUNTIME = (() => {
8
9
  const p = globalThis?.process;
9
10
  return p?.version ? ` (node/${p.version}; ${p.platform}-${p.arch})` : "";
@@ -268,12 +269,7 @@ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "
268
269
  const IMF_FIXDATE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) ([A-Z][a-z]{2}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
269
270
  const RFC850_DATE = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-([A-Z][a-z]{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
270
271
  const ASCTIME_DATE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) ([A-Z][a-z]{2}) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/;
271
- function retryAfterMs(v, now = Date.now()) {
272
- if (v == null)
273
- return undefined;
274
- const s = v.trim();
275
- if (/^\d+$/.test(s))
276
- return Math.min(Number(s), Number.MAX_SAFE_INTEGER / 1000) * 1000;
272
+ function httpDateMs(s, now = Date.now()) {
277
273
  let parts;
278
274
  let m;
279
275
  if ((m = IMF_FIXDATE.exec(s)))
@@ -293,7 +289,37 @@ function retryAfterMs(v, now = Date.now()) {
293
289
  const d = Number(day), h = Number(hh), mi = Number(mm), se = Number(ss);
294
290
  if (month < 0 || d < 1 || d > 31 || h > 23 || mi > 59 || se > 60)
295
291
  return undefined;
296
- return Math.max(0, Date.UTC(year, month, d, h, mi, se) - now);
292
+ return Date.UTC(year, month, d, h, mi, se);
293
+ }
294
+ function retryAfterMs(v, now = Date.now()) {
295
+ if (v == null)
296
+ return undefined;
297
+ const s = v.trim();
298
+ if (/^\d+$/.test(s))
299
+ return Math.min(Number(s), Number.MAX_SAFE_INTEGER / 1000) * 1000;
300
+ const at = httpDateMs(s, now);
301
+ return at === undefined ? undefined : Math.max(0, at - now);
302
+ }
303
+ const warnedRetirements = new Set();
304
+ const WARNED_RETIREMENTS_MAX = 64;
305
+ function warnIfRetiring(model, h) {
306
+ const v = h.get("Sunset");
307
+ if (!v)
308
+ return;
309
+ const at = httpDateMs(v.trim());
310
+ if (at === undefined)
311
+ return;
312
+ const day = new Date(at).toISOString().slice(0, 10);
313
+ const k = `${model}|${day}`;
314
+ if (warnedRetirements.has(k) || warnedRetirements.size >= WARNED_RETIREMENTS_MAX)
315
+ return;
316
+ warnedRetirements.add(k);
317
+ const which = model ? `model ${JSON.stringify(model)}` : "the model this client uses";
318
+ console.warn(`wontopos: ${which} retires on ${day} (UTC). From then on its calls answer 410, except ` +
319
+ "deleteStore. listModels() lists the models you can move to.");
320
+ }
321
+ function _resetRetirementWarnings() {
322
+ warnedRetirements.clear();
297
323
  }
298
324
  const BRIEF_BODY_MS = 1_000;
299
325
  class SlowBody extends Error {
@@ -318,6 +344,23 @@ function tryUrl(s, base) {
318
344
  return undefined;
319
345
  }
320
346
  }
347
+ function inBrowserPage() {
348
+ try {
349
+ const g = globalThis;
350
+ return typeof g.window !== "undefined" && typeof g.window.document !== "undefined" && typeof g.navigator !== "undefined";
351
+ }
352
+ catch {
353
+ return false;
354
+ }
355
+ }
356
+ function isServiceHost(host) {
357
+ const h = host.toLowerCase();
358
+ return h === "wontopos.com" || h.endsWith(".wontopos.com");
359
+ }
360
+ const BROWSER_REFUSAL = "wontopos: refusing to run in a browser page. The API key would be readable by anyone who " +
361
+ "opens the page, and with it every memory in its workspace. Call Wontopos from your server, " +
362
+ "or put a proxy of your own in front of it and point baseUrl there. For a local demo with a " +
363
+ "key you will revoke, pass dangerouslyAllowBrowser: true.";
321
364
  function pageHref() {
322
365
  try {
323
366
  const href = globalThis.location?.href;
@@ -394,6 +437,18 @@ const memoryFromGet = (r) => {
394
437
  return o.memory;
395
438
  return typeof o.id === "string" ? o : {};
396
439
  };
440
+ function recallShape(r) {
441
+ const o = asObj(r);
442
+ const st = asObj(o.short_term);
443
+ const lt = asObj(o.long_term);
444
+ const cx = asObj(o.context);
445
+ return {
446
+ ...o,
447
+ short_term: { ...st, turns: asRecords(st.turns) },
448
+ long_term: { ...lt, memories: asRecords(lt.memories) },
449
+ context: { ...cx, around_top_memory: asList(cx.around_top_memory).filter((x) => typeof x === "string") },
450
+ };
451
+ }
397
452
  function mergeResults(r) {
398
453
  const out = asRecords(r?.memories);
399
454
  const seen = new Set(out.map((m) => m.id).filter((id) => typeof id === "string"));
@@ -569,6 +624,15 @@ function writeKey(opts, takesImage = false) {
569
624
  (takesImage ? "add takes idempotencyKey and image." : "This call takes idempotencyKey only."));
570
625
  return opts.idempotencyKey;
571
626
  }
627
+ function newIdempotencyKey() {
628
+ const c = globalThis.crypto;
629
+ if (typeof c?.randomUUID === "function")
630
+ return `sdk-${String(c.randomUUID()).replace(/-/g, "")}`;
631
+ let hex = "";
632
+ for (let i = 0; i < 4; i++)
633
+ hex += Math.floor(Math.random() * 0x100000000).toString(16).padStart(8, "0");
634
+ return `sdk-${hex}`;
635
+ }
572
636
  function warnOnUnknownFilters(filters) {
573
637
  warnOnceUnknownKeys(filters, KNOWN_FILTER_KEYS, warnedFilterKeys, (k) => `wontopos: unknown search filter ${JSON.stringify(k)} — the API drops keys it does not know, ` +
574
638
  `so this filter has NO effect and the search is wider than you think. ` +
@@ -597,6 +661,7 @@ class Client {
597
661
  userinfo;
598
662
  pageRelative;
599
663
  customFetch;
664
+ allowBrowser;
600
665
  defaultUser;
601
666
  _rateLimit = null;
602
667
  get rateLimit() {
@@ -630,6 +695,10 @@ class Client {
630
695
  throw new Error(notAUrl(trimmed));
631
696
  this.pageRelative = !url;
632
697
  this.userinfo = Boolean(parsed.username || parsed.password);
698
+ this.allowBrowser = opts.dangerouslyAllowBrowser === true;
699
+ if (!this.allowBrowser && inBrowserPage() && isServiceHost(parsed.hostname)) {
700
+ throw new Error(BROWSER_REFUSAL);
701
+ }
633
702
  this.timeoutMs = checkMs(opts.timeoutMs) ?? 30_000;
634
703
  this.model = opts.model ?? DEFAULT_MODEL;
635
704
  if (this.model && !MODEL_RE.test(this.model)) {
@@ -698,6 +767,7 @@ class Client {
698
767
  fetch: this.fetchImpl,
699
768
  deadlineMs: this.deadlineMs,
700
769
  signal: this.signal,
770
+ dangerouslyAllowBrowser: this.allowBrowser,
701
771
  ...overrides,
702
772
  });
703
773
  }
@@ -729,22 +799,22 @@ class Client {
729
799
  if (opts.image !== undefined)
730
800
  body.image = normalizeImage(opts.image);
731
801
  warnOnUnknownMetadata(metadata);
732
- return this.post("/api/v1/memory/store", body, writeKey(opts, true));
802
+ return this.write("/api/v1/memory/store", body, writeKey(opts, true));
733
803
  }
734
804
  async store(content, userId, metadata = {}, opts = {}) {
735
805
  return this.add(content, userId, metadata, opts);
736
806
  }
737
807
  async addTurn(userMsg, assistantMsg, userId, opts = {}) {
738
- return this.post("/api/v1/memory/store-turn", { user_id: this.uid(userId), user_msg: userMsg, assistant_msg: assistantMsg }, writeKey(opts));
808
+ return this.write("/api/v1/memory/store-turn", { user_id: this.uid(userId), user_msg: userMsg, assistant_msg: assistantMsg }, writeKey(opts));
739
809
  }
740
810
  async addBulk(content, userId, category = "general", timestamp, opts = {}) {
741
811
  const body = { user_id: this.uid(userId), content, category: category ?? "general" };
742
812
  if (timestamp)
743
813
  body.timestamp = timestamp;
744
- return this.post("/api/v1/memory/bulk-store", body, writeKey(opts));
814
+ return this.write("/api/v1/memory/bulk-store", body, writeKey(opts));
745
815
  }
746
816
  async update(oldMemoryId, newContent, userId, opts = {}) {
747
- return this.post("/api/v1/memory/supersede", { user_id: this.uid(userId), old_memory_id: oldMemoryId, new_content: newContent }, writeKey(opts));
817
+ return this.write("/api/v1/memory/supersede", { user_id: this.uid(userId), old_memory_id: oldMemoryId, new_content: newContent }, writeKey(opts));
748
818
  }
749
819
  async search(query, userId, limit = 10, opts = {}) {
750
820
  checkSearchOpts(opts);
@@ -789,7 +859,7 @@ class Client {
789
859
  checkContextLimit(opts.context_limit);
790
860
  body.context_limit = opts.context_limit;
791
861
  }
792
- return this.post("/api/v1/memory/recall", withForm(body, opts.form, opts.tz));
862
+ return recallShape(await this.post("/api/v1/memory/recall", withForm(body, opts.form, opts.tz)));
793
863
  }
794
864
  async engram(name, query, userId, opts = {}) {
795
865
  checkOpts(opts, KNOWN_ENGRAM_KEYS, "engram");
@@ -994,8 +1064,12 @@ class Client {
994
1064
  warnIfStoreIdCollapses(userId);
995
1065
  return this.post("/api/v1/memory/forget", { user_id: userId });
996
1066
  }
997
- post(path, body, idempotencyKey) {
998
- return this.request("POST", path, body, idempotencyKey);
1067
+ post(path, body) {
1068
+ return this.request("POST", path, body);
1069
+ }
1070
+ write(path, body, idempotencyKey) {
1071
+ const made = idempotencyKey == null;
1072
+ return this.request("POST", path, body, made ? newIdempotencyKey() : idempotencyKey, false, made);
999
1073
  }
1000
1074
  remove(path, body) {
1001
1075
  return this.request("DELETE", path, body, undefined, true);
@@ -1115,6 +1189,7 @@ class Client {
1115
1189
  const upper = method.toUpperCase();
1116
1190
  const logPath = path.split("?")[0];
1117
1191
  const idempotent = IDEMPOTENT_METHODS.has(upper) || reads || (upper === "POST" && READ_POSTS.has(logPath));
1192
+ const replaySafe = idempotent || extraHeaders?.["Idempotency-Key"] !== undefined;
1118
1193
  const deadlineAt = this.deadlineAt();
1119
1194
  const headers = extraHeaders ? { ...this.headers, ...extraHeaders } : this.headers;
1120
1195
  let ambiguous = false;
@@ -1156,7 +1231,7 @@ class Client {
1156
1231
  if (timedOut)
1157
1232
  throw cutShort();
1158
1233
  const connect = isConnectFailure(e);
1159
- if ((idempotent || connect) && !last) {
1234
+ if ((replaySafe || connect) && !last) {
1160
1235
  if (!connect)
1161
1236
  ambiguous = true;
1162
1237
  await pauseWithoutAnswer(e?.code ?? e?.name ?? "network error");
@@ -1173,7 +1248,7 @@ class Client {
1173
1248
  throw cutShort();
1174
1249
  if (e instanceof WosError)
1175
1250
  throw e;
1176
- if (idempotent && !last)
1251
+ if (replaySafe && !last)
1177
1252
  return null;
1178
1253
  throw this.networkError(e, path);
1179
1254
  }
@@ -1200,9 +1275,10 @@ class Client {
1200
1275
  throw new WosError(res.status, "unexpected redirect — refused (the API key never follows a redirect). Check baseUrl: exact host, https://.");
1201
1276
  }
1202
1277
  this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
1278
+ warnIfRetiring(this.model, res.headers);
1203
1279
  if (!res.ok) {
1204
1280
  const ra = retryAfterMs(res.headers.get("Retry-After"));
1205
- let wait = RETRY_ALWAYS.has(res.status) || (RETRY_IF_IDEMPOTENT.has(res.status) && idempotent)
1281
+ let wait = RETRY_ALWAYS.has(res.status) || (RETRY_IF_IDEMPOTENT.has(res.status) && replaySafe)
1206
1282
  ? ra ?? this.backoffMs(attempt)
1207
1283
  : undefined;
1208
1284
  const retrying = wait !== undefined && !last && wait <= MAX_RETRY_WAIT_MS && this.fits(wait, deadlineAt);
@@ -1329,7 +1405,7 @@ class Client {
1329
1405
  clearTimeout(timer);
1330
1406
  }
1331
1407
  }
1332
- async request(method, path, body, idempotencyKey, removes = false) {
1408
+ async request(method, path, body, idempotencyKey, removes = false, madeKey = false) {
1333
1409
  const extraHeaders = this.idemHeader(idempotencyKey);
1334
1410
  let payload;
1335
1411
  try {
@@ -1357,7 +1433,7 @@ class Client {
1357
1433
  const got = data === null ? "null" : Array.isArray(data) ? "array" : typeof data;
1358
1434
  throw new WosError(res.status, `expected a JSON object in the response, got ${got}`);
1359
1435
  }
1360
- if (res.headers.get("Idempotent-Replayed") === "true" && !("replayed" in data)) {
1436
+ if (!madeKey && res.headers.get("Idempotent-Replayed") === "true" && !("replayed" in data)) {
1361
1437
  data.replayed = true;
1362
1438
  }
1363
1439
  return data;
@@ -28,9 +28,10 @@
28
28
  * Reliability: every call retries transient failures with exponential backoff +
29
29
  * jitter, honoring `Retry-After` up to 30s — 429 and a 409 that says another write
30
30
  * to the store was in flight, always; 408/502/503/504 and connection errors only when
31
- * a retry can never double-process a write (reads, the DELETE calls, or a failure at
32
- * connect time). Tune with `maxRetries` (0 disables) and `timeoutMs`, or per call site via
33
- * the `withTimeout()` / `withRetries()` clones.
31
+ * a retry can never double-process a write (reads, the DELETE calls, the memory writes,
32
+ * which carry an idempotency key, or a failure at connect time). Tune with `maxRetries`
33
+ * (0 disables) and `timeoutMs`, or per call site via the `withTimeout()` /
34
+ * `withRetries()` clones.
34
35
  *
35
36
  * Debugging: set `WONTOPOS_LOG=debug` to log method/path/status/timing/retries
36
37
  * to stderr — never memory content, request bodies, or the API key.
@@ -48,6 +49,8 @@ export declare const SEARCH_LIMIT_MAX = 20;
48
49
  /** `recall`'s surrounding-context count. 0 to 20 inclusive; 0 attaches none. */
49
50
  export declare const CONTEXT_LIMIT_MIN = 0;
50
51
  export declare const CONTEXT_LIMIT_MAX = 20;
52
+ /** @deprecated Test hook. The warn-once set is process-global by design. */
53
+ export declare function _resetRetirementWarnings(): void;
51
54
  /** Quota from a response's `X-RateLimit-*` headers, or `null` if absent. */
52
55
  export interface RateLimit {
53
56
  limit: number | null;
@@ -100,8 +103,11 @@ export declare class PermissionDeniedError extends WosError {
100
103
  /** 404 — the store or resource doesn't exist. */
101
104
  export declare class NotFoundError extends WosError {
102
105
  }
103
- /** 409: another write to this store was in flight (retried automatically; nothing was
104
- * stored), or the store id collides with an existing one (not retried). */
106
+ /** 409: another write to this store was in flight, or the first request under this
107
+ * write's idempotency key is still running (both retried automatically; nothing was
108
+ * stored); the store id collides with an existing one (not retried); or the first
109
+ * request under the key did not come back with an answer, so whether it was stored is
110
+ * not known (not retried: check before sending it again under a new key). */
105
111
  export declare class ConflictError extends WosError {
106
112
  /** The existing store id this one collides with. Set only on a collision, which
107
113
  * retrying cannot fix. */
@@ -184,7 +190,9 @@ export interface StoreResult {
184
190
  note?: string;
185
191
  /** `true` when this response was REPLAYED from a previous request with the same
186
192
  * `idempotencyKey` — nothing new was stored. Absent on a fresh write. Read it to
187
- * tell "my retry landed" from "my retry was a no-op". */
193
+ * tell "my retry landed" from "my retry was a no-op". This client sets it only for
194
+ * a key you passed, and then the earlier request can also be an attempt of this same
195
+ * call that the client sent again after an answer that left the outcome open. */
188
196
  replayed?: boolean;
189
197
  [key: string]: unknown;
190
198
  }
@@ -371,17 +379,25 @@ export interface SearchResult {
371
379
  [key: string]: unknown;
372
380
  }
373
381
  export interface RecallResult {
382
+ /** The recent conversation, oldest first. */
374
383
  short_term: {
375
- turns: unknown[];
384
+ turns: HistoryTurn[];
376
385
  count: number;
386
+ [key: string]: unknown;
377
387
  };
388
+ /** The stored memories that match the query, best first. */
378
389
  long_term: {
379
390
  memories: Memory[];
380
391
  count: number;
392
+ [key: string]: unknown;
381
393
  };
394
+ /** The conversation around the best memory, in the order it was said. Each row is
395
+ * text that starts with where it sits next to that memory: `[before] `, `[match] `
396
+ * or `[after] `. Empty when there is no best memory or `context_limit` is 0. */
382
397
  context: {
383
- around_top_memory: unknown[];
398
+ around_top_memory: string[];
384
399
  count: number;
400
+ [key: string]: unknown;
385
401
  };
386
402
  [key: string]: unknown;
387
403
  }
@@ -396,7 +412,7 @@ export interface EngramResult {
396
412
  usage?: Usage;
397
413
  [key: string]: unknown;
398
414
  }
399
- /** One turn of the short-term window, as `history()` returns it. */
415
+ /** One turn of the short-term window, as `history()` and `recall()` return it. */
400
416
  export interface HistoryTurn {
401
417
  /** `"user"` or `"assistant"`. */
402
418
  role?: string;
@@ -636,25 +652,24 @@ export interface SearchOptions {
636
652
  /**
637
653
  * Per-call options for a write.
638
654
  *
639
- * `idempotencyKey` makes repeating THIS EXACT write safe: the API replays the first
640
- * response instead of storing again, for up to 10 minutes, and answers 422 if the
641
- * same key arrives with a different body. Use it when a retry is your own (a job that
642
- * died and was re-run, a queue that redelivers). The SDK retries a write on 429, and
643
- * on a 409 that says another write to the store was in flight: the service answers
644
- * both before it processes anything, so nothing was stored. It never retries a write
645
- * on 408 / 502 / 503 / 504 or a dropped body, where the write may already have been
646
- * applied — without a key the client cannot know whether it was.
655
+ * Every memory write (`add`, `addTurn`, `addBulk`, `update`) carries an idempotency key:
656
+ * `idempotencyKey` when you pass one, otherwise one this client makes for the call. With
657
+ * it the client retries the write on 408 / 502 / 503 / 504 and on a dropped connection,
658
+ * as it does a read: the service answers a repeat with the first attempt's result instead
659
+ * of storing again, and one that arrives while the first is still running with a 409 to
660
+ * wait, which is retried as well.
647
661
  *
648
- * The key covers a retry sent after the first attempt finished. A retry that overlaps
649
- * a first attempt still running can run twice, so wait for the first to fail before
650
- * re-sending.
662
+ * Pass your own key when the retry is yours: a job that died and was re-run, a queue
663
+ * that redelivers. The service keeps a key for 24 hours and answers a repeat with the
664
+ * first successful response, marked `replayed: true`; the same key with a different body
665
+ * is refused (422). A first attempt that failed is not kept, so a repeat of it runs,
666
+ * unless it did not come back with an answer, so whether it was applied is not known:
667
+ * then the repeat is refused with a `ConflictError` (409) instead of storing it twice.
668
+ * Check before sending it again under a new key.
651
669
  *
652
670
  * The key must be UNIQUE PER LOGICAL WRITE — derive it from the thing being stored
653
671
  * (`` `import:${row.id}` ``), never a constant, or the second write replays the first
654
672
  * and is silently lost. Format: 1-128 chars of `[A-Za-z0-9._:-]`, checked locally.
655
- *
656
- * The window is best-effort and can be shorter than 10 minutes; it is not a durable
657
- * de-duplication record.
658
673
  */
659
674
  export interface WriteOptions {
660
675
  idempotencyKey?: string;
@@ -782,6 +797,12 @@ export interface ClientOptions {
782
797
  * (`res.redirected`) is refused. A fetch that sends the request to another URL
783
798
  * on purpose, such as a proxy that rewrites it, is fine. */
784
799
  fetch?: FetchLike;
800
+ /** Run in a browser page anyway. Off by default: an API key in a page is readable by
801
+ * everyone who opens it, and with it every memory in its workspace, so a client in a
802
+ * page that would send the key to the service itself is refused. A `baseUrl` of your
803
+ * own, a proxy that holds the real key, is not refused. Turn this on only for a local
804
+ * demo with a key you will revoke. */
805
+ dangerouslyAllowBrowser?: boolean;
785
806
  }
786
807
  /** Wontopos memory client. Store and recall memories, isolated per `userId`. */
787
808
  export declare class Client {
@@ -803,6 +824,8 @@ export declare class Client {
803
824
  private readonly pageRelative;
804
825
  /** Whether the caller supplied `fetch`. */
805
826
  private readonly customFetch;
827
+ /** Whether the caller asked to run in a browser page anyway. */
828
+ private readonly allowBrowser;
806
829
  /** The store every call uses unless one passes `userId`. */
807
830
  private readonly defaultUser;
808
831
  private _rateLimit;
@@ -1022,10 +1045,10 @@ export declare class Client {
1022
1045
  */
1023
1046
  getImage(userId: string | undefined, memoryId: string): Promise<ImageBytes>;
1024
1047
  /**
1025
- * Remove the PHOTO from a memory, keeping its text.
1048
+ * Remove the PHOTO from a memory. It can take the whole memory with it.
1026
1049
  *
1027
- * `preview: true` reports what would happen, including `memory_kept`, and changes
1028
- * nothing. Retried like `deleteStore`; without `preview`, a 404 after an ambiguous
1050
+ * `preview: true` reports what would happen, including `memory_kept` (`false` when
1051
+ * the memory itself would go), and changes nothing. Retried like `deleteStore`; without `preview`, a 404 after an ambiguous
1029
1052
  * failure carries the same note.
1030
1053
  *
1031
1054
  * const p = await mem.forgetImage(undefined, id, { preview: true });
@@ -1182,6 +1205,11 @@ export declare class Client {
1182
1205
  * this is destructive, so it never falls back to the default store. */
1183
1206
  deleteAll(userId: string): Promise<StatusResult>;
1184
1207
  private post;
1208
+ /** A memory write. It always carries an `Idempotency-Key`, the caller's or one made
1209
+ * for this call, so an answer that leaves the outcome open (408/502/503/504, a
1210
+ * dropped connection) can be retried: the service answers the retry with the first
1211
+ * attempt's result instead of storing again. */
1212
+ private write;
1185
1213
  /** A DELETE that removes something: a 404 after an ambiguous failure says an earlier
1186
1214
  * attempt may already have removed it. */
1187
1215
  private remove;
@@ -1223,7 +1251,8 @@ export declare class Client {
1223
1251
  * Retried: 429, and a 409 whose error carries `retry_after_ms` (another write to the
1224
1252
  * store was in flight; nothing was stored), on every method; 408/502/503/504 and
1225
1253
  * dropped connections on a call that only reads (an idempotent method, or a POST
1226
- * that only reads); connect failures on every method. A retry whose wait is over the
1254
+ * that only reads) or a write that carries an idempotency key; connect failures on
1255
+ * every method. A retry whose wait is over the
1227
1256
  * cap, or does not fit the deadline, is not made: the call fails with the error of
1228
1257
  * the response it has. So does a retry the deadline leaves no time to send, and the
1229
1258
  * retry of a call that only reads that the deadline cuts short. Any other retry cut
package/dist/wontopos.js CHANGED
@@ -1,4 +1,4 @@
1
- const VERSION = "2.2.46";
1
+ const VERSION = "2.2.47";
2
2
  const RUNTIME = (() => {
3
3
  const p = globalThis?.process;
4
4
  return p?.version ? ` (node/${p.version}; ${p.platform}-${p.arch})` : "";
@@ -263,12 +263,7 @@ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "
263
263
  const IMF_FIXDATE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) ([A-Z][a-z]{2}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
264
264
  const RFC850_DATE = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-([A-Z][a-z]{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
265
265
  const ASCTIME_DATE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) ([A-Z][a-z]{2}) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/;
266
- function retryAfterMs(v, now = Date.now()) {
267
- if (v == null)
268
- return undefined;
269
- const s = v.trim();
270
- if (/^\d+$/.test(s))
271
- return Math.min(Number(s), Number.MAX_SAFE_INTEGER / 1000) * 1000;
266
+ function httpDateMs(s, now = Date.now()) {
272
267
  let parts;
273
268
  let m;
274
269
  if ((m = IMF_FIXDATE.exec(s)))
@@ -288,7 +283,37 @@ function retryAfterMs(v, now = Date.now()) {
288
283
  const d = Number(day), h = Number(hh), mi = Number(mm), se = Number(ss);
289
284
  if (month < 0 || d < 1 || d > 31 || h > 23 || mi > 59 || se > 60)
290
285
  return undefined;
291
- return Math.max(0, Date.UTC(year, month, d, h, mi, se) - now);
286
+ return Date.UTC(year, month, d, h, mi, se);
287
+ }
288
+ function retryAfterMs(v, now = Date.now()) {
289
+ if (v == null)
290
+ return undefined;
291
+ const s = v.trim();
292
+ if (/^\d+$/.test(s))
293
+ return Math.min(Number(s), Number.MAX_SAFE_INTEGER / 1000) * 1000;
294
+ const at = httpDateMs(s, now);
295
+ return at === undefined ? undefined : Math.max(0, at - now);
296
+ }
297
+ const warnedRetirements = new Set();
298
+ const WARNED_RETIREMENTS_MAX = 64;
299
+ function warnIfRetiring(model, h) {
300
+ const v = h.get("Sunset");
301
+ if (!v)
302
+ return;
303
+ const at = httpDateMs(v.trim());
304
+ if (at === undefined)
305
+ return;
306
+ const day = new Date(at).toISOString().slice(0, 10);
307
+ const k = `${model}|${day}`;
308
+ if (warnedRetirements.has(k) || warnedRetirements.size >= WARNED_RETIREMENTS_MAX)
309
+ return;
310
+ warnedRetirements.add(k);
311
+ const which = model ? `model ${JSON.stringify(model)}` : "the model this client uses";
312
+ console.warn(`wontopos: ${which} retires on ${day} (UTC). From then on its calls answer 410, except ` +
313
+ "deleteStore. listModels() lists the models you can move to.");
314
+ }
315
+ export function _resetRetirementWarnings() {
316
+ warnedRetirements.clear();
292
317
  }
293
318
  const BRIEF_BODY_MS = 1_000;
294
319
  class SlowBody extends Error {
@@ -313,6 +338,23 @@ function tryUrl(s, base) {
313
338
  return undefined;
314
339
  }
315
340
  }
341
+ function inBrowserPage() {
342
+ try {
343
+ const g = globalThis;
344
+ return typeof g.window !== "undefined" && typeof g.window.document !== "undefined" && typeof g.navigator !== "undefined";
345
+ }
346
+ catch {
347
+ return false;
348
+ }
349
+ }
350
+ function isServiceHost(host) {
351
+ const h = host.toLowerCase();
352
+ return h === "wontopos.com" || h.endsWith(".wontopos.com");
353
+ }
354
+ const BROWSER_REFUSAL = "wontopos: refusing to run in a browser page. The API key would be readable by anyone who " +
355
+ "opens the page, and with it every memory in its workspace. Call Wontopos from your server, " +
356
+ "or put a proxy of your own in front of it and point baseUrl there. For a local demo with a " +
357
+ "key you will revoke, pass dangerouslyAllowBrowser: true.";
316
358
  function pageHref() {
317
359
  try {
318
360
  const href = globalThis.location?.href;
@@ -389,6 +431,18 @@ const memoryFromGet = (r) => {
389
431
  return o.memory;
390
432
  return typeof o.id === "string" ? o : {};
391
433
  };
434
+ function recallShape(r) {
435
+ const o = asObj(r);
436
+ const st = asObj(o.short_term);
437
+ const lt = asObj(o.long_term);
438
+ const cx = asObj(o.context);
439
+ return {
440
+ ...o,
441
+ short_term: { ...st, turns: asRecords(st.turns) },
442
+ long_term: { ...lt, memories: asRecords(lt.memories) },
443
+ context: { ...cx, around_top_memory: asList(cx.around_top_memory).filter((x) => typeof x === "string") },
444
+ };
445
+ }
392
446
  function mergeResults(r) {
393
447
  const out = asRecords(r?.memories);
394
448
  const seen = new Set(out.map((m) => m.id).filter((id) => typeof id === "string"));
@@ -553,6 +607,15 @@ function writeKey(opts, takesImage = false) {
553
607
  (takesImage ? "add takes idempotencyKey and image." : "This call takes idempotencyKey only."));
554
608
  return opts.idempotencyKey;
555
609
  }
610
+ function newIdempotencyKey() {
611
+ const c = globalThis.crypto;
612
+ if (typeof c?.randomUUID === "function")
613
+ return `sdk-${String(c.randomUUID()).replace(/-/g, "")}`;
614
+ let hex = "";
615
+ for (let i = 0; i < 4; i++)
616
+ hex += Math.floor(Math.random() * 0x100000000).toString(16).padStart(8, "0");
617
+ return `sdk-${hex}`;
618
+ }
556
619
  function warnOnUnknownFilters(filters) {
557
620
  warnOnceUnknownKeys(filters, KNOWN_FILTER_KEYS, warnedFilterKeys, (k) => `wontopos: unknown search filter ${JSON.stringify(k)} — the API drops keys it does not know, ` +
558
621
  `so this filter has NO effect and the search is wider than you think. ` +
@@ -581,6 +644,7 @@ export class Client {
581
644
  userinfo;
582
645
  pageRelative;
583
646
  customFetch;
647
+ allowBrowser;
584
648
  defaultUser;
585
649
  _rateLimit = null;
586
650
  get rateLimit() {
@@ -614,6 +678,10 @@ export class Client {
614
678
  throw new Error(notAUrl(trimmed));
615
679
  this.pageRelative = !url;
616
680
  this.userinfo = Boolean(parsed.username || parsed.password);
681
+ this.allowBrowser = opts.dangerouslyAllowBrowser === true;
682
+ if (!this.allowBrowser && inBrowserPage() && isServiceHost(parsed.hostname)) {
683
+ throw new Error(BROWSER_REFUSAL);
684
+ }
617
685
  this.timeoutMs = checkMs(opts.timeoutMs) ?? 30_000;
618
686
  this.model = opts.model ?? DEFAULT_MODEL;
619
687
  if (this.model && !MODEL_RE.test(this.model)) {
@@ -682,6 +750,7 @@ export class Client {
682
750
  fetch: this.fetchImpl,
683
751
  deadlineMs: this.deadlineMs,
684
752
  signal: this.signal,
753
+ dangerouslyAllowBrowser: this.allowBrowser,
685
754
  ...overrides,
686
755
  });
687
756
  }
@@ -713,22 +782,22 @@ export class Client {
713
782
  if (opts.image !== undefined)
714
783
  body.image = normalizeImage(opts.image);
715
784
  warnOnUnknownMetadata(metadata);
716
- return this.post("/api/v1/memory/store", body, writeKey(opts, true));
785
+ return this.write("/api/v1/memory/store", body, writeKey(opts, true));
717
786
  }
718
787
  async store(content, userId, metadata = {}, opts = {}) {
719
788
  return this.add(content, userId, metadata, opts);
720
789
  }
721
790
  async addTurn(userMsg, assistantMsg, userId, opts = {}) {
722
- return this.post("/api/v1/memory/store-turn", { user_id: this.uid(userId), user_msg: userMsg, assistant_msg: assistantMsg }, writeKey(opts));
791
+ return this.write("/api/v1/memory/store-turn", { user_id: this.uid(userId), user_msg: userMsg, assistant_msg: assistantMsg }, writeKey(opts));
723
792
  }
724
793
  async addBulk(content, userId, category = "general", timestamp, opts = {}) {
725
794
  const body = { user_id: this.uid(userId), content, category: category ?? "general" };
726
795
  if (timestamp)
727
796
  body.timestamp = timestamp;
728
- return this.post("/api/v1/memory/bulk-store", body, writeKey(opts));
797
+ return this.write("/api/v1/memory/bulk-store", body, writeKey(opts));
729
798
  }
730
799
  async update(oldMemoryId, newContent, userId, opts = {}) {
731
- return this.post("/api/v1/memory/supersede", { user_id: this.uid(userId), old_memory_id: oldMemoryId, new_content: newContent }, writeKey(opts));
800
+ return this.write("/api/v1/memory/supersede", { user_id: this.uid(userId), old_memory_id: oldMemoryId, new_content: newContent }, writeKey(opts));
732
801
  }
733
802
  async search(query, userId, limit = 10, opts = {}) {
734
803
  checkSearchOpts(opts);
@@ -773,7 +842,7 @@ export class Client {
773
842
  checkContextLimit(opts.context_limit);
774
843
  body.context_limit = opts.context_limit;
775
844
  }
776
- return this.post("/api/v1/memory/recall", withForm(body, opts.form, opts.tz));
845
+ return recallShape(await this.post("/api/v1/memory/recall", withForm(body, opts.form, opts.tz)));
777
846
  }
778
847
  async engram(name, query, userId, opts = {}) {
779
848
  checkOpts(opts, KNOWN_ENGRAM_KEYS, "engram");
@@ -978,8 +1047,12 @@ export class Client {
978
1047
  warnIfStoreIdCollapses(userId);
979
1048
  return this.post("/api/v1/memory/forget", { user_id: userId });
980
1049
  }
981
- post(path, body, idempotencyKey) {
982
- return this.request("POST", path, body, idempotencyKey);
1050
+ post(path, body) {
1051
+ return this.request("POST", path, body);
1052
+ }
1053
+ write(path, body, idempotencyKey) {
1054
+ const made = idempotencyKey == null;
1055
+ return this.request("POST", path, body, made ? newIdempotencyKey() : idempotencyKey, false, made);
983
1056
  }
984
1057
  remove(path, body) {
985
1058
  return this.request("DELETE", path, body, undefined, true);
@@ -1099,6 +1172,7 @@ export class Client {
1099
1172
  const upper = method.toUpperCase();
1100
1173
  const logPath = path.split("?")[0];
1101
1174
  const idempotent = IDEMPOTENT_METHODS.has(upper) || reads || (upper === "POST" && READ_POSTS.has(logPath));
1175
+ const replaySafe = idempotent || extraHeaders?.["Idempotency-Key"] !== undefined;
1102
1176
  const deadlineAt = this.deadlineAt();
1103
1177
  const headers = extraHeaders ? { ...this.headers, ...extraHeaders } : this.headers;
1104
1178
  let ambiguous = false;
@@ -1140,7 +1214,7 @@ export class Client {
1140
1214
  if (timedOut)
1141
1215
  throw cutShort();
1142
1216
  const connect = isConnectFailure(e);
1143
- if ((idempotent || connect) && !last) {
1217
+ if ((replaySafe || connect) && !last) {
1144
1218
  if (!connect)
1145
1219
  ambiguous = true;
1146
1220
  await pauseWithoutAnswer(e?.code ?? e?.name ?? "network error");
@@ -1157,7 +1231,7 @@ export class Client {
1157
1231
  throw cutShort();
1158
1232
  if (e instanceof WosError)
1159
1233
  throw e;
1160
- if (idempotent && !last)
1234
+ if (replaySafe && !last)
1161
1235
  return null;
1162
1236
  throw this.networkError(e, path);
1163
1237
  }
@@ -1184,9 +1258,10 @@ export class Client {
1184
1258
  throw new WosError(res.status, "unexpected redirect — refused (the API key never follows a redirect). Check baseUrl: exact host, https://.");
1185
1259
  }
1186
1260
  this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
1261
+ warnIfRetiring(this.model, res.headers);
1187
1262
  if (!res.ok) {
1188
1263
  const ra = retryAfterMs(res.headers.get("Retry-After"));
1189
- let wait = RETRY_ALWAYS.has(res.status) || (RETRY_IF_IDEMPOTENT.has(res.status) && idempotent)
1264
+ let wait = RETRY_ALWAYS.has(res.status) || (RETRY_IF_IDEMPOTENT.has(res.status) && replaySafe)
1190
1265
  ? ra ?? this.backoffMs(attempt)
1191
1266
  : undefined;
1192
1267
  const retrying = wait !== undefined && !last && wait <= MAX_RETRY_WAIT_MS && this.fits(wait, deadlineAt);
@@ -1313,7 +1388,7 @@ export class Client {
1313
1388
  clearTimeout(timer);
1314
1389
  }
1315
1390
  }
1316
- async request(method, path, body, idempotencyKey, removes = false) {
1391
+ async request(method, path, body, idempotencyKey, removes = false, madeKey = false) {
1317
1392
  const extraHeaders = this.idemHeader(idempotencyKey);
1318
1393
  let payload;
1319
1394
  try {
@@ -1341,7 +1416,7 @@ export class Client {
1341
1416
  const got = data === null ? "null" : Array.isArray(data) ? "array" : typeof data;
1342
1417
  throw new WosError(res.status, `expected a JSON object in the response, got ${got}`);
1343
1418
  }
1344
- if (res.headers.get("Idempotent-Replayed") === "true" && !("replayed" in data)) {
1419
+ if (!madeKey && res.headers.get("Idempotent-Replayed") === "true" && !("replayed" in data)) {
1345
1420
  data.replayed = true;
1346
1421
  }
1347
1422
  return data;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wontopos",
3
- "version": "2.2.46",
3
+ "version": "2.2.47",
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",