wontopos 2.2.34 → 2.2.35
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 +71 -5
- package/dist/wontopos.d.ts +74 -60
- package/dist/wontopos.js +229 -114
- package/package.json +4 -7
package/README.md
CHANGED
|
@@ -23,15 +23,23 @@ const ctx = await mem.recall("what does alice drink?", "alice");
|
|
|
23
23
|
|
|
24
24
|
## Why
|
|
25
25
|
|
|
26
|
-
- **
|
|
27
|
-
- **No LLM in the loop** — `store` / `search` / `recall` never call a language model. You pay
|
|
26
|
+
- **The same in every language** — identical recall whichever language a memory was written in (Korean · Japanese · Chinese · English).
|
|
27
|
+
- **No LLM in the loop** — `store` / `search` / `recall` never call a language model. You pay retrieval, not generation.
|
|
28
28
|
- **Bounded retrieval** — `recall()` returns a small, fixed-size slice (~1,200 tokens) regardless of how much you've stored.
|
|
29
29
|
|
|
30
30
|
## Methods
|
|
31
31
|
|
|
32
32
|
`add` · `addTurn` · `addBulk` · `update` · `search` · `searchFull` · `recall` · `history` · `stats` · `get` · `listMemories` · `delete` · `deleteAll`
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
Where the store id goes depends on whether the method takes a payload. A method that
|
|
35
|
+
writes or searches puts the payload first and the store second — `mem.add("...", "alice")`,
|
|
36
|
+
`mem.search("...", "alice")`. A method that only addresses a store takes it first —
|
|
37
|
+
`mem.listMemories("alice")`, `mem.stats("alice")`, `mem.delete("alice", memoryId)`.
|
|
38
|
+
Most of them let you leave it out for the client's default store. Four take it
|
|
39
|
+
positionally and cannot — `get`, `delete`, `lineage` and `forgetImage` want an explicit
|
|
40
|
+
`undefined` there, because a second required argument follows it. Python omits it in
|
|
41
|
+
all four; this is the one place the three clients differ in shape rather than meaning.
|
|
42
|
+
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.
|
|
35
43
|
|
|
36
44
|
## Who said it (speakers)
|
|
37
45
|
|
|
@@ -72,7 +80,12 @@ const hits = await mem.search("...the conversation so far...", "alice", 10,
|
|
|
72
80
|
|
|
73
81
|
Built in, no configuration needed:
|
|
74
82
|
|
|
75
|
-
- **Automatic retries** — 429
|
|
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
|
|
87
|
+
deletes that address a whole store — `listStores`, `listSpeakers`, `deleteStore`,
|
|
88
|
+
`removeSpeaker`, `forgetImage` — are GET or DELETE and do retry a 502. Twice, with
|
|
76
89
|
exponential backoff + jitter, honoring the server's `Retry-After`. Tune with
|
|
77
90
|
`new Client({ maxRetries })`; `0` disables.
|
|
78
91
|
- **Redirects refused** — the API key never follows a 3xx to another host.
|
|
@@ -189,7 +202,60 @@ data to fix it.
|
|
|
189
202
|
|
|
190
203
|
The three clients release in lockstep — same version, same surface, same day.
|
|
191
204
|
Patch releases are additive: nothing is removed or reordered within a minor line.
|
|
192
|
-
|
|
205
|
+
Two have bent that, deliberately and named at the top of their entry — 2.2.34 moved the
|
|
206
|
+
default engine, and 2.2.35 gives `search` the count range `recall` has always had. A
|
|
207
|
+
rule you can bend without saying so is not a rule, so both are stated rather than left
|
|
208
|
+
for a reader to hit.
|
|
209
|
+
|
|
210
|
+
- **2.2.35** — fixes, and **two behaviour changes**, both listed first because a patch
|
|
211
|
+
release is not the place to find one by surprise.
|
|
212
|
+
|
|
213
|
+
⚠️ **`search`'s count is now 5–20, and out of range is refused rather than
|
|
214
|
+
adjusted.** `recall` has carried that range from the start; search had no contract
|
|
215
|
+
anywhere, so the clients sent whatever they were given and the service capped at 50
|
|
216
|
+
with no floor. `search(q, limit=50)` and `search(q, limit=3)` both worked on 2.2.34
|
|
217
|
+
and now raise. The default is still 10, so a call that passes no count is unaffected.
|
|
218
|
+
Asking for 20 and silently getting 10 reads as "that is all there is", which is why
|
|
219
|
+
this refuses instead of clamping.
|
|
220
|
+
|
|
221
|
+
⚠️ **A Rust `limit` of 0 is no longer rewritten to 10.** It was, and the caller whose
|
|
222
|
+
prompt budget computed zero was handed ten memories and the bill for them. It is now
|
|
223
|
+
refused under the range above rather than quietly changed to something nobody asked
|
|
224
|
+
for.
|
|
225
|
+
|
|
226
|
+
The fixes: a `null` idempotency key no longer becomes the literal key `"null"`, which
|
|
227
|
+
had made every write on that path share one key so the second onward stored nothing
|
|
228
|
+
(TypeScript). The async client no longer reads an explicit `retries=2` as "the caller
|
|
229
|
+
said nothing" (Python). The image read is actually streamed, so the 64MB cap bounds a
|
|
230
|
+
compressed body instead of measuring bytes already in memory (Python async), and that
|
|
231
|
+
route's ERROR body is capped too (Rust). `iter_images` stops on a repeated cursor the
|
|
232
|
+
way `iter_memories` always did (Python). `iterImages` no longer throws on the ordinary
|
|
233
|
+
end of a walk (TypeScript). `get_image` retries a 429 and a connect-level failure like
|
|
234
|
+
every other call — it had none at all — in the **sync Python client and TypeScript**;
|
|
235
|
+
the async Python client and the Rust crate still make a single attempt there, and say
|
|
236
|
+
so at the method. An empty or control-character API key is refused at the call site
|
|
237
|
+
rather than at the network in **Python and Rust**; TypeScript still refuses only empty
|
|
238
|
+
and whitespace. And a page walk that reaches its ceiling now raises instead of
|
|
239
|
+
returning a truncated list that looks complete.
|
|
240
|
+
- **2.2.34** — the default engine is `tablet-2`: same token price as `tablet-1`, and
|
|
241
|
+
the one that serves images and re-ask. Pin the old one with
|
|
242
|
+
`model="tablet-1"` for the previous behaviour exactly. (The engine's own default
|
|
243
|
+
count differs between the two, but these SDKs always send one, so a call through
|
|
244
|
+
them is unaffected.) New `search_full` /
|
|
245
|
+
`searchFull` keeps every field the answer came with — `search` was merging away
|
|
246
|
+
the photos and `verify_used`, both of which you were billed for. Also: results arrive
|
|
247
|
+
best-first — take the order as given. `similarity` is a raw closeness score, and
|
|
248
|
+
sorting by it makes results worse.
|
|
249
|
+
- **2.2.33** — `get_image` can return a different FORMAT than you uploaded: the
|
|
250
|
+
service re-encodes on downscale, so a large PNG comes back as WebP. Take the file
|
|
251
|
+
extension from the response Content-Type, not from what you sent.
|
|
252
|
+
- **2.2.32** — `get_image` returns the picture the SERVICE holds, not your original.
|
|
253
|
+
An image whose long edge is over 1568px is downscaled on the way in, and that
|
|
254
|
+
smaller picture is what is stored and handed back. Keep your own copy if you need
|
|
255
|
+
the full file.
|
|
256
|
+
- **2.2.31** — images, by-speaker, lineage and revisions are SDK methods in all three
|
|
257
|
+
clients; `search` and `recall` take `verify`, `max_images` and recall's `limit` /
|
|
258
|
+
`context_limit`.
|
|
193
259
|
- **2.2.30** — documentation only: this changelog, which had not been updated
|
|
194
260
|
in the shipped package since 2.2.10.
|
|
195
261
|
- **2.2.29** — a store id that was PASSED but unusable no longer becomes the
|
package/dist/wontopos.d.ts
CHANGED
|
@@ -15,12 +15,15 @@
|
|
|
15
15
|
* so with no `userId` anywhere the zero-setup path just works.
|
|
16
16
|
*
|
|
17
17
|
* The API key picks *which memory* (your account); `model` picks *which engine*
|
|
18
|
-
* reads it.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* reads it. Models on the shared pool read the same memory, so you can store with
|
|
19
|
+
* one and recall with another; `listModels()` reports each model's `memory` as
|
|
20
|
+
* `"shared"` or `"isolated"`, and an isolated one starts empty. Set a default in
|
|
21
|
+
* the constructor, or override a single call with
|
|
22
|
+
* `mem.withModel("tablet-1").recall(...)`.
|
|
21
23
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
+
* Recall quality does not depend on which language a memory was written in: a
|
|
25
|
+
* memory stored in one language is found by a question asked in another. Storing
|
|
26
|
+
* and searching call no LLM.
|
|
24
27
|
*
|
|
25
28
|
* Reliability: every call retries transient failures with exponential backoff +
|
|
26
29
|
* jitter, honoring `Retry-After` — 429 always; 502/503 and connection errors only
|
|
@@ -39,6 +42,8 @@
|
|
|
39
42
|
*/
|
|
40
43
|
/** Test hook — the warn-once set is process-global by design. */
|
|
41
44
|
export declare function _resetStoreIdWarnings(): void;
|
|
45
|
+
export declare const SEARCH_LIMIT_MIN = 5;
|
|
46
|
+
export declare const SEARCH_LIMIT_MAX = 20;
|
|
42
47
|
/** Quota from a response's `X-RateLimit-*` headers, or `null` if absent. */
|
|
43
48
|
export interface RateLimit {
|
|
44
49
|
limit: number | null;
|
|
@@ -104,7 +109,7 @@ export interface Memory {
|
|
|
104
109
|
* re-sorting by `similarity` overrides the ranking and makes results worse. Take the
|
|
105
110
|
* list in the order given. There is no `score` field. */
|
|
106
111
|
similarity?: number;
|
|
107
|
-
/**
|
|
112
|
+
/** Importance weight the service assigns to the memory. */
|
|
108
113
|
importance?: number;
|
|
109
114
|
/** Month bucket the memory belongs to (e.g. "2026-07"). Omitted when temporal fields are stripped. */
|
|
110
115
|
time_bucket?: string;
|
|
@@ -128,11 +133,11 @@ export interface StoreResult {
|
|
|
128
133
|
/**
|
|
129
134
|
* Present when `status` is `"duplicate"`: the id of the memory this write collided with.
|
|
130
135
|
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
+
* A genuinely new fact that only varies a detail of one already stored ("no meetings
|
|
137
|
+
* before 10am" next to "no meetings on Fridays") can land here too, so a duplicate is
|
|
138
|
+
* not always a harmless no-op: read this id, and either store one sentence that states
|
|
139
|
+
* both or `update()` the existing memory with the combined statement. Without it a
|
|
140
|
+
* dropped write gave the caller nothing to act on.
|
|
136
141
|
*/
|
|
137
142
|
duplicate_of?: string;
|
|
138
143
|
/** Present when the store was a duplicate and something (e.g. a speaker tag) was dropped. */
|
|
@@ -153,9 +158,9 @@ export interface StatusResult {
|
|
|
153
158
|
*
|
|
154
159
|
* What the service keeps is NOT your original. An image whose long edge is over
|
|
155
160
|
* 1568px is downscaled to 1568 before anything else happens, and that smaller
|
|
156
|
-
* picture is what gets
|
|
157
|
-
*
|
|
158
|
-
*
|
|
161
|
+
* picture is what gets stored, and handed back by `getImage`. Nothing on our side
|
|
162
|
+
* ever uses more than 1568, so the extra pixels would be bytes nobody reads. This
|
|
163
|
+
* is a memory
|
|
159
164
|
* service, not a photo host: the picture it holds has the resolution of a memory.
|
|
160
165
|
*
|
|
161
166
|
* It can also come back in a different FORMAT. Downscaling means re-encoding, and
|
|
@@ -302,14 +307,15 @@ export interface UpdateResult {
|
|
|
302
307
|
/** `searchFull` → everything one search answered with, not just the merged memories.
|
|
303
308
|
*
|
|
304
309
|
* `search` returns the memories and nothing else, which is the right answer for
|
|
305
|
-
* almost every call. Two options make it the wrong one: `max_images` asks for
|
|
306
|
-
*
|
|
307
|
-
* `
|
|
310
|
+
* almost every call. Two options make it the wrong one: `max_images` asks for
|
|
311
|
+
* photos, which arrive in their own field, and `verify` is reported on by
|
|
312
|
+
* `verify_used`. Merging away both means paying for a
|
|
308
313
|
* search you shaped and never seeing what came of it. */
|
|
309
314
|
export interface SearchResult {
|
|
310
|
-
/**
|
|
315
|
+
/** What others said, and general memories. */
|
|
311
316
|
memories: Memory[];
|
|
312
|
-
/** The assistant's own words (speaker "me"); `[]` on a model
|
|
317
|
+
/** The assistant's own words (speaker "me"); `[]` on a model that does not keep
|
|
318
|
+
* them apart. */
|
|
313
319
|
self_memories: Memory[];
|
|
314
320
|
/** Image memories, when `max_images` asked for any; `[]` otherwise. */
|
|
315
321
|
images: Memory[];
|
|
@@ -355,7 +361,7 @@ export interface StatsResult {
|
|
|
355
361
|
short_term_turns?: number;
|
|
356
362
|
[key: string]: unknown;
|
|
357
363
|
}
|
|
358
|
-
/** `listMemories` → one page of a store's raw memories (
|
|
364
|
+
/** `listMemories` → one page of a store's raw memories (the text you stored, and its metadata). */
|
|
359
365
|
export interface MemoryPage {
|
|
360
366
|
memories: Memory[];
|
|
361
367
|
count: number;
|
|
@@ -363,9 +369,9 @@ export interface MemoryPage {
|
|
|
363
369
|
next_cursor: string | null;
|
|
364
370
|
[key: string]: unknown;
|
|
365
371
|
}
|
|
366
|
-
/** `searchSelf` result:
|
|
372
|
+
/** `searchSelf` result: general memories and the assistant's own, kept apart. */
|
|
367
373
|
export interface SelfSearchResult {
|
|
368
|
-
/**
|
|
374
|
+
/** What others said, and general memories. */
|
|
369
375
|
memories: Memory[];
|
|
370
376
|
/** The assistant's own words (stored with speaker "me"); `[]` on non-self models. */
|
|
371
377
|
self_memories: Memory[];
|
|
@@ -428,10 +434,11 @@ export interface SpeakersList {
|
|
|
428
434
|
[key: string]: unknown;
|
|
429
435
|
}
|
|
430
436
|
/**
|
|
431
|
-
* Narrow a search to part of a store
|
|
432
|
-
*
|
|
437
|
+
* Narrow a search to part of a store. The filter chooses what is searched, not what
|
|
438
|
+
* is kept afterwards — so a narrow filter still returns your full `limit` when that
|
|
439
|
+
* many matches sit inside it.
|
|
433
440
|
*
|
|
434
|
-
* Retrieval
|
|
441
|
+
* Retrieval behaves the same in every language, so these behave
|
|
435
442
|
* identically in every language. Unlisted keys are dropped by the API rather than
|
|
436
443
|
* rejected, so a typo silently widens the search — spell them exactly.
|
|
437
444
|
*/
|
|
@@ -465,7 +472,7 @@ export interface SearchOptions {
|
|
|
465
472
|
/**
|
|
466
473
|
* Re-ask passes, 0–3. After the first retrieval the service asks the engine again up
|
|
467
474
|
* to this many times, each time telling it what was already returned so it skips
|
|
468
|
-
* those and reaches further back.
|
|
475
|
+
* those and reaches further back. No LLM runs at any
|
|
469
476
|
* value. Stops early when a pass finds nothing new; `verify_used` in the response
|
|
470
477
|
* says how many actually ran.
|
|
471
478
|
*
|
|
@@ -495,8 +502,11 @@ export interface SearchOptions {
|
|
|
495
502
|
* `idempotencyKey` makes repeating THIS EXACT write safe: the API replays the first
|
|
496
503
|
* response instead of storing again, for 10 minutes, and answers 422 if the same key
|
|
497
504
|
* arrives with a different body. Use it when a retry is your own (a job that died and
|
|
498
|
-
* was re-run, a queue that redelivers)
|
|
499
|
-
*
|
|
505
|
+
* was re-run, a queue that redelivers). The SDK retries a write on exactly one
|
|
506
|
+
* status: 429, which the service answers before it processes anything, so nothing
|
|
507
|
+
* was stored. It never retries a write on 502 / 503 or a dropped body, where the
|
|
508
|
+
* request may already have been stored and billed — without a key it cannot know
|
|
509
|
+
* whether that first attempt landed.
|
|
500
510
|
*
|
|
501
511
|
* The key must be UNIQUE PER LOGICAL WRITE — derive it from the thing being stored
|
|
502
512
|
* (`` `import:${row.id}` ``), never a constant, or the second write replays the first
|
|
@@ -610,36 +620,36 @@ export declare class Client {
|
|
|
610
620
|
store(content: string, userId?: string, metadata?: Record<string, unknown>, opts?: WriteOptions): Promise<StoreResult>;
|
|
611
621
|
/** Store a conversation turn (user + assistant). Payload first, userId last — same shape as add/search. */
|
|
612
622
|
addTurn(userMsg: string, assistantMsg: string, userId?: string, opts?: WriteOptions): Promise<StatusResult>;
|
|
613
|
-
/** Bulk-ingest a large blob of text
|
|
623
|
+
/** Bulk-ingest a large blob of text in one call. For backfilling.
|
|
614
624
|
* The call most worth an `opts.idempotencyKey`: a backfill that dies halfway and is
|
|
615
|
-
* re-run would otherwise re-
|
|
625
|
+
* re-run would otherwise re-ingest and re-bill the whole blob. */
|
|
616
626
|
addBulk(content: string, userId?: string, category?: string, timestamp?: string, opts?: WriteOptions): Promise<StatusResult>;
|
|
617
627
|
/** Supersede an old memory with new content. Payload first, userId last — same shape as add/search. */
|
|
618
628
|
update(oldMemoryId: string, newContent: string, userId?: string, opts?: WriteOptions): Promise<UpdateResult>;
|
|
619
|
-
/**
|
|
620
|
-
*
|
|
621
|
-
* `limit` bounds
|
|
622
|
-
* (Scroll 1.2+)
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
629
|
+
/** Search a store's memories. Returns them most relevant first.
|
|
630
|
+
*
|
|
631
|
+
* `limit` bounds `memories`, not the returned array. On a model that keeps the
|
|
632
|
+
* assistant's own words separate (Scroll 1.2+) those come back as well, so the
|
|
633
|
+
* array can hold more than `limit`. They were retrieved and billed either way;
|
|
634
|
+
* dropping them would only hide what you already paid for. Size a prompt window
|
|
635
|
+
* on the array you get back, not on `limit`. `searchSelf()` hands the two back
|
|
636
|
+
* apart. */
|
|
627
637
|
search(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<Memory[]>;
|
|
628
|
-
/** Search a self-memory model (Scroll 1.2+): both
|
|
629
|
-
* `{ memories, self_memories }` — `memories` is
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
638
|
+
/** Search a self-memory model (Scroll 1.2+): both fields from ONE call. Returns
|
|
639
|
+
* `{ memories, self_memories }` — `memories` is what others said and general
|
|
640
|
+
* memories, `self_memories` is the assistant's OWN words (stored with speaker
|
|
641
|
+
* "me"), kept apart so whoever reads them never confuses who said what. On a model
|
|
642
|
+
* that does not keep them apart, `self_memories` is `[]`. `userId` may be omitted. */
|
|
633
643
|
searchSelf(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<SelfSearchResult>;
|
|
634
644
|
/**
|
|
635
|
-
* Search, and keep every
|
|
645
|
+
* Search, and keep every field the answer came with.
|
|
636
646
|
*
|
|
637
647
|
* Same request as {@link Client.search} — reach for this one when the options make
|
|
638
648
|
* the merged array an incomplete answer:
|
|
639
649
|
*
|
|
640
650
|
* const r = await mem.searchFull("the day we moved", "alice", 10,
|
|
641
651
|
* { max_images: 3, verify: 2 });
|
|
642
|
-
* r.images.length; // the
|
|
652
|
+
* r.images.length; // the photos, which `search` drops
|
|
643
653
|
* r.verify_used; // how many re-ask passes actually ran (you are billed per pass)
|
|
644
654
|
*
|
|
645
655
|
* Both options need Tablet 2 or newer and are refused (403) on an older engine
|
|
@@ -661,11 +671,13 @@ export declare class Client {
|
|
|
661
671
|
* did not ask for.
|
|
662
672
|
*/
|
|
663
673
|
limit?: number;
|
|
664
|
-
/**
|
|
674
|
+
/** How much surrounding context is attached around the best match, 0–20. Default 10;
|
|
665
675
|
* 0 attaches none. Same model floor as `limit`. */
|
|
666
676
|
context_limit?: number;
|
|
667
677
|
}): Promise<RecallResult>;
|
|
668
|
-
/** Run a built-in engram ("deep_recall" | "timeline" | "gather"
|
|
678
|
+
/** Run a built-in engram ("deep_recall" | "timeline" | "gather" | "equilibrium" |
|
|
679
|
+
* "tone_stabilizer"; the service is the authority — an unknown name comes back with
|
|
680
|
+
* the list it accepts). Returns the merged result.
|
|
669
681
|
* `form`/`tz` render memory times (memoir/archive) on Scroll 1.2+, same as search/recall. */
|
|
670
682
|
engram(name: string, query: string, userId?: string, opts?: {
|
|
671
683
|
form?: string;
|
|
@@ -675,13 +687,14 @@ export declare class Client {
|
|
|
675
687
|
history(userId?: string): Promise<HistoryTurn[]>;
|
|
676
688
|
/** Memory counts for a store: { total_memories, short_term_turns }. */
|
|
677
689
|
stats(userId?: string): Promise<StatsResult>;
|
|
678
|
-
/** Fetch ONE memory by id — the
|
|
690
|
+
/** Fetch ONE memory by id — the text you stored, and its metadata.
|
|
679
691
|
* The id is what `add`/`store` or `listMemories` returned. Same visibility as
|
|
680
|
-
* `listMemories`: an id from another store,
|
|
681
|
-
* invalidated memory rejects with `NotFoundError`. `
|
|
692
|
+
* `listMemories`: an id from another store, an internal record id, or an
|
|
693
|
+
* invalidated memory rejects with `NotFoundError`. Pass `undefined` as `userId` for
|
|
694
|
+
* the default store — it is positional here, not omittable as it is in Python. */
|
|
682
695
|
get(userId: string | undefined, memoryId: string): Promise<Memory>;
|
|
683
|
-
/** List a store's stored memories — the
|
|
684
|
-
*
|
|
696
|
+
/** List a store's stored memories — the text you stored, plus its metadata.
|
|
697
|
+
* Paginated: pass the returned `next_cursor` back as `cursor` for the next
|
|
685
698
|
* page; a `null` cursor means the last page. Use it to browse or export a store.
|
|
686
699
|
*
|
|
687
700
|
* let cursor: string | null = null;
|
|
@@ -697,14 +710,14 @@ export declare class Client {
|
|
|
697
710
|
cursor?: string;
|
|
698
711
|
}): Promise<MemoryPage>;
|
|
699
712
|
/** Async-iterate every stored memory in a store, paging under the hood — no cursor
|
|
700
|
-
* bookkeeping.
|
|
713
|
+
* bookkeeping. The text you stored and its metadata only.
|
|
701
714
|
*
|
|
702
715
|
* for await (const m of mem.iterMemories()) console.log(m.id, m.content);
|
|
703
716
|
*/
|
|
704
717
|
iterMemories(userId?: string, opts?: {
|
|
705
718
|
pageSize?: number;
|
|
706
719
|
}): AsyncGenerator<Memory>;
|
|
707
|
-
/** Collect ALL of a store's memories into an array (
|
|
720
|
+
/** Collect ALL of a store's memories into an array (the text you stored, and its metadata). */
|
|
708
721
|
exportMemories(userId?: string): Promise<Memory[]>;
|
|
709
722
|
/**
|
|
710
723
|
* Fetch the bytes of an image memory — the picture the SERVICE holds.
|
|
@@ -712,8 +725,8 @@ export declare class Client {
|
|
|
712
725
|
* Not necessarily your upload, in size OR in format: an image whose long edge was
|
|
713
726
|
* over 1568px was downscaled to 1568 on the way in and re-encoded — lossless
|
|
714
727
|
* formats as WebP, so a PNG comes back as `image/webp`; JPEG stays JPEG — and that
|
|
715
|
-
* smaller picture is what
|
|
716
|
-
*
|
|
728
|
+
* smaller picture is what is stored and comes back here. Nothing on our side ever
|
|
729
|
+
* uses more than 1568, so the extra pixels would be bytes nobody reads.
|
|
717
730
|
* Keep your own copy for the full-resolution file.
|
|
718
731
|
*
|
|
719
732
|
* The type is sniffed from the bytes themselves rather than from whatever the file
|
|
@@ -831,9 +844,9 @@ export declare class Client {
|
|
|
831
844
|
* assistant's own words, otherwise a person's name. Same cursor paging as
|
|
832
845
|
* `listImages`.
|
|
833
846
|
*
|
|
834
|
-
* `chunks` / `points_to_delete` report
|
|
835
|
-
*
|
|
836
|
-
*
|
|
847
|
+
* `chunks` / `points_to_delete` report how many internal records a delete would
|
|
848
|
+
* actually remove — usually more than `returned`, and worth showing to whoever is
|
|
849
|
+
* about to confirm one.
|
|
837
850
|
*/
|
|
838
851
|
bySpeaker(speaker: string, userId?: string, opts?: {
|
|
839
852
|
limit?: number;
|
|
@@ -875,7 +888,8 @@ export declare class Client {
|
|
|
875
888
|
listSpeakers(userId?: string): Promise<SpeakersList>;
|
|
876
889
|
/** Unregister a person. Their memories stay; the name tag goes. */
|
|
877
890
|
removeSpeaker(speaker: string, userId?: string): Promise<SpeakerOpResult>;
|
|
878
|
-
/** Delete a single memory by id. `userId`
|
|
891
|
+
/** Delete a single memory by id. Pass `undefined` as `userId` for the default
|
|
892
|
+
* store — it is positional here, not omittable as it is in Python. */
|
|
879
893
|
delete(userId: string | undefined, memoryId: string): Promise<StatusResult>;
|
|
880
894
|
/** Delete ALL memories for a store (GDPR erase). `userId` is required on purpose -
|
|
881
895
|
* this is destructive, so it never falls back to the default store. */
|
package/dist/wontopos.js
CHANGED
|
@@ -15,12 +15,15 @@
|
|
|
15
15
|
* so with no `userId` anywhere the zero-setup path just works.
|
|
16
16
|
*
|
|
17
17
|
* The API key picks *which memory* (your account); `model` picks *which engine*
|
|
18
|
-
* reads it.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* reads it. Models on the shared pool read the same memory, so you can store with
|
|
19
|
+
* one and recall with another; `listModels()` reports each model's `memory` as
|
|
20
|
+
* `"shared"` or `"isolated"`, and an isolated one starts empty. Set a default in
|
|
21
|
+
* the constructor, or override a single call with
|
|
22
|
+
* `mem.withModel("tablet-1").recall(...)`.
|
|
21
23
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
+
* Recall quality does not depend on which language a memory was written in: a
|
|
25
|
+
* memory stored in one language is found by a question asked in another. Storing
|
|
26
|
+
* and searching call no LLM.
|
|
24
27
|
*
|
|
25
28
|
* Reliability: every call retries transient failures with exponential backoff +
|
|
26
29
|
* jitter, honoring `Retry-After` — 429 always; 502/503 and connection errors only
|
|
@@ -37,7 +40,7 @@
|
|
|
37
40
|
* runtime's (never disabled by this SDK; Node 18+ floors TLS at 1.2). Prefer
|
|
38
41
|
* `Client.fromEnv()` over keys in source code.
|
|
39
42
|
*/
|
|
40
|
-
const VERSION = "2.2.
|
|
43
|
+
const VERSION = "2.2.35";
|
|
41
44
|
/** Runtime info helps support debug a report ("node 18 on Windows...") —
|
|
42
45
|
* platform only, never anything identifying. Browsers have no `process` (and
|
|
43
46
|
* silently drop the UA header anyway). */
|
|
@@ -57,11 +60,12 @@ function logDebug(msg) {
|
|
|
57
60
|
const DEFAULT_BASE_URL = "https://api.wontopos.com";
|
|
58
61
|
/** The engine every call uses unless the caller names another.
|
|
59
62
|
*
|
|
60
|
-
* Tablet 2 costs the same per token as Tablet 1 and is the
|
|
61
|
-
*
|
|
62
|
-
* names nothing gets the engine that can answer the most.
|
|
63
|
-
* memory, so switching is a header, not a
|
|
64
|
-
*
|
|
63
|
+
* Tablet 2 costs the same per token as Tablet 1 and is the one that
|
|
64
|
+
* serves images, re-ask passes (`verify`), and `self_memories`, so a caller who
|
|
65
|
+
* names nothing gets the engine that can answer the most. Models on the shared
|
|
66
|
+
* pool read the same memory, so switching between them is a header, not a
|
|
67
|
+
* migration. Pin an older one explicitly with
|
|
68
|
+
* `new Client({ apiKey, model: "tablet-1" })` or `withModel("tablet-1")`. */
|
|
65
69
|
const DEFAULT_MODEL = "tablet-2";
|
|
66
70
|
/** 429 = rate-limited BEFORE processing → always safe to retry (no write, no bill). */
|
|
67
71
|
const RETRY_ALWAYS = new Set([429]);
|
|
@@ -139,15 +143,44 @@ export function _resetStoreIdWarnings() {
|
|
|
139
143
|
* answer with a JSON object — see the empty-body check in `request`. */
|
|
140
144
|
const NO_BODY_STATUS = new Set([204, 205, 304]);
|
|
141
145
|
/** Paging backstop: bound the walk so a fresh-cursor-forever server can't loop us. */
|
|
142
|
-
|
|
146
|
+
// A page walk has to stop somewhere, and 1,000,000 pages was not a stop: at 100 per
|
|
147
|
+
// page that is 100 million memories, so a server minting a fresh cursor every time
|
|
148
|
+
// would spend hours and a million billed requests before it fired. 20,000 pages is two
|
|
149
|
+
// million memories — past any real store, reached in minutes.
|
|
150
|
+
//
|
|
151
|
+
// ★And it has to be LOUD. Falling out of the loop yielded a truncated list that looks
|
|
152
|
+
// exactly like a complete one; the caller writes it to a file believing it is the
|
|
153
|
+
// whole store. Hitting this now throws.
|
|
154
|
+
const MAX_PAGES = 20_000;
|
|
155
|
+
export const SEARCH_LIMIT_MIN = 5;
|
|
156
|
+
export const SEARCH_LIMIT_MAX = 20;
|
|
157
|
+
/** `search`'s count, refused out of range rather than quietly adjusted.
|
|
158
|
+
*
|
|
159
|
+
* `recall` has carried this contract from the start — 5 to 20, and the service
|
|
160
|
+
* refuses anything else, because asking for 20 and silently getting 10 reads as
|
|
161
|
+
* "that is all there is". Search had no contract at all: the three clients sent
|
|
162
|
+
* whatever they were given, the MCP server allowed 1 to 60, and the service quietly
|
|
163
|
+
* capped at 50 with no floor. Four surfaces, four answers, and the caller could not
|
|
164
|
+
* tell which one they got.
|
|
165
|
+
*
|
|
166
|
+
* Refusing rather than clamping is the same decision as 2.2.35's, where a Rust
|
|
167
|
+
* `limit` of 0 had been rewritten to 10 and the caller was handed ten memories they
|
|
168
|
+
* had not asked for, and the bill for them. */
|
|
169
|
+
function checkSearchLimit(limit) {
|
|
170
|
+
if (!Number.isInteger(limit) || limit < SEARCH_LIMIT_MIN || limit > SEARCH_LIMIT_MAX) {
|
|
171
|
+
throw new WosError(0, `limit must be an integer between ${SEARCH_LIMIT_MIN} and ${SEARCH_LIMIT_MAX}, got ${limit}. ` +
|
|
172
|
+
`Out of range is refused rather than adjusted, so a short answer always means ` +
|
|
173
|
+
`the store was short.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
143
176
|
/**
|
|
144
177
|
* Put an image into the shape the API expects, and fail loudly on the parts we can
|
|
145
178
|
* check from here.
|
|
146
179
|
*
|
|
147
|
-
* Deliberately NOT checked: the byte ceiling. It is a
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
180
|
+
* Deliberately NOT checked: the byte ceiling. It is a service setting, so a number
|
|
181
|
+
* baked in here would drift the first time the service is reconfigured and would
|
|
182
|
+
* reject an image the service would have taken.
|
|
183
|
+
* What we do check is what cannot change: an empty payload, and a `data:` URL
|
|
151
184
|
* prefix — browsers and file pickers hand you `data:image/jpeg;base64,…`, the API
|
|
152
185
|
* wants the part after the comma, and the difference is invisible until the write
|
|
153
186
|
* fails with "not a readable image".
|
|
@@ -241,21 +274,18 @@ const asObj = (x) => (x != null && typeof x === "object" && !Array.isArray(x) ?
|
|
|
241
274
|
// user code. Drop elements that aren't objects and keep the valid records, so one
|
|
242
275
|
// bad element never poisons the batch (and never destroys it).
|
|
243
276
|
const asRecords = (x) => asList(x).filter((m) => m != null && typeof m === "object" && !Array.isArray(m));
|
|
244
|
-
|
|
245
|
-
// passes them; on Scroll 1.2+ this renders each returned memory's time in that form.
|
|
246
|
-
/** Every memory a search returned — main lane and self lane together.
|
|
277
|
+
/** Every memory a search returned, from both fields, as one array.
|
|
247
278
|
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* and the dropped memories were already on the wire and already paid for.
|
|
279
|
+
* Some models answer with the assistant's own words in `self_memories`, not
|
|
280
|
+
* repeated in `memories`. `search()` used to return `memories` alone, so an
|
|
281
|
+
* assistant turn stored with `addTurn` was missing from its results on those
|
|
282
|
+
* models while the same query returned it on others — upgrading made search
|
|
283
|
+
* return LESS, and what went missing had already been retrieved and paid for.
|
|
254
284
|
*
|
|
255
|
-
* Both
|
|
256
|
-
* caller can still tell who said what. Callers who want
|
|
257
|
-
* `searchSelf()`, which
|
|
258
|
-
function
|
|
285
|
+
* Both fields are returned here, de-duplicated by id, each memory keeping its
|
|
286
|
+
* `speaker` so a caller can still tell who said what. Callers who want them kept
|
|
287
|
+
* apart use `searchSelf()`, which is what that method is for. */
|
|
288
|
+
function mergeResults(r) {
|
|
259
289
|
const main = asRecords(r?.memories);
|
|
260
290
|
const mine = asRecords(r?.self_memories);
|
|
261
291
|
if (mine.length === 0)
|
|
@@ -263,6 +293,9 @@ function mergeLanes(r) {
|
|
|
263
293
|
const seen = new Set(main.map((m) => m.id).filter((id) => typeof id === "string"));
|
|
264
294
|
return main.concat(mine.filter((m) => !(typeof m.id === "string" && seen.has(m.id))));
|
|
265
295
|
}
|
|
296
|
+
/** Attach a delivery-form override ("memoir"/"archive") + tz to a request body, the
|
|
297
|
+
* way search passes them; on Scroll 1.2+ this renders each returned memory's time in
|
|
298
|
+
* that form. The server validates the form (400 on an unknown one). */
|
|
266
299
|
function withForm(body, form, tz) {
|
|
267
300
|
if (form !== undefined)
|
|
268
301
|
body.form = form;
|
|
@@ -403,6 +436,12 @@ export class Client {
|
|
|
403
436
|
throw new Error("apiKey is required");
|
|
404
437
|
if (/\s/.test(key))
|
|
405
438
|
throw new Error("apiKey contains whitespace - check for a stray newline or paste error");
|
|
439
|
+
// `\s` does not cover NUL, 0x01 or DEL, and those travel into the header and fail
|
|
440
|
+
// deep inside fetch as the mystery 401 this check exists to prevent. Python has
|
|
441
|
+
// `_HEADER_CTL_RE` and Rust tests `is_control`; this was the client that did not,
|
|
442
|
+
// while all three READMEs announced the guard without naming a language.
|
|
443
|
+
if (/[\x00-\x1f\x7f]/.test(key))
|
|
444
|
+
throw new Error("apiKey contains a control character - check for a stray byte or paste error");
|
|
406
445
|
this.apiKey = key;
|
|
407
446
|
this.base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
408
447
|
// Coerce a non-finite or non-positive timeout back to the default — NaN
|
|
@@ -448,7 +487,12 @@ export class Client {
|
|
|
448
487
|
// (Python already used urlsplit here; this was the copy that did not.)
|
|
449
488
|
let host;
|
|
450
489
|
try {
|
|
451
|
-
|
|
490
|
+
// `URL.hostname` keeps the brackets on an IPv6 literal — `http://[::1]:8080`
|
|
491
|
+
// gives back "[::1]", which was never in LOOPBACK_HOSTS, so a self-hosted
|
|
492
|
+
// client on the v6 loopback was told its key was travelling in cleartext when
|
|
493
|
+
// it was not. Python's urlsplit strips them and Rust strips them by hand; this
|
|
494
|
+
// was the copy that did not. Strip them here so all three agree.
|
|
495
|
+
host = new URL(this.base).hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
452
496
|
}
|
|
453
497
|
catch {
|
|
454
498
|
host = ""; // unparseable → not loopback → warn, which is the safe direction
|
|
@@ -571,9 +615,9 @@ export class Client {
|
|
|
571
615
|
addTurn(userMsg, assistantMsg, userId, opts = {}) {
|
|
572
616
|
return this.post("/api/v1/memory/store-turn", { user_id: this.uid(userId), user_msg: userMsg, assistant_msg: assistantMsg }, opts.idempotencyKey);
|
|
573
617
|
}
|
|
574
|
-
/** Bulk-ingest a large blob of text
|
|
618
|
+
/** Bulk-ingest a large blob of text in one call. For backfilling.
|
|
575
619
|
* The call most worth an `opts.idempotencyKey`: a backfill that dies halfway and is
|
|
576
|
-
* re-run would otherwise re-
|
|
620
|
+
* re-run would otherwise re-ingest and re-bill the whole blob. */
|
|
577
621
|
addBulk(content, userId, category = "general", timestamp, opts = {}) {
|
|
578
622
|
const body = { user_id: this.uid(userId), content, category };
|
|
579
623
|
if (timestamp)
|
|
@@ -585,40 +629,42 @@ export class Client {
|
|
|
585
629
|
return this.post("/api/v1/memory/supersede", { user_id: this.uid(userId), old_memory_id: oldMemoryId, new_content: newContent }, opts.idempotencyKey);
|
|
586
630
|
}
|
|
587
631
|
// ----- read -----
|
|
588
|
-
/**
|
|
632
|
+
/** Search a store's memories. Returns them most relevant first.
|
|
589
633
|
*
|
|
590
|
-
* `limit` bounds
|
|
591
|
-
* (Scroll 1.2+)
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
634
|
+
* `limit` bounds `memories`, not the returned array. On a model that keeps the
|
|
635
|
+
* assistant's own words separate (Scroll 1.2+) those come back as well, so the
|
|
636
|
+
* array can hold more than `limit`. They were retrieved and billed either way;
|
|
637
|
+
* dropping them would only hide what you already paid for. Size a prompt window
|
|
638
|
+
* on the array you get back, not on `limit`. `searchSelf()` hands the two back
|
|
639
|
+
* apart. */
|
|
596
640
|
async search(query, userId, limit = 10, opts = {}) {
|
|
597
641
|
// Reserved fields win over ...opts: an app that forwards untrusted input as
|
|
598
642
|
// opts must not be able to override the store (user_id), query, or limit.
|
|
599
643
|
warnOnUnknownFilters(opts.filters);
|
|
644
|
+
checkSearchLimit(limit);
|
|
600
645
|
const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
|
|
601
|
-
return
|
|
646
|
+
return mergeResults(r);
|
|
602
647
|
}
|
|
603
|
-
/** Search a self-memory model (Scroll 1.2+): both
|
|
604
|
-
* `{ memories, self_memories }` — `memories` is
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
648
|
+
/** Search a self-memory model (Scroll 1.2+): both fields from ONE call. Returns
|
|
649
|
+
* `{ memories, self_memories }` — `memories` is what others said and general
|
|
650
|
+
* memories, `self_memories` is the assistant's OWN words (stored with speaker
|
|
651
|
+
* "me"), kept apart so whoever reads them never confuses who said what. On a model
|
|
652
|
+
* that does not keep them apart, `self_memories` is `[]`. `userId` may be omitted. */
|
|
608
653
|
async searchSelf(query, userId, limit = 10, opts = {}) {
|
|
609
654
|
warnOnUnknownFilters(opts.filters);
|
|
655
|
+
checkSearchLimit(limit);
|
|
610
656
|
const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
|
|
611
657
|
return { memories: asRecords(r.memories), self_memories: asRecords(r.self_memories) };
|
|
612
658
|
}
|
|
613
659
|
/**
|
|
614
|
-
* Search, and keep every
|
|
660
|
+
* Search, and keep every field the answer came with.
|
|
615
661
|
*
|
|
616
662
|
* Same request as {@link Client.search} — reach for this one when the options make
|
|
617
663
|
* the merged array an incomplete answer:
|
|
618
664
|
*
|
|
619
665
|
* const r = await mem.searchFull("the day we moved", "alice", 10,
|
|
620
666
|
* { max_images: 3, verify: 2 });
|
|
621
|
-
* r.images.length; // the
|
|
667
|
+
* r.images.length; // the photos, which `search` drops
|
|
622
668
|
* r.verify_used; // how many re-ask passes actually ran (you are billed per pass)
|
|
623
669
|
*
|
|
624
670
|
* Both options need Tablet 2 or newer and are refused (403) on an older engine
|
|
@@ -626,8 +672,9 @@ export class Client {
|
|
|
626
672
|
*/
|
|
627
673
|
async searchFull(query, userId, limit = 10, opts = {}) {
|
|
628
674
|
warnOnUnknownFilters(opts.filters);
|
|
675
|
+
checkSearchLimit(limit);
|
|
629
676
|
const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
|
|
630
|
-
// Spread first so a
|
|
677
|
+
// Spread first so a field added later still arrives; the known ones are then
|
|
631
678
|
// normalized, because a broken proxy can null any of them.
|
|
632
679
|
return {
|
|
633
680
|
...r,
|
|
@@ -647,7 +694,9 @@ export class Client {
|
|
|
647
694
|
body.context_limit = opts.context_limit;
|
|
648
695
|
return this.post("/api/v1/memory/recall", withForm(body, opts.form, opts.tz));
|
|
649
696
|
}
|
|
650
|
-
/** Run a built-in engram ("deep_recall" | "timeline" | "gather"
|
|
697
|
+
/** Run a built-in engram ("deep_recall" | "timeline" | "gather" | "equilibrium" |
|
|
698
|
+
* "tone_stabilizer"; the service is the authority — an unknown name comes back with
|
|
699
|
+
* the list it accepts). Returns the merged result.
|
|
651
700
|
* `form`/`tz` render memory times (memoir/archive) on Scroll 1.2+, same as search/recall. */
|
|
652
701
|
engram(name, query, userId, opts = {}) {
|
|
653
702
|
return this.post("/api/v1/engram/run", withForm({ name, user_id: this.uid(userId), query }, opts.form, opts.tz));
|
|
@@ -661,10 +710,11 @@ export class Client {
|
|
|
661
710
|
stats(userId) {
|
|
662
711
|
return this.post("/api/v1/memory/stats", { user_id: this.uid(userId) });
|
|
663
712
|
}
|
|
664
|
-
/** Fetch ONE memory by id — the
|
|
713
|
+
/** Fetch ONE memory by id — the text you stored, and its metadata.
|
|
665
714
|
* The id is what `add`/`store` or `listMemories` returned. Same visibility as
|
|
666
|
-
* `listMemories`: an id from another store,
|
|
667
|
-
* invalidated memory rejects with `NotFoundError`. `
|
|
715
|
+
* `listMemories`: an id from another store, an internal record id, or an
|
|
716
|
+
* invalidated memory rejects with `NotFoundError`. Pass `undefined` as `userId` for
|
|
717
|
+
* the default store — it is positional here, not omittable as it is in Python. */
|
|
668
718
|
get(userId, memoryId) {
|
|
669
719
|
// Sync throw (same shape as `delete`), so a missing id fails loudly even
|
|
670
720
|
// when the caller forgets to await.
|
|
@@ -673,8 +723,8 @@ export class Client {
|
|
|
673
723
|
}
|
|
674
724
|
return this.post("/api/v1/memory/get", { user_id: this.uid(userId), memory_id: memoryId }).then((r) => asObj(r.memory));
|
|
675
725
|
}
|
|
676
|
-
/** List a store's stored memories — the
|
|
677
|
-
*
|
|
726
|
+
/** List a store's stored memories — the text you stored, plus its metadata.
|
|
727
|
+
* Paginated: pass the returned `next_cursor` back as `cursor` for the next
|
|
678
728
|
* page; a `null` cursor means the last page. Use it to browse or export a store.
|
|
679
729
|
*
|
|
680
730
|
* let cursor: string | null = null;
|
|
@@ -692,7 +742,7 @@ export class Client {
|
|
|
692
742
|
return this.post("/api/v1/memory/list", body);
|
|
693
743
|
}
|
|
694
744
|
/** Async-iterate every stored memory in a store, paging under the hood — no cursor
|
|
695
|
-
* bookkeeping.
|
|
745
|
+
* bookkeeping. The text you stored and its metadata only.
|
|
696
746
|
*
|
|
697
747
|
* for await (const m of mem.iterMemories()) console.log(m.id, m.content);
|
|
698
748
|
*/
|
|
@@ -707,12 +757,14 @@ export class Client {
|
|
|
707
757
|
yield m;
|
|
708
758
|
const next = p.next_cursor ?? undefined;
|
|
709
759
|
if (!next || seen.has(next))
|
|
710
|
-
|
|
760
|
+
return;
|
|
711
761
|
seen.add(next);
|
|
712
762
|
cursor = next;
|
|
713
763
|
}
|
|
764
|
+
throw new WosError(0, `stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
|
|
765
|
+
`answer, not the whole store.`);
|
|
714
766
|
}
|
|
715
|
-
/** Collect ALL of a store's memories into an array (
|
|
767
|
+
/** Collect ALL of a store's memories into an array (the text you stored, and its metadata). */
|
|
716
768
|
async exportMemories(userId) {
|
|
717
769
|
const out = [];
|
|
718
770
|
for await (const m of this.iterMemories(userId))
|
|
@@ -726,8 +778,8 @@ export class Client {
|
|
|
726
778
|
* Not necessarily your upload, in size OR in format: an image whose long edge was
|
|
727
779
|
* over 1568px was downscaled to 1568 on the way in and re-encoded — lossless
|
|
728
780
|
* formats as WebP, so a PNG comes back as `image/webp`; JPEG stays JPEG — and that
|
|
729
|
-
* smaller picture is what
|
|
730
|
-
*
|
|
781
|
+
* smaller picture is what is stored and comes back here. Nothing on our side ever
|
|
782
|
+
* uses more than 1568, so the extra pixels would be bytes nobody reads.
|
|
731
783
|
* Keep your own copy for the full-resolution file.
|
|
732
784
|
*
|
|
733
785
|
* The type is sniffed from the bytes themselves rather than from whatever the file
|
|
@@ -800,17 +852,24 @@ export class Client {
|
|
|
800
852
|
const p = await this.listImages(userId, { limit: opts.pageSize, before, skipIds });
|
|
801
853
|
for (const m of asRecords(p.images))
|
|
802
854
|
yield m;
|
|
855
|
+
// `return`, not `break`: the throw below is the MAX_PAGES backstop, and a
|
|
856
|
+
// `break` fell straight into it — so the ordinary end of a walk raised
|
|
857
|
+
// "the store did not end" on a store that had just ended. Python is safe
|
|
858
|
+
// here by using for/else and Rust by carrying an `ended` flag; TypeScript
|
|
859
|
+
// has neither, so the exit has to leave the function outright.
|
|
803
860
|
if (!p.has_more || !p.next_before)
|
|
804
|
-
|
|
861
|
+
return;
|
|
805
862
|
// The cursor is the PAIR: several images can share a timestamp, so `before`
|
|
806
863
|
// alone repeats across pages legitimately.
|
|
807
864
|
const key = `${p.next_before}|${(Array.isArray(p.next_skip_ids) ? p.next_skip_ids : []).join(",")}`;
|
|
808
865
|
if (seen.has(key))
|
|
809
|
-
|
|
866
|
+
return;
|
|
810
867
|
seen.add(key);
|
|
811
868
|
before = p.next_before;
|
|
812
869
|
skipIds = Array.isArray(p.next_skip_ids) ? p.next_skip_ids : undefined;
|
|
813
870
|
}
|
|
871
|
+
throw new WosError(0, `stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
|
|
872
|
+
`answer, not the whole store.`);
|
|
814
873
|
}
|
|
815
874
|
// ----- how much has this memory been edited -----
|
|
816
875
|
/**
|
|
@@ -890,9 +949,9 @@ export class Client {
|
|
|
890
949
|
* assistant's own words, otherwise a person's name. Same cursor paging as
|
|
891
950
|
* `listImages`.
|
|
892
951
|
*
|
|
893
|
-
* `chunks` / `points_to_delete` report
|
|
894
|
-
*
|
|
895
|
-
*
|
|
952
|
+
* `chunks` / `points_to_delete` report how many internal records a delete would
|
|
953
|
+
* actually remove — usually more than `returned`, and worth showing to whoever is
|
|
954
|
+
* about to confirm one.
|
|
896
955
|
*/
|
|
897
956
|
bySpeaker(speaker, userId, opts = {}) {
|
|
898
957
|
if (!speaker || typeof speaker !== "string" || speaker.trim() === "") {
|
|
@@ -975,14 +1034,18 @@ export class Client {
|
|
|
975
1034
|
/** The store's registered people, each with its memory count. */
|
|
976
1035
|
async listSpeakers(userId) {
|
|
977
1036
|
const r = await this.request("GET", `/api/v1/memory/speakers?user_id=${encodeURIComponent(this.uid(userId))}`);
|
|
978
|
-
|
|
1037
|
+
// The spread came second, so a present-but-null `speakers` overwrote the default
|
|
1038
|
+
// and the caller got null typed as an array. Every other list route goes through
|
|
1039
|
+
// asRecords; this one did not.
|
|
1040
|
+
return { ...r, speakers: asRecords(r?.speakers) };
|
|
979
1041
|
}
|
|
980
1042
|
/** Unregister a person. Their memories stay; the name tag goes. */
|
|
981
1043
|
removeSpeaker(speaker, userId) {
|
|
982
1044
|
return this.request("DELETE", "/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
|
|
983
1045
|
}
|
|
984
1046
|
// ----- delete -----
|
|
985
|
-
/** Delete a single memory by id. `userId`
|
|
1047
|
+
/** Delete a single memory by id. Pass `undefined` as `userId` for the default
|
|
1048
|
+
* store — it is positional here, not omittable as it is in Python. */
|
|
986
1049
|
delete(userId, memoryId) {
|
|
987
1050
|
// Guard: without a memory_id the API's forget endpoint means "delete the
|
|
988
1051
|
// whole store". An undefined/"" slipping in here must never become a wipe.
|
|
@@ -1025,65 +1088,107 @@ export class Client {
|
|
|
1025
1088
|
* (`NotFoundError`) and 401 behaving the same as everywhere else.
|
|
1026
1089
|
*/
|
|
1027
1090
|
async requestBytes(path, body) {
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1091
|
+
// This route had no retry loop at all, while the module doc promised "every call
|
|
1092
|
+
// retries transient failures … 429 always". Measured with maxRetries: 4 against a
|
|
1093
|
+
// server answering 429 — stats() made five requests, getImage() made one. The
|
|
1094
|
+
// largest and most rate-limit-prone call in the SDK was the one that gave up
|
|
1095
|
+
// immediately. The loop lives inside the function because honouring `Retry-After`
|
|
1096
|
+
// means still holding the response.
|
|
1097
|
+
const attempts = this.maxRetries + 1;
|
|
1098
|
+
for (let attempt = 0;; attempt++) {
|
|
1099
|
+
const ctrl = new AbortController();
|
|
1100
|
+
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
|
1101
|
+
let res;
|
|
1102
|
+
try {
|
|
1103
|
+
res = await this.fetchImpl(`${this.base}${path}`, {
|
|
1104
|
+
method: "POST",
|
|
1105
|
+
headers: this.headers,
|
|
1106
|
+
body: JSON.stringify(body),
|
|
1107
|
+
signal: ctrl.signal,
|
|
1108
|
+
redirect: "manual",
|
|
1109
|
+
});
|
|
1044
1110
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1111
|
+
catch (e) {
|
|
1112
|
+
clearTimeout(timer);
|
|
1113
|
+
const timedOut = e?.name === "AbortError" || ctrl.signal.aborted;
|
|
1114
|
+
// Same rule as `request()`: a connect-level failure never reached the server,
|
|
1115
|
+
// so re-sending cannot double-process anything. The 429 loop below was added
|
|
1116
|
+
// in 2.2.35 and this branch was not, which left the module doc's promise —
|
|
1117
|
+
// "every call retries transient failures" — still false for `getImage` on
|
|
1118
|
+
// exactly the failure a retry is for. A timeout is ambiguous and stays final.
|
|
1119
|
+
if (!timedOut && isConnectFailure(e) && attempt + 1 < attempts) {
|
|
1120
|
+
await new Promise((r) => setTimeout(r, this.backoffMs(attempt)));
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
throw new APIConnectionError(0, timedOut ? `request timed out after ${this.timeoutMs}ms` : `network error: ${e?.message ?? e}`);
|
|
1052
1124
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1125
|
+
try {
|
|
1126
|
+
this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
|
|
1127
|
+
if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
|
|
1128
|
+
void res.body?.cancel();
|
|
1129
|
+
throw errorFor(res.status, "the API answered with a redirect; refusing to follow it");
|
|
1130
|
+
}
|
|
1131
|
+
if (!res.ok) {
|
|
1132
|
+
if (RETRY_ALWAYS.has(res.status) && attempt + 1 < attempts) {
|
|
1133
|
+
const retryAfter = res.headers.get("Retry-After");
|
|
1134
|
+
void res.body?.cancel();
|
|
1135
|
+
clearTimeout(timer);
|
|
1136
|
+
await new Promise((r) => setTimeout(r, this.backoffMs(attempt, retryAfter)));
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
let msg = await this.readCapped(res).catch(() => "");
|
|
1140
|
+
try {
|
|
1141
|
+
const data = JSON.parse(msg);
|
|
1142
|
+
const err = data?.error;
|
|
1143
|
+
if (err && typeof err === "object")
|
|
1144
|
+
msg = err.message ?? err.type ?? msg;
|
|
1145
|
+
else if (typeof err === "string")
|
|
1146
|
+
msg = err;
|
|
1147
|
+
}
|
|
1148
|
+
catch {
|
|
1149
|
+
/* keep raw text */
|
|
1150
|
+
}
|
|
1151
|
+
if (msg.length > MAX_ERR_MSG)
|
|
1152
|
+
msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
|
|
1153
|
+
throw errorFor(res.status, msg || `HTTP ${res.status}`);
|
|
1154
|
+
}
|
|
1155
|
+
// Translated, not raw. A timeout that fires while the body is streaming used to
|
|
1156
|
+
// escape as `AbortError` — not a WosError, not an APIConnectionError — so a
|
|
1157
|
+
// caller's `catch (e) { if (e instanceof WosError) … }` handled every JSON route
|
|
1158
|
+
// and let this one through unclassified. The JSON path already translated it.
|
|
1159
|
+
let buf;
|
|
1055
1160
|
try {
|
|
1056
|
-
|
|
1057
|
-
const err = data?.error;
|
|
1058
|
-
if (err && typeof err === "object")
|
|
1059
|
-
msg = err.message ?? err.type ?? msg;
|
|
1060
|
-
else if (typeof err === "string")
|
|
1061
|
-
msg = err;
|
|
1161
|
+
buf = await this.readCappedBytes(res);
|
|
1062
1162
|
}
|
|
1063
|
-
catch {
|
|
1064
|
-
|
|
1163
|
+
catch (e) {
|
|
1164
|
+
if (e?.name === "AbortError" || ctrl.signal.aborted) {
|
|
1165
|
+
throw new APIConnectionError(0, `request timed out after ${this.timeoutMs}ms`);
|
|
1166
|
+
}
|
|
1167
|
+
throw e;
|
|
1065
1168
|
}
|
|
1066
|
-
if (
|
|
1067
|
-
|
|
1068
|
-
|
|
1169
|
+
if (buf.byteLength === 0) {
|
|
1170
|
+
// An empty 200 would otherwise read as "here is your image" and write a
|
|
1171
|
+
// zero-byte file — indistinguishable from an image we lost.
|
|
1172
|
+
throw new WosError(res.status, "empty image body — the service returned no bytes");
|
|
1173
|
+
}
|
|
1174
|
+
return { bytes: buf, contentType: res.headers.get("content-type") ?? "application/octet-stream" };
|
|
1069
1175
|
}
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
// An empty 200 would otherwise read as "here is your image" and write a
|
|
1073
|
-
// zero-byte file — indistinguishable from an image we lost.
|
|
1074
|
-
throw new WosError(res.status, "empty image body — the service returned no bytes");
|
|
1176
|
+
finally {
|
|
1177
|
+
clearTimeout(timer);
|
|
1075
1178
|
}
|
|
1076
|
-
return { bytes: buf, contentType: res.headers.get("content-type") ?? "application/octet-stream" };
|
|
1077
|
-
}
|
|
1078
|
-
finally {
|
|
1079
|
-
clearTimeout(timer);
|
|
1080
1179
|
}
|
|
1081
1180
|
}
|
|
1082
1181
|
/** Validate an idempotency key BEFORE the network. The API answers a malformed
|
|
1083
1182
|
* key with a 400, which on a retry path reads as "my write failed" when in fact
|
|
1084
1183
|
* it was never attempted — cheaper and clearer to reject it here. */
|
|
1085
1184
|
idemHeader(key) {
|
|
1086
|
-
|
|
1185
|
+
// `=== undefined` let `null` through: RegExp.test stringifies its argument, so
|
|
1186
|
+
// `null` became the literal key "null" and passed the format check. A key read
|
|
1187
|
+
// from JSON or a database row arrives as null, not undefined, and every write on
|
|
1188
|
+
// that path then shared one key — the second onward returned the first response
|
|
1189
|
+
// and stored nothing. Python guards with `is None`; Rust's Option makes it
|
|
1190
|
+
// unrepresentable.
|
|
1191
|
+
if (key === undefined || key === null)
|
|
1087
1192
|
return undefined;
|
|
1088
1193
|
if (!IDEMPOTENCY_KEY_RE.test(key)) {
|
|
1089
1194
|
throw new Error(`invalid idempotencyKey: ${JSON.stringify(key)} — 1-128 chars of [A-Za-z0-9._:-]`);
|
|
@@ -1118,8 +1223,18 @@ export class Client {
|
|
|
1118
1223
|
if (cl && Number(cl) > MAX_RESPONSE_BYTES) {
|
|
1119
1224
|
throw new WosError(0, `response too large (${cl} bytes) — refusing to buffer it`);
|
|
1120
1225
|
}
|
|
1121
|
-
if (!res.body)
|
|
1122
|
-
|
|
1226
|
+
if (!res.body) {
|
|
1227
|
+
// `arrayBuffer()` has no ceiling, and only the content-length pre-check stands
|
|
1228
|
+
// in front of it — which a server simply omits. Reachable through the documented
|
|
1229
|
+
// `fetch` seam (test doubles, polyfills, wrappers that rebuild the Response),
|
|
1230
|
+
// and the option's own doc promises "the size cap still applies". Measured: a
|
|
1231
|
+
// body-less Response with no content-length buffered 73MB past the 64MB cap.
|
|
1232
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
1233
|
+
if (buf.byteLength > MAX_RESPONSE_BYTES) {
|
|
1234
|
+
throw new WosError(0, `response too large (${buf.byteLength} bytes) — refusing it`);
|
|
1235
|
+
}
|
|
1236
|
+
return buf;
|
|
1237
|
+
}
|
|
1123
1238
|
const reader = res.body.getReader();
|
|
1124
1239
|
const parts = [];
|
|
1125
1240
|
let size = 0;
|
package/package.json
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wontopos",
|
|
3
|
-
"version": "2.2.
|
|
4
|
-
"description": "Wontopos
|
|
5
|
-
"repository": "github:wontopos/wontopos-typescript",
|
|
3
|
+
"version": "2.2.35",
|
|
4
|
+
"description": "Wontopos — long-term memory for AI agents. Identical recall in every language, ~100× lower LLM bill.",
|
|
6
5
|
"keywords": [
|
|
7
6
|
"memory",
|
|
8
7
|
"ai",
|
|
9
8
|
"agent",
|
|
10
9
|
"llm",
|
|
11
|
-
"
|
|
12
|
-
"embedding",
|
|
13
|
-
"semantic",
|
|
10
|
+
"multilingual",
|
|
14
11
|
"rag",
|
|
15
12
|
"context"
|
|
16
13
|
],
|
|
@@ -39,7 +36,7 @@
|
|
|
39
36
|
"scripts": {
|
|
40
37
|
"build": "tsc",
|
|
41
38
|
"test": "npm run build && node --test tests/client.test.mjs",
|
|
42
|
-
"prepublishOnly": "npm run build"
|
|
39
|
+
"prepublishOnly": "npm run build && node scripts/pre-publish.mjs"
|
|
43
40
|
},
|
|
44
41
|
"devDependencies": {
|
|
45
42
|
"typescript": "^5.4.0"
|