wontopos 2.2.35 → 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 +36 -4
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/wontopos.js +1582 -0
- package/dist/wontopos.d.ts +58 -0
- package/dist/wontopos.js +210 -49
- package/package.json +5 -4
package/dist/wontopos.d.ts
CHANGED
|
@@ -44,6 +44,9 @@
|
|
|
44
44
|
export declare function _resetStoreIdWarnings(): void;
|
|
45
45
|
export declare const SEARCH_LIMIT_MIN = 5;
|
|
46
46
|
export declare const SEARCH_LIMIT_MAX = 20;
|
|
47
|
+
/** `recall`'s surrounding-context count. 0 to 20 inclusive; 0 attaches none. */
|
|
48
|
+
export declare const CONTEXT_LIMIT_MIN = 0;
|
|
49
|
+
export declare const CONTEXT_LIMIT_MAX = 20;
|
|
47
50
|
/** Quota from a response's `X-RateLimit-*` headers, or `null` if absent. */
|
|
48
51
|
export interface RateLimit {
|
|
49
52
|
limit: number | null;
|
|
@@ -547,6 +550,22 @@ export interface ClientOptions {
|
|
|
547
550
|
* key on an options object is not an error in TS at runtime). Both names work;
|
|
548
551
|
* `maxRetries` wins if somehow both are given. */
|
|
549
552
|
retries?: number;
|
|
553
|
+
/** A total budget for one call, in ms, across every attempt.
|
|
554
|
+
*
|
|
555
|
+
* `timeoutMs` bounds ONE attempt. At the defaults — 30s, two retries — a single
|
|
556
|
+
* call can hold a connection for 30s + backoff + 30s + backoff + 30s, over a
|
|
557
|
+
* minute, and a server handler awaiting it has no way to say "I only have five
|
|
558
|
+
* seconds". This is that way. Unset means no overall budget. */
|
|
559
|
+
deadlineMs?: number;
|
|
560
|
+
/** The caller's `AbortSignal` — cancel work already in flight.
|
|
561
|
+
*
|
|
562
|
+
* The SDK aborts on its own timeout; this is the seam for the caller's reason.
|
|
563
|
+
* When someone closes the chat window, the recall that window asked for should
|
|
564
|
+
* end, and the backoff sleep waiting to retry it should end too, instead of
|
|
565
|
+
* running to completion and billing for an answer nobody will read.
|
|
566
|
+
*
|
|
567
|
+
* Per call, clone: `mem.withSignal(ctrl.signal).recall(...)`. */
|
|
568
|
+
signal?: AbortSignal;
|
|
550
569
|
/** The `fetch` used for every request (default: the global `fetch`).
|
|
551
570
|
*
|
|
552
571
|
* This is the seam for anything the runtime cannot express through options.
|
|
@@ -576,6 +595,8 @@ export declare class Client {
|
|
|
576
595
|
private readonly apiKey;
|
|
577
596
|
private readonly model;
|
|
578
597
|
private readonly maxRetries;
|
|
598
|
+
private readonly deadlineMs?;
|
|
599
|
+
private readonly signal?;
|
|
579
600
|
/** Transport. Held as a field (not read off `globalThis` per call) so a caller
|
|
580
601
|
* who passes one gets it for every request, including from cloned clients. */
|
|
581
602
|
private readonly fetchImpl;
|
|
@@ -603,6 +624,18 @@ export declare class Client {
|
|
|
603
624
|
withModel(model: string): Client;
|
|
604
625
|
/** A client bound to `userId` as its default store (everything else kept). */
|
|
605
626
|
withUser(userId: string): Client;
|
|
627
|
+
/** A client bound to a caller's `AbortSignal` (everything else kept).
|
|
628
|
+
*
|
|
629
|
+
* const ctrl = new AbortController();
|
|
630
|
+
* req.on("close", () => ctrl.abort());
|
|
631
|
+
* const r = await mem.withSignal(ctrl.signal).recall(q, user);
|
|
632
|
+
*
|
|
633
|
+
* Aborting ends the request in flight AND any backoff sleep waiting to retry it. */
|
|
634
|
+
withSignal(signal: AbortSignal): Client;
|
|
635
|
+
/** A client with a total budget per call, across every retry (everything else
|
|
636
|
+
* kept). `mem.withDeadline(5_000).search(q)` inside a handler that has five
|
|
637
|
+
* seconds. */
|
|
638
|
+
withDeadline(deadlineMs: number): Client;
|
|
606
639
|
/** A client with a different per-request timeout in ms (everything else kept).
|
|
607
640
|
* `mem.withTimeout(120_000).addBulk(bigBlob)` — this slow call only. */
|
|
608
641
|
withTimeout(timeoutMs: number): Client;
|
|
@@ -907,6 +940,31 @@ export declare class Client {
|
|
|
907
940
|
* a non-2xx we re-read the body as text and reuse `errorFor`, which keeps 404
|
|
908
941
|
* (`NotFoundError`) and 401 behaving the same as everywhere else.
|
|
909
942
|
*/
|
|
943
|
+
/** Where this call's budget runs out, or `undefined` when it has none. Computed
|
|
944
|
+
* once per call, not per attempt — a budget recomputed each attempt is not a
|
|
945
|
+
* budget. */
|
|
946
|
+
private deadlineAt;
|
|
947
|
+
/** The abort wiring for ONE attempt: this SDK's per-attempt timeout, the caller's
|
|
948
|
+
* signal, and whatever is left of the overall deadline — whichever fires first.
|
|
949
|
+
*
|
|
950
|
+
* One function, two callers (`request` and `requestBytes`), because a guard living
|
|
951
|
+
* in one of two request paths is exactly how `getImage` ended up with no retry loop
|
|
952
|
+
* while the module doc promised every call had one. */
|
|
953
|
+
private beginAttempt;
|
|
954
|
+
/** What to say when an attempt was cut short. The three reasons need different
|
|
955
|
+
* words: a timeout may have landed server-side, a cancellation certainly did not
|
|
956
|
+
* matter to the caller, and an exhausted budget is the caller's own setting. */
|
|
957
|
+
private abortMessage;
|
|
958
|
+
/** Sleep between attempts — but wake the moment the caller aborts, and give up
|
|
959
|
+
* rather than wait out a backoff the budget cannot cover. A backoff that runs to
|
|
960
|
+
* completion after the caller gave up wastes exactly as long as the request it was
|
|
961
|
+
* waiting to repeat.
|
|
962
|
+
*
|
|
963
|
+
* Clamping the wait to what is left and retrying anyway is the same answer as
|
|
964
|
+
* having no deadline at all: the call still spends the whole allowance, and the one
|
|
965
|
+
* attempt it buys has nothing left to finish in. If the wait does not fit, the
|
|
966
|
+
* budget is already decided. */
|
|
967
|
+
private backoffSleep;
|
|
910
968
|
private requestBytes;
|
|
911
969
|
/** Validate an idempotency key BEFORE the network. The API answers a malformed
|
|
912
970
|
* key with a 400, which on a retry path reads as "my write failed" when in fact
|
package/dist/wontopos.js
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
* runtime's (never disabled by this SDK; Node 18+ floors TLS at 1.2). Prefer
|
|
41
41
|
* `Client.fromEnv()` over keys in source code.
|
|
42
42
|
*/
|
|
43
|
-
const VERSION = "2.2.
|
|
43
|
+
const VERSION = "2.2.36";
|
|
44
44
|
/** Runtime info helps support debug a report ("node 18 on Windows...") —
|
|
45
45
|
* platform only, never anything identifying. Browsers have no `process` (and
|
|
46
46
|
* silently drop the UA header anyway). */
|
|
@@ -76,7 +76,7 @@ const RETRY_IF_IDEMPOTENT = new Set([502, 503]);
|
|
|
76
76
|
const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
77
77
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
|
|
78
78
|
/** Refuse to buffer absurd responses (real ones are a few KB) — protects the
|
|
79
|
-
* process if a
|
|
79
|
+
* process if a custom baseUrl points somewhere broken or hostile. */
|
|
80
80
|
const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
81
81
|
/** Model names travel in a header — only header-safe characters. */
|
|
82
82
|
const MODEL_RE = /^[A-Za-z0-9._-]+$/;
|
|
@@ -154,25 +154,50 @@ const NO_BODY_STATUS = new Set([204, 205, 304]);
|
|
|
154
154
|
const MAX_PAGES = 20_000;
|
|
155
155
|
export const SEARCH_LIMIT_MIN = 5;
|
|
156
156
|
export const SEARCH_LIMIT_MAX = 20;
|
|
157
|
-
/** `
|
|
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.
|
|
158
162
|
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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.
|
|
165
181
|
*
|
|
166
182
|
* Refusing rather than clamping is the same decision as 2.2.35's, where a Rust
|
|
167
183
|
* `limit` of 0 had been rewritten to 10 and the caller was handed ten memories they
|
|
168
184
|
* had not asked for, and the bill for them. */
|
|
169
|
-
function
|
|
185
|
+
function checkCount(limit, name) {
|
|
170
186
|
if (!Number.isInteger(limit) || limit < SEARCH_LIMIT_MIN || limit > SEARCH_LIMIT_MAX) {
|
|
171
|
-
throw new
|
|
187
|
+
throw new Error(`${name} must be an integer between ${SEARCH_LIMIT_MIN} and ${SEARCH_LIMIT_MAX}, got ${limit}. ` +
|
|
172
188
|
`Out of range is refused rather than adjusted, so a short answer always means ` +
|
|
173
189
|
`the store was short.`);
|
|
174
190
|
}
|
|
175
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
|
+
}
|
|
176
201
|
/**
|
|
177
202
|
* Put an image into the shape the API expects, and fail loudly on the parts we can
|
|
178
203
|
* check from here.
|
|
@@ -416,6 +441,8 @@ export class Client {
|
|
|
416
441
|
apiKey;
|
|
417
442
|
model;
|
|
418
443
|
maxRetries;
|
|
444
|
+
deadlineMs;
|
|
445
|
+
signal;
|
|
419
446
|
/** Transport. Held as a field (not read off `globalThis` per call) so a caller
|
|
420
447
|
* who passes one gets it for every request, including from cloned clients. */
|
|
421
448
|
fetchImpl;
|
|
@@ -459,6 +486,11 @@ export class Client {
|
|
|
459
486
|
// "retries exhausted"; Infinity would retry forever.
|
|
460
487
|
const mr = opts.maxRetries ?? opts.retries;
|
|
461
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;
|
|
462
494
|
// Bind the global so `fetch` is not called as a method of `globalThis`, which
|
|
463
495
|
// throws "Illegal invocation" on some runtimes. A caller-supplied fetch is
|
|
464
496
|
// taken as given — it is already whatever they meant to hand us.
|
|
@@ -479,7 +511,7 @@ export class Client {
|
|
|
479
511
|
if (this.model)
|
|
480
512
|
this.headers["X-WOS-Model"] = this.model;
|
|
481
513
|
// An API key on plain HTTP travels readable by anyone on the path. Loopback
|
|
482
|
-
// is fine (local dev
|
|
514
|
+
// is fine (local dev, or a proxy on the same box); anything else gets a
|
|
483
515
|
// warning, not an error, so private-network gateways keep working.
|
|
484
516
|
// Parse, don't split. `http://127.0.0.1:9@evil.example` splits to "127.0.0.1" — the
|
|
485
517
|
// USERINFO, not the host — so this check called it loopback and stayed quiet while
|
|
@@ -488,7 +520,7 @@ export class Client {
|
|
|
488
520
|
let host;
|
|
489
521
|
try {
|
|
490
522
|
// `URL.hostname` keeps the brackets on an IPv6 literal — `http://[::1]:8080`
|
|
491
|
-
// gives back "[::1]", which was never in LOOPBACK_HOSTS, so a
|
|
523
|
+
// gives back "[::1]", which was never in LOOPBACK_HOSTS, so a local
|
|
492
524
|
// client on the v6 loopback was told its key was travelling in cleartext when
|
|
493
525
|
// it was not. Python's urlsplit strips them and Rust strips them by hand; this
|
|
494
526
|
// was the copy that did not. Strip them here so all three agree.
|
|
@@ -567,6 +599,11 @@ export class Client {
|
|
|
567
599
|
// friends would silently fall back to the global fetch and a proxy-bound
|
|
568
600
|
// client would stop connecting the moment it was cloned.
|
|
569
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,
|
|
570
607
|
...overrides,
|
|
571
608
|
});
|
|
572
609
|
}
|
|
@@ -582,6 +619,22 @@ export class Client {
|
|
|
582
619
|
withUser(userId) {
|
|
583
620
|
return this.clone({ userId });
|
|
584
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
|
+
}
|
|
585
638
|
/** A client with a different per-request timeout in ms (everything else kept).
|
|
586
639
|
* `mem.withTimeout(120_000).addBulk(bigBlob)` — this slow call only. */
|
|
587
640
|
withTimeout(timeoutMs) {
|
|
@@ -641,7 +694,7 @@ export class Client {
|
|
|
641
694
|
// Reserved fields win over ...opts: an app that forwards untrusted input as
|
|
642
695
|
// opts must not be able to override the store (user_id), query, or limit.
|
|
643
696
|
warnOnUnknownFilters(opts.filters);
|
|
644
|
-
|
|
697
|
+
checkCount(limit, "limit");
|
|
645
698
|
const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
|
|
646
699
|
return mergeResults(r);
|
|
647
700
|
}
|
|
@@ -652,7 +705,7 @@ export class Client {
|
|
|
652
705
|
* that does not keep them apart, `self_memories` is `[]`. `userId` may be omitted. */
|
|
653
706
|
async searchSelf(query, userId, limit = 10, opts = {}) {
|
|
654
707
|
warnOnUnknownFilters(opts.filters);
|
|
655
|
-
|
|
708
|
+
checkCount(limit, "limit");
|
|
656
709
|
const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
|
|
657
710
|
return { memories: asRecords(r.memories), self_memories: asRecords(r.self_memories) };
|
|
658
711
|
}
|
|
@@ -672,7 +725,7 @@ export class Client {
|
|
|
672
725
|
*/
|
|
673
726
|
async searchFull(query, userId, limit = 10, opts = {}) {
|
|
674
727
|
warnOnUnknownFilters(opts.filters);
|
|
675
|
-
|
|
728
|
+
checkCount(limit, "limit");
|
|
676
729
|
const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
|
|
677
730
|
// Spread first so a field added later still arrives; the known ones are then
|
|
678
731
|
// normalized, because a broken proxy can null any of them.
|
|
@@ -686,12 +739,20 @@ export class Client {
|
|
|
686
739
|
/** One-call LLM context: short-term turns + long-term matches + surrounding context.
|
|
687
740
|
* `form` ("memoir"/"archive", Scroll 1.2+) renders each long-term memory's time in
|
|
688
741
|
* that form; `tz` is your UTC-offset hours for that rendering. */
|
|
689
|
-
|
|
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 = {}) {
|
|
690
747
|
const body = { user_id: this.uid(userId), query };
|
|
691
|
-
if (opts.limit !== undefined)
|
|
748
|
+
if (opts.limit !== undefined) {
|
|
749
|
+
checkCount(opts.limit, "limit");
|
|
692
750
|
body.limit = opts.limit;
|
|
693
|
-
|
|
751
|
+
}
|
|
752
|
+
if (opts.context_limit !== undefined) {
|
|
753
|
+
checkContextLimit(opts.context_limit);
|
|
694
754
|
body.context_limit = opts.context_limit;
|
|
755
|
+
}
|
|
695
756
|
return this.post("/api/v1/memory/recall", withForm(body, opts.form, opts.tz));
|
|
696
757
|
}
|
|
697
758
|
/** Run a built-in engram ("deep_recall" | "timeline" | "gather" | "equilibrium" |
|
|
@@ -761,7 +822,7 @@ export class Client {
|
|
|
761
822
|
seen.add(next);
|
|
762
823
|
cursor = next;
|
|
763
824
|
}
|
|
764
|
-
throw new
|
|
825
|
+
throw new Error(`stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
|
|
765
826
|
`answer, not the whole store.`);
|
|
766
827
|
}
|
|
767
828
|
/** Collect ALL of a store's memories into an array (the text you stored, and its metadata). */
|
|
@@ -868,7 +929,7 @@ export class Client {
|
|
|
868
929
|
before = p.next_before;
|
|
869
930
|
skipIds = Array.isArray(p.next_skip_ids) ? p.next_skip_ids : undefined;
|
|
870
931
|
}
|
|
871
|
-
throw new
|
|
932
|
+
throw new Error(`stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
|
|
872
933
|
`answer, not the whole store.`);
|
|
873
934
|
}
|
|
874
935
|
// ----- how much has this memory been edited -----
|
|
@@ -1087,6 +1148,101 @@ export class Client {
|
|
|
1087
1148
|
* a non-2xx we re-read the body as text and reuse `errorFor`, which keeps 404
|
|
1088
1149
|
* (`NotFoundError`) and 401 behaving the same as everywhere else.
|
|
1089
1150
|
*/
|
|
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
|
+
}
|
|
1181
|
+
const ctrl = new AbortController();
|
|
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);
|
|
1196
|
+
}
|
|
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"));
|
|
1228
|
+
}
|
|
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
|
+
}
|
|
1090
1246
|
async requestBytes(path, body) {
|
|
1091
1247
|
// This route had no retry loop at all, while the module doc promised "every call
|
|
1092
1248
|
// retries transient failures … 429 always". Measured with maxRetries: 4 against a
|
|
@@ -1095,32 +1251,32 @@ export class Client {
|
|
|
1095
1251
|
// immediately. The loop lives inside the function because honouring `Retry-After`
|
|
1096
1252
|
// means still holding the response.
|
|
1097
1253
|
const attempts = this.maxRetries + 1;
|
|
1254
|
+
const deadlineAt = this.deadlineAt();
|
|
1098
1255
|
for (let attempt = 0;; attempt++) {
|
|
1099
|
-
const
|
|
1100
|
-
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
|
1256
|
+
const att = this.beginAttempt(deadlineAt);
|
|
1101
1257
|
let res;
|
|
1102
1258
|
try {
|
|
1103
1259
|
res = await this.fetchImpl(`${this.base}${path}`, {
|
|
1104
1260
|
method: "POST",
|
|
1105
1261
|
headers: this.headers,
|
|
1106
1262
|
body: JSON.stringify(body),
|
|
1107
|
-
signal:
|
|
1263
|
+
signal: att.signal,
|
|
1108
1264
|
redirect: "manual",
|
|
1109
1265
|
});
|
|
1110
1266
|
}
|
|
1111
1267
|
catch (e) {
|
|
1112
|
-
|
|
1113
|
-
const timedOut = e?.name === "AbortError" ||
|
|
1268
|
+
att.clear();
|
|
1269
|
+
const timedOut = e?.name === "AbortError" || att.signal.aborted;
|
|
1114
1270
|
// Same rule as `request()`: a connect-level failure never reached the server,
|
|
1115
1271
|
// so re-sending cannot double-process anything. The 429 loop below was added
|
|
1116
1272
|
// in 2.2.35 and this branch was not, which left the module doc's promise —
|
|
1117
1273
|
// "every call retries transient failures" — still false for `getImage` on
|
|
1118
1274
|
// exactly the failure a retry is for. A timeout is ambiguous and stays final.
|
|
1119
1275
|
if (!timedOut && isConnectFailure(e) && attempt + 1 < attempts) {
|
|
1120
|
-
await
|
|
1276
|
+
await this.backoffSleep(this.backoffMs(attempt), deadlineAt);
|
|
1121
1277
|
continue;
|
|
1122
1278
|
}
|
|
1123
|
-
throw new APIConnectionError(0, timedOut ?
|
|
1279
|
+
throw new APIConnectionError(0, timedOut ? this.abortMessage(att.why()) : `network error: ${e?.message ?? e}`);
|
|
1124
1280
|
}
|
|
1125
1281
|
try {
|
|
1126
1282
|
this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
|
|
@@ -1132,8 +1288,8 @@ export class Client {
|
|
|
1132
1288
|
if (RETRY_ALWAYS.has(res.status) && attempt + 1 < attempts) {
|
|
1133
1289
|
const retryAfter = res.headers.get("Retry-After");
|
|
1134
1290
|
void res.body?.cancel();
|
|
1135
|
-
|
|
1136
|
-
await
|
|
1291
|
+
att.clear();
|
|
1292
|
+
await this.backoffSleep(this.backoffMs(attempt, retryAfter), deadlineAt);
|
|
1137
1293
|
continue;
|
|
1138
1294
|
}
|
|
1139
1295
|
let msg = await this.readCapped(res).catch(() => "");
|
|
@@ -1161,7 +1317,7 @@ export class Client {
|
|
|
1161
1317
|
buf = await this.readCappedBytes(res);
|
|
1162
1318
|
}
|
|
1163
1319
|
catch (e) {
|
|
1164
|
-
if (e?.name === "AbortError" ||
|
|
1320
|
+
if (e?.name === "AbortError" || att.signal.aborted) {
|
|
1165
1321
|
throw new APIConnectionError(0, `request timed out after ${this.timeoutMs}ms`);
|
|
1166
1322
|
}
|
|
1167
1323
|
throw e;
|
|
@@ -1174,7 +1330,7 @@ export class Client {
|
|
|
1174
1330
|
return { bytes: buf, contentType: res.headers.get("content-type") ?? "application/octet-stream" };
|
|
1175
1331
|
}
|
|
1176
1332
|
finally {
|
|
1177
|
-
|
|
1333
|
+
att.clear();
|
|
1178
1334
|
}
|
|
1179
1335
|
}
|
|
1180
1336
|
}
|
|
@@ -1221,7 +1377,7 @@ export class Client {
|
|
|
1221
1377
|
async readCappedBytes(res) {
|
|
1222
1378
|
const cl = res.headers.get("content-length");
|
|
1223
1379
|
if (cl && Number(cl) > MAX_RESPONSE_BYTES) {
|
|
1224
|
-
throw new WosError(
|
|
1380
|
+
throw new WosError(res.status, `response too large (${cl} bytes) — refusing to buffer it`);
|
|
1225
1381
|
}
|
|
1226
1382
|
if (!res.body) {
|
|
1227
1383
|
// `arrayBuffer()` has no ceiling, and only the content-length pre-check stands
|
|
@@ -1231,7 +1387,7 @@ export class Client {
|
|
|
1231
1387
|
// body-less Response with no content-length buffered 73MB past the 64MB cap.
|
|
1232
1388
|
const buf = new Uint8Array(await res.arrayBuffer());
|
|
1233
1389
|
if (buf.byteLength > MAX_RESPONSE_BYTES) {
|
|
1234
|
-
throw new WosError(
|
|
1390
|
+
throw new WosError(res.status, `response too large (${buf.byteLength} bytes) — refusing it`);
|
|
1235
1391
|
}
|
|
1236
1392
|
return buf;
|
|
1237
1393
|
}
|
|
@@ -1245,7 +1401,7 @@ export class Client {
|
|
|
1245
1401
|
size += value.byteLength;
|
|
1246
1402
|
if (size > MAX_RESPONSE_BYTES) {
|
|
1247
1403
|
void reader.cancel();
|
|
1248
|
-
throw new WosError(
|
|
1404
|
+
throw new WosError(res.status, "response too large — refusing to buffer it");
|
|
1249
1405
|
}
|
|
1250
1406
|
parts.push(value);
|
|
1251
1407
|
}
|
|
@@ -1263,26 +1419,26 @@ export class Client {
|
|
|
1263
1419
|
// Debug logging shows the path WITHOUT its query string — a query can carry
|
|
1264
1420
|
// a store id (listSpeakers), and logs must never carry data.
|
|
1265
1421
|
const logPath = path.split("?")[0];
|
|
1422
|
+
const deadlineAt = this.deadlineAt();
|
|
1266
1423
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
1267
1424
|
const start = Date.now();
|
|
1268
|
-
const
|
|
1269
|
-
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
|
1425
|
+
const att = this.beginAttempt(deadlineAt);
|
|
1270
1426
|
let res;
|
|
1271
1427
|
try {
|
|
1272
1428
|
res = await this.fetchImpl(`${this.base}${path}`, {
|
|
1273
1429
|
method,
|
|
1274
1430
|
headers: extraHeaders ? { ...this.headers, ...extraHeaders } : this.headers,
|
|
1275
1431
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
1276
|
-
signal:
|
|
1432
|
+
signal: att.signal,
|
|
1277
1433
|
// Never follow a redirect: fetch would forward the API key to
|
|
1278
1434
|
// wherever a 3xx points. The API never legitimately redirects.
|
|
1279
1435
|
redirect: "manual",
|
|
1280
1436
|
});
|
|
1281
1437
|
}
|
|
1282
1438
|
catch (e) {
|
|
1283
|
-
|
|
1439
|
+
att.clear();
|
|
1284
1440
|
// Timeouts are ambiguous (the write may have landed) — don't retry those.
|
|
1285
|
-
const timedOut = e?.name === "AbortError" ||
|
|
1441
|
+
const timedOut = e?.name === "AbortError" || att.signal.aborted;
|
|
1286
1442
|
// Other network errors retry only when a retry can't double-process a
|
|
1287
1443
|
// write: idempotent methods always; writes only for connect-level
|
|
1288
1444
|
// failures (the request never reached the server). A mid-stream drop on
|
|
@@ -1291,24 +1447,24 @@ export class Client {
|
|
|
1291
1447
|
if (!timedOut && safe && attempt + 1 < attempts) {
|
|
1292
1448
|
const delay = this.backoffMs(attempt);
|
|
1293
1449
|
logDebug(`${method} ${logPath}: ${e?.code ?? e?.name ?? "network error"} — retrying in ${delay}ms (attempt ${attempt + 1}/${attempts})`);
|
|
1294
|
-
await
|
|
1450
|
+
await this.backoffSleep(delay, deadlineAt);
|
|
1295
1451
|
continue;
|
|
1296
1452
|
}
|
|
1297
|
-
throw new APIConnectionError(0, timedOut ?
|
|
1453
|
+
throw new APIConnectionError(0, timedOut ? this.abortMessage(att.why()) : `network error: ${e?.message ?? e}`);
|
|
1298
1454
|
}
|
|
1299
1455
|
const retryable = RETRY_ALWAYS.has(res.status) ||
|
|
1300
1456
|
(RETRY_IF_IDEMPOTENT.has(res.status) && IDEMPOTENT_METHODS.has(method.toUpperCase()));
|
|
1301
1457
|
if (retryable && attempt + 1 < attempts) {
|
|
1302
|
-
|
|
1458
|
+
att.clear();
|
|
1303
1459
|
const retryAfter = res.headers.get("Retry-After");
|
|
1304
1460
|
void res.body?.cancel();
|
|
1305
1461
|
const delay = this.backoffMs(attempt, retryAfter);
|
|
1306
1462
|
logDebug(`${method} ${logPath} -> ${res.status} — retrying in ${delay}ms (attempt ${attempt + 1}/${attempts})`);
|
|
1307
|
-
await
|
|
1463
|
+
await this.backoffSleep(delay, deadlineAt);
|
|
1308
1464
|
continue;
|
|
1309
1465
|
}
|
|
1310
1466
|
if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
|
|
1311
|
-
|
|
1467
|
+
att.clear();
|
|
1312
1468
|
void res.body?.cancel(); // release the stream — otherwise the connection leaks
|
|
1313
1469
|
throw new WosError(res.status, "unexpected redirect — refused (the API key never follows a redirect). Check baseUrl: exact host, https://.");
|
|
1314
1470
|
}
|
|
@@ -1321,7 +1477,7 @@ export class Client {
|
|
|
1321
1477
|
text = await this.readCapped(res);
|
|
1322
1478
|
}
|
|
1323
1479
|
catch (e) {
|
|
1324
|
-
if (e?.name === "AbortError" ||
|
|
1480
|
+
if (e?.name === "AbortError" || att.signal.aborted) {
|
|
1325
1481
|
throw new APIConnectionError(0, `request timed out after ${this.timeoutMs}ms`);
|
|
1326
1482
|
}
|
|
1327
1483
|
if (e instanceof WosError)
|
|
@@ -1331,7 +1487,7 @@ export class Client {
|
|
|
1331
1487
|
throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
|
|
1332
1488
|
}
|
|
1333
1489
|
finally {
|
|
1334
|
-
|
|
1490
|
+
att.clear();
|
|
1335
1491
|
}
|
|
1336
1492
|
logDebug(`${method} ${logPath} -> ${res.status} in ${Date.now() - start}ms (attempt ${attempt + 1}/${attempts})`);
|
|
1337
1493
|
if (!res.ok) {
|
|
@@ -1392,12 +1548,17 @@ export class Client {
|
|
|
1392
1548
|
// `Idempotent-Replayed: true`, and it answers the question someone using an
|
|
1393
1549
|
// idempotency key is asking: did my retry write, or is this the first response
|
|
1394
1550
|
// coming back again?
|
|
1395
|
-
|
|
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)) {
|
|
1396
1557
|
data.replayed = true;
|
|
1397
1558
|
}
|
|
1398
1559
|
return data;
|
|
1399
1560
|
}
|
|
1400
|
-
throw new
|
|
1561
|
+
throw new Error("retries exhausted"); // unreachable; keeps the compiler happy
|
|
1401
1562
|
}
|
|
1402
1563
|
}
|
|
1403
1564
|
/** Back-compat alias: older code used `WME`. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wontopos",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.36",
|
|
4
4
|
"description": "Wontopos — long-term memory for AI agents. Identical recall in every language, ~100× lower LLM bill.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"memory",
|
|
@@ -15,13 +15,14 @@
|
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"author": "Wontopos",
|
|
17
17
|
"type": "module",
|
|
18
|
-
"main": "./dist/wontopos.js",
|
|
18
|
+
"main": "./dist/cjs/wontopos.js",
|
|
19
19
|
"module": "./dist/wontopos.js",
|
|
20
20
|
"types": "./dist/wontopos.d.ts",
|
|
21
21
|
"exports": {
|
|
22
22
|
".": {
|
|
23
23
|
"types": "./dist/wontopos.d.ts",
|
|
24
24
|
"import": "./dist/wontopos.js",
|
|
25
|
+
"require": "./dist/cjs/wontopos.js",
|
|
25
26
|
"default": "./dist/wontopos.js"
|
|
26
27
|
}
|
|
27
28
|
},
|
|
@@ -34,8 +35,8 @@
|
|
|
34
35
|
"node": ">=18"
|
|
35
36
|
},
|
|
36
37
|
"scripts": {
|
|
37
|
-
"build": "tsc",
|
|
38
|
-
"test": "npm run build && node --test tests/client.test.mjs",
|
|
38
|
+
"build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist/cjs/package.json','{\\\"type\\\":\\\"commonjs\\\"}\\n')\"",
|
|
39
|
+
"test": "npm run build && node --test tests/client.test.mjs tests/cjs.test.cjs",
|
|
39
40
|
"prepublishOnly": "npm run build && node scripts/pre-publish.mjs"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|