wontopos 2.2.12 → 2.2.13
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/dist/wontopos.js +60 -19
- package/package.json +1 -1
package/dist/wontopos.js
CHANGED
|
@@ -32,12 +32,17 @@
|
|
|
32
32
|
* runtime's (never disabled by this SDK; Node 18+ floors TLS at 1.2). Prefer
|
|
33
33
|
* `Client.fromEnv()` over keys in source code.
|
|
34
34
|
*/
|
|
35
|
-
const VERSION = "2.2.
|
|
35
|
+
const VERSION = "2.2.13";
|
|
36
36
|
const USER_AGENT = `wontopos-node/${VERSION}`;
|
|
37
37
|
const DEFAULT_BASE_URL = "https://api.wontopos.com";
|
|
38
38
|
const DEFAULT_MODEL = "tablet-1";
|
|
39
|
-
/**
|
|
40
|
-
const
|
|
39
|
+
/** 429 = rate-limited BEFORE processing → always safe to retry (no write, no bill). */
|
|
40
|
+
const RETRY_ALWAYS = new Set([429]);
|
|
41
|
+
/** 502/503 are ambiguous for a write (gateway may return them AFTER the backend
|
|
42
|
+
* processed + billed the request), so retry them only for idempotent methods —
|
|
43
|
+
* a retried POST could double-store / double-bill. */
|
|
44
|
+
const RETRY_IF_IDEMPOTENT = new Set([502, 503]);
|
|
45
|
+
const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
41
46
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
|
|
42
47
|
/** Refuse to buffer absurd responses (real ones are a few KB) — protects the
|
|
43
48
|
* process if a self-hosted baseUrl points somewhere broken or hostile. */
|
|
@@ -153,7 +158,11 @@ export class Client {
|
|
|
153
158
|
throw new Error(`invalid model name: ${JSON.stringify(this.model)} (letters, digits, '.', '_', '-' only)`);
|
|
154
159
|
}
|
|
155
160
|
this.defaultUser = opts.userId || DEFAULT_USER;
|
|
156
|
-
|
|
161
|
+
// Coerce a non-finite maxRetries (NaN/Infinity) back to the default — otherwise
|
|
162
|
+
// NaN makes `attempts = NaN + 1` and the loop runs zero requests then throws
|
|
163
|
+
// "retries exhausted"; Infinity would retry forever.
|
|
164
|
+
const mr = opts.maxRetries;
|
|
165
|
+
this.maxRetries = typeof mr === "number" && Number.isFinite(mr) ? Math.max(0, Math.floor(mr)) : 2;
|
|
157
166
|
this.headers = {
|
|
158
167
|
"X-API-Key": this.apiKey,
|
|
159
168
|
"Content-Type": "application/json",
|
|
@@ -165,8 +174,9 @@ export class Client {
|
|
|
165
174
|
// An API key on plain HTTP travels readable by anyone on the path. Loopback
|
|
166
175
|
// is fine (local dev / self-hosted on the same box); anything else gets a
|
|
167
176
|
// warning, not an error, so private-network gateways keep working.
|
|
168
|
-
const host = this.base.replace(/^https
|
|
169
|
-
|
|
177
|
+
const host = this.base.replace(/^https?:\/\//i, "").split(/[/:]/)[0].toLowerCase();
|
|
178
|
+
// Scheme compare is case-insensitive — `HTTP://` connects in plaintext too.
|
|
179
|
+
if (/^http:\/\//i.test(this.base) && !LOOPBACK_HOSTS.has(host)) {
|
|
170
180
|
console.warn("wontopos: baseUrl uses plain HTTP on a non-local host, so the API key travels unencrypted. Use https://.");
|
|
171
181
|
}
|
|
172
182
|
}
|
|
@@ -174,10 +184,13 @@ export class Client {
|
|
|
174
184
|
* keys out of source code. Node/Bun/Deno-with-env only. */
|
|
175
185
|
static fromEnv(opts = {}) {
|
|
176
186
|
const env = globalThis?.process?.env;
|
|
177
|
-
|
|
178
|
-
|
|
187
|
+
// First env var with a NON-EMPTY value wins — an empty WONTOPOS_API_KEY must
|
|
188
|
+
// not shadow a valid WOS_API_KEY (`??` only skips null/undefined, not "").
|
|
189
|
+
const key = [env?.WONTOPOS_API_KEY, env?.WOS_API_KEY].find((v) => v && v.trim());
|
|
190
|
+
if (!key)
|
|
179
191
|
throw new Error("set WONTOPOS_API_KEY (or WOS_API_KEY) in the environment");
|
|
180
|
-
|
|
192
|
+
// apiKey LAST so a stray apiKey in opts can't override the env key.
|
|
193
|
+
return new Client({ ...opts, apiKey: key });
|
|
181
194
|
}
|
|
182
195
|
/** Never expose the key: `JSON.stringify(client)` gets the redacted view. */
|
|
183
196
|
toJSON() {
|
|
@@ -244,7 +257,9 @@ export class Client {
|
|
|
244
257
|
// ----- read -----
|
|
245
258
|
/** Semantic search over a store's memories. Returns memories, most relevant first. */
|
|
246
259
|
async search(query, userId, limit = 10, opts = {}) {
|
|
247
|
-
|
|
260
|
+
// Reserved fields win over ...opts: an app that forwards untrusted input as
|
|
261
|
+
// opts must not be able to override the store (user_id), query, or limit.
|
|
262
|
+
const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
|
|
248
263
|
return (r.memories ?? []);
|
|
249
264
|
}
|
|
250
265
|
/** One-call LLM context: short-term turns + long-term matches + surrounding context. */
|
|
@@ -289,13 +304,17 @@ export class Client {
|
|
|
289
304
|
*/
|
|
290
305
|
async *iterMemories(userId, opts = {}) {
|
|
291
306
|
let cursor;
|
|
307
|
+
const seen = new Set();
|
|
292
308
|
for (;;) {
|
|
293
309
|
const page = await this.listMemories(userId, { limit: opts.pageSize ?? 100, cursor });
|
|
294
310
|
for (const m of page.memories ?? [])
|
|
295
311
|
yield m;
|
|
296
|
-
|
|
297
|
-
|
|
312
|
+
const next = page.next_cursor ?? undefined;
|
|
313
|
+
// Stop on last page OR a server that repeats a cursor (would loop forever).
|
|
314
|
+
if (!next || seen.has(next))
|
|
298
315
|
break;
|
|
316
|
+
seen.add(next);
|
|
317
|
+
cursor = next;
|
|
299
318
|
}
|
|
300
319
|
}
|
|
301
320
|
/** Collect ALL of a store's memories into an array (original text + metadata, no vectors). */
|
|
@@ -333,8 +352,8 @@ export class Client {
|
|
|
333
352
|
}
|
|
334
353
|
/** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
|
|
335
354
|
deleteStore(userId) {
|
|
336
|
-
if (!userId)
|
|
337
|
-
throw new Error("userId is required — deleteStore never falls back to the default store.");
|
|
355
|
+
if (!userId || !userId.trim())
|
|
356
|
+
throw new Error("userId is required (non-blank) — deleteStore never falls back to the default store.");
|
|
338
357
|
return this.request("DELETE", "/api/v1/memory/collection", { user_id: userId });
|
|
339
358
|
}
|
|
340
359
|
// ----- speakers (who said it) -----
|
|
@@ -366,8 +385,8 @@ export class Client {
|
|
|
366
385
|
/** Delete ALL memories for a store (GDPR erase). `userId` is required on purpose -
|
|
367
386
|
* this is destructive, so it never falls back to the default store. */
|
|
368
387
|
deleteAll(userId) {
|
|
369
|
-
if (!userId)
|
|
370
|
-
throw new Error("userId is required for deleteAll —
|
|
388
|
+
if (!userId || !userId.trim())
|
|
389
|
+
throw new Error("userId is required (non-blank) for deleteAll — a blank/whitespace id would wipe the default store.");
|
|
371
390
|
return this.post("/api/v1/memory/forget", { user_id: userId });
|
|
372
391
|
}
|
|
373
392
|
// ----- internal -----
|
|
@@ -380,6 +399,10 @@ export class Client {
|
|
|
380
399
|
const s = Number(retryAfter);
|
|
381
400
|
if (Number.isFinite(s) && s >= 0)
|
|
382
401
|
return Math.min(30_000, s * 1000);
|
|
402
|
+
// HTTP-date form (RFC 9110), e.g. "Wed, 21 Oct 2015 07:28:00 GMT".
|
|
403
|
+
const t = Date.parse(retryAfter);
|
|
404
|
+
if (Number.isFinite(t))
|
|
405
|
+
return Math.min(30_000, Math.max(0, t - Date.now()));
|
|
383
406
|
}
|
|
384
407
|
return Math.min(8_000, 500 * 2 ** attempt) + Math.random() * 250;
|
|
385
408
|
}
|
|
@@ -441,18 +464,36 @@ export class Client {
|
|
|
441
464
|
}
|
|
442
465
|
throw new APIConnectionError(0, timedOut ? `request timed out after ${this.timeoutMs}ms` : `network error: ${e?.message ?? e}`);
|
|
443
466
|
}
|
|
444
|
-
|
|
445
|
-
|
|
467
|
+
const retryable = RETRY_ALWAYS.has(res.status) ||
|
|
468
|
+
(RETRY_IF_IDEMPOTENT.has(res.status) && IDEMPOTENT_METHODS.has(method.toUpperCase()));
|
|
469
|
+
if (retryable && attempt + 1 < attempts) {
|
|
470
|
+
clearTimeout(timer);
|
|
446
471
|
const retryAfter = res.headers.get("Retry-After");
|
|
447
472
|
void res.body?.cancel();
|
|
448
473
|
await new Promise((r) => setTimeout(r, this.backoffMs(attempt, retryAfter)));
|
|
449
474
|
continue;
|
|
450
475
|
}
|
|
451
476
|
if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
|
|
477
|
+
clearTimeout(timer);
|
|
452
478
|
throw new WosError(res.status, "unexpected redirect — refused (the API key never follows a redirect). Check baseUrl: exact host, https://.");
|
|
453
479
|
}
|
|
454
480
|
this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
|
|
455
|
-
|
|
481
|
+
// Keep the abort timer alive THROUGH the body read: fetch resolves on
|
|
482
|
+
// headers, so a server that sends headers then trickles/hangs the body
|
|
483
|
+
// would otherwise stall forever. An abort mid-body surfaces as a timeout.
|
|
484
|
+
let text;
|
|
485
|
+
try {
|
|
486
|
+
text = await this.readCapped(res);
|
|
487
|
+
}
|
|
488
|
+
catch (e) {
|
|
489
|
+
if (e?.name === "AbortError" || ctrl.signal.aborted) {
|
|
490
|
+
throw new APIConnectionError(0, `request timed out after ${this.timeoutMs}ms`);
|
|
491
|
+
}
|
|
492
|
+
throw e;
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
clearTimeout(timer);
|
|
496
|
+
}
|
|
456
497
|
if (!res.ok) {
|
|
457
498
|
// Server returns either Anthropic-style envelope
|
|
458
499
|
// {"type":"error","error":{"type":...,"message":...,"request_id":...}}
|
package/package.json
CHANGED