wontopos 2.2.12 → 2.2.14

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.
@@ -22,9 +22,10 @@
22
22
  * Pure semantic retrieval — no keyword/BM25 matching, so it works the SAME in every
23
23
  * language. Storing and searching call no LLM; you only pay for embeddings.
24
24
  *
25
- * Reliability: every call retries transient failures (429 / 502 / 503 and
26
- * connection errors) with exponential backoff + jitter, honoring `Retry-After`.
27
- * Tune with `maxRetries` (0 disables) and `timeoutMs`.
25
+ * Reliability: every call retries transient failures with exponential backoff +
26
+ * jitter, honoring `Retry-After` — 429 always; 502/503 and connection errors only
27
+ * when a retry can never double-process a write (idempotent calls, or a failure
28
+ * at connect time). Tune with `maxRetries` (0 disables) and `timeoutMs`.
28
29
  *
29
30
  * Security posture (not configurable off): redirects are refused so the API key
30
31
  * can never follow one to another host; responses over 64MB are refused; the key
@@ -226,8 +227,9 @@ export interface ClientOptions {
226
227
  /** Default store for every call. Override per call by passing `userId`. Defaults
227
228
  * to the account's built-in `default` store. */
228
229
  userId?: string;
229
- /** How many times to retry transient failures (429/502/503 and connection
230
- * errors) before throwing. 0 disables retries (default 2). */
230
+ /** How many times to retry transient failures before throwing — 429 always;
231
+ * 502/503 and connection errors only when a retry can never double-process a
232
+ * write. 0 disables retries (default 2). */
231
233
  maxRetries?: number;
232
234
  }
233
235
  /** Wontopos memory client. Store and recall memories, isolated per `userId`. */
package/dist/wontopos.js CHANGED
@@ -22,9 +22,10 @@
22
22
  * Pure semantic retrieval — no keyword/BM25 matching, so it works the SAME in every
23
23
  * language. Storing and searching call no LLM; you only pay for embeddings.
24
24
  *
25
- * Reliability: every call retries transient failures (429 / 502 / 503 and
26
- * connection errors) with exponential backoff + jitter, honoring `Retry-After`.
27
- * Tune with `maxRetries` (0 disables) and `timeoutMs`.
25
+ * Reliability: every call retries transient failures with exponential backoff +
26
+ * jitter, honoring `Retry-After` — 429 always; 502/503 and connection errors only
27
+ * when a retry can never double-process a write (idempotent calls, or a failure
28
+ * at connect time). Tune with `maxRetries` (0 disables) and `timeoutMs`.
28
29
  *
29
30
  * Security posture (not configurable off): redirects are refused so the API key
30
31
  * can never follow one to another host; responses over 64MB are refused; the key
@@ -32,18 +33,54 @@
32
33
  * runtime's (never disabled by this SDK; Node 18+ floors TLS at 1.2). Prefer
33
34
  * `Client.fromEnv()` over keys in source code.
34
35
  */
35
- const VERSION = "2.2.12";
36
+ const VERSION = "2.2.14";
36
37
  const USER_AGENT = `wontopos-node/${VERSION}`;
37
38
  const DEFAULT_BASE_URL = "https://api.wontopos.com";
38
39
  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]);
40
+ /** 429 = rate-limited BEFORE processing → always safe to retry (no write, no bill). */
41
+ const RETRY_ALWAYS = new Set([429]);
42
+ /** 502/503 are ambiguous for a write (gateway may return them AFTER the backend
43
+ * processed + billed the request), so retry them only for idempotent methods —
44
+ * a retried POST could double-store / double-bill. */
45
+ const RETRY_IF_IDEMPOTENT = new Set([502, 503]);
46
+ const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
41
47
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
42
48
  /** Refuse to buffer absurd responses (real ones are a few KB) — protects the
43
49
  * process if a self-hosted baseUrl points somewhere broken or hostile. */
44
50
  const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
45
51
  /** Model names travel in a header — only header-safe characters. */
46
52
  const MODEL_RE = /^[A-Za-z0-9._-]+$/;
53
+ /** Error codes that only occur while ESTABLISHING a connection — the request
54
+ * never reached the server, so a retry can't double-process a write. Mid-stream
55
+ * codes (ECONNRESET, EPIPE, UND_ERR_SOCKET, ETIMEDOUT) are ambiguous: the server
56
+ * may already have processed (and billed) the request, so they're excluded. */
57
+ const CONNECT_FAIL_CODES = new Set([
58
+ "ECONNREFUSED",
59
+ "ENOTFOUND",
60
+ "EAI_AGAIN",
61
+ "EHOSTUNREACH",
62
+ "ENETUNREACH",
63
+ "ENETDOWN",
64
+ "EADDRNOTAVAIL",
65
+ "UND_ERR_CONNECT_TIMEOUT",
66
+ ]);
67
+ /** Walk an error's `cause` chain (and AggregateError members — happy-eyeballs
68
+ * connects) looking for a connect-level failure code. */
69
+ function isConnectFailure(e) {
70
+ const stack = [e];
71
+ for (let steps = 0; stack.length && steps < 24; steps++) {
72
+ const cur = stack.pop();
73
+ if (!cur || typeof cur !== "object")
74
+ continue;
75
+ if (typeof cur.code === "string" && CONNECT_FAIL_CODES.has(cur.code))
76
+ return true;
77
+ if (cur.cause)
78
+ stack.push(cur.cause);
79
+ if (Array.isArray(cur.errors))
80
+ stack.push(...cur.errors.slice(0, 8));
81
+ }
82
+ return false;
83
+ }
47
84
  /** `wos-abc...wxyz` — enough to tell keys apart, never enough to use. */
48
85
  function maskKey(key) {
49
86
  return key.length > 12 ? `${key.slice(0, 4)}...${key.slice(-4)}` : "***";
@@ -147,13 +184,21 @@ export class Client {
147
184
  throw new Error("apiKey contains whitespace - check for a stray newline or paste error");
148
185
  this.apiKey = key;
149
186
  this.base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
150
- this.timeoutMs = opts.timeoutMs ?? 30_000;
187
+ // Coerce a non-finite or non-positive timeout back to the default — NaN
188
+ // (e.g. Number() of an unset env var) would otherwise make the abort timer
189
+ // fire immediately and every request "time out after NaNms".
190
+ const tm = opts.timeoutMs;
191
+ this.timeoutMs = typeof tm === "number" && Number.isFinite(tm) && tm > 0 ? tm : 30_000;
151
192
  this.model = opts.model ?? DEFAULT_MODEL;
152
193
  if (this.model && !MODEL_RE.test(this.model)) {
153
194
  throw new Error(`invalid model name: ${JSON.stringify(this.model)} (letters, digits, '.', '_', '-' only)`);
154
195
  }
155
196
  this.defaultUser = opts.userId || DEFAULT_USER;
156
- this.maxRetries = Math.max(0, opts.maxRetries ?? 2);
197
+ // Coerce a non-finite maxRetries (NaN/Infinity) back to the default — otherwise
198
+ // NaN makes `attempts = NaN + 1` and the loop runs zero requests then throws
199
+ // "retries exhausted"; Infinity would retry forever.
200
+ const mr = opts.maxRetries;
201
+ this.maxRetries = typeof mr === "number" && Number.isFinite(mr) ? Math.max(0, Math.floor(mr)) : 2;
157
202
  this.headers = {
158
203
  "X-API-Key": this.apiKey,
159
204
  "Content-Type": "application/json",
@@ -165,8 +210,9 @@ export class Client {
165
210
  // An API key on plain HTTP travels readable by anyone on the path. Loopback
166
211
  // is fine (local dev / self-hosted on the same box); anything else gets a
167
212
  // 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)) {
213
+ const host = this.base.replace(/^https?:\/\//i, "").split(/[/:]/)[0].toLowerCase();
214
+ // Scheme compare is case-insensitive — `HTTP://` connects in plaintext too.
215
+ if (/^http:\/\//i.test(this.base) && !LOOPBACK_HOSTS.has(host)) {
170
216
  console.warn("wontopos: baseUrl uses plain HTTP on a non-local host, so the API key travels unencrypted. Use https://.");
171
217
  }
172
218
  }
@@ -174,10 +220,13 @@ export class Client {
174
220
  * keys out of source code. Node/Bun/Deno-with-env only. */
175
221
  static fromEnv(opts = {}) {
176
222
  const env = globalThis?.process?.env;
177
- const key = env?.WONTOPOS_API_KEY ?? env?.WOS_API_KEY;
178
- if (!key || !key.trim())
223
+ // First env var with a NON-EMPTY value wins — an empty WONTOPOS_API_KEY must
224
+ // not shadow a valid WOS_API_KEY (`??` only skips null/undefined, not "").
225
+ const key = [env?.WONTOPOS_API_KEY, env?.WOS_API_KEY].find((v) => v && v.trim());
226
+ if (!key)
179
227
  throw new Error("set WONTOPOS_API_KEY (or WOS_API_KEY) in the environment");
180
- return new Client({ apiKey: key, ...opts });
228
+ // apiKey LAST so a stray apiKey in opts can't override the env key.
229
+ return new Client({ ...opts, apiKey: key });
181
230
  }
182
231
  /** Never expose the key: `JSON.stringify(client)` gets the redacted view. */
183
232
  toJSON() {
@@ -244,7 +293,9 @@ export class Client {
244
293
  // ----- read -----
245
294
  /** Semantic search over a store's memories. Returns memories, most relevant first. */
246
295
  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 });
296
+ // Reserved fields win over ...opts: an app that forwards untrusted input as
297
+ // opts must not be able to override the store (user_id), query, or limit.
298
+ const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
248
299
  return (r.memories ?? []);
249
300
  }
250
301
  /** One-call LLM context: short-term turns + long-term matches + surrounding context. */
@@ -289,13 +340,17 @@ export class Client {
289
340
  */
290
341
  async *iterMemories(userId, opts = {}) {
291
342
  let cursor;
343
+ const seen = new Set();
292
344
  for (;;) {
293
345
  const page = await this.listMemories(userId, { limit: opts.pageSize ?? 100, cursor });
294
346
  for (const m of page.memories ?? [])
295
347
  yield m;
296
- cursor = page.next_cursor ?? undefined;
297
- if (!cursor)
348
+ const next = page.next_cursor ?? undefined;
349
+ // Stop on last page OR a server that repeats a cursor (would loop forever).
350
+ if (!next || seen.has(next))
298
351
  break;
352
+ seen.add(next);
353
+ cursor = next;
299
354
  }
300
355
  }
301
356
  /** Collect ALL of a store's memories into an array (original text + metadata, no vectors). */
@@ -333,8 +388,8 @@ export class Client {
333
388
  }
334
389
  /** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
335
390
  deleteStore(userId) {
336
- if (!userId)
337
- throw new Error("userId is required — deleteStore never falls back to the default store.");
391
+ if (!userId || !userId.trim())
392
+ throw new Error("userId is required (non-blank) — deleteStore never falls back to the default store.");
338
393
  return this.request("DELETE", "/api/v1/memory/collection", { user_id: userId });
339
394
  }
340
395
  // ----- speakers (who said it) -----
@@ -366,8 +421,8 @@ export class Client {
366
421
  /** Delete ALL memories for a store (GDPR erase). `userId` is required on purpose -
367
422
  * this is destructive, so it never falls back to the default store. */
368
423
  deleteAll(userId) {
369
- if (!userId)
370
- throw new Error("userId is required for deleteAll — it never falls back to the default store.");
424
+ if (!userId || !userId.trim())
425
+ throw new Error("userId is required (non-blank) for deleteAll — a blank/whitespace id would wipe the default store.");
371
426
  return this.post("/api/v1/memory/forget", { user_id: userId });
372
427
  }
373
428
  // ----- internal -----
@@ -380,6 +435,10 @@ export class Client {
380
435
  const s = Number(retryAfter);
381
436
  if (Number.isFinite(s) && s >= 0)
382
437
  return Math.min(30_000, s * 1000);
438
+ // HTTP-date form (RFC 9110), e.g. "Wed, 21 Oct 2015 07:28:00 GMT".
439
+ const t = Date.parse(retryAfter);
440
+ if (Number.isFinite(t))
441
+ return Math.min(30_000, Math.max(0, t - Date.now()));
383
442
  }
384
443
  return Math.min(8_000, 500 * 2 ** attempt) + Math.random() * 250;
385
444
  }
@@ -435,24 +494,51 @@ export class Client {
435
494
  clearTimeout(timer);
436
495
  // Timeouts are ambiguous (the write may have landed) — don't retry those.
437
496
  const timedOut = e?.name === "AbortError" || ctrl.signal.aborted;
438
- if (!timedOut && attempt + 1 < attempts) {
497
+ // Other network errors retry only when a retry can't double-process a
498
+ // write: idempotent methods always; writes only for connect-level
499
+ // failures (the request never reached the server). A mid-stream drop on
500
+ // a POST may already have stored + billed server-side.
501
+ const safe = IDEMPOTENT_METHODS.has(method.toUpperCase()) || isConnectFailure(e);
502
+ if (!timedOut && safe && attempt + 1 < attempts) {
439
503
  await new Promise((r) => setTimeout(r, this.backoffMs(attempt)));
440
504
  continue;
441
505
  }
442
506
  throw new APIConnectionError(0, timedOut ? `request timed out after ${this.timeoutMs}ms` : `network error: ${e?.message ?? e}`);
443
507
  }
444
- clearTimeout(timer);
445
- if (RETRY_STATUSES.has(res.status) && attempt + 1 < attempts) {
508
+ const retryable = RETRY_ALWAYS.has(res.status) ||
509
+ (RETRY_IF_IDEMPOTENT.has(res.status) && IDEMPOTENT_METHODS.has(method.toUpperCase()));
510
+ if (retryable && attempt + 1 < attempts) {
511
+ clearTimeout(timer);
446
512
  const retryAfter = res.headers.get("Retry-After");
447
513
  void res.body?.cancel();
448
514
  await new Promise((r) => setTimeout(r, this.backoffMs(attempt, retryAfter)));
449
515
  continue;
450
516
  }
451
517
  if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
518
+ clearTimeout(timer);
452
519
  throw new WosError(res.status, "unexpected redirect — refused (the API key never follows a redirect). Check baseUrl: exact host, https://.");
453
520
  }
454
521
  this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
455
- const text = await this.readCapped(res);
522
+ // Keep the abort timer alive THROUGH the body read: fetch resolves on
523
+ // headers, so a server that sends headers then trickles/hangs the body
524
+ // would otherwise stall forever. An abort mid-body surfaces as a timeout.
525
+ let text;
526
+ try {
527
+ text = await this.readCapped(res);
528
+ }
529
+ catch (e) {
530
+ if (e?.name === "AbortError" || ctrl.signal.aborted) {
531
+ throw new APIConnectionError(0, `request timed out after ${this.timeoutMs}ms`);
532
+ }
533
+ if (e instanceof WosError)
534
+ throw e; // the size cap — already the right error
535
+ // A drop while READING the body is a transport failure — surface it as
536
+ // APIConnectionError, not a raw fetch TypeError. Ambiguous, never retried.
537
+ throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
538
+ }
539
+ finally {
540
+ clearTimeout(timer);
541
+ }
456
542
  if (!res.ok) {
457
543
  // Server returns either Anthropic-style envelope
458
544
  // {"type":"error","error":{"type":...,"message":...,"request_id":...}}
@@ -479,7 +565,16 @@ export class Client {
479
565
  }
480
566
  throw errorFor(res.status, msg, requestId);
481
567
  }
482
- return text ? JSON.parse(text) : null;
568
+ if (!text)
569
+ return null;
570
+ try {
571
+ return JSON.parse(text);
572
+ }
573
+ catch (e) {
574
+ // A 2xx with a corrupt body is a real failure — surface it, don't leak
575
+ // a raw SyntaxError (parity with the Rust SDK).
576
+ throw new WosError(res.status, `invalid JSON in response: ${e?.message ?? e}`);
577
+ }
483
578
  }
484
579
  throw new WosError(0, "retries exhausted"); // unreachable; keeps the compiler happy
485
580
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wontopos",
3
- "version": "2.2.12",
3
+ "version": "2.2.14",
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",