wontopos 2.2.36 → 2.2.38
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 +5 -100
- package/dist/cjs/wontopos.d.cts +1048 -0
- package/dist/cjs/wontopos.js +106 -719
- package/dist/wontopos.d.ts +63 -3
- package/dist/wontopos.js +106 -719
- package/package.json +17 -7
package/dist/cjs/wontopos.js
CHANGED
|
@@ -1,61 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Wontopos — long-term memory for AI, in three lines.
|
|
4
|
-
*
|
|
5
|
-
* npm install wontopos
|
|
6
|
-
*
|
|
7
|
-
* import { Client } from "wontopos";
|
|
8
|
-
* const mem = new Client({ apiKey: "wos-...", userId: "alice" }); // set the store once
|
|
9
|
-
* await mem.createStore(); // create it (uses the client's userId)
|
|
10
|
-
* await mem.add("she prefers tea over coffee"); // no userId needed
|
|
11
|
-
* const hits = await mem.search("what does alice drink?");
|
|
12
|
-
*
|
|
13
|
-
* Set `userId` once on the client and every call uses it; override any single call
|
|
14
|
-
* by passing `userId` to it. Stores are explicit: the store must exist first
|
|
15
|
-
* (`createStore`) or calls return 404. Every account starts with a `default` store,
|
|
16
|
-
* so with no `userId` anywhere the zero-setup path just works.
|
|
17
|
-
*
|
|
18
|
-
* The API key picks *which memory* (your account); `model` picks *which engine*
|
|
19
|
-
* reads it. Models on the shared pool read the same memory, so you can store with
|
|
20
|
-
* one and recall with another; `listModels()` reports each model's `memory` as
|
|
21
|
-
* `"shared"` or `"isolated"`, and an isolated one starts empty. Set a default in
|
|
22
|
-
* the constructor, or override a single call with
|
|
23
|
-
* `mem.withModel("tablet-1").recall(...)`.
|
|
24
|
-
*
|
|
25
|
-
* Recall quality does not depend on which language a memory was written in: a
|
|
26
|
-
* memory stored in one language is found by a question asked in another. Storing
|
|
27
|
-
* and searching call no LLM.
|
|
28
|
-
*
|
|
29
|
-
* Reliability: every call retries transient failures with exponential backoff +
|
|
30
|
-
* jitter, honoring `Retry-After` — 429 always; 502/503 and connection errors only
|
|
31
|
-
* when a retry can never double-process a write (idempotent calls, or a failure
|
|
32
|
-
* at connect time). Tune with `maxRetries` (0 disables) and `timeoutMs`, or per
|
|
33
|
-
* call site via the `withTimeout()` / `withRetries()` clones.
|
|
34
|
-
*
|
|
35
|
-
* Debugging: set `WONTOPOS_LOG=debug` to log method/path/status/timing/retries
|
|
36
|
-
* to stderr — never memory content, request bodies, or the API key.
|
|
37
|
-
*
|
|
38
|
-
* Security posture (not configurable off): redirects are refused so the API key
|
|
39
|
-
* can never follow one to another host; responses over 64MB are refused; the key
|
|
40
|
-
* is masked in `toJSON`/inspect output. TLS certificate verification is the
|
|
41
|
-
* runtime's (never disabled by this SDK; Node 18+ floors TLS at 1.2). Prefer
|
|
42
|
-
* `Client.fromEnv()` over keys in source code.
|
|
43
|
-
*/
|
|
44
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
3
|
exports.WME = exports.Client = exports.ServerError = exports.RateLimitError = exports.ConflictError = exports.NotFoundError = exports.PermissionDeniedError = exports.PaymentRequiredError = exports.AuthenticationError = exports.BadRequestError = exports.APIConnectionError = exports.WosError = exports.CONTEXT_LIMIT_MAX = exports.CONTEXT_LIMIT_MIN = exports.SEARCH_LIMIT_MAX = exports.SEARCH_LIMIT_MIN = void 0;
|
|
46
4
|
exports._resetStoreIdWarnings = _resetStoreIdWarnings;
|
|
47
5
|
exports._resetFilterWarnings = _resetFilterWarnings;
|
|
48
|
-
const VERSION = "2.2.
|
|
49
|
-
/** Runtime info helps support debug a report ("node 18 on Windows...") —
|
|
50
|
-
* platform only, never anything identifying. Browsers have no `process` (and
|
|
51
|
-
* silently drop the UA header anyway). */
|
|
6
|
+
const VERSION = "2.2.38";
|
|
52
7
|
const RUNTIME = (() => {
|
|
53
8
|
const p = globalThis?.process;
|
|
54
9
|
return p?.version ? ` (node/${p.version}; ${p.platform}-${p.arch})` : "";
|
|
55
10
|
})();
|
|
56
11
|
const USER_AGENT = `wontopos-node/${VERSION}${RUNTIME}`;
|
|
57
|
-
/** Wire-level debug logging: set WONTOPOS_LOG=debug. Logs method/path/status/
|
|
58
|
-
* timing/retries to stderr — NEVER memory content, request bodies, or the key. */
|
|
59
12
|
function logDebug(msg) {
|
|
60
13
|
const env = globalThis?.process?.env;
|
|
61
14
|
if (typeof env?.WONTOPOS_LOG === "string" && env.WONTOPOS_LOG.toLowerCase() === "debug") {
|
|
@@ -63,63 +16,25 @@ function logDebug(msg) {
|
|
|
63
16
|
}
|
|
64
17
|
}
|
|
65
18
|
const DEFAULT_BASE_URL = "https://api.wontopos.com";
|
|
66
|
-
/** The engine every call uses unless the caller names another.
|
|
67
|
-
*
|
|
68
|
-
* Tablet 2 costs the same per token as Tablet 1 and is the one that
|
|
69
|
-
* serves images, re-ask passes (`verify`), and `self_memories`, so a caller who
|
|
70
|
-
* names nothing gets the engine that can answer the most. Models on the shared
|
|
71
|
-
* pool read the same memory, so switching between them is a header, not a
|
|
72
|
-
* migration. Pin an older one explicitly with
|
|
73
|
-
* `new Client({ apiKey, model: "tablet-1" })` or `withModel("tablet-1")`. */
|
|
74
19
|
const DEFAULT_MODEL = "tablet-2";
|
|
75
|
-
/** 429 = rate-limited BEFORE processing → always safe to retry (no write, no bill). */
|
|
76
20
|
const RETRY_ALWAYS = new Set([429]);
|
|
77
|
-
/** 502/503 are ambiguous for a write (gateway may return them AFTER the backend
|
|
78
|
-
* processed + billed the request), so retry them only for idempotent methods —
|
|
79
|
-
* a retried POST could double-store / double-bill. */
|
|
80
21
|
const RETRY_IF_IDEMPOTENT = new Set([502, 503]);
|
|
81
22
|
const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
82
23
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
|
|
83
|
-
/** Refuse to buffer absurd responses (real ones are a few KB) — protects the
|
|
84
|
-
* process if a custom baseUrl points somewhere broken or hostile. */
|
|
85
24
|
const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
86
|
-
/** Model names travel in a header — only header-safe characters. */
|
|
87
25
|
const MODEL_RE = /^[A-Za-z0-9._-]+$/;
|
|
88
|
-
/** Cap a server-controlled error message so a hostile body can't blow up a log. */
|
|
89
26
|
const MAX_ERR_MSG = 4096;
|
|
90
|
-
/** What the API accepts as an `Idempotency-Key`. Checked client-side so a bad key
|
|
91
|
-
* fails before the request instead of coming back as a 400 mid-retry. */
|
|
92
27
|
const IDEMPOTENCY_KEY_RE = /^[A-Za-z0-9._:\-]{1,128}$/;
|
|
93
|
-
/**
|
|
94
|
-
* The API normalizes a store id: lowercased, and every character outside
|
|
95
|
-
* `[a-z0-9_]` becomes `_`. So `Alice.Smith`, `alice-smith` and `alice_smith` are
|
|
96
|
-
* ALL the same store.
|
|
97
|
-
*
|
|
98
|
-
* That is a data-exposure hazard for the most common way this SDK is used — one
|
|
99
|
-
* store per end user. Two accounts whose ids differ only by punctuation or case
|
|
100
|
-
* (`bob.lee@x.com` / `bob-lee@x.com`) silently share every memory, and nothing in
|
|
101
|
-
* the response says so: the note only appears when `createStore` creates one, and
|
|
102
|
-
* an app that reuses an existing store never sees it.
|
|
103
|
-
*
|
|
104
|
-
* We cannot refuse the id — the API accepts it, and callers may have written it
|
|
105
|
-
* this way for a year. So we say it once, loudly, at the moment it happens.
|
|
106
|
-
*/
|
|
107
28
|
function normalizeStoreId(id) {
|
|
108
29
|
return id.toLowerCase().replace(/[^a-z0-9_]/g, "_");
|
|
109
30
|
}
|
|
110
|
-
/** Ids already warned about, so each is reported once.
|
|
111
|
-
*
|
|
112
|
-
* Bounded on purpose. The warning fires on ids that fold, and the shape it exists
|
|
113
|
-
* to catch is an email — which this SDK's own documented pattern ("one store per
|
|
114
|
-
* end user") turns into one entry PER USER, held for the life of the process and
|
|
115
|
-
* never released, even when the client is. A server that forwards 50,000 distinct user
|
|
116
|
-
* ids would grow this to 50,000 entries, leaking in exactly the case the warning
|
|
117
|
-
* exists to catch. A `Set`
|
|
118
|
-
* keeps insertion order, so the oldest entry is the one evicted; dropping the
|
|
119
|
-
* oldest rather than refusing new ones means a collision that first shows up late
|
|
120
|
-
* still gets its one warning. */
|
|
121
31
|
const WARNED_STORE_IDS_MAX = 1024;
|
|
122
|
-
|
|
32
|
+
function assertUsableStoreId(userId) {
|
|
33
|
+
if (userId !== undefined && (typeof userId !== "string" || !userId.trim())) {
|
|
34
|
+
throw new Error(`userId must be a non-blank string; got ${JSON.stringify(userId)}. Omit it to use the client's ` +
|
|
35
|
+
"default store, or pass a real store id — anything else would silently write into the default store.");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
123
38
|
const WARNED_FILTER_KEYS_MAX = 1024;
|
|
124
39
|
const warnedStoreIds = new Set();
|
|
125
40
|
function warnIfStoreIdCollapses(id) {
|
|
@@ -140,53 +55,15 @@ function warnIfStoreIdCollapses(id) {
|
|
|
140
55
|
`punctuation share ONE store and therefore one set of memories — if these ids come from your ` +
|
|
141
56
|
`end users, normalize them yourself first so two people can never collide.`);
|
|
142
57
|
}
|
|
143
|
-
/** Test hook — the warn-once set is process-global by design. */
|
|
144
58
|
function _resetStoreIdWarnings() {
|
|
145
59
|
warnedStoreIds.clear();
|
|
146
60
|
}
|
|
147
|
-
/** Statuses that legitimately carry NO body (RFC 9110). Everything else must
|
|
148
|
-
* answer with a JSON object — see the empty-body check in `request`. */
|
|
149
61
|
const NO_BODY_STATUS = new Set([204, 205, 304]);
|
|
150
|
-
/** Paging backstop: bound the walk so a fresh-cursor-forever server can't loop us. */
|
|
151
|
-
// A page walk has to stop somewhere, and 1,000,000 pages was not a stop: at 100 per
|
|
152
|
-
// page that is 100 million memories, so a server minting a fresh cursor every time
|
|
153
|
-
// would spend hours and a million billed requests before it fired. 20,000 pages is two
|
|
154
|
-
// million memories — past any real store, reached in minutes.
|
|
155
|
-
//
|
|
156
|
-
// ★And it has to be LOUD. Falling out of the loop yielded a truncated list that looks
|
|
157
|
-
// exactly like a complete one; the caller writes it to a file believing it is the
|
|
158
|
-
// whole store. Hitting this now throws.
|
|
159
62
|
const MAX_PAGES = 20_000;
|
|
160
63
|
exports.SEARCH_LIMIT_MIN = 5;
|
|
161
64
|
exports.SEARCH_LIMIT_MAX = 20;
|
|
162
|
-
/** `recall`'s surrounding-context count. 0 to 20 inclusive; 0 attaches none. */
|
|
163
65
|
exports.CONTEXT_LIMIT_MIN = 0;
|
|
164
66
|
exports.CONTEXT_LIMIT_MAX = 20;
|
|
165
|
-
/** The 5-to-20 count shared by `search` and `recall`, refused out of range rather
|
|
166
|
-
* than quietly adjusted.
|
|
167
|
-
*
|
|
168
|
-
* The service has refused anything else from the start, because asking for 20 and
|
|
169
|
-
* silently getting 10 reads as "that is all there is". Search had no contract at
|
|
170
|
-
* all: the three clients sent whatever they were given, the MCP server allowed 1 to
|
|
171
|
-
* 60, and the service quietly capped at 50 with no floor. Four surfaces, four
|
|
172
|
-
* answers, and the caller could not tell which one they got.
|
|
173
|
-
*
|
|
174
|
-
* ★ `recall` said it enforced this and did not. Its doc comment promised "out of
|
|
175
|
-
* range is refused, not clamped" while the value went straight to the wire in all
|
|
176
|
-
* three SDKs — so `limit: 500` travelled to the engine and died there. A comment
|
|
177
|
-
* that describes a guard is not a guard, and this is the shape of defect a reviewer
|
|
178
|
-
* reading one diff can never see: the promise and the missing code were written
|
|
179
|
-
* months apart.
|
|
180
|
-
*
|
|
181
|
-
* Thrown as a plain `Error`, like every other argument check in this file. It is
|
|
182
|
-
* NOT a `WosError` — nothing was sent, and `WosError` with status 0 means
|
|
183
|
-
* `APIConnectionError`, "the request never got a response". Reusing that status for
|
|
184
|
-
* a typo told a caller with `if (e.status === 0) retry()` to retry a bad argument
|
|
185
|
-
* forever.
|
|
186
|
-
*
|
|
187
|
-
* Refusing rather than clamping is the same decision as 2.2.35's, where a Rust
|
|
188
|
-
* `limit` of 0 had been rewritten to 10 and the caller was handed ten memories they
|
|
189
|
-
* had not asked for, and the bill for them. */
|
|
190
67
|
function checkCount(limit, name) {
|
|
191
68
|
if (!Number.isInteger(limit) || limit < exports.SEARCH_LIMIT_MIN || limit > exports.SEARCH_LIMIT_MAX) {
|
|
192
69
|
throw new Error(`${name} must be an integer between ${exports.SEARCH_LIMIT_MIN} and ${exports.SEARCH_LIMIT_MAX}, got ${limit}. ` +
|
|
@@ -194,27 +71,11 @@ function checkCount(limit, name) {
|
|
|
194
71
|
`the store was short.`);
|
|
195
72
|
}
|
|
196
73
|
}
|
|
197
|
-
/** `recall`'s `context_limit`, 0 to 20. Same reason as the count above: the doc
|
|
198
|
-
* comment on `recall` promises out-of-range is refused, and a promise the client
|
|
199
|
-
* does not keep is worse than no promise — the caller reads the doc, sends 50, and
|
|
200
|
-
* the failure arrives from the service with no hint that the SDK knew all along. */
|
|
201
74
|
function checkContextLimit(n) {
|
|
202
75
|
if (!Number.isInteger(n) || n < exports.CONTEXT_LIMIT_MIN || n > exports.CONTEXT_LIMIT_MAX) {
|
|
203
76
|
throw new Error(`context_limit must be an integer between ${exports.CONTEXT_LIMIT_MIN} and ${exports.CONTEXT_LIMIT_MAX}, got ${n}.`);
|
|
204
77
|
}
|
|
205
78
|
}
|
|
206
|
-
/**
|
|
207
|
-
* Put an image into the shape the API expects, and fail loudly on the parts we can
|
|
208
|
-
* check from here.
|
|
209
|
-
*
|
|
210
|
-
* Deliberately NOT checked: the byte ceiling. It is a service setting, so a number
|
|
211
|
-
* baked in here would drift the first time the service is reconfigured and would
|
|
212
|
-
* reject an image the service would have taken.
|
|
213
|
-
* What we do check is what cannot change: an empty payload, and a `data:` URL
|
|
214
|
-
* prefix — browsers and file pickers hand you `data:image/jpeg;base64,…`, the API
|
|
215
|
-
* wants the part after the comma, and the difference is invisible until the write
|
|
216
|
-
* fails with "not a readable image".
|
|
217
|
-
*/
|
|
218
79
|
function normalizeImage(image) {
|
|
219
80
|
if (!image || typeof image !== "object") {
|
|
220
81
|
throw new Error("image must be an object — { data: '<base64>' }.");
|
|
@@ -222,15 +83,8 @@ function normalizeImage(image) {
|
|
|
222
83
|
if (typeof image.data !== "string" || image.data.trim() === "") {
|
|
223
84
|
throw new Error("image.data is required — base64 of the image (a data: URL is fine).");
|
|
224
85
|
}
|
|
225
|
-
// Trim before looking for the prefix. Checked against the raw string, one leading
|
|
226
|
-
// space (` data:image/png;base64,…`) hides the prefix, and the literal
|
|
227
|
-
// `data:image/png;base64,` travels as part of the base64. The engine answers 400
|
|
228
|
-
// "image could not be read" and the caller has no way to tell why.
|
|
229
86
|
const raw = image.data.trim();
|
|
230
87
|
const comma = raw.startsWith("data:") ? raw.indexOf(",") : -1;
|
|
231
|
-
// Strip whitespace in the middle as well. `base64` and `openssl base64` wrap at 76
|
|
232
|
-
// columns, and trimming only the ends leaves those line breaks in the payload. The
|
|
233
|
-
// base64 alphabet contains no whitespace, so removing all of it is safe.
|
|
234
88
|
const data = (comma === -1 ? raw : raw.slice(comma + 1)).replace(/\s+/g, "");
|
|
235
89
|
if (data === "")
|
|
236
90
|
throw new Error("image.data is empty after stripping its data: prefix.");
|
|
@@ -241,10 +95,6 @@ function normalizeImage(image) {
|
|
|
241
95
|
out.taken_at = image.taken_at;
|
|
242
96
|
return out;
|
|
243
97
|
}
|
|
244
|
-
/** Error codes that only occur while ESTABLISHING a connection — the request
|
|
245
|
-
* never reached the server, so a retry can't double-process a write. Mid-stream
|
|
246
|
-
* codes (ECONNRESET, EPIPE, UND_ERR_SOCKET, ETIMEDOUT) are ambiguous: the server
|
|
247
|
-
* may already have processed (and billed) the request, so they're excluded. */
|
|
248
98
|
const CONNECT_FAIL_CODES = new Set([
|
|
249
99
|
"ECONNREFUSED",
|
|
250
100
|
"ENOTFOUND",
|
|
@@ -255,8 +105,6 @@ const CONNECT_FAIL_CODES = new Set([
|
|
|
255
105
|
"EADDRNOTAVAIL",
|
|
256
106
|
"UND_ERR_CONNECT_TIMEOUT",
|
|
257
107
|
]);
|
|
258
|
-
/** Walk an error's `cause` chain (and AggregateError members — happy-eyeballs
|
|
259
|
-
* connects) looking for a connect-level failure code. */
|
|
260
108
|
function isConnectFailure(e) {
|
|
261
109
|
const stack = [e];
|
|
262
110
|
for (let steps = 0; stack.length && steps < 24; steps++) {
|
|
@@ -272,7 +120,6 @@ function isConnectFailure(e) {
|
|
|
272
120
|
}
|
|
273
121
|
return false;
|
|
274
122
|
}
|
|
275
|
-
/** `wos-abc...wxyz` — enough to tell keys apart, never enough to use. */
|
|
276
123
|
function maskKey(key) {
|
|
277
124
|
return key.length > 12 ? `${key.slice(0, 4)}...${key.slice(-4)}` : "***";
|
|
278
125
|
}
|
|
@@ -280,7 +127,7 @@ function parseRateLimit(h) {
|
|
|
280
127
|
const num = (name) => {
|
|
281
128
|
const v = h.get(name);
|
|
282
129
|
if (v == null || v.trim() === "")
|
|
283
|
-
return null;
|
|
130
|
+
return null;
|
|
284
131
|
const n = Number(v);
|
|
285
132
|
return Number.isFinite(n) ? n : null;
|
|
286
133
|
};
|
|
@@ -291,30 +138,9 @@ function parseRateLimit(h) {
|
|
|
291
138
|
return null;
|
|
292
139
|
return { limit, remaining, reset };
|
|
293
140
|
}
|
|
294
|
-
// A list/object field the API promises. `x ?? []` only replaces null/undefined — a
|
|
295
|
-
// broken or hostile server sending a truthy wrong type (`"memories": "oops"`) would
|
|
296
|
-
// slip through as a string and blow up the caller's `for (…of…)`. Coerce anything
|
|
297
|
-
// that isn't actually an array/object to the empty value.
|
|
298
141
|
const asList = (x) => (Array.isArray(x) ? x : []);
|
|
299
142
|
const asObj = (x) => (x != null && typeof x === "object" && !Array.isArray(x) ? x : {});
|
|
300
|
-
// A list of API records (memories, turns, models...) — every element is an object
|
|
301
|
-
// by contract. `asList` only fixes the CONTAINER: a hostile/broken server sending
|
|
302
|
-
// `{"memories": [null, 1, "x", {...}]}` still handed the caller those non-objects,
|
|
303
|
-
// typed as Memory[], and the first `m.content` threw a raw TypeError from inside
|
|
304
|
-
// user code. Drop elements that aren't objects and keep the valid records, so one
|
|
305
|
-
// bad element never poisons the batch (and never destroys it).
|
|
306
143
|
const asRecords = (x) => asList(x).filter((m) => m != null && typeof m === "object" && !Array.isArray(m));
|
|
307
|
-
/** Every memory a search returned, from both fields, as one array.
|
|
308
|
-
*
|
|
309
|
-
* Some models answer with the assistant's own words in `self_memories`, not
|
|
310
|
-
* repeated in `memories`. `search()` used to return `memories` alone, so an
|
|
311
|
-
* assistant turn stored with `addTurn` was missing from its results on those
|
|
312
|
-
* models while the same query returned it on others — upgrading made search
|
|
313
|
-
* return LESS, and what went missing had already been retrieved and paid for.
|
|
314
|
-
*
|
|
315
|
-
* Both fields are returned here, de-duplicated by id, each memory keeping its
|
|
316
|
-
* `speaker` so a caller can still tell who said what. Callers who want them kept
|
|
317
|
-
* apart use `searchSelf()`, which is what that method is for. */
|
|
318
144
|
function mergeResults(r) {
|
|
319
145
|
const main = asRecords(r?.memories);
|
|
320
146
|
const mine = asRecords(r?.self_memories);
|
|
@@ -323,9 +149,6 @@ function mergeResults(r) {
|
|
|
323
149
|
const seen = new Set(main.map((m) => m.id).filter((id) => typeof id === "string"));
|
|
324
150
|
return main.concat(mine.filter((m) => !(typeof m.id === "string" && seen.has(m.id))));
|
|
325
151
|
}
|
|
326
|
-
/** Attach a delivery-form override ("memoir"/"archive") + tz to a request body, the
|
|
327
|
-
* way search passes them; on Scroll 1.2+ this renders each returned memory's time in
|
|
328
|
-
* that form. The server validates the form (400 on an unknown one). */
|
|
329
152
|
function withForm(body, form, tz) {
|
|
330
153
|
if (form !== undefined)
|
|
331
154
|
body.form = form;
|
|
@@ -335,62 +158,39 @@ function withForm(body, form, tz) {
|
|
|
335
158
|
}
|
|
336
159
|
class WosError extends Error {
|
|
337
160
|
status;
|
|
338
|
-
/** The server's id for the request when it sent one — include it when contacting support. */
|
|
339
161
|
requestId;
|
|
340
162
|
constructor(status, message, requestId) {
|
|
341
163
|
super(`[${status}] ${message}${requestId ? ` (request_id: ${requestId})` : ""}`);
|
|
342
|
-
this.name = new.target.name;
|
|
164
|
+
this.name = new.target.name;
|
|
343
165
|
this.status = status;
|
|
344
166
|
this.requestId = requestId;
|
|
345
167
|
}
|
|
346
168
|
}
|
|
347
169
|
exports.WosError = WosError;
|
|
348
|
-
// Typed subclasses so callers can branch on the failure — `catch (e) { if (e instanceof
|
|
349
|
-
// RateLimitError) … }`. Each is a WosError, so a broad `instanceof WosError` still works.
|
|
350
|
-
/** The request never got a response (DNS/TLS/timeout/connection). `status` is 0. */
|
|
351
170
|
class APIConnectionError extends WosError {
|
|
352
171
|
}
|
|
353
172
|
exports.APIConnectionError = APIConnectionError;
|
|
354
|
-
/** 400 — the request was malformed (bad arguments). */
|
|
355
173
|
class BadRequestError extends WosError {
|
|
356
174
|
}
|
|
357
175
|
exports.BadRequestError = BadRequestError;
|
|
358
|
-
/** 401 — the API key is missing, wrong, or revoked. */
|
|
359
176
|
class AuthenticationError extends WosError {
|
|
360
177
|
}
|
|
361
178
|
exports.AuthenticationError = AuthenticationError;
|
|
362
|
-
/** 402 — no card on file or the balance is depleted. Top up to continue. */
|
|
363
179
|
class PaymentRequiredError extends WosError {
|
|
364
180
|
}
|
|
365
181
|
exports.PaymentRequiredError = PaymentRequiredError;
|
|
366
|
-
/** 403 — the key/model isn't allowed to do this. */
|
|
367
182
|
class PermissionDeniedError extends WosError {
|
|
368
183
|
}
|
|
369
184
|
exports.PermissionDeniedError = PermissionDeniedError;
|
|
370
|
-
/** 404 — the store or resource doesn't exist (create the store first). */
|
|
371
185
|
class NotFoundError extends WosError {
|
|
372
186
|
}
|
|
373
187
|
exports.NotFoundError = NotFoundError;
|
|
374
|
-
/** 409 — a concurrent write to the same store. Retry. */
|
|
375
188
|
class ConflictError extends WosError {
|
|
376
189
|
}
|
|
377
190
|
exports.ConflictError = ConflictError;
|
|
378
|
-
/** 429 — too many requests. Back off and retry (the client already retries these). */
|
|
379
191
|
class RateLimitError extends WosError {
|
|
380
192
|
}
|
|
381
193
|
exports.RateLimitError = RateLimitError;
|
|
382
|
-
/**
|
|
383
|
-
* 5xx — the service failed.
|
|
384
|
-
*
|
|
385
|
-
* `502` / `503` are transient: the client already retries them for calls where a
|
|
386
|
-
* retry cannot double-process a write, and retrying yourself is reasonable.
|
|
387
|
-
*
|
|
388
|
-
* `501` is NOT transient. It means the engine behind the model you selected does
|
|
389
|
-
* not implement that endpoint at all (the error carries `model` and `endpoint`).
|
|
390
|
-
* Retrying can never succeed — pick a model that supports it (`listModels`) or
|
|
391
|
-
* drop the call. Treating the whole 5xx range as retryable sends a caller into a
|
|
392
|
-
* loop that cannot end.
|
|
393
|
-
*/
|
|
394
194
|
class ServerError extends WosError {
|
|
395
195
|
}
|
|
396
196
|
exports.ServerError = ServerError;
|
|
@@ -403,17 +203,12 @@ const STATUS_ERRORS = {
|
|
|
403
203
|
409: ConflictError,
|
|
404
204
|
429: RateLimitError,
|
|
405
205
|
};
|
|
406
|
-
/** Build the most specific WosError subclass for an HTTP status. */
|
|
407
|
-
// Internal factory — the typed error CLASSES are the public surface.
|
|
408
206
|
function errorFor(status, message, requestId) {
|
|
409
207
|
const Cls = status === 0
|
|
410
208
|
? APIConnectionError
|
|
411
209
|
: STATUS_ERRORS[status] ?? (status >= 500 && status < 600 ? ServerError : WosError);
|
|
412
210
|
return new Cls(status, message, requestId);
|
|
413
211
|
}
|
|
414
|
-
/** The filter keys the API acts on. Anything else it DROPS silently, so a typo
|
|
415
|
-
* widens the search instead of failing — we warn rather than reject, because the
|
|
416
|
-
* API may grow a key before this package is updated. */
|
|
417
212
|
const KNOWN_FILTER_KEYS = new Set([
|
|
418
213
|
"categories",
|
|
419
214
|
"event_from",
|
|
@@ -423,6 +218,47 @@ const KNOWN_FILTER_KEYS = new Set([
|
|
|
423
218
|
"min_importance",
|
|
424
219
|
]);
|
|
425
220
|
const warnedFilterKeys = new Set();
|
|
221
|
+
const KNOWN_SEARCH_KEYS = new Set([
|
|
222
|
+
"cache_control", "speaker", "filters", "verify", "max_images", "extra",
|
|
223
|
+
]);
|
|
224
|
+
const RESERVED_SEARCH_KEYS = new Set(["user_id", "query", "max_results"]);
|
|
225
|
+
const KNOWN_RECALL_KEYS = new Set(["form", "tz", "limit", "context_limit"]);
|
|
226
|
+
function checkSearchOpts(opts) {
|
|
227
|
+
checkOpts(opts, KNOWN_SEARCH_KEYS, "search");
|
|
228
|
+
}
|
|
229
|
+
function checkOpts(opts, known, what) {
|
|
230
|
+
for (const k of Object.keys(opts)) {
|
|
231
|
+
if (RESERVED_SEARCH_KEYS.has(k))
|
|
232
|
+
throw new Error(`${JSON.stringify(k)} is set by the call, not by options — pass it as an argument. ` +
|
|
233
|
+
`An app forwarding untrusted input as options cannot steer the store, the query ` +
|
|
234
|
+
`or the count, and this says so rather than dropping it silently.`);
|
|
235
|
+
if (known.has(k))
|
|
236
|
+
continue;
|
|
237
|
+
const near = [...known].find((n) => n !== "extra" && editWithin(k, n, 2));
|
|
238
|
+
throw new Error(`unknown ${what} option ${JSON.stringify(k)}` +
|
|
239
|
+
(near ? ` — did you mean ${JSON.stringify(near)}?` : "") +
|
|
240
|
+
`. The service drops keys it does not know and answers anyway, so this would ` +
|
|
241
|
+
`have looked like it worked.` +
|
|
242
|
+
(known.has("extra")
|
|
243
|
+
? ` Pass it under \`extra\` if the service accepts it and this client does not know it yet.`
|
|
244
|
+
: ""));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function editWithin(a, b, max) {
|
|
248
|
+
if (Math.abs(a.length - b.length) > max)
|
|
249
|
+
return false;
|
|
250
|
+
const prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
251
|
+
for (let i = 1; i <= a.length; i++) {
|
|
252
|
+
let diag = prev[0];
|
|
253
|
+
prev[0] = i;
|
|
254
|
+
for (let j = 1; j <= b.length; j++) {
|
|
255
|
+
const tmp = prev[j];
|
|
256
|
+
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
257
|
+
diag = tmp;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return prev[b.length] <= max;
|
|
261
|
+
}
|
|
426
262
|
function warnOnUnknownFilters(filters) {
|
|
427
263
|
if (!filters || typeof filters !== "object" || Array.isArray(filters))
|
|
428
264
|
return;
|
|
@@ -430,8 +266,6 @@ function warnOnUnknownFilters(filters) {
|
|
|
430
266
|
if (KNOWN_FILTER_KEYS.has(k) || warnedFilterKeys.has(k))
|
|
431
267
|
continue;
|
|
432
268
|
warnedFilterKeys.add(k);
|
|
433
|
-
// 2.2.27 capped the store-id warn set and left this sibling unbounded. An app that
|
|
434
|
-
// forwards user-supplied filter keys grows it forever, one entry per distinct typo.
|
|
435
269
|
while (warnedFilterKeys.size > WARNED_FILTER_KEYS_MAX) {
|
|
436
270
|
const oldest = warnedFilterKeys.values().next().value;
|
|
437
271
|
if (oldest === undefined)
|
|
@@ -443,12 +277,10 @@ function warnOnUnknownFilters(filters) {
|
|
|
443
277
|
`Known keys: ${[...KNOWN_FILTER_KEYS].join(", ")}.`);
|
|
444
278
|
}
|
|
445
279
|
}
|
|
446
|
-
/** Test hook — the warn-once set is process-global by design. */
|
|
447
280
|
function _resetFilterWarnings() {
|
|
448
281
|
warnedFilterKeys.clear();
|
|
449
282
|
}
|
|
450
283
|
const DEFAULT_USER = "default";
|
|
451
|
-
/** Wontopos memory client. Store and recall memories, isolated per `userId`. */
|
|
452
284
|
class Client {
|
|
453
285
|
base;
|
|
454
286
|
headers;
|
|
@@ -458,57 +290,40 @@ class Client {
|
|
|
458
290
|
maxRetries;
|
|
459
291
|
deadlineMs;
|
|
460
292
|
signal;
|
|
461
|
-
/** Transport. Held as a field (not read off `globalThis` per call) so a caller
|
|
462
|
-
* who passes one gets it for every request, including from cloned clients. */
|
|
463
293
|
fetchImpl;
|
|
464
|
-
/** The store every call uses unless one passes `userId`. */
|
|
465
294
|
defaultUser;
|
|
466
295
|
_rateLimit = null;
|
|
467
|
-
/** Quota from the MOST RECENT call: `{ limit, remaining, reset }` (or `null` before
|
|
468
|
-
* the first call). Read it to self-throttle — the client already retries 429s, but
|
|
469
|
-
* this lets you slow down before hitting the wall. */
|
|
470
296
|
get rateLimit() {
|
|
471
297
|
return this._rateLimit;
|
|
472
298
|
}
|
|
473
299
|
constructor(opts) {
|
|
474
|
-
// Trim the key and reject inner whitespace — a stray newline from a file or
|
|
475
|
-
// env var otherwise turns into a mystery 401 (or a mangled header).
|
|
476
300
|
const key = (opts?.apiKey ?? "").trim();
|
|
477
301
|
if (!key)
|
|
478
302
|
throw new Error("apiKey is required");
|
|
479
303
|
if (/\s/.test(key))
|
|
480
304
|
throw new Error("apiKey contains whitespace - check for a stray newline or paste error");
|
|
481
|
-
// `\s` does not cover NUL, 0x01 or DEL, and those travel into the header and fail
|
|
482
|
-
// deep inside fetch as the mystery 401 this check exists to prevent. Python has
|
|
483
|
-
// `_HEADER_CTL_RE` and Rust tests `is_control`; this was the client that did not,
|
|
484
|
-
// while all three READMEs announced the guard without naming a language.
|
|
485
305
|
if (/[\x00-\x1f\x7f]/.test(key))
|
|
486
306
|
throw new Error("apiKey contains a control character - check for a stray byte or paste error");
|
|
307
|
+
if (/[^\x00-\x7f]/.test(key)) {
|
|
308
|
+
const bad = key.match(/[^\x00-\x7f]/)?.[0];
|
|
309
|
+
throw new Error(`apiKey contains a non-ASCII character (${JSON.stringify(bad)}) - rich text turns '-' into an ` +
|
|
310
|
+
"en dash; copy the key from a plain-text field");
|
|
311
|
+
}
|
|
487
312
|
this.apiKey = key;
|
|
488
313
|
this.base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
489
|
-
// Coerce a non-finite or non-positive timeout back to the default — NaN
|
|
490
|
-
// (e.g. Number() of an unset env var) would otherwise make the abort timer
|
|
491
|
-
// fire immediately and every request "time out after NaNms".
|
|
492
314
|
const tm = opts.timeoutMs;
|
|
493
315
|
this.timeoutMs = typeof tm === "number" && Number.isFinite(tm) && tm > 0 ? tm : 30_000;
|
|
494
316
|
this.model = opts.model ?? DEFAULT_MODEL;
|
|
495
317
|
if (this.model && !MODEL_RE.test(this.model)) {
|
|
496
318
|
throw new Error(`invalid model name: ${JSON.stringify(this.model)} (letters, digits, '.', '_', '-' only)`);
|
|
497
319
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
// NaN makes `attempts = NaN + 1` and the loop runs zero requests then throws
|
|
501
|
-
// "retries exhausted"; Infinity would retry forever.
|
|
320
|
+
assertUsableStoreId(opts.userId);
|
|
321
|
+
this.defaultUser = opts.userId ?? DEFAULT_USER;
|
|
502
322
|
const mr = opts.maxRetries ?? opts.retries;
|
|
503
323
|
this.maxRetries = typeof mr === "number" && Number.isFinite(mr) ? Math.max(0, Math.floor(mr)) : 2;
|
|
504
324
|
const dl = opts.deadlineMs;
|
|
505
|
-
// A zero or negative budget would fail every call before it starts — treat it as
|
|
506
|
-
// "no budget", the same way a non-positive timeout falls back to the default.
|
|
507
325
|
this.deadlineMs = typeof dl === "number" && Number.isFinite(dl) && dl > 0 ? dl : undefined;
|
|
508
326
|
this.signal = opts.signal;
|
|
509
|
-
// Bind the global so `fetch` is not called as a method of `globalThis`, which
|
|
510
|
-
// throws "Illegal invocation" on some runtimes. A caller-supplied fetch is
|
|
511
|
-
// taken as given — it is already whatever they meant to hand us.
|
|
512
327
|
const f = opts.fetch;
|
|
513
328
|
if (f !== undefined && typeof f !== "function") {
|
|
514
329
|
throw new Error("fetch must be a function (a fetch-compatible transport)");
|
|
@@ -520,85 +335,39 @@ class Client {
|
|
|
520
335
|
this.headers = {
|
|
521
336
|
"X-API-Key": this.apiKey,
|
|
522
337
|
"Content-Type": "application/json",
|
|
523
|
-
// Browsers silently drop User-Agent (forbidden header) — harmless.
|
|
524
338
|
"User-Agent": USER_AGENT,
|
|
525
339
|
};
|
|
526
340
|
if (this.model)
|
|
527
341
|
this.headers["X-WOS-Model"] = this.model;
|
|
528
|
-
// An API key on plain HTTP travels readable by anyone on the path. Loopback
|
|
529
|
-
// is fine (local dev, or a proxy on the same box); anything else gets a
|
|
530
|
-
// warning, not an error, so private-network gateways keep working.
|
|
531
|
-
// Parse, don't split. `http://127.0.0.1:9@evil.example` splits to "127.0.0.1" — the
|
|
532
|
-
// USERINFO, not the host — so this check called it loopback and stayed quiet while
|
|
533
|
-
// the key travelled in cleartext to evil.example. `new URL()` knows the difference.
|
|
534
|
-
// (Python already used urlsplit here; this was the copy that did not.)
|
|
535
342
|
let host;
|
|
536
343
|
try {
|
|
537
|
-
// `URL.hostname` keeps the brackets on an IPv6 literal — `http://[::1]:8080`
|
|
538
|
-
// gives back "[::1]", which was never in LOOPBACK_HOSTS, so a local
|
|
539
|
-
// client on the v6 loopback was told its key was travelling in cleartext when
|
|
540
|
-
// it was not. Python's urlsplit strips them and Rust strips them by hand; this
|
|
541
|
-
// was the copy that did not. Strip them here so all three agree.
|
|
542
344
|
host = new URL(this.base).hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
543
345
|
}
|
|
544
346
|
catch {
|
|
545
|
-
host = "";
|
|
347
|
+
host = "";
|
|
546
348
|
}
|
|
547
|
-
// Scheme compare is case-insensitive — `HTTP://` connects in plaintext too.
|
|
548
349
|
if (/^http:\/\//i.test(this.base) && !LOOPBACK_HOSTS.has(host)) {
|
|
549
350
|
console.warn("wontopos: baseUrl uses plain HTTP on a non-local host, so the API key travels unencrypted. Use https://.");
|
|
550
351
|
}
|
|
551
|
-
// TypeScript `private` is erased at runtime: `apiKey` is an ordinary enumerable
|
|
552
|
-
// property, so `{...client}` copies it and `JSON.stringify({...client})` prints the
|
|
553
|
-
// key in full. The existing masking covers `JSON.stringify(client)` and
|
|
554
|
-
// `util.inspect(client)` — but spreading an object into a log record is the common
|
|
555
|
-
// shape in structured logging, and it went straight past both. Make the key and the
|
|
556
|
-
// prepared auth header non-enumerable so a spread cannot pick them up; they stay
|
|
557
|
-
// readable inside the class, which is all the code needs.
|
|
558
352
|
Object.defineProperty(this, "apiKey", { enumerable: false });
|
|
559
353
|
Object.defineProperty(this, "headers", { enumerable: false });
|
|
560
354
|
}
|
|
561
|
-
/** A client whose key comes from `WONTOPOS_API_KEY` (or `WOS_API_KEY`) — keeps
|
|
562
|
-
* keys out of source code. Node/Bun/Deno-with-env only. */
|
|
563
355
|
static fromEnv(opts = {}) {
|
|
564
356
|
const env = globalThis?.process?.env;
|
|
565
|
-
// First env var with a NON-EMPTY value wins — an empty WONTOPOS_API_KEY must
|
|
566
|
-
// not shadow a valid WOS_API_KEY (`??` only skips null/undefined, not "").
|
|
567
357
|
const key = [env?.WONTOPOS_API_KEY, env?.WOS_API_KEY].find((v) => v && v.trim());
|
|
568
358
|
if (!key)
|
|
569
359
|
throw new Error("set WONTOPOS_API_KEY (or WOS_API_KEY) in the environment");
|
|
570
|
-
// apiKey LAST so a stray apiKey in opts can't override the env key.
|
|
571
360
|
return new Client({ ...opts, apiKey: key });
|
|
572
361
|
}
|
|
573
|
-
/** Never expose the key: `JSON.stringify(client)` gets the redacted view. */
|
|
574
362
|
toJSON() {
|
|
575
363
|
return { baseUrl: this.base, model: this.model, userId: this.defaultUser, apiKey: maskKey(this.apiKey) };
|
|
576
364
|
}
|
|
577
|
-
/** Never expose the key: Node's `console.log(client)` gets the redacted view. */
|
|
578
365
|
[Symbol.for("nodejs.util.inspect.custom")]() {
|
|
579
366
|
return `Client(${this.base}, model=${this.model}, userId=${this.defaultUser}, apiKey=${maskKey(this.apiKey)})`;
|
|
580
367
|
}
|
|
581
|
-
/** Resolve a call's store: the explicit userId, else the client default. */
|
|
582
368
|
uid(userId) {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
// tenant id and got nothing. Falling back there writes one customer's memories into
|
|
586
|
-
// whatever store this client defaults to, silently. Omission is a choice; a blank
|
|
587
|
-
// string is a bug, and it should say so where it happened.
|
|
588
|
-
//
|
|
589
|
-
// The check is `typeof !== "string"`, not `!String(userId).trim()`. The old form
|
|
590
|
-
// only caught a blank STRING, and the value a failed lookup actually produces in
|
|
591
|
-
// JavaScript is `null` — `String(null)` is the truthy "null", so it sailed through
|
|
592
|
-
// the guard and then `null || default` sent the write to the client's default
|
|
593
|
-
// store. `0` (an integer primary key) did the same. Both are the exact outcome the
|
|
594
|
-
// guard was written to stop, and both were still silent.
|
|
595
|
-
//
|
|
596
|
-
// `undefined` alone means "omitted", because that is what an absent argument is.
|
|
597
|
-
if (userId !== undefined && (typeof userId !== "string" || !userId.trim())) {
|
|
598
|
-
throw new Error(`userId must be a non-blank string; got ${JSON.stringify(userId)}. Omit it to use the client's ` +
|
|
599
|
-
"default store, or pass a real store id — anything else would silently write into the default store.");
|
|
600
|
-
}
|
|
601
|
-
const id = userId || this.defaultUser;
|
|
369
|
+
assertUsableStoreId(userId);
|
|
370
|
+
const id = userId ?? this.defaultUser;
|
|
602
371
|
warnIfStoreIdCollapses(id);
|
|
603
372
|
return id;
|
|
604
373
|
}
|
|
@@ -610,140 +379,77 @@ class Client {
|
|
|
610
379
|
model: this.model,
|
|
611
380
|
userId: this.defaultUser,
|
|
612
381
|
maxRetries: this.maxRetries,
|
|
613
|
-
// Carry the transport across clones. Without this, `withModel(...)` and
|
|
614
|
-
// friends would silently fall back to the global fetch and a proxy-bound
|
|
615
|
-
// client would stop connecting the moment it was cloned.
|
|
616
382
|
fetch: this.fetchImpl,
|
|
617
|
-
// Carried for the same reason as `fetch` above: a clone that quietly dropped
|
|
618
|
-
// the caller's signal would keep working right up until the moment someone
|
|
619
|
-
// needed to cancel it.
|
|
620
383
|
deadlineMs: this.deadlineMs,
|
|
621
384
|
signal: this.signal,
|
|
622
385
|
...overrides,
|
|
623
386
|
});
|
|
624
387
|
}
|
|
625
|
-
/**
|
|
626
|
-
* A client that uses `model` (sent as `X-WOS-Model`). Use it to set the default
|
|
627
|
-
* (`new Client({ apiKey, model })`) or to override one call
|
|
628
|
-
* (`client.withModel("tablet-1").recall(...)`).
|
|
629
|
-
*/
|
|
630
388
|
withModel(model) {
|
|
631
389
|
return this.clone({ model });
|
|
632
390
|
}
|
|
633
|
-
/** A client bound to `userId` as its default store (everything else kept). */
|
|
634
391
|
withUser(userId) {
|
|
392
|
+
if (userId === undefined) {
|
|
393
|
+
throw new Error("withUser() needs a store id. It was called with undefined — usually a lookup that " +
|
|
394
|
+
"found nothing. Use the client's own default instead of calling withUser at all.");
|
|
395
|
+
}
|
|
635
396
|
return this.clone({ userId });
|
|
636
397
|
}
|
|
637
|
-
/** A client bound to a caller's `AbortSignal` (everything else kept).
|
|
638
|
-
*
|
|
639
|
-
* const ctrl = new AbortController();
|
|
640
|
-
* req.on("close", () => ctrl.abort());
|
|
641
|
-
* const r = await mem.withSignal(ctrl.signal).recall(q, user);
|
|
642
|
-
*
|
|
643
|
-
* Aborting ends the request in flight AND any backoff sleep waiting to retry it. */
|
|
644
398
|
withSignal(signal) {
|
|
645
399
|
return this.clone({ signal });
|
|
646
400
|
}
|
|
647
|
-
/** A client with a total budget per call, across every retry (everything else
|
|
648
|
-
* kept). `mem.withDeadline(5_000).search(q)` inside a handler that has five
|
|
649
|
-
* seconds. */
|
|
650
401
|
withDeadline(deadlineMs) {
|
|
651
402
|
return this.clone({ deadlineMs });
|
|
652
403
|
}
|
|
653
|
-
/** A client with a different per-request timeout in ms (everything else kept).
|
|
654
|
-
* `mem.withTimeout(120_000).addBulk(bigBlob)` — this slow call only. */
|
|
655
404
|
withTimeout(timeoutMs) {
|
|
656
405
|
return this.clone({ timeoutMs });
|
|
657
406
|
}
|
|
658
|
-
/** A client with a different retry budget (everything else kept). 0 disables retries. */
|
|
659
407
|
withRetries(maxRetries) {
|
|
660
408
|
return this.clone({ maxRetries });
|
|
661
409
|
}
|
|
662
|
-
// ----- write -----
|
|
663
|
-
// `userId` is optional in every call below - omit it to use the client's default
|
|
664
|
-
// store (set via `new Client({ apiKey, userId })` or `withUser`).
|
|
665
|
-
/** Store one memory. `metadata` optional — e.g. `{ event_date: "2026-03-01" }` for when the
|
|
666
|
-
* content actually happened. A plain date works (read as UTC midnight); a full RFC3339
|
|
667
|
-
* timestamp also works; older examples here show the long form.
|
|
668
|
-
* `metadata.speaker`: "me" = the assistant's own words, or a person's name (up to 50 per store).
|
|
669
|
-
*
|
|
670
|
-
* `opts.idempotencyKey`: see {@link WriteOptions} — pass one to make YOUR retry
|
|
671
|
-
* of this exact write safe to repeat. */
|
|
672
410
|
add(content, userId, metadata = {}, opts = {}) {
|
|
673
411
|
const body = { user_id: this.uid(userId), content, metadata };
|
|
674
412
|
if (opts.image !== undefined)
|
|
675
413
|
body.image = normalizeImage(opts.image);
|
|
676
414
|
return this.post("/api/v1/memory/store", body, opts.idempotencyKey);
|
|
677
415
|
}
|
|
678
|
-
/** Alias of `add` — store one memory. Same surface as the Python SDK's `store`. */
|
|
679
416
|
store(content, userId, metadata = {}, opts = {}) {
|
|
680
417
|
return this.add(content, userId, metadata, opts);
|
|
681
418
|
}
|
|
682
|
-
/** Store a conversation turn (user + assistant). Payload first, userId last — same shape as add/search. */
|
|
683
419
|
addTurn(userMsg, assistantMsg, userId, opts = {}) {
|
|
684
420
|
return this.post("/api/v1/memory/store-turn", { user_id: this.uid(userId), user_msg: userMsg, assistant_msg: assistantMsg }, opts.idempotencyKey);
|
|
685
421
|
}
|
|
686
|
-
/** Bulk-ingest a large blob of text in one call. For backfilling.
|
|
687
|
-
* The call most worth an `opts.idempotencyKey`: a backfill that dies halfway and is
|
|
688
|
-
* re-run would otherwise re-ingest and re-bill the whole blob. */
|
|
689
422
|
addBulk(content, userId, category = "general", timestamp, opts = {}) {
|
|
690
423
|
const body = { user_id: this.uid(userId), content, category };
|
|
691
424
|
if (timestamp)
|
|
692
425
|
body.timestamp = timestamp;
|
|
693
426
|
return this.post("/api/v1/memory/bulk-store", body, opts.idempotencyKey);
|
|
694
427
|
}
|
|
695
|
-
/** Supersede an old memory with new content. Payload first, userId last — same shape as add/search. */
|
|
696
428
|
update(oldMemoryId, newContent, userId, opts = {}) {
|
|
697
429
|
return this.post("/api/v1/memory/supersede", { user_id: this.uid(userId), old_memory_id: oldMemoryId, new_content: newContent }, opts.idempotencyKey);
|
|
698
430
|
}
|
|
699
|
-
// ----- read -----
|
|
700
|
-
/** Search a store's memories. Returns them most relevant first.
|
|
701
|
-
*
|
|
702
|
-
* `limit` bounds `memories`, not the returned array. On a model that keeps the
|
|
703
|
-
* assistant's own words separate (Scroll 1.2+) those come back as well, so the
|
|
704
|
-
* array can hold more than `limit`. They were retrieved and billed either way;
|
|
705
|
-
* dropping them would only hide what you already paid for. Size a prompt window
|
|
706
|
-
* on the array you get back, not on `limit`. `searchSelf()` hands the two back
|
|
707
|
-
* apart. */
|
|
708
431
|
async search(query, userId, limit = 10, opts = {}) {
|
|
709
|
-
|
|
710
|
-
// opts must not be able to override the store (user_id), query, or limit.
|
|
432
|
+
checkSearchOpts(opts);
|
|
711
433
|
warnOnUnknownFilters(opts.filters);
|
|
712
434
|
checkCount(limit, "limit");
|
|
713
|
-
const
|
|
435
|
+
const { extra, ...known } = opts;
|
|
436
|
+
const r = await this.post("/api/v1/memory/search", { ...extra, ...known, user_id: this.uid(userId), query, max_results: limit });
|
|
714
437
|
return mergeResults(r);
|
|
715
438
|
}
|
|
716
|
-
/** Search a self-memory model (Scroll 1.2+): both fields from ONE call. Returns
|
|
717
|
-
* `{ memories, self_memories }` — `memories` is what others said and general
|
|
718
|
-
* memories, `self_memories` is the assistant's OWN words (stored with speaker
|
|
719
|
-
* "me"), kept apart so whoever reads them never confuses who said what. On a model
|
|
720
|
-
* that does not keep them apart, `self_memories` is `[]`. `userId` may be omitted. */
|
|
721
439
|
async searchSelf(query, userId, limit = 10, opts = {}) {
|
|
440
|
+
checkSearchOpts(opts);
|
|
722
441
|
warnOnUnknownFilters(opts.filters);
|
|
723
442
|
checkCount(limit, "limit");
|
|
724
|
-
const
|
|
443
|
+
const { extra, ...known } = opts;
|
|
444
|
+
const r = await this.post("/api/v1/memory/search", { ...extra, ...known, user_id: this.uid(userId), query, max_results: limit });
|
|
725
445
|
return { memories: asRecords(r.memories), self_memories: asRecords(r.self_memories) };
|
|
726
446
|
}
|
|
727
|
-
/**
|
|
728
|
-
* Search, and keep every field the answer came with.
|
|
729
|
-
*
|
|
730
|
-
* Same request as {@link Client.search} — reach for this one when the options make
|
|
731
|
-
* the merged array an incomplete answer:
|
|
732
|
-
*
|
|
733
|
-
* const r = await mem.searchFull("the day we moved", "alice", 10,
|
|
734
|
-
* { max_images: 3, verify: 2 });
|
|
735
|
-
* r.images.length; // the photos, which `search` drops
|
|
736
|
-
* r.verify_used; // how many re-ask passes actually ran (you are billed per pass)
|
|
737
|
-
*
|
|
738
|
-
* Both options need Tablet 2 or newer and are refused (403) on an older engine
|
|
739
|
-
* rather than accepted and ignored.
|
|
740
|
-
*/
|
|
741
447
|
async searchFull(query, userId, limit = 10, opts = {}) {
|
|
448
|
+
checkSearchOpts(opts);
|
|
742
449
|
warnOnUnknownFilters(opts.filters);
|
|
743
450
|
checkCount(limit, "limit");
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
// normalized, because a broken proxy can null any of them.
|
|
451
|
+
const { extra, ...known } = opts;
|
|
452
|
+
const r = await this.post("/api/v1/memory/search", { ...extra, ...known, user_id: this.uid(userId), query, max_results: limit });
|
|
747
453
|
return {
|
|
748
454
|
...r,
|
|
749
455
|
memories: asRecords(r.memories),
|
|
@@ -751,14 +457,8 @@ class Client {
|
|
|
751
457
|
images: asRecords(r.images),
|
|
752
458
|
};
|
|
753
459
|
}
|
|
754
|
-
/** One-call LLM context: short-term turns + long-term matches + surrounding context.
|
|
755
|
-
* `form` ("memoir"/"archive", Scroll 1.2+) renders each long-term memory's time in
|
|
756
|
-
* that form; `tz` is your UTC-offset hours for that rendering. */
|
|
757
|
-
// `async` so an argument mistake arrives the way every other failure does — a
|
|
758
|
-
// rejected promise. Validating inside a non-async method threw synchronously, so
|
|
759
|
-
// `mem.recall(...).catch(h)` walked straight past the handler while the identical
|
|
760
|
-
// mistake in `search` (which is async) landed in it. Two shapes for one error.
|
|
761
460
|
async recall(query, userId, opts = {}) {
|
|
461
|
+
checkOpts(opts, KNOWN_RECALL_KEYS, "recall");
|
|
762
462
|
const body = { user_id: this.uid(userId), query };
|
|
763
463
|
if (opts.limit !== undefined) {
|
|
764
464
|
checkCount(opts.limit, "limit");
|
|
@@ -770,63 +470,37 @@ class Client {
|
|
|
770
470
|
}
|
|
771
471
|
return this.post("/api/v1/memory/recall", withForm(body, opts.form, opts.tz));
|
|
772
472
|
}
|
|
773
|
-
/** Run a built-in engram ("deep_recall" | "timeline" | "gather" | "equilibrium" |
|
|
774
|
-
* "tone_stabilizer"; the service is the authority — an unknown name comes back with
|
|
775
|
-
* the list it accepts). Returns the merged result.
|
|
776
|
-
* `form`/`tz` render memory times (memoir/archive) on Scroll 1.2+, same as search/recall. */
|
|
777
473
|
engram(name, query, userId, opts = {}) {
|
|
778
474
|
return this.post("/api/v1/engram/run", withForm({ name, user_id: this.uid(userId), query }, opts.form, opts.tz));
|
|
779
475
|
}
|
|
780
|
-
/** Recent conversation turns (short-term memory). */
|
|
781
476
|
async history(userId) {
|
|
782
477
|
const r = await this.post("/api/v1/memory/history", { user_id: this.uid(userId) });
|
|
783
478
|
return asRecords(r.turns);
|
|
784
479
|
}
|
|
785
|
-
/** Memory counts for a store: { total_memories, short_term_turns }. */
|
|
786
480
|
stats(userId) {
|
|
787
481
|
return this.post("/api/v1/memory/stats", { user_id: this.uid(userId) });
|
|
788
482
|
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
483
|
+
async usage(days = 7) {
|
|
484
|
+
if (!Number.isInteger(days) || days < 1 || days > 365) {
|
|
485
|
+
throw new Error(`days must be an integer between 1 and 365, got ${days}.`);
|
|
486
|
+
}
|
|
487
|
+
return this.request("GET", `/api/v1/won/usage?days=${days}`);
|
|
488
|
+
}
|
|
794
489
|
get(userId, memoryId) {
|
|
795
|
-
|
|
796
|
-
// when the caller forgets to await.
|
|
797
|
-
if (!memoryId || typeof memoryId !== "string") {
|
|
490
|
+
if (typeof memoryId !== "string" || !memoryId.trim()) {
|
|
798
491
|
throw new Error("memoryId is required — the id that add/store or listMemories returned.");
|
|
799
492
|
}
|
|
800
|
-
return this.post("/api/v1/memory/get", { user_id: this.uid(userId), memory_id: memoryId }).then((r) => asObj(r.memory));
|
|
801
|
-
}
|
|
802
|
-
/** List a store's stored memories — the text you stored, plus its metadata.
|
|
803
|
-
* Paginated: pass the returned `next_cursor` back as `cursor` for the next
|
|
804
|
-
* page; a `null` cursor means the last page. Use it to browse or export a store.
|
|
805
|
-
*
|
|
806
|
-
* let cursor: string | null = null;
|
|
807
|
-
* const all: Memory[] = [];
|
|
808
|
-
* do {
|
|
809
|
-
* const page = await mem.listMemories(undefined, { cursor: cursor ?? undefined });
|
|
810
|
-
* all.push(...page.memories);
|
|
811
|
-
* cursor = page.next_cursor;
|
|
812
|
-
* } while (cursor);
|
|
813
|
-
*/
|
|
493
|
+
return this.post("/api/v1/memory/get", { user_id: this.uid(userId), memory_id: memoryId.trim() }).then((r) => asObj(r.memory));
|
|
494
|
+
}
|
|
814
495
|
listMemories(userId, opts = {}) {
|
|
815
496
|
const body = { user_id: this.uid(userId), limit: opts.limit ?? 100 };
|
|
816
497
|
if (opts.cursor)
|
|
817
498
|
body.cursor = opts.cursor;
|
|
818
499
|
return this.post("/api/v1/memory/list", body);
|
|
819
500
|
}
|
|
820
|
-
/** Async-iterate every stored memory in a store, paging under the hood — no cursor
|
|
821
|
-
* bookkeeping. The text you stored and its metadata only.
|
|
822
|
-
*
|
|
823
|
-
* for await (const m of mem.iterMemories()) console.log(m.id, m.content);
|
|
824
|
-
*/
|
|
825
501
|
async *iterMemories(userId, opts = {}) {
|
|
826
502
|
let cursor;
|
|
827
503
|
const seen = new Set();
|
|
828
|
-
// Backstop: the cursor-repeat guard catches a repeated cursor, but not a
|
|
829
|
-
// server that mints a FRESH cursor every page forever — bound the walk.
|
|
830
504
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
831
505
|
const p = await this.listMemories(userId, { limit: opts.pageSize ?? 100, cursor });
|
|
832
506
|
for (const m of asRecords(p.memories))
|
|
@@ -840,71 +514,27 @@ class Client {
|
|
|
840
514
|
throw new Error(`stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
|
|
841
515
|
`answer, not the whole store.`);
|
|
842
516
|
}
|
|
843
|
-
/** Collect ALL of a store's memories into an array (the text you stored, and its metadata). */
|
|
844
517
|
async exportMemories(userId) {
|
|
845
518
|
const out = [];
|
|
846
519
|
for await (const m of this.iterMemories(userId))
|
|
847
520
|
out.push(m);
|
|
848
521
|
return out;
|
|
849
522
|
}
|
|
850
|
-
// ----- images (Tablet 2 and newer) -----
|
|
851
|
-
/**
|
|
852
|
-
* Fetch the bytes of an image memory — the picture the SERVICE holds.
|
|
853
|
-
*
|
|
854
|
-
* Not necessarily your upload, in size OR in format: an image whose long edge was
|
|
855
|
-
* over 1568px was downscaled to 1568 on the way in and re-encoded — lossless
|
|
856
|
-
* formats as WebP, so a PNG comes back as `image/webp`; JPEG stays JPEG — and that
|
|
857
|
-
* smaller picture is what is stored and comes back here. Nothing on our side ever
|
|
858
|
-
* uses more than 1568, so the extra pixels would be bytes nobody reads.
|
|
859
|
-
* Keep your own copy for the full-resolution file.
|
|
860
|
-
*
|
|
861
|
-
* The type is sniffed from the bytes themselves rather than from whatever the file
|
|
862
|
-
* was called, so name the file from `contentType` rather than from what you sent.
|
|
863
|
-
* When the format changed, the response also carries `x-wos-image-converted-from`
|
|
864
|
-
* naming what you uploaded. Rejects with `NotFoundError` when this memory has no
|
|
865
|
-
* image, or when the service keeps no image bytes at all — it says "no" instead of
|
|
866
|
-
* handing back something empty, so "a memory with no image" never looks the same as
|
|
867
|
-
* "an image we lost".
|
|
868
|
-
*
|
|
869
|
-
* const { bytes, contentType } = await mem.getImage(undefined, id);
|
|
870
|
-
* const ext = contentType.split("/")[1]; // "webp" for a downscaled PNG
|
|
871
|
-
* await fs.writeFile(`image.${ext}`, bytes);
|
|
872
|
-
*/
|
|
873
523
|
getImage(userId, memoryId) {
|
|
874
|
-
if (
|
|
524
|
+
if (typeof memoryId !== "string" || !memoryId.trim()) {
|
|
875
525
|
throw new Error("memoryId is required — the id that add/store or listImages returned.");
|
|
876
526
|
}
|
|
877
|
-
return this.requestBytes("/api/v1/memory/image", { user_id: this.uid(userId), memory_id: memoryId });
|
|
878
|
-
}
|
|
879
|
-
/**
|
|
880
|
-
* Remove the PHOTO from a memory, keeping its text.
|
|
881
|
-
*
|
|
882
|
-
* Except when there is no text: an image stored without a caption *is* the memory, so
|
|
883
|
-
* deleting the image deletes it. That is the one case worth checking before you
|
|
884
|
-
* commit, which is what `preview` is for — it reports `memory_kept` and changes
|
|
885
|
-
* nothing.
|
|
886
|
-
*
|
|
887
|
-
* const p = await mem.forgetImage(undefined, id, { preview: true });
|
|
888
|
-
* if (p.memory_kept === false) { /* this would delete the whole memory *\/ }
|
|
889
|
-
*/
|
|
527
|
+
return this.requestBytes("/api/v1/memory/image", { user_id: this.uid(userId), memory_id: memoryId.trim() });
|
|
528
|
+
}
|
|
890
529
|
forgetImage(userId, memoryId, opts = {}) {
|
|
891
|
-
if (
|
|
530
|
+
if (typeof memoryId !== "string" || !memoryId.trim()) {
|
|
892
531
|
throw new Error("memoryId is required — the id of the memory whose image you want removed.");
|
|
893
532
|
}
|
|
894
|
-
const body = { user_id: this.uid(userId), memory_id: memoryId };
|
|
533
|
+
const body = { user_id: this.uid(userId), memory_id: memoryId.trim() };
|
|
895
534
|
if (opts.preview)
|
|
896
535
|
body.preview = true;
|
|
897
536
|
return this.request("DELETE", "/api/v1/memory/image", body);
|
|
898
537
|
}
|
|
899
|
-
/**
|
|
900
|
-
* List a store's image memories, newest first, and count them.
|
|
901
|
-
*
|
|
902
|
-
* `count` is the TOTAL in the store, not the size of the page — so you can show
|
|
903
|
-
* "142 images" without walking every page. Paging is by cursor, not offset: hand
|
|
904
|
-
* `next_before` and `next_skip_ids` back as `before` / `skipIds`. The pair exists
|
|
905
|
-
* because several images can share a timestamp, and a timestamp alone would either
|
|
906
|
-
* repeat them or skip them.
|
|
907
|
-
*/
|
|
908
538
|
listImages(userId, opts = {}) {
|
|
909
539
|
const body = { user_id: this.uid(userId) };
|
|
910
540
|
if (opts.limit !== undefined)
|
|
@@ -915,28 +545,16 @@ class Client {
|
|
|
915
545
|
body.skip_ids = opts.skipIds;
|
|
916
546
|
return this.post("/api/v1/memory/images", body);
|
|
917
547
|
}
|
|
918
|
-
/** Async-iterate every image memory, paging under the hood. */
|
|
919
548
|
async *iterImages(userId, opts = {}) {
|
|
920
549
|
let before;
|
|
921
550
|
let skipIds;
|
|
922
|
-
// Same repeat-cursor guard as `iterMemories`. A server that hands back the
|
|
923
|
-
// cursor it was just given would otherwise re-yield one page MAX_PAGES times,
|
|
924
|
-
// and the caller reads those repeats as more images. The page cap alone bounds
|
|
925
|
-
// the walk; it does not stop the duplicates.
|
|
926
551
|
const seen = new Set();
|
|
927
552
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
928
553
|
const p = await this.listImages(userId, { limit: opts.pageSize, before, skipIds });
|
|
929
554
|
for (const m of asRecords(p.images))
|
|
930
555
|
yield m;
|
|
931
|
-
// `return`, not `break`: the throw below is the MAX_PAGES backstop, and a
|
|
932
|
-
// `break` fell straight into it — so the ordinary end of a walk raised
|
|
933
|
-
// "the store did not end" on a store that had just ended. Python is safe
|
|
934
|
-
// here by using for/else and Rust by carrying an `ended` flag; TypeScript
|
|
935
|
-
// has neither, so the exit has to leave the function outright.
|
|
936
556
|
if (!p.has_more || !p.next_before)
|
|
937
557
|
return;
|
|
938
|
-
// The cursor is the PAIR: several images can share a timestamp, so `before`
|
|
939
|
-
// alone repeats across pages legitimately.
|
|
940
558
|
const key = `${p.next_before}|${(Array.isArray(p.next_skip_ids) ? p.next_skip_ids : []).join(",")}`;
|
|
941
559
|
if (seen.has(key))
|
|
942
560
|
return;
|
|
@@ -947,49 +565,6 @@ class Client {
|
|
|
947
565
|
throw new Error(`stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
|
|
948
566
|
`answer, not the whole store.`);
|
|
949
567
|
}
|
|
950
|
-
// ----- how much has this memory been edited -----
|
|
951
|
-
/**
|
|
952
|
-
* How much of this store has been altered since it was written.
|
|
953
|
-
*
|
|
954
|
-
* This one is aimed at the MODEL, not at you. An assistant leaning on its own memory
|
|
955
|
-
* should be able to ask how far that memory has been edited underneath it — a store
|
|
956
|
-
* nobody has touched reads differently from one where a third of the facts were
|
|
957
|
-
* rewritten. Free, and it never reaches the retrieval path.
|
|
958
|
-
*
|
|
959
|
-
* Counts memories a transform touched (supersede, update, retract, image removed).
|
|
960
|
-
* Deletions are NOT counted: a deleted memory leaves nothing to count. Neither are
|
|
961
|
-
* the internal records derived from what you stored — nobody stored those directly,
|
|
962
|
-
* so they do not belong in a ratio that answers "how much of MY memory changed".
|
|
963
|
-
*
|
|
964
|
-
* By default this returns COUNTS ONLY, and the answer is the same size for a store
|
|
965
|
-
* of a hundred memories and a store of a hundred million. That is deliberate: a model
|
|
966
|
-
* asks this mid-conversation, and an answer that grew with the store would be
|
|
967
|
-
* unusable for exactly the customers who most need to ask.
|
|
968
|
-
*
|
|
969
|
-
* To see WHICH memories, ask for one side and page through it — at most 20 per call,
|
|
970
|
-
* and never both sides in one response:
|
|
971
|
-
*
|
|
972
|
-
* ```ts
|
|
973
|
-
* const { revised, unrevised, total } = await mem.revisions(); // numbers only
|
|
974
|
-
*
|
|
975
|
-
* let before: string | undefined, skipIds: string[] | undefined;
|
|
976
|
-
* for (;;) {
|
|
977
|
-
* const p = await mem.revisions(undefined, { include: "revised", before, skipIds });
|
|
978
|
-
* for (const m of p.memories ?? []) console.log(m.content);
|
|
979
|
-
* if (!p.has_more || !p.next_before) break;
|
|
980
|
-
* before = p.next_before;
|
|
981
|
-
* skipIds = p.next_skip_ids;
|
|
982
|
-
* }
|
|
983
|
-
* ```
|
|
984
|
-
*
|
|
985
|
-
* Pages are ordered by when each memory was STORED, not by when it was edited.
|
|
986
|
-
* The response says so in `ordered_by`.
|
|
987
|
-
*
|
|
988
|
-
* Served from `/api/v1/won/*`, not `/api/v1/memory/*`. Won is the surface for calls
|
|
989
|
-
* a model makes ABOUT its memory rather than calls an application makes WITH it, and
|
|
990
|
-
* the address says so. The old path still answers, for clients published before
|
|
991
|
-
* 2026-08-18, and both share one rate-limit budget.
|
|
992
|
-
*/
|
|
993
568
|
revisions(userId, opts = {}) {
|
|
994
569
|
const body = {};
|
|
995
570
|
if (opts.include !== undefined)
|
|
@@ -1000,35 +575,15 @@ class Client {
|
|
|
1000
575
|
body.before = opts.before;
|
|
1001
576
|
if (opts.skipIds !== undefined)
|
|
1002
577
|
body.skip_ids = opts.skipIds;
|
|
1003
|
-
// `user_id` LAST, after the caller's fields — an `opts` spread that can land on top
|
|
1004
|
-
// of it is how a client ends up reading another store. Same order as `search`.
|
|
1005
578
|
body.user_id = this.uid(userId);
|
|
1006
579
|
return this.post("/api/v1/won/revisions", body);
|
|
1007
580
|
}
|
|
1008
|
-
/**
|
|
1009
|
-
* The full chain of edits behind one memory, oldest first.
|
|
1010
|
-
*
|
|
1011
|
-
* `is_current` marks the version in force. Use it to answer "what did this used to
|
|
1012
|
-
* say, and when did it change" — `revisions()` tells you HOW MUCH a store moved,
|
|
1013
|
-
* this tells you what happened to one fact.
|
|
1014
|
-
*/
|
|
1015
581
|
lineage(userId, memoryId) {
|
|
1016
|
-
if (
|
|
582
|
+
if (typeof memoryId !== "string" || !memoryId.trim()) {
|
|
1017
583
|
throw new Error("memoryId is required — the memory whose history you want.");
|
|
1018
584
|
}
|
|
1019
|
-
return this.post("/api/v1/memory/lineage", { user_id: this.uid(userId), memory_id: memoryId });
|
|
1020
|
-
}
|
|
1021
|
-
/**
|
|
1022
|
-
* What one person said, newest first.
|
|
1023
|
-
*
|
|
1024
|
-
* `speaker` is the tag written at store time (`metadata.speaker`) — `"me"` for the
|
|
1025
|
-
* assistant's own words, otherwise a person's name. Same cursor paging as
|
|
1026
|
-
* `listImages`.
|
|
1027
|
-
*
|
|
1028
|
-
* `chunks` / `points_to_delete` report how many internal records a delete would
|
|
1029
|
-
* actually remove — usually more than `returned`, and worth showing to whoever is
|
|
1030
|
-
* about to confirm one.
|
|
1031
|
-
*/
|
|
585
|
+
return this.post("/api/v1/memory/lineage", { user_id: this.uid(userId), memory_id: memoryId.trim() });
|
|
586
|
+
}
|
|
1032
587
|
bySpeaker(speaker, userId, opts = {}) {
|
|
1033
588
|
if (!speaker || typeof speaker !== "string" || speaker.trim() === "") {
|
|
1034
589
|
throw new Error('speaker is required — "me" for the assistant, or a person\'s name.');
|
|
@@ -1042,150 +597,68 @@ class Client {
|
|
|
1042
597
|
body.skip_ids = opts.skipIds;
|
|
1043
598
|
return this.post("/api/v1/memory/by-speaker", body);
|
|
1044
599
|
}
|
|
1045
|
-
/** Check connectivity AND that the API key works. Resolves `true`, or rejects — an
|
|
1046
|
-
* `AuthenticationError` (bad key), `PaymentRequiredError` (valid key but no card /
|
|
1047
|
-
* depleted balance), or `APIConnectionError` (unreachable). Makes one metered request. */
|
|
1048
600
|
async ping() {
|
|
1049
601
|
await this.request("GET", "/api/v1/memory/collections");
|
|
1050
602
|
return true;
|
|
1051
603
|
}
|
|
1052
|
-
// ----- models -----
|
|
1053
|
-
/** Available models: `[{ id, name, available, memory }, ...]`. Needs no API key. */
|
|
1054
604
|
async listModels() {
|
|
1055
605
|
const data = await this.request("GET", "/api/v1/models");
|
|
1056
606
|
return asRecords(data.models);
|
|
1057
607
|
}
|
|
1058
|
-
// ----- engrams -----
|
|
1059
|
-
/**
|
|
1060
|
-
* The engrams (and delivery forms) the selected model can actually run.
|
|
1061
|
-
*
|
|
1062
|
-
* Ask rather than hard-code: a name copied from the docs freezes a caller to the
|
|
1063
|
-
* catalogue as it was that day, and anything added later stays invisible. The
|
|
1064
|
-
* service is the authority. What
|
|
1065
|
-
* comes back depends on the model (delivery forms need Scroll 1.2+), so pass
|
|
1066
|
-
* `withModel()` if you want another model's catalogue.
|
|
1067
|
-
*
|
|
1068
|
-
* const { engrams, forms } = await mem.listEngrams();
|
|
1069
|
-
*/
|
|
1070
608
|
async listEngrams() {
|
|
1071
609
|
const data = await this.request("GET", "/api/v1/engram");
|
|
1072
610
|
return {
|
|
1073
611
|
engrams: asRecords(data.engrams),
|
|
1074
612
|
forms: asRecords(data.forms),
|
|
1075
|
-
// Present when the selected model has no engram support — the service explains
|
|
1076
|
-
// rather than returning a bare empty list.
|
|
1077
613
|
note: typeof data.note === "string" ? data.note : undefined,
|
|
1078
614
|
};
|
|
1079
615
|
}
|
|
1080
|
-
// ----- stores -----
|
|
1081
|
-
/** Create a store — the `userId` you read and write under. Stores are explicit:
|
|
1082
|
-
* a store must exist before you `add` to or `search` it, otherwise those calls
|
|
1083
|
-
* return 404. Idempotent. Every account starts with a `default` store.
|
|
1084
|
-
* Returns `{ user_id, status }` (`status` is `"created"` or `"exists"`). */
|
|
1085
616
|
createStore(userId) {
|
|
1086
617
|
return this.post("/api/v1/memory/collection", { user_id: this.uid(userId) });
|
|
1087
618
|
}
|
|
1088
|
-
/** List your stores: `[{ user_id, created_at }, ...]` (`default` first). */
|
|
1089
619
|
async listStores() {
|
|
1090
620
|
const r = await this.request("GET", "/api/v1/memory/collections");
|
|
1091
621
|
return asRecords(r.collections);
|
|
1092
622
|
}
|
|
1093
|
-
/** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
|
|
1094
623
|
deleteStore(userId) {
|
|
1095
|
-
// Validate before warning. The warning helper lowercases the id, so a non-string
|
|
1096
|
-
// reaches it and dies as "id.toLowerCase is not a function" — a TypeError from
|
|
1097
|
-
// inside the SDK instead of a sentence about the id.
|
|
1098
624
|
if (typeof userId !== "string" || !userId.trim())
|
|
1099
625
|
throw new Error("userId is required (a non-blank string) — deleteStore never falls back to the default store.");
|
|
1100
|
-
warnIfStoreIdCollapses(userId);
|
|
626
|
+
warnIfStoreIdCollapses(userId);
|
|
1101
627
|
return this.request("DELETE", "/api/v1/memory/collection", { user_id: userId });
|
|
1102
628
|
}
|
|
1103
|
-
// ----- speakers (who said it) -----
|
|
1104
|
-
/** Register a person for this store. Speakers are explicit: register once, then
|
|
1105
|
-
* store with `{ speaker }`. `"me"` (the assistant itself) never needs
|
|
1106
|
-
* registration. A store registers up to 50 people to start. */
|
|
1107
629
|
addSpeaker(speaker, userId) {
|
|
1108
630
|
return this.post("/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
|
|
1109
631
|
}
|
|
1110
|
-
/** The store's registered people, each with its memory count. */
|
|
1111
632
|
async listSpeakers(userId) {
|
|
1112
633
|
const r = await this.request("GET", `/api/v1/memory/speakers?user_id=${encodeURIComponent(this.uid(userId))}`);
|
|
1113
|
-
// The spread came second, so a present-but-null `speakers` overwrote the default
|
|
1114
|
-
// and the caller got null typed as an array. Every other list route goes through
|
|
1115
|
-
// asRecords; this one did not.
|
|
1116
634
|
return { ...r, speakers: asRecords(r?.speakers) };
|
|
1117
635
|
}
|
|
1118
|
-
/** Unregister a person. Their memories stay; the name tag goes. */
|
|
1119
636
|
removeSpeaker(speaker, userId) {
|
|
1120
637
|
return this.request("DELETE", "/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
|
|
1121
638
|
}
|
|
1122
|
-
// ----- delete -----
|
|
1123
|
-
/** Delete a single memory by id. Pass `undefined` as `userId` for the default
|
|
1124
|
-
* store — it is positional here, not omittable as it is in Python. */
|
|
1125
639
|
delete(userId, memoryId) {
|
|
1126
|
-
// Guard: without a memory_id the API's forget endpoint means "delete the
|
|
1127
|
-
// whole store". An undefined/"" slipping in here must never become a wipe.
|
|
1128
|
-
//
|
|
1129
|
-
// `.trim()` matters as much as the emptiness check: " " is truthy, so without it
|
|
1130
|
-
// a blank string passes the guard and travels as memory_id. A server that trims it
|
|
1131
|
-
// back to nothing reads the request as the whole-store form, which is the most
|
|
1132
|
-
// destructive way for this SDK to be wrong.
|
|
1133
640
|
if (typeof memoryId !== "string" || !memoryId.trim()) {
|
|
1134
641
|
throw new Error("memoryId is required (non-blank). To delete every memory in a store, call deleteAll(userId) explicitly.");
|
|
1135
642
|
}
|
|
1136
643
|
return this.post("/api/v1/memory/forget", { user_id: this.uid(userId), memory_id: memoryId.trim() });
|
|
1137
644
|
}
|
|
1138
|
-
/** Delete ALL memories for a store (GDPR erase). `userId` is required on purpose -
|
|
1139
|
-
* this is destructive, so it never falls back to the default store. */
|
|
1140
645
|
deleteAll(userId) {
|
|
1141
646
|
if (typeof userId !== "string" || !userId.trim())
|
|
1142
647
|
throw new Error("userId is required (a non-blank string) for deleteAll — anything else would wipe the default store.");
|
|
1143
|
-
// These two take the store id directly instead of going through uid(), so the
|
|
1144
|
-
// collision warning — the one that says `Alice.Smith` and `alice_smith` are ONE
|
|
1145
|
-
// store — never fired on the two calls that DESTROY data. Erasing the wrong
|
|
1146
|
-
// tenant's memories is exactly the outcome that warning exists to prevent.
|
|
1147
648
|
warnIfStoreIdCollapses(userId);
|
|
1148
649
|
return this.post("/api/v1/memory/forget", { user_id: userId });
|
|
1149
650
|
}
|
|
1150
|
-
// ----- internal -----
|
|
1151
651
|
post(path, body, idempotencyKey) {
|
|
1152
652
|
return this.request("POST", path, body, idempotencyKey);
|
|
1153
653
|
}
|
|
1154
|
-
/**
|
|
1155
|
-
* One request that answers with BYTES rather than JSON.
|
|
1156
|
-
*
|
|
1157
|
-
* Only `/memory/image` does this, and it is why this cannot go through `request()`:
|
|
1158
|
-
* that path reads the body as text and insists the result parses to a JSON object.
|
|
1159
|
-
* Feeding it a JPEG would throw "invalid JSON in response" — a confusing error for a
|
|
1160
|
-
* call that actually succeeded.
|
|
1161
|
-
*
|
|
1162
|
-
* Errors still arrive as JSON, so those are handed back to the normal machinery: on
|
|
1163
|
-
* a non-2xx we re-read the body as text and reuse `errorFor`, which keeps 404
|
|
1164
|
-
* (`NotFoundError`) and 401 behaving the same as everywhere else.
|
|
1165
|
-
*/
|
|
1166
|
-
/** Where this call's budget runs out, or `undefined` when it has none. Computed
|
|
1167
|
-
* once per call, not per attempt — a budget recomputed each attempt is not a
|
|
1168
|
-
* budget. */
|
|
1169
654
|
deadlineAt() {
|
|
1170
655
|
return this.deadlineMs === undefined ? undefined : Date.now() + this.deadlineMs;
|
|
1171
656
|
}
|
|
1172
|
-
/** The abort wiring for ONE attempt: this SDK's per-attempt timeout, the caller's
|
|
1173
|
-
* signal, and whatever is left of the overall deadline — whichever fires first.
|
|
1174
|
-
*
|
|
1175
|
-
* One function, two callers (`request` and `requestBytes`), because a guard living
|
|
1176
|
-
* in one of two request paths is exactly how `getImage` ended up with no retry loop
|
|
1177
|
-
* while the module doc promised every call had one. */
|
|
1178
657
|
beginAttempt(deadlineAt) {
|
|
1179
658
|
let why = "timeout";
|
|
1180
659
|
let budget = this.timeoutMs;
|
|
1181
660
|
if (deadlineAt !== undefined) {
|
|
1182
661
|
const left = deadlineAt - Date.now();
|
|
1183
|
-
// Refuse rather than open a socket there is no time to use. Python's
|
|
1184
|
-
// `_attempt_budget` and Rust's `attempt_budget` have raised here from the start.
|
|
1185
|
-
// This path instead clamped the budget to 0 and began the attempt anyway, which
|
|
1186
|
-
// left `setTimeout(…, 0)` — it fires on the next timer phase — racing a fetch
|
|
1187
|
-
// that a fast server answers first. The same call then sometimes reported its
|
|
1188
|
-
// deadline and sometimes returned a result: one run in three, measured.
|
|
1189
662
|
if (left <= 0)
|
|
1190
663
|
throw new APIConnectionError(0, this.abortMessage("deadline"));
|
|
1191
664
|
if (left < budget) {
|
|
@@ -1203,9 +676,6 @@ class Client {
|
|
|
1203
676
|
if (outer) {
|
|
1204
677
|
if (outer.aborted)
|
|
1205
678
|
onCaller();
|
|
1206
|
-
// Removed in clear(). `{ once: true }` would not be enough: one long-lived
|
|
1207
|
-
// signal per user session would still collect one listener per request until
|
|
1208
|
-
// the session ended.
|
|
1209
679
|
else
|
|
1210
680
|
outer.addEventListener("abort", onCaller);
|
|
1211
681
|
}
|
|
@@ -1218,9 +688,6 @@ class Client {
|
|
|
1218
688
|
why: () => why,
|
|
1219
689
|
};
|
|
1220
690
|
}
|
|
1221
|
-
/** What to say when an attempt was cut short. The three reasons need different
|
|
1222
|
-
* words: a timeout may have landed server-side, a cancellation certainly did not
|
|
1223
|
-
* matter to the caller, and an exhausted budget is the caller's own setting. */
|
|
1224
691
|
abortMessage(why) {
|
|
1225
692
|
if (why === "caller")
|
|
1226
693
|
return "cancelled — the caller's AbortSignal fired";
|
|
@@ -1228,15 +695,6 @@ class Client {
|
|
|
1228
695
|
return `deadline of ${this.deadlineMs}ms exhausted`;
|
|
1229
696
|
return `request timed out after ${this.timeoutMs}ms`;
|
|
1230
697
|
}
|
|
1231
|
-
/** Sleep between attempts — but wake the moment the caller aborts, and give up
|
|
1232
|
-
* rather than wait out a backoff the budget cannot cover. A backoff that runs to
|
|
1233
|
-
* completion after the caller gave up wastes exactly as long as the request it was
|
|
1234
|
-
* waiting to repeat.
|
|
1235
|
-
*
|
|
1236
|
-
* Clamping the wait to what is left and retrying anyway is the same answer as
|
|
1237
|
-
* having no deadline at all: the call still spends the whole allowance, and the one
|
|
1238
|
-
* attempt it buys has nothing left to finish in. If the wait does not fit, the
|
|
1239
|
-
* budget is already decided. */
|
|
1240
698
|
async backoffSleep(ms, deadlineAt) {
|
|
1241
699
|
if (deadlineAt !== undefined && ms > deadlineAt - Date.now()) {
|
|
1242
700
|
throw new APIConnectionError(0, this.abortMessage("deadline"));
|
|
@@ -1259,12 +717,6 @@ class Client {
|
|
|
1259
717
|
});
|
|
1260
718
|
}
|
|
1261
719
|
async requestBytes(path, body) {
|
|
1262
|
-
// This route had no retry loop at all, while the module doc promised "every call
|
|
1263
|
-
// retries transient failures … 429 always". Measured with maxRetries: 4 against a
|
|
1264
|
-
// server answering 429 — stats() made five requests, getImage() made one. The
|
|
1265
|
-
// largest and most rate-limit-prone call in the SDK was the one that gave up
|
|
1266
|
-
// immediately. The loop lives inside the function because honouring `Retry-After`
|
|
1267
|
-
// means still holding the response.
|
|
1268
720
|
const attempts = this.maxRetries + 1;
|
|
1269
721
|
const deadlineAt = this.deadlineAt();
|
|
1270
722
|
for (let attempt = 0;; attempt++) {
|
|
@@ -1282,11 +734,6 @@ class Client {
|
|
|
1282
734
|
catch (e) {
|
|
1283
735
|
att.clear();
|
|
1284
736
|
const timedOut = e?.name === "AbortError" || att.signal.aborted;
|
|
1285
|
-
// Same rule as `request()`: a connect-level failure never reached the server,
|
|
1286
|
-
// so re-sending cannot double-process anything. The 429 loop below was added
|
|
1287
|
-
// in 2.2.35 and this branch was not, which left the module doc's promise —
|
|
1288
|
-
// "every call retries transient failures" — still false for `getImage` on
|
|
1289
|
-
// exactly the failure a retry is for. A timeout is ambiguous and stays final.
|
|
1290
737
|
if (!timedOut && isConnectFailure(e) && attempt + 1 < attempts) {
|
|
1291
738
|
await this.backoffSleep(this.backoffMs(attempt), deadlineAt);
|
|
1292
739
|
continue;
|
|
@@ -1317,29 +764,22 @@ class Client {
|
|
|
1317
764
|
msg = err;
|
|
1318
765
|
}
|
|
1319
766
|
catch {
|
|
1320
|
-
/* keep raw text */
|
|
1321
767
|
}
|
|
1322
768
|
if (msg.length > MAX_ERR_MSG)
|
|
1323
769
|
msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
|
|
1324
770
|
throw errorFor(res.status, msg || `HTTP ${res.status}`);
|
|
1325
771
|
}
|
|
1326
|
-
// Translated, not raw. A timeout that fires while the body is streaming used to
|
|
1327
|
-
// escape as `AbortError` — not a WosError, not an APIConnectionError — so a
|
|
1328
|
-
// caller's `catch (e) { if (e instanceof WosError) … }` handled every JSON route
|
|
1329
|
-
// and let this one through unclassified. The JSON path already translated it.
|
|
1330
772
|
let buf;
|
|
1331
773
|
try {
|
|
1332
774
|
buf = await this.readCappedBytes(res);
|
|
1333
775
|
}
|
|
1334
776
|
catch (e) {
|
|
1335
777
|
if (e?.name === "AbortError" || att.signal.aborted) {
|
|
1336
|
-
throw new APIConnectionError(0,
|
|
778
|
+
throw new APIConnectionError(0, this.abortMessage(att.why()));
|
|
1337
779
|
}
|
|
1338
780
|
throw e;
|
|
1339
781
|
}
|
|
1340
782
|
if (buf.byteLength === 0) {
|
|
1341
|
-
// An empty 200 would otherwise read as "here is your image" and write a
|
|
1342
|
-
// zero-byte file — indistinguishable from an image we lost.
|
|
1343
783
|
throw new WosError(res.status, "empty image body — the service returned no bytes");
|
|
1344
784
|
}
|
|
1345
785
|
return { bytes: buf, contentType: res.headers.get("content-type") ?? "application/octet-stream" };
|
|
@@ -1349,16 +789,7 @@ class Client {
|
|
|
1349
789
|
}
|
|
1350
790
|
}
|
|
1351
791
|
}
|
|
1352
|
-
/** Validate an idempotency key BEFORE the network. The API answers a malformed
|
|
1353
|
-
* key with a 400, which on a retry path reads as "my write failed" when in fact
|
|
1354
|
-
* it was never attempted — cheaper and clearer to reject it here. */
|
|
1355
792
|
idemHeader(key) {
|
|
1356
|
-
// `=== undefined` let `null` through: RegExp.test stringifies its argument, so
|
|
1357
|
-
// `null` became the literal key "null" and passed the format check. A key read
|
|
1358
|
-
// from JSON or a database row arrives as null, not undefined, and every write on
|
|
1359
|
-
// that path then shared one key — the second onward returned the first response
|
|
1360
|
-
// and stored nothing. Python guards with `is None`; Rust's Option makes it
|
|
1361
|
-
// unrepresentable.
|
|
1362
793
|
if (key === undefined || key === null)
|
|
1363
794
|
return undefined;
|
|
1364
795
|
if (!IDEMPOTENCY_KEY_RE.test(key)) {
|
|
@@ -1366,40 +797,26 @@ class Client {
|
|
|
1366
797
|
}
|
|
1367
798
|
return { "Idempotency-Key": key };
|
|
1368
799
|
}
|
|
1369
|
-
/** Seconds→ms to sleep before retry `attempt` (0-based). Honors Retry-After. */
|
|
1370
800
|
backoffMs(attempt, retryAfter) {
|
|
1371
801
|
if (retryAfter) {
|
|
1372
802
|
const s = Number(retryAfter);
|
|
1373
803
|
if (Number.isFinite(s) && s >= 0)
|
|
1374
804
|
return Math.min(30_000, s * 1000);
|
|
1375
|
-
// HTTP-date form (RFC 9110), e.g. "Wed, 21 Oct 2015 07:28:00 GMT".
|
|
1376
805
|
const t = Date.parse(retryAfter);
|
|
1377
806
|
if (Number.isFinite(t))
|
|
1378
807
|
return Math.min(30_000, Math.max(0, t - Date.now()));
|
|
1379
808
|
}
|
|
1380
809
|
return Math.min(8_000, 500 * 2 ** attempt) + Math.random() * 250;
|
|
1381
810
|
}
|
|
1382
|
-
/** Read the body with a hard size cap, so a broken/hostile endpoint can't
|
|
1383
|
-
* make the process buffer gigabytes. */
|
|
1384
811
|
async readCapped(res) {
|
|
1385
812
|
return new TextDecoder().decode(await this.readCappedBytes(res));
|
|
1386
813
|
}
|
|
1387
|
-
/** The same cap, for a body that is not text.
|
|
1388
|
-
*
|
|
1389
|
-
* `arrayBuffer()` would buffer whatever arrives. The cap exists for a hostile or
|
|
1390
|
-
* broken `baseUrl`, and an endpoint that returns megabytes is where that costs the
|
|
1391
|
-
* most. One implementation, two callers, so they cannot drift. */
|
|
1392
814
|
async readCappedBytes(res) {
|
|
1393
815
|
const cl = res.headers.get("content-length");
|
|
1394
816
|
if (cl && Number(cl) > MAX_RESPONSE_BYTES) {
|
|
1395
817
|
throw new WosError(res.status, `response too large (${cl} bytes) — refusing to buffer it`);
|
|
1396
818
|
}
|
|
1397
819
|
if (!res.body) {
|
|
1398
|
-
// `arrayBuffer()` has no ceiling, and only the content-length pre-check stands
|
|
1399
|
-
// in front of it — which a server simply omits. Reachable through the documented
|
|
1400
|
-
// `fetch` seam (test doubles, polyfills, wrappers that rebuild the Response),
|
|
1401
|
-
// and the option's own doc promises "the size cap still applies". Measured: a
|
|
1402
|
-
// body-less Response with no content-length buffered 73MB past the 64MB cap.
|
|
1403
820
|
const buf = new Uint8Array(await res.arrayBuffer());
|
|
1404
821
|
if (buf.byteLength > MAX_RESPONSE_BYTES) {
|
|
1405
822
|
throw new WosError(res.status, `response too large (${buf.byteLength} bytes) — refusing it`);
|
|
@@ -1431,10 +848,16 @@ class Client {
|
|
|
1431
848
|
async request(method, path, body, idempotencyKey) {
|
|
1432
849
|
const extraHeaders = this.idemHeader(idempotencyKey);
|
|
1433
850
|
const attempts = this.maxRetries + 1;
|
|
1434
|
-
// Debug logging shows the path WITHOUT its query string — a query can carry
|
|
1435
|
-
// a store id (listSpeakers), and logs must never carry data.
|
|
1436
851
|
const logPath = path.split("?")[0];
|
|
1437
852
|
const deadlineAt = this.deadlineAt();
|
|
853
|
+
let payload;
|
|
854
|
+
try {
|
|
855
|
+
payload = body === undefined ? undefined : JSON.stringify(body);
|
|
856
|
+
}
|
|
857
|
+
catch (e) {
|
|
858
|
+
throw new Error(`request body could not be serialized: ${e instanceof Error ? e.message : String(e)}. ` +
|
|
859
|
+
"Pass plain JSON values — an ORM entity, a BigInt or a circular reference cannot be sent.");
|
|
860
|
+
}
|
|
1438
861
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
1439
862
|
const start = Date.now();
|
|
1440
863
|
const att = this.beginAttempt(deadlineAt);
|
|
@@ -1443,21 +866,14 @@ class Client {
|
|
|
1443
866
|
res = await this.fetchImpl(`${this.base}${path}`, {
|
|
1444
867
|
method,
|
|
1445
868
|
headers: extraHeaders ? { ...this.headers, ...extraHeaders } : this.headers,
|
|
1446
|
-
body:
|
|
869
|
+
body: payload,
|
|
1447
870
|
signal: att.signal,
|
|
1448
|
-
// Never follow a redirect: fetch would forward the API key to
|
|
1449
|
-
// wherever a 3xx points. The API never legitimately redirects.
|
|
1450
871
|
redirect: "manual",
|
|
1451
872
|
});
|
|
1452
873
|
}
|
|
1453
874
|
catch (e) {
|
|
1454
875
|
att.clear();
|
|
1455
|
-
// Timeouts are ambiguous (the write may have landed) — don't retry those.
|
|
1456
876
|
const timedOut = e?.name === "AbortError" || att.signal.aborted;
|
|
1457
|
-
// Other network errors retry only when a retry can't double-process a
|
|
1458
|
-
// write: idempotent methods always; writes only for connect-level
|
|
1459
|
-
// failures (the request never reached the server). A mid-stream drop on
|
|
1460
|
-
// a POST may already have stored + billed server-side.
|
|
1461
877
|
const safe = IDEMPOTENT_METHODS.has(method.toUpperCase()) || isConnectFailure(e);
|
|
1462
878
|
if (!timedOut && safe && attempt + 1 < attempts) {
|
|
1463
879
|
const delay = this.backoffMs(attempt);
|
|
@@ -1480,25 +896,20 @@ class Client {
|
|
|
1480
896
|
}
|
|
1481
897
|
if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
|
|
1482
898
|
att.clear();
|
|
1483
|
-
void res.body?.cancel();
|
|
899
|
+
void res.body?.cancel();
|
|
1484
900
|
throw new WosError(res.status, "unexpected redirect — refused (the API key never follows a redirect). Check baseUrl: exact host, https://.");
|
|
1485
901
|
}
|
|
1486
902
|
this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
|
|
1487
|
-
// Keep the abort timer alive THROUGH the body read: fetch resolves on
|
|
1488
|
-
// headers, so a server that sends headers then trickles/hangs the body
|
|
1489
|
-
// would otherwise stall forever. An abort mid-body surfaces as a timeout.
|
|
1490
903
|
let text;
|
|
1491
904
|
try {
|
|
1492
905
|
text = await this.readCapped(res);
|
|
1493
906
|
}
|
|
1494
907
|
catch (e) {
|
|
1495
908
|
if (e?.name === "AbortError" || att.signal.aborted) {
|
|
1496
|
-
throw new APIConnectionError(0,
|
|
909
|
+
throw new APIConnectionError(0, this.abortMessage(att.why()));
|
|
1497
910
|
}
|
|
1498
911
|
if (e instanceof WosError)
|
|
1499
|
-
throw e;
|
|
1500
|
-
// A drop while READING the body is a transport failure — surface it as
|
|
1501
|
-
// APIConnectionError, not a raw fetch TypeError. Ambiguous, never retried.
|
|
912
|
+
throw e;
|
|
1502
913
|
throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
|
|
1503
914
|
}
|
|
1504
915
|
finally {
|
|
@@ -1506,9 +917,6 @@ class Client {
|
|
|
1506
917
|
}
|
|
1507
918
|
logDebug(`${method} ${logPath} -> ${res.status} in ${Date.now() - start}ms (attempt ${attempt + 1}/${attempts})`);
|
|
1508
919
|
if (!res.ok) {
|
|
1509
|
-
// Server returns either Anthropic-style envelope
|
|
1510
|
-
// {"type":"error","error":{"type":...,"message":...,"request_id":...}}
|
|
1511
|
-
// or simple {"error":"reason"}. Fall back to raw text.
|
|
1512
920
|
let msg = text;
|
|
1513
921
|
let requestId;
|
|
1514
922
|
try {
|
|
@@ -1527,17 +935,11 @@ class Client {
|
|
|
1527
935
|
}
|
|
1528
936
|
}
|
|
1529
937
|
catch {
|
|
1530
|
-
/* keep raw text */
|
|
1531
938
|
}
|
|
1532
|
-
// Cap the FINAL message (a string `error` field can be as huge as the body),
|
|
1533
|
-
// so a hostile server can't turn our exception/log into a giant string.
|
|
1534
939
|
if (typeof msg === "string" && msg.length > MAX_ERR_MSG)
|
|
1535
940
|
msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
|
|
1536
941
|
throw errorFor(res.status, msg, requestId);
|
|
1537
942
|
}
|
|
1538
|
-
// An empty body is only legal when the STATUS says there is no body. Accepting
|
|
1539
|
-
// any empty 2xx would make an `add()` answered by a truncating proxy return `{}`,
|
|
1540
|
-
// which reads as a successful write with no id. All three SDKs apply this rule.
|
|
1541
943
|
if (!text) {
|
|
1542
944
|
if (NO_BODY_STATUS.has(res.status))
|
|
1543
945
|
return {};
|
|
@@ -1548,35 +950,20 @@ class Client {
|
|
|
1548
950
|
data = JSON.parse(text);
|
|
1549
951
|
}
|
|
1550
952
|
catch (e) {
|
|
1551
|
-
// A 2xx with a corrupt body is a real failure — surface it, don't leak
|
|
1552
|
-
// a raw SyntaxError (parity with the Rust SDK).
|
|
1553
953
|
throw new WosError(res.status, `invalid JSON in response: ${e?.message ?? e}`);
|
|
1554
954
|
}
|
|
1555
|
-
// Enforce a JSON OBJECT: every method reads the result with `.field`, so a
|
|
1556
|
-
// body that parses to null / a number / a string / an array (a broken or
|
|
1557
|
-
// hostile server) must be a clean WosError, not a `null.memories` TypeError.
|
|
1558
955
|
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
1559
956
|
const got = data === null ? "null" : Array.isArray(data) ? "array" : typeof data;
|
|
1560
957
|
throw new WosError(res.status, `expected a JSON object in the response, got ${got}`);
|
|
1561
958
|
}
|
|
1562
|
-
// Surface whether the write was stored or replayed. The server says so with
|
|
1563
|
-
// `Idempotent-Replayed: true`, and it answers the question someone using an
|
|
1564
|
-
// idempotency key is asking: did my retry write, or is this the first response
|
|
1565
|
-
// coming back again?
|
|
1566
|
-
//
|
|
1567
|
-
// Only when the body does not already carry the field. These responses are
|
|
1568
|
-
// widening — the next engine adds `images`, `hop`, `tip_of_tongue`, `policy` —
|
|
1569
|
-
// and a client that writes into a server's object is one release away from
|
|
1570
|
-
// overwriting a real answer with its own guess. What the service said wins.
|
|
1571
959
|
if (res.headers.get("Idempotent-Replayed") === "true" && !("replayed" in data)) {
|
|
1572
960
|
data.replayed = true;
|
|
1573
961
|
}
|
|
1574
962
|
return data;
|
|
1575
963
|
}
|
|
1576
|
-
throw new Error("retries exhausted");
|
|
964
|
+
throw new Error("retries exhausted");
|
|
1577
965
|
}
|
|
1578
966
|
}
|
|
1579
967
|
exports.Client = Client;
|
|
1580
|
-
/** Back-compat alias: older code used `WME`. */
|
|
1581
968
|
exports.WME = Client;
|
|
1582
969
|
exports.default = Client;
|