wontopos 2.2.33 → 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 +87 -9
- package/dist/wontopos.d.ts +124 -76
- package/dist/wontopos.js +303 -149
- package/package.json +5 -7
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). */
|
|
@@ -55,7 +58,15 @@ function logDebug(msg) {
|
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
const DEFAULT_BASE_URL = "https://api.wontopos.com";
|
|
58
|
-
|
|
61
|
+
/** The engine every call uses unless the caller names another.
|
|
62
|
+
*
|
|
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")`. */
|
|
69
|
+
const DEFAULT_MODEL = "tablet-2";
|
|
59
70
|
/** 429 = rate-limited BEFORE processing → always safe to retry (no write, no bill). */
|
|
60
71
|
const RETRY_ALWAYS = new Set([429]);
|
|
61
72
|
/** 502/503 are ambiguous for a write (gateway may return them AFTER the backend
|
|
@@ -96,8 +107,9 @@ function normalizeStoreId(id) {
|
|
|
96
107
|
* Bounded on purpose. The warning fires on ids that fold, and the shape it exists
|
|
97
108
|
* to catch is an email — which this SDK's own documented pattern ("one store per
|
|
98
109
|
* end user") turns into one entry PER USER, held for the life of the process and
|
|
99
|
-
* never released, even when the client is. A server
|
|
100
|
-
* this to 50,000 entries,
|
|
110
|
+
* never released, even when the client is. A server that forwards 50,000 distinct user
|
|
111
|
+
* ids would grow this to 50,000 entries, leaking in exactly the case the warning
|
|
112
|
+
* exists to catch. A `Set`
|
|
101
113
|
* keeps insertion order, so the oldest entry is the one evicted; dropping the
|
|
102
114
|
* oldest rather than refusing new ones means a collision that first shows up late
|
|
103
115
|
* still gets its one warning. */
|
|
@@ -131,15 +143,44 @@ export function _resetStoreIdWarnings() {
|
|
|
131
143
|
* answer with a JSON object — see the empty-body check in `request`. */
|
|
132
144
|
const NO_BODY_STATUS = new Set([204, 205, 304]);
|
|
133
145
|
/** Paging backstop: bound the walk so a fresh-cursor-forever server can't loop us. */
|
|
134
|
-
|
|
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
|
+
}
|
|
135
176
|
/**
|
|
136
177
|
* Put an image into the shape the API expects, and fail loudly on the parts we can
|
|
137
178
|
* check from here.
|
|
138
179
|
*
|
|
139
|
-
* Deliberately NOT checked: the byte ceiling. It is a
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
|
143
184
|
* prefix — browsers and file pickers hand you `data:image/jpeg;base64,…`, the API
|
|
144
185
|
* wants the part after the comma, and the difference is invisible until the write
|
|
145
186
|
* fails with "not a readable image".
|
|
@@ -151,16 +192,15 @@ function normalizeImage(image) {
|
|
|
151
192
|
if (typeof image.data !== "string" || image.data.trim() === "") {
|
|
152
193
|
throw new Error("image.data is required — base64 of the image (a data: URL is fine).");
|
|
153
194
|
}
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
195
|
+
// Trim before looking for the prefix. Checked against the raw string, one leading
|
|
196
|
+
// space (` data:image/png;base64,…`) hides the prefix, and the literal
|
|
197
|
+
// `data:image/png;base64,` travels as part of the base64. The engine answers 400
|
|
198
|
+
// "image could not be read" and the caller has no way to tell why.
|
|
158
199
|
const raw = image.data.trim();
|
|
159
200
|
const comma = raw.startsWith("data:") ? raw.indexOf(",") : -1;
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
// 전부 빼는 게 안전하다.
|
|
201
|
+
// Strip whitespace in the middle as well. `base64` and `openssl base64` wrap at 76
|
|
202
|
+
// columns, and trimming only the ends leaves those line breaks in the payload. The
|
|
203
|
+
// base64 alphabet contains no whitespace, so removing all of it is safe.
|
|
164
204
|
const data = (comma === -1 ? raw : raw.slice(comma + 1)).replace(/\s+/g, "");
|
|
165
205
|
if (data === "")
|
|
166
206
|
throw new Error("image.data is empty after stripping its data: prefix.");
|
|
@@ -234,21 +274,18 @@ const asObj = (x) => (x != null && typeof x === "object" && !Array.isArray(x) ?
|
|
|
234
274
|
// user code. Drop elements that aren't objects and keep the valid records, so one
|
|
235
275
|
// bad element never poisons the batch (and never destroys it).
|
|
236
276
|
const asRecords = (x) => asList(x).filter((m) => m != null && typeof m === "object" && !Array.isArray(m));
|
|
237
|
-
|
|
238
|
-
// passes them; on Scroll 1.2+ this renders each returned memory's time in that form.
|
|
239
|
-
/** Every memory a search returned — main lane and self lane together.
|
|
277
|
+
/** Every memory a search returned, from both fields, as one array.
|
|
240
278
|
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
* 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.
|
|
247
284
|
*
|
|
248
|
-
* Both
|
|
249
|
-
* caller can still tell who said what. Callers who want
|
|
250
|
-
* `searchSelf()`, which
|
|
251
|
-
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) {
|
|
252
289
|
const main = asRecords(r?.memories);
|
|
253
290
|
const mine = asRecords(r?.self_memories);
|
|
254
291
|
if (mine.length === 0)
|
|
@@ -256,6 +293,9 @@ function mergeLanes(r) {
|
|
|
256
293
|
const seen = new Set(main.map((m) => m.id).filter((id) => typeof id === "string"));
|
|
257
294
|
return main.concat(mine.filter((m) => !(typeof m.id === "string" && seen.has(m.id))));
|
|
258
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). */
|
|
259
299
|
function withForm(body, form, tz) {
|
|
260
300
|
if (form !== undefined)
|
|
261
301
|
body.form = form;
|
|
@@ -309,8 +349,8 @@ export class RateLimitError extends WosError {
|
|
|
309
349
|
* `501` is NOT transient. It means the engine behind the model you selected does
|
|
310
350
|
* not implement that endpoint at all (the error carries `model` and `endpoint`).
|
|
311
351
|
* Retrying can never succeed — pick a model that supports it (`listModels`) or
|
|
312
|
-
* drop the call.
|
|
313
|
-
*
|
|
352
|
+
* drop the call. Treating the whole 5xx range as retryable sends a caller into a
|
|
353
|
+
* loop that cannot end.
|
|
314
354
|
*/
|
|
315
355
|
export class ServerError extends WosError {
|
|
316
356
|
}
|
|
@@ -396,6 +436,12 @@ export class Client {
|
|
|
396
436
|
throw new Error("apiKey is required");
|
|
397
437
|
if (/\s/.test(key))
|
|
398
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");
|
|
399
445
|
this.apiKey = key;
|
|
400
446
|
this.base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
401
447
|
// Coerce a non-finite or non-positive timeout back to the default — NaN
|
|
@@ -441,7 +487,12 @@ export class Client {
|
|
|
441
487
|
// (Python already used urlsplit here; this was the copy that did not.)
|
|
442
488
|
let host;
|
|
443
489
|
try {
|
|
444
|
-
|
|
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, "");
|
|
445
496
|
}
|
|
446
497
|
catch {
|
|
447
498
|
host = ""; // unparseable → not loopback → warn, which is the safe direction
|
|
@@ -545,8 +596,7 @@ export class Client {
|
|
|
545
596
|
// store (set via `new Client({ apiKey, userId })` or `withUser`).
|
|
546
597
|
/** Store one memory. `metadata` optional — e.g. `{ event_date: "2026-03-01" }` for when the
|
|
547
598
|
* content actually happened. A plain date works (read as UTC midnight); a full RFC3339
|
|
548
|
-
* timestamp also works
|
|
549
|
-
* an opaque 400, which is why older examples here show the long one.
|
|
599
|
+
* timestamp also works; older examples here show the long form.
|
|
550
600
|
* `metadata.speaker`: "me" = the assistant's own words, or a person's name (up to 50 per store).
|
|
551
601
|
*
|
|
552
602
|
* `opts.idempotencyKey`: see {@link WriteOptions} — pass one to make YOUR retry
|
|
@@ -565,9 +615,9 @@ export class Client {
|
|
|
565
615
|
addTurn(userMsg, assistantMsg, userId, opts = {}) {
|
|
566
616
|
return this.post("/api/v1/memory/store-turn", { user_id: this.uid(userId), user_msg: userMsg, assistant_msg: assistantMsg }, opts.idempotencyKey);
|
|
567
617
|
}
|
|
568
|
-
/** Bulk-ingest a large blob of text
|
|
618
|
+
/** Bulk-ingest a large blob of text in one call. For backfilling.
|
|
569
619
|
* The call most worth an `opts.idempotencyKey`: a backfill that dies halfway and is
|
|
570
|
-
* re-run would otherwise re-
|
|
620
|
+
* re-run would otherwise re-ingest and re-bill the whole blob. */
|
|
571
621
|
addBulk(content, userId, category = "general", timestamp, opts = {}) {
|
|
572
622
|
const body = { user_id: this.uid(userId), content, category };
|
|
573
623
|
if (timestamp)
|
|
@@ -579,31 +629,60 @@ export class Client {
|
|
|
579
629
|
return this.post("/api/v1/memory/supersede", { user_id: this.uid(userId), old_memory_id: oldMemoryId, new_content: newContent }, opts.idempotencyKey);
|
|
580
630
|
}
|
|
581
631
|
// ----- read -----
|
|
582
|
-
/**
|
|
632
|
+
/** Search a store's memories. Returns them most relevant first.
|
|
583
633
|
*
|
|
584
|
-
* `limit` bounds
|
|
585
|
-
* (Scroll 1.2+)
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
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. */
|
|
590
640
|
async search(query, userId, limit = 10, opts = {}) {
|
|
591
641
|
// Reserved fields win over ...opts: an app that forwards untrusted input as
|
|
592
642
|
// opts must not be able to override the store (user_id), query, or limit.
|
|
593
643
|
warnOnUnknownFilters(opts.filters);
|
|
644
|
+
checkSearchLimit(limit);
|
|
594
645
|
const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
|
|
595
|
-
return
|
|
646
|
+
return mergeResults(r);
|
|
596
647
|
}
|
|
597
|
-
/** Search a self-memory model (Scroll 1.2+): both
|
|
598
|
-
* `{ memories, self_memories }` — `memories` is
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
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. */
|
|
602
653
|
async searchSelf(query, userId, limit = 10, opts = {}) {
|
|
603
654
|
warnOnUnknownFilters(opts.filters);
|
|
655
|
+
checkSearchLimit(limit);
|
|
604
656
|
const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
|
|
605
657
|
return { memories: asRecords(r.memories), self_memories: asRecords(r.self_memories) };
|
|
606
658
|
}
|
|
659
|
+
/**
|
|
660
|
+
* Search, and keep every field the answer came with.
|
|
661
|
+
*
|
|
662
|
+
* Same request as {@link Client.search} — reach for this one when the options make
|
|
663
|
+
* the merged array an incomplete answer:
|
|
664
|
+
*
|
|
665
|
+
* const r = await mem.searchFull("the day we moved", "alice", 10,
|
|
666
|
+
* { max_images: 3, verify: 2 });
|
|
667
|
+
* r.images.length; // the photos, which `search` drops
|
|
668
|
+
* r.verify_used; // how many re-ask passes actually ran (you are billed per pass)
|
|
669
|
+
*
|
|
670
|
+
* Both options need Tablet 2 or newer and are refused (403) on an older engine
|
|
671
|
+
* rather than accepted and ignored.
|
|
672
|
+
*/
|
|
673
|
+
async searchFull(query, userId, limit = 10, opts = {}) {
|
|
674
|
+
warnOnUnknownFilters(opts.filters);
|
|
675
|
+
checkSearchLimit(limit);
|
|
676
|
+
const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
|
|
677
|
+
// Spread first so a field added later still arrives; the known ones are then
|
|
678
|
+
// normalized, because a broken proxy can null any of them.
|
|
679
|
+
return {
|
|
680
|
+
...r,
|
|
681
|
+
memories: asRecords(r.memories),
|
|
682
|
+
self_memories: asRecords(r.self_memories),
|
|
683
|
+
images: asRecords(r.images),
|
|
684
|
+
};
|
|
685
|
+
}
|
|
607
686
|
/** One-call LLM context: short-term turns + long-term matches + surrounding context.
|
|
608
687
|
* `form` ("memoir"/"archive", Scroll 1.2+) renders each long-term memory's time in
|
|
609
688
|
* that form; `tz` is your UTC-offset hours for that rendering. */
|
|
@@ -615,7 +694,9 @@ export class Client {
|
|
|
615
694
|
body.context_limit = opts.context_limit;
|
|
616
695
|
return this.post("/api/v1/memory/recall", withForm(body, opts.form, opts.tz));
|
|
617
696
|
}
|
|
618
|
-
/** 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.
|
|
619
700
|
* `form`/`tz` render memory times (memoir/archive) on Scroll 1.2+, same as search/recall. */
|
|
620
701
|
engram(name, query, userId, opts = {}) {
|
|
621
702
|
return this.post("/api/v1/engram/run", withForm({ name, user_id: this.uid(userId), query }, opts.form, opts.tz));
|
|
@@ -629,10 +710,11 @@ export class Client {
|
|
|
629
710
|
stats(userId) {
|
|
630
711
|
return this.post("/api/v1/memory/stats", { user_id: this.uid(userId) });
|
|
631
712
|
}
|
|
632
|
-
/** Fetch ONE memory by id — the
|
|
713
|
+
/** Fetch ONE memory by id — the text you stored, and its metadata.
|
|
633
714
|
* The id is what `add`/`store` or `listMemories` returned. Same visibility as
|
|
634
|
-
* `listMemories`: an id from another store,
|
|
635
|
-
* 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. */
|
|
636
718
|
get(userId, memoryId) {
|
|
637
719
|
// Sync throw (same shape as `delete`), so a missing id fails loudly even
|
|
638
720
|
// when the caller forgets to await.
|
|
@@ -641,8 +723,8 @@ export class Client {
|
|
|
641
723
|
}
|
|
642
724
|
return this.post("/api/v1/memory/get", { user_id: this.uid(userId), memory_id: memoryId }).then((r) => asObj(r.memory));
|
|
643
725
|
}
|
|
644
|
-
/** List a store's stored memories — the
|
|
645
|
-
*
|
|
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
|
|
646
728
|
* page; a `null` cursor means the last page. Use it to browse or export a store.
|
|
647
729
|
*
|
|
648
730
|
* let cursor: string | null = null;
|
|
@@ -660,7 +742,7 @@ export class Client {
|
|
|
660
742
|
return this.post("/api/v1/memory/list", body);
|
|
661
743
|
}
|
|
662
744
|
/** Async-iterate every stored memory in a store, paging under the hood — no cursor
|
|
663
|
-
* bookkeeping.
|
|
745
|
+
* bookkeeping. The text you stored and its metadata only.
|
|
664
746
|
*
|
|
665
747
|
* for await (const m of mem.iterMemories()) console.log(m.id, m.content);
|
|
666
748
|
*/
|
|
@@ -675,12 +757,14 @@ export class Client {
|
|
|
675
757
|
yield m;
|
|
676
758
|
const next = p.next_cursor ?? undefined;
|
|
677
759
|
if (!next || seen.has(next))
|
|
678
|
-
|
|
760
|
+
return;
|
|
679
761
|
seen.add(next);
|
|
680
762
|
cursor = next;
|
|
681
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.`);
|
|
682
766
|
}
|
|
683
|
-
/** 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). */
|
|
684
768
|
async exportMemories(userId) {
|
|
685
769
|
const out = [];
|
|
686
770
|
for await (const m of this.iterMemories(userId))
|
|
@@ -694,8 +778,8 @@ export class Client {
|
|
|
694
778
|
* Not necessarily your upload, in size OR in format: an image whose long edge was
|
|
695
779
|
* over 1568px was downscaled to 1568 on the way in and re-encoded — lossless
|
|
696
780
|
* formats as WebP, so a PNG comes back as `image/webp`; JPEG stays JPEG — and that
|
|
697
|
-
* smaller picture is what
|
|
698
|
-
*
|
|
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.
|
|
699
783
|
* Keep your own copy for the full-resolution file.
|
|
700
784
|
*
|
|
701
785
|
* The type is sniffed from the bytes themselves rather than from whatever the file
|
|
@@ -759,15 +843,33 @@ export class Client {
|
|
|
759
843
|
async *iterImages(userId, opts = {}) {
|
|
760
844
|
let before;
|
|
761
845
|
let skipIds;
|
|
846
|
+
// Same repeat-cursor guard as `iterMemories`. A server that hands back the
|
|
847
|
+
// cursor it was just given would otherwise re-yield one page MAX_PAGES times,
|
|
848
|
+
// and the caller reads those repeats as more images. The page cap alone bounds
|
|
849
|
+
// the walk; it does not stop the duplicates.
|
|
850
|
+
const seen = new Set();
|
|
762
851
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
763
852
|
const p = await this.listImages(userId, { limit: opts.pageSize, before, skipIds });
|
|
764
853
|
for (const m of asRecords(p.images))
|
|
765
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.
|
|
766
860
|
if (!p.has_more || !p.next_before)
|
|
767
|
-
|
|
861
|
+
return;
|
|
862
|
+
// The cursor is the PAIR: several images can share a timestamp, so `before`
|
|
863
|
+
// alone repeats across pages legitimately.
|
|
864
|
+
const key = `${p.next_before}|${(Array.isArray(p.next_skip_ids) ? p.next_skip_ids : []).join(",")}`;
|
|
865
|
+
if (seen.has(key))
|
|
866
|
+
return;
|
|
867
|
+
seen.add(key);
|
|
768
868
|
before = p.next_before;
|
|
769
869
|
skipIds = Array.isArray(p.next_skip_ids) ? p.next_skip_ids : undefined;
|
|
770
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.`);
|
|
771
873
|
}
|
|
772
874
|
// ----- how much has this memory been edited -----
|
|
773
875
|
/**
|
|
@@ -780,10 +882,10 @@ export class Client {
|
|
|
780
882
|
*
|
|
781
883
|
* Counts memories a transform touched (supersede, update, retract, image removed).
|
|
782
884
|
* Deletions are NOT counted: a deleted memory leaves nothing to count. Neither are
|
|
783
|
-
*
|
|
784
|
-
* do not belong in a ratio that answers "how much of MY memory changed".
|
|
885
|
+
* the internal records derived from what you stored — nobody stored those directly,
|
|
886
|
+
* so they do not belong in a ratio that answers "how much of MY memory changed".
|
|
785
887
|
*
|
|
786
|
-
*
|
|
888
|
+
* By default this returns COUNTS ONLY, and the answer is the same size for a store
|
|
787
889
|
* of a hundred memories and a store of a hundred million. That is deliberate: a model
|
|
788
890
|
* asks this mid-conversation, and an answer that grew with the store would be
|
|
789
891
|
* unusable for exactly the customers who most need to ask.
|
|
@@ -804,10 +906,10 @@ export class Client {
|
|
|
804
906
|
* }
|
|
805
907
|
* ```
|
|
806
908
|
*
|
|
807
|
-
*
|
|
909
|
+
* Pages are ordered by when each memory was STORED, not by when it was edited.
|
|
808
910
|
* The response says so in `ordered_by`.
|
|
809
911
|
*
|
|
810
|
-
*
|
|
912
|
+
* Served from `/api/v1/won/*`, not `/api/v1/memory/*`. Won is the surface for calls
|
|
811
913
|
* a model makes ABOUT its memory rather than calls an application makes WITH it, and
|
|
812
914
|
* the address says so. The old path still answers, for clients published before
|
|
813
915
|
* 2026-08-18, and both share one rate-limit budget.
|
|
@@ -847,9 +949,9 @@ export class Client {
|
|
|
847
949
|
* assistant's own words, otherwise a person's name. Same cursor paging as
|
|
848
950
|
* `listImages`.
|
|
849
951
|
*
|
|
850
|
-
* `chunks` / `points_to_delete` report
|
|
851
|
-
*
|
|
852
|
-
*
|
|
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.
|
|
853
955
|
*/
|
|
854
956
|
bySpeaker(speaker, userId, opts = {}) {
|
|
855
957
|
if (!speaker || typeof speaker !== "string" || speaker.trim() === "") {
|
|
@@ -881,10 +983,9 @@ export class Client {
|
|
|
881
983
|
/**
|
|
882
984
|
* The engrams (and delivery forms) the selected model can actually run.
|
|
883
985
|
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
886
|
-
*
|
|
887
|
-
* and stopped hard-coding for the same reason. The service is the authority: what
|
|
986
|
+
* Ask rather than hard-code: a name copied from the docs freezes a caller to the
|
|
987
|
+
* catalogue as it was that day, and anything added later stays invisible. The
|
|
988
|
+
* service is the authority. What
|
|
888
989
|
* comes back depends on the model (delivery forms need Scroll 1.2+), so pass
|
|
889
990
|
* `withModel()` if you want another model's catalogue.
|
|
890
991
|
*
|
|
@@ -915,9 +1016,9 @@ export class Client {
|
|
|
915
1016
|
}
|
|
916
1017
|
/** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
|
|
917
1018
|
deleteStore(userId) {
|
|
918
|
-
// Validate
|
|
919
|
-
//
|
|
920
|
-
//
|
|
1019
|
+
// Validate before warning. The warning helper lowercases the id, so a non-string
|
|
1020
|
+
// reaches it and dies as "id.toLowerCase is not a function" — a TypeError from
|
|
1021
|
+
// inside the SDK instead of a sentence about the id.
|
|
921
1022
|
if (typeof userId !== "string" || !userId.trim())
|
|
922
1023
|
throw new Error("userId is required (a non-blank string) — deleteStore never falls back to the default store.");
|
|
923
1024
|
warnIfStoreIdCollapses(userId); // destructive: the collision warning belongs here too
|
|
@@ -933,23 +1034,26 @@ export class Client {
|
|
|
933
1034
|
/** The store's registered people, each with its memory count. */
|
|
934
1035
|
async listSpeakers(userId) {
|
|
935
1036
|
const r = await this.request("GET", `/api/v1/memory/speakers?user_id=${encodeURIComponent(this.uid(userId))}`);
|
|
936
|
-
|
|
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) };
|
|
937
1041
|
}
|
|
938
1042
|
/** Unregister a person. Their memories stay; the name tag goes. */
|
|
939
1043
|
removeSpeaker(speaker, userId) {
|
|
940
1044
|
return this.request("DELETE", "/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
|
|
941
1045
|
}
|
|
942
1046
|
// ----- delete -----
|
|
943
|
-
/** 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. */
|
|
944
1049
|
delete(userId, memoryId) {
|
|
945
1050
|
// Guard: without a memory_id the API's forget endpoint means "delete the
|
|
946
1051
|
// whole store". An undefined/"" slipping in here must never become a wipe.
|
|
947
1052
|
//
|
|
948
|
-
// `.trim()` matters as much as the emptiness check: " " is truthy, so it
|
|
949
|
-
//
|
|
950
|
-
// reads the request as the whole-store form
|
|
951
|
-
// this SDK to be wrong.
|
|
952
|
-
// was the one that did not.
|
|
1053
|
+
// `.trim()` matters as much as the emptiness check: " " is truthy, so without it
|
|
1054
|
+
// a blank string passes the guard and travels as memory_id. A server that trims it
|
|
1055
|
+
// back to nothing reads the request as the whole-store form, which is the most
|
|
1056
|
+
// destructive way for this SDK to be wrong.
|
|
953
1057
|
if (typeof memoryId !== "string" || !memoryId.trim()) {
|
|
954
1058
|
throw new Error("memoryId is required (non-blank). To delete every memory in a store, call deleteAll(userId) explicitly.");
|
|
955
1059
|
}
|
|
@@ -984,65 +1088,107 @@ export class Client {
|
|
|
984
1088
|
* (`NotFoundError`) and 401 behaving the same as everywhere else.
|
|
985
1089
|
*/
|
|
986
1090
|
async requestBytes(path, body) {
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
+
});
|
|
1003
1110
|
}
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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}`);
|
|
1011
1124
|
}
|
|
1012
|
-
|
|
1013
|
-
|
|
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;
|
|
1014
1160
|
try {
|
|
1015
|
-
|
|
1016
|
-
const err = data?.error;
|
|
1017
|
-
if (err && typeof err === "object")
|
|
1018
|
-
msg = err.message ?? err.type ?? msg;
|
|
1019
|
-
else if (typeof err === "string")
|
|
1020
|
-
msg = err;
|
|
1161
|
+
buf = await this.readCappedBytes(res);
|
|
1021
1162
|
}
|
|
1022
|
-
catch {
|
|
1023
|
-
|
|
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;
|
|
1024
1168
|
}
|
|
1025
|
-
if (
|
|
1026
|
-
|
|
1027
|
-
|
|
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" };
|
|
1028
1175
|
}
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
// An empty 200 would otherwise read as "here is your image" and write a
|
|
1032
|
-
// zero-byte file — indistinguishable from an image we lost.
|
|
1033
|
-
throw new WosError(res.status, "empty image body — the service returned no bytes");
|
|
1176
|
+
finally {
|
|
1177
|
+
clearTimeout(timer);
|
|
1034
1178
|
}
|
|
1035
|
-
return { bytes: buf, contentType: res.headers.get("content-type") ?? "application/octet-stream" };
|
|
1036
|
-
}
|
|
1037
|
-
finally {
|
|
1038
|
-
clearTimeout(timer);
|
|
1039
1179
|
}
|
|
1040
1180
|
}
|
|
1041
1181
|
/** Validate an idempotency key BEFORE the network. The API answers a malformed
|
|
1042
1182
|
* key with a 400, which on a retry path reads as "my write failed" when in fact
|
|
1043
1183
|
* it was never attempted — cheaper and clearer to reject it here. */
|
|
1044
1184
|
idemHeader(key) {
|
|
1045
|
-
|
|
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)
|
|
1046
1192
|
return undefined;
|
|
1047
1193
|
if (!IDEMPOTENCY_KEY_RE.test(key)) {
|
|
1048
1194
|
throw new Error(`invalid idempotencyKey: ${JSON.stringify(key)} — 1-128 chars of [A-Za-z0-9._:-]`);
|
|
@@ -1069,17 +1215,26 @@ export class Client {
|
|
|
1069
1215
|
}
|
|
1070
1216
|
/** The same cap, for a body that is not text.
|
|
1071
1217
|
*
|
|
1072
|
-
* `
|
|
1073
|
-
*
|
|
1074
|
-
*
|
|
1075
|
-
* megabytes was not. One implementation, two callers, so they cannot drift. */
|
|
1218
|
+
* `arrayBuffer()` would buffer whatever arrives. The cap exists for a hostile or
|
|
1219
|
+
* broken `baseUrl`, and an endpoint that returns megabytes is where that costs the
|
|
1220
|
+
* most. One implementation, two callers, so they cannot drift. */
|
|
1076
1221
|
async readCappedBytes(res) {
|
|
1077
1222
|
const cl = res.headers.get("content-length");
|
|
1078
1223
|
if (cl && Number(cl) > MAX_RESPONSE_BYTES) {
|
|
1079
1224
|
throw new WosError(0, `response too large (${cl} bytes) — refusing to buffer it`);
|
|
1080
1225
|
}
|
|
1081
|
-
if (!res.body)
|
|
1082
|
-
|
|
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
|
+
}
|
|
1083
1238
|
const reader = res.body.getReader();
|
|
1084
1239
|
const parts = [];
|
|
1085
1240
|
let size = 0;
|
|
@@ -1209,10 +1364,9 @@ export class Client {
|
|
|
1209
1364
|
msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
|
|
1210
1365
|
throw errorFor(res.status, msg, requestId);
|
|
1211
1366
|
}
|
|
1212
|
-
// An empty body is only legal when the STATUS says there is no body.
|
|
1213
|
-
//
|
|
1214
|
-
//
|
|
1215
|
-
// and Rust SDKs raised on the same response. Same rule in all three now.
|
|
1367
|
+
// An empty body is only legal when the STATUS says there is no body. Accepting
|
|
1368
|
+
// any empty 2xx would make an `add()` answered by a truncating proxy return `{}`,
|
|
1369
|
+
// which reads as a successful write with no id. All three SDKs apply this rule.
|
|
1216
1370
|
if (!text) {
|
|
1217
1371
|
if (NO_BODY_STATUS.has(res.status))
|
|
1218
1372
|
return {};
|
|
@@ -1234,10 +1388,10 @@ export class Client {
|
|
|
1234
1388
|
const got = data === null ? "null" : Array.isArray(data) ? "array" : typeof data;
|
|
1235
1389
|
throw new WosError(res.status, `expected a JSON object in the response, got ${got}`);
|
|
1236
1390
|
}
|
|
1237
|
-
//
|
|
1238
|
-
//
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1391
|
+
// Surface whether the write was stored or replayed. The server says so with
|
|
1392
|
+
// `Idempotent-Replayed: true`, and it answers the question someone using an
|
|
1393
|
+
// idempotency key is asking: did my retry write, or is this the first response
|
|
1394
|
+
// coming back again?
|
|
1241
1395
|
if (res.headers.get("Idempotent-Replayed") === "true") {
|
|
1242
1396
|
data.replayed = true;
|
|
1243
1397
|
}
|