wontopos 2.2.34 → 2.2.36
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 +107 -9
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/wontopos.js +1582 -0
- package/dist/wontopos.d.ts +132 -60
- package/dist/wontopos.js +410 -134
- package/package.json +8 -10
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.36";
|
|
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]);
|
|
@@ -72,7 +76,7 @@ const RETRY_IF_IDEMPOTENT = new Set([502, 503]);
|
|
|
72
76
|
const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
73
77
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
|
|
74
78
|
/** Refuse to buffer absurd responses (real ones are a few KB) — protects the
|
|
75
|
-
* process if a
|
|
79
|
+
* process if a custom baseUrl points somewhere broken or hostile. */
|
|
76
80
|
const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
77
81
|
/** Model names travel in a header — only header-safe characters. */
|
|
78
82
|
const MODEL_RE = /^[A-Za-z0-9._-]+$/;
|
|
@@ -139,15 +143,69 @@ 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
|
+
/** `recall`'s surrounding-context count. 0 to 20 inclusive; 0 attaches none. */
|
|
158
|
+
export const CONTEXT_LIMIT_MIN = 0;
|
|
159
|
+
export const CONTEXT_LIMIT_MAX = 20;
|
|
160
|
+
/** The 5-to-20 count shared by `search` and `recall`, refused out of range rather
|
|
161
|
+
* than quietly adjusted.
|
|
162
|
+
*
|
|
163
|
+
* The service has refused anything else from the start, because asking for 20 and
|
|
164
|
+
* silently getting 10 reads as "that is all there is". Search had no contract at
|
|
165
|
+
* all: the three clients sent whatever they were given, the MCP server allowed 1 to
|
|
166
|
+
* 60, and the service quietly capped at 50 with no floor. Four surfaces, four
|
|
167
|
+
* answers, and the caller could not tell which one they got.
|
|
168
|
+
*
|
|
169
|
+
* ★ `recall` said it enforced this and did not. Its doc comment promised "out of
|
|
170
|
+
* range is refused, not clamped" while the value went straight to the wire in all
|
|
171
|
+
* three SDKs — so `limit: 500` travelled to the engine and died there. A comment
|
|
172
|
+
* that describes a guard is not a guard, and this is the shape of defect a reviewer
|
|
173
|
+
* reading one diff can never see: the promise and the missing code were written
|
|
174
|
+
* months apart.
|
|
175
|
+
*
|
|
176
|
+
* Thrown as a plain `Error`, like every other argument check in this file. It is
|
|
177
|
+
* NOT a `WosError` — nothing was sent, and `WosError` with status 0 means
|
|
178
|
+
* `APIConnectionError`, "the request never got a response". Reusing that status for
|
|
179
|
+
* a typo told a caller with `if (e.status === 0) retry()` to retry a bad argument
|
|
180
|
+
* forever.
|
|
181
|
+
*
|
|
182
|
+
* Refusing rather than clamping is the same decision as 2.2.35's, where a Rust
|
|
183
|
+
* `limit` of 0 had been rewritten to 10 and the caller was handed ten memories they
|
|
184
|
+
* had not asked for, and the bill for them. */
|
|
185
|
+
function checkCount(limit, name) {
|
|
186
|
+
if (!Number.isInteger(limit) || limit < SEARCH_LIMIT_MIN || limit > SEARCH_LIMIT_MAX) {
|
|
187
|
+
throw new Error(`${name} must be an integer between ${SEARCH_LIMIT_MIN} and ${SEARCH_LIMIT_MAX}, got ${limit}. ` +
|
|
188
|
+
`Out of range is refused rather than adjusted, so a short answer always means ` +
|
|
189
|
+
`the store was short.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** `recall`'s `context_limit`, 0 to 20. Same reason as the count above: the doc
|
|
193
|
+
* comment on `recall` promises out-of-range is refused, and a promise the client
|
|
194
|
+
* does not keep is worse than no promise — the caller reads the doc, sends 50, and
|
|
195
|
+
* the failure arrives from the service with no hint that the SDK knew all along. */
|
|
196
|
+
function checkContextLimit(n) {
|
|
197
|
+
if (!Number.isInteger(n) || n < CONTEXT_LIMIT_MIN || n > CONTEXT_LIMIT_MAX) {
|
|
198
|
+
throw new Error(`context_limit must be an integer between ${CONTEXT_LIMIT_MIN} and ${CONTEXT_LIMIT_MAX}, got ${n}.`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
143
201
|
/**
|
|
144
202
|
* Put an image into the shape the API expects, and fail loudly on the parts we can
|
|
145
203
|
* check from here.
|
|
146
204
|
*
|
|
147
|
-
* Deliberately NOT checked: the byte ceiling. It is a
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
205
|
+
* Deliberately NOT checked: the byte ceiling. It is a service setting, so a number
|
|
206
|
+
* baked in here would drift the first time the service is reconfigured and would
|
|
207
|
+
* reject an image the service would have taken.
|
|
208
|
+
* What we do check is what cannot change: an empty payload, and a `data:` URL
|
|
151
209
|
* prefix — browsers and file pickers hand you `data:image/jpeg;base64,…`, the API
|
|
152
210
|
* wants the part after the comma, and the difference is invisible until the write
|
|
153
211
|
* fails with "not a readable image".
|
|
@@ -241,21 +299,18 @@ const asObj = (x) => (x != null && typeof x === "object" && !Array.isArray(x) ?
|
|
|
241
299
|
// user code. Drop elements that aren't objects and keep the valid records, so one
|
|
242
300
|
// bad element never poisons the batch (and never destroys it).
|
|
243
301
|
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.
|
|
302
|
+
/** Every memory a search returned, from both fields, as one array.
|
|
247
303
|
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* and the dropped memories were already on the wire and already paid for.
|
|
304
|
+
* Some models answer with the assistant's own words in `self_memories`, not
|
|
305
|
+
* repeated in `memories`. `search()` used to return `memories` alone, so an
|
|
306
|
+
* assistant turn stored with `addTurn` was missing from its results on those
|
|
307
|
+
* models while the same query returned it on others — upgrading made search
|
|
308
|
+
* return LESS, and what went missing had already been retrieved and paid for.
|
|
254
309
|
*
|
|
255
|
-
* Both
|
|
256
|
-
* caller can still tell who said what. Callers who want
|
|
257
|
-
* `searchSelf()`, which
|
|
258
|
-
function
|
|
310
|
+
* Both fields are returned here, de-duplicated by id, each memory keeping its
|
|
311
|
+
* `speaker` so a caller can still tell who said what. Callers who want them kept
|
|
312
|
+
* apart use `searchSelf()`, which is what that method is for. */
|
|
313
|
+
function mergeResults(r) {
|
|
259
314
|
const main = asRecords(r?.memories);
|
|
260
315
|
const mine = asRecords(r?.self_memories);
|
|
261
316
|
if (mine.length === 0)
|
|
@@ -263,6 +318,9 @@ function mergeLanes(r) {
|
|
|
263
318
|
const seen = new Set(main.map((m) => m.id).filter((id) => typeof id === "string"));
|
|
264
319
|
return main.concat(mine.filter((m) => !(typeof m.id === "string" && seen.has(m.id))));
|
|
265
320
|
}
|
|
321
|
+
/** Attach a delivery-form override ("memoir"/"archive") + tz to a request body, the
|
|
322
|
+
* way search passes them; on Scroll 1.2+ this renders each returned memory's time in
|
|
323
|
+
* that form. The server validates the form (400 on an unknown one). */
|
|
266
324
|
function withForm(body, form, tz) {
|
|
267
325
|
if (form !== undefined)
|
|
268
326
|
body.form = form;
|
|
@@ -383,6 +441,8 @@ export class Client {
|
|
|
383
441
|
apiKey;
|
|
384
442
|
model;
|
|
385
443
|
maxRetries;
|
|
444
|
+
deadlineMs;
|
|
445
|
+
signal;
|
|
386
446
|
/** Transport. Held as a field (not read off `globalThis` per call) so a caller
|
|
387
447
|
* who passes one gets it for every request, including from cloned clients. */
|
|
388
448
|
fetchImpl;
|
|
@@ -403,6 +463,12 @@ export class Client {
|
|
|
403
463
|
throw new Error("apiKey is required");
|
|
404
464
|
if (/\s/.test(key))
|
|
405
465
|
throw new Error("apiKey contains whitespace - check for a stray newline or paste error");
|
|
466
|
+
// `\s` does not cover NUL, 0x01 or DEL, and those travel into the header and fail
|
|
467
|
+
// deep inside fetch as the mystery 401 this check exists to prevent. Python has
|
|
468
|
+
// `_HEADER_CTL_RE` and Rust tests `is_control`; this was the client that did not,
|
|
469
|
+
// while all three READMEs announced the guard without naming a language.
|
|
470
|
+
if (/[\x00-\x1f\x7f]/.test(key))
|
|
471
|
+
throw new Error("apiKey contains a control character - check for a stray byte or paste error");
|
|
406
472
|
this.apiKey = key;
|
|
407
473
|
this.base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
408
474
|
// Coerce a non-finite or non-positive timeout back to the default — NaN
|
|
@@ -420,6 +486,11 @@ export class Client {
|
|
|
420
486
|
// "retries exhausted"; Infinity would retry forever.
|
|
421
487
|
const mr = opts.maxRetries ?? opts.retries;
|
|
422
488
|
this.maxRetries = typeof mr === "number" && Number.isFinite(mr) ? Math.max(0, Math.floor(mr)) : 2;
|
|
489
|
+
const dl = opts.deadlineMs;
|
|
490
|
+
// A zero or negative budget would fail every call before it starts — treat it as
|
|
491
|
+
// "no budget", the same way a non-positive timeout falls back to the default.
|
|
492
|
+
this.deadlineMs = typeof dl === "number" && Number.isFinite(dl) && dl > 0 ? dl : undefined;
|
|
493
|
+
this.signal = opts.signal;
|
|
423
494
|
// Bind the global so `fetch` is not called as a method of `globalThis`, which
|
|
424
495
|
// throws "Illegal invocation" on some runtimes. A caller-supplied fetch is
|
|
425
496
|
// taken as given — it is already whatever they meant to hand us.
|
|
@@ -440,7 +511,7 @@ export class Client {
|
|
|
440
511
|
if (this.model)
|
|
441
512
|
this.headers["X-WOS-Model"] = this.model;
|
|
442
513
|
// An API key on plain HTTP travels readable by anyone on the path. Loopback
|
|
443
|
-
// is fine (local dev
|
|
514
|
+
// is fine (local dev, or a proxy on the same box); anything else gets a
|
|
444
515
|
// warning, not an error, so private-network gateways keep working.
|
|
445
516
|
// Parse, don't split. `http://127.0.0.1:9@evil.example` splits to "127.0.0.1" — the
|
|
446
517
|
// USERINFO, not the host — so this check called it loopback and stayed quiet while
|
|
@@ -448,7 +519,12 @@ export class Client {
|
|
|
448
519
|
// (Python already used urlsplit here; this was the copy that did not.)
|
|
449
520
|
let host;
|
|
450
521
|
try {
|
|
451
|
-
|
|
522
|
+
// `URL.hostname` keeps the brackets on an IPv6 literal — `http://[::1]:8080`
|
|
523
|
+
// gives back "[::1]", which was never in LOOPBACK_HOSTS, so a local
|
|
524
|
+
// client on the v6 loopback was told its key was travelling in cleartext when
|
|
525
|
+
// it was not. Python's urlsplit strips them and Rust strips them by hand; this
|
|
526
|
+
// was the copy that did not. Strip them here so all three agree.
|
|
527
|
+
host = new URL(this.base).hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
452
528
|
}
|
|
453
529
|
catch {
|
|
454
530
|
host = ""; // unparseable → not loopback → warn, which is the safe direction
|
|
@@ -523,6 +599,11 @@ export class Client {
|
|
|
523
599
|
// friends would silently fall back to the global fetch and a proxy-bound
|
|
524
600
|
// client would stop connecting the moment it was cloned.
|
|
525
601
|
fetch: this.fetchImpl,
|
|
602
|
+
// Carried for the same reason as `fetch` above: a clone that quietly dropped
|
|
603
|
+
// the caller's signal would keep working right up until the moment someone
|
|
604
|
+
// needed to cancel it.
|
|
605
|
+
deadlineMs: this.deadlineMs,
|
|
606
|
+
signal: this.signal,
|
|
526
607
|
...overrides,
|
|
527
608
|
});
|
|
528
609
|
}
|
|
@@ -538,6 +619,22 @@ export class Client {
|
|
|
538
619
|
withUser(userId) {
|
|
539
620
|
return this.clone({ userId });
|
|
540
621
|
}
|
|
622
|
+
/** A client bound to a caller's `AbortSignal` (everything else kept).
|
|
623
|
+
*
|
|
624
|
+
* const ctrl = new AbortController();
|
|
625
|
+
* req.on("close", () => ctrl.abort());
|
|
626
|
+
* const r = await mem.withSignal(ctrl.signal).recall(q, user);
|
|
627
|
+
*
|
|
628
|
+
* Aborting ends the request in flight AND any backoff sleep waiting to retry it. */
|
|
629
|
+
withSignal(signal) {
|
|
630
|
+
return this.clone({ signal });
|
|
631
|
+
}
|
|
632
|
+
/** A client with a total budget per call, across every retry (everything else
|
|
633
|
+
* kept). `mem.withDeadline(5_000).search(q)` inside a handler that has five
|
|
634
|
+
* seconds. */
|
|
635
|
+
withDeadline(deadlineMs) {
|
|
636
|
+
return this.clone({ deadlineMs });
|
|
637
|
+
}
|
|
541
638
|
/** A client with a different per-request timeout in ms (everything else kept).
|
|
542
639
|
* `mem.withTimeout(120_000).addBulk(bigBlob)` — this slow call only. */
|
|
543
640
|
withTimeout(timeoutMs) {
|
|
@@ -571,9 +668,9 @@ export class Client {
|
|
|
571
668
|
addTurn(userMsg, assistantMsg, userId, opts = {}) {
|
|
572
669
|
return this.post("/api/v1/memory/store-turn", { user_id: this.uid(userId), user_msg: userMsg, assistant_msg: assistantMsg }, opts.idempotencyKey);
|
|
573
670
|
}
|
|
574
|
-
/** Bulk-ingest a large blob of text
|
|
671
|
+
/** Bulk-ingest a large blob of text in one call. For backfilling.
|
|
575
672
|
* The call most worth an `opts.idempotencyKey`: a backfill that dies halfway and is
|
|
576
|
-
* re-run would otherwise re-
|
|
673
|
+
* re-run would otherwise re-ingest and re-bill the whole blob. */
|
|
577
674
|
addBulk(content, userId, category = "general", timestamp, opts = {}) {
|
|
578
675
|
const body = { user_id: this.uid(userId), content, category };
|
|
579
676
|
if (timestamp)
|
|
@@ -585,40 +682,42 @@ export class Client {
|
|
|
585
682
|
return this.post("/api/v1/memory/supersede", { user_id: this.uid(userId), old_memory_id: oldMemoryId, new_content: newContent }, opts.idempotencyKey);
|
|
586
683
|
}
|
|
587
684
|
// ----- read -----
|
|
588
|
-
/**
|
|
685
|
+
/** Search a store's memories. Returns them most relevant first.
|
|
589
686
|
*
|
|
590
|
-
* `limit` bounds
|
|
591
|
-
* (Scroll 1.2+)
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
687
|
+
* `limit` bounds `memories`, not the returned array. On a model that keeps the
|
|
688
|
+
* assistant's own words separate (Scroll 1.2+) those come back as well, so the
|
|
689
|
+
* array can hold more than `limit`. They were retrieved and billed either way;
|
|
690
|
+
* dropping them would only hide what you already paid for. Size a prompt window
|
|
691
|
+
* on the array you get back, not on `limit`. `searchSelf()` hands the two back
|
|
692
|
+
* apart. */
|
|
596
693
|
async search(query, userId, limit = 10, opts = {}) {
|
|
597
694
|
// Reserved fields win over ...opts: an app that forwards untrusted input as
|
|
598
695
|
// opts must not be able to override the store (user_id), query, or limit.
|
|
599
696
|
warnOnUnknownFilters(opts.filters);
|
|
697
|
+
checkCount(limit, "limit");
|
|
600
698
|
const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
|
|
601
|
-
return
|
|
699
|
+
return mergeResults(r);
|
|
602
700
|
}
|
|
603
|
-
/** Search a self-memory model (Scroll 1.2+): both
|
|
604
|
-
* `{ memories, self_memories }` — `memories` is
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
701
|
+
/** Search a self-memory model (Scroll 1.2+): both fields from ONE call. Returns
|
|
702
|
+
* `{ memories, self_memories }` — `memories` is what others said and general
|
|
703
|
+
* memories, `self_memories` is the assistant's OWN words (stored with speaker
|
|
704
|
+
* "me"), kept apart so whoever reads them never confuses who said what. On a model
|
|
705
|
+
* that does not keep them apart, `self_memories` is `[]`. `userId` may be omitted. */
|
|
608
706
|
async searchSelf(query, userId, limit = 10, opts = {}) {
|
|
609
707
|
warnOnUnknownFilters(opts.filters);
|
|
708
|
+
checkCount(limit, "limit");
|
|
610
709
|
const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
|
|
611
710
|
return { memories: asRecords(r.memories), self_memories: asRecords(r.self_memories) };
|
|
612
711
|
}
|
|
613
712
|
/**
|
|
614
|
-
* Search, and keep every
|
|
713
|
+
* Search, and keep every field the answer came with.
|
|
615
714
|
*
|
|
616
715
|
* Same request as {@link Client.search} — reach for this one when the options make
|
|
617
716
|
* the merged array an incomplete answer:
|
|
618
717
|
*
|
|
619
718
|
* const r = await mem.searchFull("the day we moved", "alice", 10,
|
|
620
719
|
* { max_images: 3, verify: 2 });
|
|
621
|
-
* r.images.length; // the
|
|
720
|
+
* r.images.length; // the photos, which `search` drops
|
|
622
721
|
* r.verify_used; // how many re-ask passes actually ran (you are billed per pass)
|
|
623
722
|
*
|
|
624
723
|
* Both options need Tablet 2 or newer and are refused (403) on an older engine
|
|
@@ -626,8 +725,9 @@ export class Client {
|
|
|
626
725
|
*/
|
|
627
726
|
async searchFull(query, userId, limit = 10, opts = {}) {
|
|
628
727
|
warnOnUnknownFilters(opts.filters);
|
|
728
|
+
checkCount(limit, "limit");
|
|
629
729
|
const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
|
|
630
|
-
// Spread first so a
|
|
730
|
+
// Spread first so a field added later still arrives; the known ones are then
|
|
631
731
|
// normalized, because a broken proxy can null any of them.
|
|
632
732
|
return {
|
|
633
733
|
...r,
|
|
@@ -639,15 +739,25 @@ export class Client {
|
|
|
639
739
|
/** One-call LLM context: short-term turns + long-term matches + surrounding context.
|
|
640
740
|
* `form` ("memoir"/"archive", Scroll 1.2+) renders each long-term memory's time in
|
|
641
741
|
* that form; `tz` is your UTC-offset hours for that rendering. */
|
|
642
|
-
|
|
742
|
+
// `async` so an argument mistake arrives the way every other failure does — a
|
|
743
|
+
// rejected promise. Validating inside a non-async method threw synchronously, so
|
|
744
|
+
// `mem.recall(...).catch(h)` walked straight past the handler while the identical
|
|
745
|
+
// mistake in `search` (which is async) landed in it. Two shapes for one error.
|
|
746
|
+
async recall(query, userId, opts = {}) {
|
|
643
747
|
const body = { user_id: this.uid(userId), query };
|
|
644
|
-
if (opts.limit !== undefined)
|
|
748
|
+
if (opts.limit !== undefined) {
|
|
749
|
+
checkCount(opts.limit, "limit");
|
|
645
750
|
body.limit = opts.limit;
|
|
646
|
-
|
|
751
|
+
}
|
|
752
|
+
if (opts.context_limit !== undefined) {
|
|
753
|
+
checkContextLimit(opts.context_limit);
|
|
647
754
|
body.context_limit = opts.context_limit;
|
|
755
|
+
}
|
|
648
756
|
return this.post("/api/v1/memory/recall", withForm(body, opts.form, opts.tz));
|
|
649
757
|
}
|
|
650
|
-
/** Run a built-in engram ("deep_recall" | "timeline" | "gather"
|
|
758
|
+
/** Run a built-in engram ("deep_recall" | "timeline" | "gather" | "equilibrium" |
|
|
759
|
+
* "tone_stabilizer"; the service is the authority — an unknown name comes back with
|
|
760
|
+
* the list it accepts). Returns the merged result.
|
|
651
761
|
* `form`/`tz` render memory times (memoir/archive) on Scroll 1.2+, same as search/recall. */
|
|
652
762
|
engram(name, query, userId, opts = {}) {
|
|
653
763
|
return this.post("/api/v1/engram/run", withForm({ name, user_id: this.uid(userId), query }, opts.form, opts.tz));
|
|
@@ -661,10 +771,11 @@ export class Client {
|
|
|
661
771
|
stats(userId) {
|
|
662
772
|
return this.post("/api/v1/memory/stats", { user_id: this.uid(userId) });
|
|
663
773
|
}
|
|
664
|
-
/** Fetch ONE memory by id — the
|
|
774
|
+
/** Fetch ONE memory by id — the text you stored, and its metadata.
|
|
665
775
|
* 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`. `
|
|
776
|
+
* `listMemories`: an id from another store, an internal record id, or an
|
|
777
|
+
* invalidated memory rejects with `NotFoundError`. Pass `undefined` as `userId` for
|
|
778
|
+
* the default store — it is positional here, not omittable as it is in Python. */
|
|
668
779
|
get(userId, memoryId) {
|
|
669
780
|
// Sync throw (same shape as `delete`), so a missing id fails loudly even
|
|
670
781
|
// when the caller forgets to await.
|
|
@@ -673,8 +784,8 @@ export class Client {
|
|
|
673
784
|
}
|
|
674
785
|
return this.post("/api/v1/memory/get", { user_id: this.uid(userId), memory_id: memoryId }).then((r) => asObj(r.memory));
|
|
675
786
|
}
|
|
676
|
-
/** List a store's stored memories — the
|
|
677
|
-
*
|
|
787
|
+
/** List a store's stored memories — the text you stored, plus its metadata.
|
|
788
|
+
* Paginated: pass the returned `next_cursor` back as `cursor` for the next
|
|
678
789
|
* page; a `null` cursor means the last page. Use it to browse or export a store.
|
|
679
790
|
*
|
|
680
791
|
* let cursor: string | null = null;
|
|
@@ -692,7 +803,7 @@ export class Client {
|
|
|
692
803
|
return this.post("/api/v1/memory/list", body);
|
|
693
804
|
}
|
|
694
805
|
/** Async-iterate every stored memory in a store, paging under the hood — no cursor
|
|
695
|
-
* bookkeeping.
|
|
806
|
+
* bookkeeping. The text you stored and its metadata only.
|
|
696
807
|
*
|
|
697
808
|
* for await (const m of mem.iterMemories()) console.log(m.id, m.content);
|
|
698
809
|
*/
|
|
@@ -707,12 +818,14 @@ export class Client {
|
|
|
707
818
|
yield m;
|
|
708
819
|
const next = p.next_cursor ?? undefined;
|
|
709
820
|
if (!next || seen.has(next))
|
|
710
|
-
|
|
821
|
+
return;
|
|
711
822
|
seen.add(next);
|
|
712
823
|
cursor = next;
|
|
713
824
|
}
|
|
825
|
+
throw new Error(`stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
|
|
826
|
+
`answer, not the whole store.`);
|
|
714
827
|
}
|
|
715
|
-
/** Collect ALL of a store's memories into an array (
|
|
828
|
+
/** Collect ALL of a store's memories into an array (the text you stored, and its metadata). */
|
|
716
829
|
async exportMemories(userId) {
|
|
717
830
|
const out = [];
|
|
718
831
|
for await (const m of this.iterMemories(userId))
|
|
@@ -726,8 +839,8 @@ export class Client {
|
|
|
726
839
|
* Not necessarily your upload, in size OR in format: an image whose long edge was
|
|
727
840
|
* over 1568px was downscaled to 1568 on the way in and re-encoded — lossless
|
|
728
841
|
* formats as WebP, so a PNG comes back as `image/webp`; JPEG stays JPEG — and that
|
|
729
|
-
* smaller picture is what
|
|
730
|
-
*
|
|
842
|
+
* smaller picture is what is stored and comes back here. Nothing on our side ever
|
|
843
|
+
* uses more than 1568, so the extra pixels would be bytes nobody reads.
|
|
731
844
|
* Keep your own copy for the full-resolution file.
|
|
732
845
|
*
|
|
733
846
|
* The type is sniffed from the bytes themselves rather than from whatever the file
|
|
@@ -800,17 +913,24 @@ export class Client {
|
|
|
800
913
|
const p = await this.listImages(userId, { limit: opts.pageSize, before, skipIds });
|
|
801
914
|
for (const m of asRecords(p.images))
|
|
802
915
|
yield m;
|
|
916
|
+
// `return`, not `break`: the throw below is the MAX_PAGES backstop, and a
|
|
917
|
+
// `break` fell straight into it — so the ordinary end of a walk raised
|
|
918
|
+
// "the store did not end" on a store that had just ended. Python is safe
|
|
919
|
+
// here by using for/else and Rust by carrying an `ended` flag; TypeScript
|
|
920
|
+
// has neither, so the exit has to leave the function outright.
|
|
803
921
|
if (!p.has_more || !p.next_before)
|
|
804
|
-
|
|
922
|
+
return;
|
|
805
923
|
// The cursor is the PAIR: several images can share a timestamp, so `before`
|
|
806
924
|
// alone repeats across pages legitimately.
|
|
807
925
|
const key = `${p.next_before}|${(Array.isArray(p.next_skip_ids) ? p.next_skip_ids : []).join(",")}`;
|
|
808
926
|
if (seen.has(key))
|
|
809
|
-
|
|
927
|
+
return;
|
|
810
928
|
seen.add(key);
|
|
811
929
|
before = p.next_before;
|
|
812
930
|
skipIds = Array.isArray(p.next_skip_ids) ? p.next_skip_ids : undefined;
|
|
813
931
|
}
|
|
932
|
+
throw new Error(`stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
|
|
933
|
+
`answer, not the whole store.`);
|
|
814
934
|
}
|
|
815
935
|
// ----- how much has this memory been edited -----
|
|
816
936
|
/**
|
|
@@ -890,9 +1010,9 @@ export class Client {
|
|
|
890
1010
|
* assistant's own words, otherwise a person's name. Same cursor paging as
|
|
891
1011
|
* `listImages`.
|
|
892
1012
|
*
|
|
893
|
-
* `chunks` / `points_to_delete` report
|
|
894
|
-
*
|
|
895
|
-
*
|
|
1013
|
+
* `chunks` / `points_to_delete` report how many internal records a delete would
|
|
1014
|
+
* actually remove — usually more than `returned`, and worth showing to whoever is
|
|
1015
|
+
* about to confirm one.
|
|
896
1016
|
*/
|
|
897
1017
|
bySpeaker(speaker, userId, opts = {}) {
|
|
898
1018
|
if (!speaker || typeof speaker !== "string" || speaker.trim() === "") {
|
|
@@ -975,14 +1095,18 @@ export class Client {
|
|
|
975
1095
|
/** The store's registered people, each with its memory count. */
|
|
976
1096
|
async listSpeakers(userId) {
|
|
977
1097
|
const r = await this.request("GET", `/api/v1/memory/speakers?user_id=${encodeURIComponent(this.uid(userId))}`);
|
|
978
|
-
|
|
1098
|
+
// The spread came second, so a present-but-null `speakers` overwrote the default
|
|
1099
|
+
// and the caller got null typed as an array. Every other list route goes through
|
|
1100
|
+
// asRecords; this one did not.
|
|
1101
|
+
return { ...r, speakers: asRecords(r?.speakers) };
|
|
979
1102
|
}
|
|
980
1103
|
/** Unregister a person. Their memories stay; the name tag goes. */
|
|
981
1104
|
removeSpeaker(speaker, userId) {
|
|
982
1105
|
return this.request("DELETE", "/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
|
|
983
1106
|
}
|
|
984
1107
|
// ----- delete -----
|
|
985
|
-
/** Delete a single memory by id. `userId`
|
|
1108
|
+
/** Delete a single memory by id. Pass `undefined` as `userId` for the default
|
|
1109
|
+
* store — it is positional here, not omittable as it is in Python. */
|
|
986
1110
|
delete(userId, memoryId) {
|
|
987
1111
|
// Guard: without a memory_id the API's forget endpoint means "delete the
|
|
988
1112
|
// whole store". An undefined/"" slipping in here must never become a wipe.
|
|
@@ -1024,66 +1148,203 @@ export class Client {
|
|
|
1024
1148
|
* a non-2xx we re-read the body as text and reuse `errorFor`, which keeps 404
|
|
1025
1149
|
* (`NotFoundError`) and 401 behaving the same as everywhere else.
|
|
1026
1150
|
*/
|
|
1027
|
-
|
|
1151
|
+
/** Where this call's budget runs out, or `undefined` when it has none. Computed
|
|
1152
|
+
* once per call, not per attempt — a budget recomputed each attempt is not a
|
|
1153
|
+
* budget. */
|
|
1154
|
+
deadlineAt() {
|
|
1155
|
+
return this.deadlineMs === undefined ? undefined : Date.now() + this.deadlineMs;
|
|
1156
|
+
}
|
|
1157
|
+
/** The abort wiring for ONE attempt: this SDK's per-attempt timeout, the caller's
|
|
1158
|
+
* signal, and whatever is left of the overall deadline — whichever fires first.
|
|
1159
|
+
*
|
|
1160
|
+
* One function, two callers (`request` and `requestBytes`), because a guard living
|
|
1161
|
+
* in one of two request paths is exactly how `getImage` ended up with no retry loop
|
|
1162
|
+
* while the module doc promised every call had one. */
|
|
1163
|
+
beginAttempt(deadlineAt) {
|
|
1164
|
+
let why = "timeout";
|
|
1165
|
+
let budget = this.timeoutMs;
|
|
1166
|
+
if (deadlineAt !== undefined) {
|
|
1167
|
+
const left = deadlineAt - Date.now();
|
|
1168
|
+
// Refuse rather than open a socket there is no time to use. Python's
|
|
1169
|
+
// `_attempt_budget` and Rust's `attempt_budget` have raised here from the start.
|
|
1170
|
+
// This path instead clamped the budget to 0 and began the attempt anyway, which
|
|
1171
|
+
// left `setTimeout(…, 0)` — it fires on the next timer phase — racing a fetch
|
|
1172
|
+
// that a fast server answers first. The same call then sometimes reported its
|
|
1173
|
+
// deadline and sometimes returned a result: one run in three, measured.
|
|
1174
|
+
if (left <= 0)
|
|
1175
|
+
throw new APIConnectionError(0, this.abortMessage("deadline"));
|
|
1176
|
+
if (left < budget) {
|
|
1177
|
+
budget = left;
|
|
1178
|
+
why = "deadline";
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1028
1181
|
const ctrl = new AbortController();
|
|
1029
|
-
const timer = setTimeout(() => ctrl.abort(),
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
}
|
|
1182
|
+
const timer = setTimeout(() => ctrl.abort(), budget);
|
|
1183
|
+
const outer = this.signal;
|
|
1184
|
+
const onCaller = () => {
|
|
1185
|
+
why = "caller";
|
|
1186
|
+
ctrl.abort();
|
|
1187
|
+
};
|
|
1188
|
+
if (outer) {
|
|
1189
|
+
if (outer.aborted)
|
|
1190
|
+
onCaller();
|
|
1191
|
+
// Removed in clear(). `{ once: true }` would not be enough: one long-lived
|
|
1192
|
+
// signal per user session would still collect one listener per request until
|
|
1193
|
+
// the session ended.
|
|
1194
|
+
else
|
|
1195
|
+
outer.addEventListener("abort", onCaller);
|
|
1039
1196
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1197
|
+
return {
|
|
1198
|
+
signal: ctrl.signal,
|
|
1199
|
+
clear: () => {
|
|
1200
|
+
clearTimeout(timer);
|
|
1201
|
+
outer?.removeEventListener("abort", onCaller);
|
|
1202
|
+
},
|
|
1203
|
+
why: () => why,
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
/** What to say when an attempt was cut short. The three reasons need different
|
|
1207
|
+
* words: a timeout may have landed server-side, a cancellation certainly did not
|
|
1208
|
+
* matter to the caller, and an exhausted budget is the caller's own setting. */
|
|
1209
|
+
abortMessage(why) {
|
|
1210
|
+
if (why === "caller")
|
|
1211
|
+
return "cancelled — the caller's AbortSignal fired";
|
|
1212
|
+
if (why === "deadline")
|
|
1213
|
+
return `deadline of ${this.deadlineMs}ms exhausted`;
|
|
1214
|
+
return `request timed out after ${this.timeoutMs}ms`;
|
|
1215
|
+
}
|
|
1216
|
+
/** Sleep between attempts — but wake the moment the caller aborts, and give up
|
|
1217
|
+
* rather than wait out a backoff the budget cannot cover. A backoff that runs to
|
|
1218
|
+
* completion after the caller gave up wastes exactly as long as the request it was
|
|
1219
|
+
* waiting to repeat.
|
|
1220
|
+
*
|
|
1221
|
+
* Clamping the wait to what is left and retrying anyway is the same answer as
|
|
1222
|
+
* having no deadline at all: the call still spends the whole allowance, and the one
|
|
1223
|
+
* attempt it buys has nothing left to finish in. If the wait does not fit, the
|
|
1224
|
+
* budget is already decided. */
|
|
1225
|
+
async backoffSleep(ms, deadlineAt) {
|
|
1226
|
+
if (deadlineAt !== undefined && ms > deadlineAt - Date.now()) {
|
|
1227
|
+
throw new APIConnectionError(0, this.abortMessage("deadline"));
|
|
1046
1228
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1229
|
+
const outer = this.signal;
|
|
1230
|
+
if (!outer) {
|
|
1231
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if (outer.aborted)
|
|
1235
|
+
return;
|
|
1236
|
+
await new Promise((resolve) => {
|
|
1237
|
+
const done = () => {
|
|
1238
|
+
clearTimeout(timer);
|
|
1239
|
+
outer.removeEventListener("abort", done);
|
|
1240
|
+
resolve();
|
|
1241
|
+
};
|
|
1242
|
+
const timer = setTimeout(done, ms);
|
|
1243
|
+
outer.addEventListener("abort", done);
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
async requestBytes(path, body) {
|
|
1247
|
+
// This route had no retry loop at all, while the module doc promised "every call
|
|
1248
|
+
// retries transient failures … 429 always". Measured with maxRetries: 4 against a
|
|
1249
|
+
// server answering 429 — stats() made five requests, getImage() made one. The
|
|
1250
|
+
// largest and most rate-limit-prone call in the SDK was the one that gave up
|
|
1251
|
+
// immediately. The loop lives inside the function because honouring `Retry-After`
|
|
1252
|
+
// means still holding the response.
|
|
1253
|
+
const attempts = this.maxRetries + 1;
|
|
1254
|
+
const deadlineAt = this.deadlineAt();
|
|
1255
|
+
for (let attempt = 0;; attempt++) {
|
|
1256
|
+
const att = this.beginAttempt(deadlineAt);
|
|
1257
|
+
let res;
|
|
1258
|
+
try {
|
|
1259
|
+
res = await this.fetchImpl(`${this.base}${path}`, {
|
|
1260
|
+
method: "POST",
|
|
1261
|
+
headers: this.headers,
|
|
1262
|
+
body: JSON.stringify(body),
|
|
1263
|
+
signal: att.signal,
|
|
1264
|
+
redirect: "manual",
|
|
1265
|
+
});
|
|
1052
1266
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1267
|
+
catch (e) {
|
|
1268
|
+
att.clear();
|
|
1269
|
+
const timedOut = e?.name === "AbortError" || att.signal.aborted;
|
|
1270
|
+
// Same rule as `request()`: a connect-level failure never reached the server,
|
|
1271
|
+
// so re-sending cannot double-process anything. The 429 loop below was added
|
|
1272
|
+
// in 2.2.35 and this branch was not, which left the module doc's promise —
|
|
1273
|
+
// "every call retries transient failures" — still false for `getImage` on
|
|
1274
|
+
// exactly the failure a retry is for. A timeout is ambiguous and stays final.
|
|
1275
|
+
if (!timedOut && isConnectFailure(e) && attempt + 1 < attempts) {
|
|
1276
|
+
await this.backoffSleep(this.backoffMs(attempt), deadlineAt);
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
throw new APIConnectionError(0, timedOut ? this.abortMessage(att.why()) : `network error: ${e?.message ?? e}`);
|
|
1280
|
+
}
|
|
1281
|
+
try {
|
|
1282
|
+
this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
|
|
1283
|
+
if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
|
|
1284
|
+
void res.body?.cancel();
|
|
1285
|
+
throw errorFor(res.status, "the API answered with a redirect; refusing to follow it");
|
|
1286
|
+
}
|
|
1287
|
+
if (!res.ok) {
|
|
1288
|
+
if (RETRY_ALWAYS.has(res.status) && attempt + 1 < attempts) {
|
|
1289
|
+
const retryAfter = res.headers.get("Retry-After");
|
|
1290
|
+
void res.body?.cancel();
|
|
1291
|
+
att.clear();
|
|
1292
|
+
await this.backoffSleep(this.backoffMs(attempt, retryAfter), deadlineAt);
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
let msg = await this.readCapped(res).catch(() => "");
|
|
1296
|
+
try {
|
|
1297
|
+
const data = JSON.parse(msg);
|
|
1298
|
+
const err = data?.error;
|
|
1299
|
+
if (err && typeof err === "object")
|
|
1300
|
+
msg = err.message ?? err.type ?? msg;
|
|
1301
|
+
else if (typeof err === "string")
|
|
1302
|
+
msg = err;
|
|
1303
|
+
}
|
|
1304
|
+
catch {
|
|
1305
|
+
/* keep raw text */
|
|
1306
|
+
}
|
|
1307
|
+
if (msg.length > MAX_ERR_MSG)
|
|
1308
|
+
msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
|
|
1309
|
+
throw errorFor(res.status, msg || `HTTP ${res.status}`);
|
|
1310
|
+
}
|
|
1311
|
+
// Translated, not raw. A timeout that fires while the body is streaming used to
|
|
1312
|
+
// escape as `AbortError` — not a WosError, not an APIConnectionError — so a
|
|
1313
|
+
// caller's `catch (e) { if (e instanceof WosError) … }` handled every JSON route
|
|
1314
|
+
// and let this one through unclassified. The JSON path already translated it.
|
|
1315
|
+
let buf;
|
|
1055
1316
|
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;
|
|
1317
|
+
buf = await this.readCappedBytes(res);
|
|
1062
1318
|
}
|
|
1063
|
-
catch {
|
|
1064
|
-
|
|
1319
|
+
catch (e) {
|
|
1320
|
+
if (e?.name === "AbortError" || att.signal.aborted) {
|
|
1321
|
+
throw new APIConnectionError(0, `request timed out after ${this.timeoutMs}ms`);
|
|
1322
|
+
}
|
|
1323
|
+
throw e;
|
|
1065
1324
|
}
|
|
1066
|
-
if (
|
|
1067
|
-
|
|
1068
|
-
|
|
1325
|
+
if (buf.byteLength === 0) {
|
|
1326
|
+
// An empty 200 would otherwise read as "here is your image" and write a
|
|
1327
|
+
// zero-byte file — indistinguishable from an image we lost.
|
|
1328
|
+
throw new WosError(res.status, "empty image body — the service returned no bytes");
|
|
1329
|
+
}
|
|
1330
|
+
return { bytes: buf, contentType: res.headers.get("content-type") ?? "application/octet-stream" };
|
|
1069
1331
|
}
|
|
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");
|
|
1332
|
+
finally {
|
|
1333
|
+
att.clear();
|
|
1075
1334
|
}
|
|
1076
|
-
return { bytes: buf, contentType: res.headers.get("content-type") ?? "application/octet-stream" };
|
|
1077
|
-
}
|
|
1078
|
-
finally {
|
|
1079
|
-
clearTimeout(timer);
|
|
1080
1335
|
}
|
|
1081
1336
|
}
|
|
1082
1337
|
/** Validate an idempotency key BEFORE the network. The API answers a malformed
|
|
1083
1338
|
* key with a 400, which on a retry path reads as "my write failed" when in fact
|
|
1084
1339
|
* it was never attempted — cheaper and clearer to reject it here. */
|
|
1085
1340
|
idemHeader(key) {
|
|
1086
|
-
|
|
1341
|
+
// `=== undefined` let `null` through: RegExp.test stringifies its argument, so
|
|
1342
|
+
// `null` became the literal key "null" and passed the format check. A key read
|
|
1343
|
+
// from JSON or a database row arrives as null, not undefined, and every write on
|
|
1344
|
+
// that path then shared one key — the second onward returned the first response
|
|
1345
|
+
// and stored nothing. Python guards with `is None`; Rust's Option makes it
|
|
1346
|
+
// unrepresentable.
|
|
1347
|
+
if (key === undefined || key === null)
|
|
1087
1348
|
return undefined;
|
|
1088
1349
|
if (!IDEMPOTENCY_KEY_RE.test(key)) {
|
|
1089
1350
|
throw new Error(`invalid idempotencyKey: ${JSON.stringify(key)} — 1-128 chars of [A-Za-z0-9._:-]`);
|
|
@@ -1116,10 +1377,20 @@ export class Client {
|
|
|
1116
1377
|
async readCappedBytes(res) {
|
|
1117
1378
|
const cl = res.headers.get("content-length");
|
|
1118
1379
|
if (cl && Number(cl) > MAX_RESPONSE_BYTES) {
|
|
1119
|
-
throw new WosError(
|
|
1380
|
+
throw new WosError(res.status, `response too large (${cl} bytes) — refusing to buffer it`);
|
|
1381
|
+
}
|
|
1382
|
+
if (!res.body) {
|
|
1383
|
+
// `arrayBuffer()` has no ceiling, and only the content-length pre-check stands
|
|
1384
|
+
// in front of it — which a server simply omits. Reachable through the documented
|
|
1385
|
+
// `fetch` seam (test doubles, polyfills, wrappers that rebuild the Response),
|
|
1386
|
+
// and the option's own doc promises "the size cap still applies". Measured: a
|
|
1387
|
+
// body-less Response with no content-length buffered 73MB past the 64MB cap.
|
|
1388
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
1389
|
+
if (buf.byteLength > MAX_RESPONSE_BYTES) {
|
|
1390
|
+
throw new WosError(res.status, `response too large (${buf.byteLength} bytes) — refusing it`);
|
|
1391
|
+
}
|
|
1392
|
+
return buf;
|
|
1120
1393
|
}
|
|
1121
|
-
if (!res.body)
|
|
1122
|
-
return new Uint8Array(await res.arrayBuffer());
|
|
1123
1394
|
const reader = res.body.getReader();
|
|
1124
1395
|
const parts = [];
|
|
1125
1396
|
let size = 0;
|
|
@@ -1130,7 +1401,7 @@ export class Client {
|
|
|
1130
1401
|
size += value.byteLength;
|
|
1131
1402
|
if (size > MAX_RESPONSE_BYTES) {
|
|
1132
1403
|
void reader.cancel();
|
|
1133
|
-
throw new WosError(
|
|
1404
|
+
throw new WosError(res.status, "response too large — refusing to buffer it");
|
|
1134
1405
|
}
|
|
1135
1406
|
parts.push(value);
|
|
1136
1407
|
}
|
|
@@ -1148,26 +1419,26 @@ export class Client {
|
|
|
1148
1419
|
// Debug logging shows the path WITHOUT its query string — a query can carry
|
|
1149
1420
|
// a store id (listSpeakers), and logs must never carry data.
|
|
1150
1421
|
const logPath = path.split("?")[0];
|
|
1422
|
+
const deadlineAt = this.deadlineAt();
|
|
1151
1423
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
1152
1424
|
const start = Date.now();
|
|
1153
|
-
const
|
|
1154
|
-
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
|
1425
|
+
const att = this.beginAttempt(deadlineAt);
|
|
1155
1426
|
let res;
|
|
1156
1427
|
try {
|
|
1157
1428
|
res = await this.fetchImpl(`${this.base}${path}`, {
|
|
1158
1429
|
method,
|
|
1159
1430
|
headers: extraHeaders ? { ...this.headers, ...extraHeaders } : this.headers,
|
|
1160
1431
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
1161
|
-
signal:
|
|
1432
|
+
signal: att.signal,
|
|
1162
1433
|
// Never follow a redirect: fetch would forward the API key to
|
|
1163
1434
|
// wherever a 3xx points. The API never legitimately redirects.
|
|
1164
1435
|
redirect: "manual",
|
|
1165
1436
|
});
|
|
1166
1437
|
}
|
|
1167
1438
|
catch (e) {
|
|
1168
|
-
|
|
1439
|
+
att.clear();
|
|
1169
1440
|
// Timeouts are ambiguous (the write may have landed) — don't retry those.
|
|
1170
|
-
const timedOut = e?.name === "AbortError" ||
|
|
1441
|
+
const timedOut = e?.name === "AbortError" || att.signal.aborted;
|
|
1171
1442
|
// Other network errors retry only when a retry can't double-process a
|
|
1172
1443
|
// write: idempotent methods always; writes only for connect-level
|
|
1173
1444
|
// failures (the request never reached the server). A mid-stream drop on
|
|
@@ -1176,24 +1447,24 @@ export class Client {
|
|
|
1176
1447
|
if (!timedOut && safe && attempt + 1 < attempts) {
|
|
1177
1448
|
const delay = this.backoffMs(attempt);
|
|
1178
1449
|
logDebug(`${method} ${logPath}: ${e?.code ?? e?.name ?? "network error"} — retrying in ${delay}ms (attempt ${attempt + 1}/${attempts})`);
|
|
1179
|
-
await
|
|
1450
|
+
await this.backoffSleep(delay, deadlineAt);
|
|
1180
1451
|
continue;
|
|
1181
1452
|
}
|
|
1182
|
-
throw new APIConnectionError(0, timedOut ?
|
|
1453
|
+
throw new APIConnectionError(0, timedOut ? this.abortMessage(att.why()) : `network error: ${e?.message ?? e}`);
|
|
1183
1454
|
}
|
|
1184
1455
|
const retryable = RETRY_ALWAYS.has(res.status) ||
|
|
1185
1456
|
(RETRY_IF_IDEMPOTENT.has(res.status) && IDEMPOTENT_METHODS.has(method.toUpperCase()));
|
|
1186
1457
|
if (retryable && attempt + 1 < attempts) {
|
|
1187
|
-
|
|
1458
|
+
att.clear();
|
|
1188
1459
|
const retryAfter = res.headers.get("Retry-After");
|
|
1189
1460
|
void res.body?.cancel();
|
|
1190
1461
|
const delay = this.backoffMs(attempt, retryAfter);
|
|
1191
1462
|
logDebug(`${method} ${logPath} -> ${res.status} — retrying in ${delay}ms (attempt ${attempt + 1}/${attempts})`);
|
|
1192
|
-
await
|
|
1463
|
+
await this.backoffSleep(delay, deadlineAt);
|
|
1193
1464
|
continue;
|
|
1194
1465
|
}
|
|
1195
1466
|
if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
|
|
1196
|
-
|
|
1467
|
+
att.clear();
|
|
1197
1468
|
void res.body?.cancel(); // release the stream — otherwise the connection leaks
|
|
1198
1469
|
throw new WosError(res.status, "unexpected redirect — refused (the API key never follows a redirect). Check baseUrl: exact host, https://.");
|
|
1199
1470
|
}
|
|
@@ -1206,7 +1477,7 @@ export class Client {
|
|
|
1206
1477
|
text = await this.readCapped(res);
|
|
1207
1478
|
}
|
|
1208
1479
|
catch (e) {
|
|
1209
|
-
if (e?.name === "AbortError" ||
|
|
1480
|
+
if (e?.name === "AbortError" || att.signal.aborted) {
|
|
1210
1481
|
throw new APIConnectionError(0, `request timed out after ${this.timeoutMs}ms`);
|
|
1211
1482
|
}
|
|
1212
1483
|
if (e instanceof WosError)
|
|
@@ -1216,7 +1487,7 @@ export class Client {
|
|
|
1216
1487
|
throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
|
|
1217
1488
|
}
|
|
1218
1489
|
finally {
|
|
1219
|
-
|
|
1490
|
+
att.clear();
|
|
1220
1491
|
}
|
|
1221
1492
|
logDebug(`${method} ${logPath} -> ${res.status} in ${Date.now() - start}ms (attempt ${attempt + 1}/${attempts})`);
|
|
1222
1493
|
if (!res.ok) {
|
|
@@ -1277,12 +1548,17 @@ export class Client {
|
|
|
1277
1548
|
// `Idempotent-Replayed: true`, and it answers the question someone using an
|
|
1278
1549
|
// idempotency key is asking: did my retry write, or is this the first response
|
|
1279
1550
|
// coming back again?
|
|
1280
|
-
|
|
1551
|
+
//
|
|
1552
|
+
// Only when the body does not already carry the field. These responses are
|
|
1553
|
+
// widening — the next engine adds `images`, `hop`, `tip_of_tongue`, `policy` —
|
|
1554
|
+
// and a client that writes into a server's object is one release away from
|
|
1555
|
+
// overwriting a real answer with its own guess. What the service said wins.
|
|
1556
|
+
if (res.headers.get("Idempotent-Replayed") === "true" && !("replayed" in data)) {
|
|
1281
1557
|
data.replayed = true;
|
|
1282
1558
|
}
|
|
1283
1559
|
return data;
|
|
1284
1560
|
}
|
|
1285
|
-
throw new
|
|
1561
|
+
throw new Error("retries exhausted"); // unreachable; keeps the compiler happy
|
|
1286
1562
|
}
|
|
1287
1563
|
}
|
|
1288
1564
|
/** Back-compat alias: older code used `WME`. */
|