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.
Files changed (2) hide show
  1. package/dist/wontopos.js +60 -19
  2. 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.12";
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
- /** Statuses where the request was NOT processed, so retrying is always safe. */
40
- const RETRY_STATUSES = new Set([429, 502, 503]);
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
- this.maxRetries = Math.max(0, opts.maxRetries ?? 2);
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?:\/\//, "").split(/[/:]/)[0].toLowerCase();
169
- if (this.base.startsWith("http://") && !LOOPBACK_HOSTS.has(host)) {
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
- const key = env?.WONTOPOS_API_KEY ?? env?.WOS_API_KEY;
178
- if (!key || !key.trim())
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
- return new Client({ apiKey: key, ...opts });
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
- const r = await this.post("/api/v1/memory/search", { user_id: this.uid(userId), query, max_results: limit, ...opts });
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
- cursor = page.next_cursor ?? undefined;
297
- if (!cursor)
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 — it never falls back to the default store.");
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
- clearTimeout(timer);
445
- if (RETRY_STATUSES.has(res.status) && attempt + 1 < attempts) {
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
- const text = await this.readCapped(res);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wontopos",
3
- "version": "2.2.12",
3
+ "version": "2.2.13",
4
4
  "description": "Wontopos — long-term memory for AI agents. Pure semantic retrieval, identical recall in every language, ~100× lower LLM bill.",
5
5
  "keywords": [
6
6
  "memory",