wontopos 2.2.11 → 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.
@@ -32,12 +32,47 @@
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
+ /** Quota from a response's `X-RateLimit-*` headers, or `null` if absent. */
36
+ export interface RateLimit {
37
+ limit: number | null;
38
+ remaining: number | null;
39
+ reset: number | null;
40
+ }
35
41
  export declare class WosError extends Error {
36
42
  readonly status: number;
37
43
  /** The server's id for the request when it sent one — include it when contacting support. */
38
44
  readonly requestId?: string;
39
45
  constructor(status: number, message: string, requestId?: string);
40
46
  }
47
+ /** The request never got a response (DNS/TLS/timeout/connection). `status` is 0. */
48
+ export declare class APIConnectionError extends WosError {
49
+ }
50
+ /** 400 — the request was malformed (bad arguments). */
51
+ export declare class BadRequestError extends WosError {
52
+ }
53
+ /** 401 — the API key is missing, wrong, or revoked. */
54
+ export declare class AuthenticationError extends WosError {
55
+ }
56
+ /** 402 — no card on file or the balance is depleted. Top up to continue. */
57
+ export declare class PaymentRequiredError extends WosError {
58
+ }
59
+ /** 403 — the key/model isn't allowed to do this. */
60
+ export declare class PermissionDeniedError extends WosError {
61
+ }
62
+ /** 404 — the store or resource doesn't exist (create the store first). */
63
+ export declare class NotFoundError extends WosError {
64
+ }
65
+ /** 409 — a concurrent write to the same store. Retry. */
66
+ export declare class ConflictError extends WosError {
67
+ }
68
+ /** 429 — too many requests. Back off and retry (the client already retries these). */
69
+ export declare class RateLimitError extends WosError {
70
+ }
71
+ /** 5xx — the service failed. Safe to retry. */
72
+ export declare class ServerError extends WosError {
73
+ }
74
+ /** Build the most specific WosError subclass for an HTTP status. */
75
+ export declare function errorFor(status: number, message: string, requestId?: string): WosError;
41
76
  export interface Usage {
42
77
  input_tokens?: number;
43
78
  output_tokens?: number;
@@ -205,6 +240,11 @@ export declare class Client {
205
240
  private readonly maxRetries;
206
241
  /** The store every call uses unless one passes `userId`. */
207
242
  private readonly defaultUser;
243
+ private _rateLimit;
244
+ /** Quota from the MOST RECENT call: `{ limit, remaining, reset }` (or `null` before
245
+ * the first call). Read it to self-throttle — the client already retries 429s, but
246
+ * this lets you slow down before hitting the wall. */
247
+ get rateLimit(): RateLimit | null;
208
248
  constructor(opts: ClientOptions);
209
249
  /** A client whose key comes from `WONTOPOS_API_KEY` (or `WOS_API_KEY`) — keeps
210
250
  * keys out of source code. Node/Bun/Deno-with-env only. */
@@ -259,6 +299,20 @@ export declare class Client {
259
299
  limit?: number;
260
300
  cursor?: string;
261
301
  }): Promise<MemoryPage>;
302
+ /** Async-iterate every stored memory in a store, paging under the hood — no cursor
303
+ * bookkeeping. Original text + metadata only, never vectors.
304
+ *
305
+ * for await (const m of mem.iterMemories()) console.log(m.id, m.content);
306
+ */
307
+ iterMemories(userId?: string, opts?: {
308
+ pageSize?: number;
309
+ }): AsyncGenerator<Memory>;
310
+ /** Collect ALL of a store's memories into an array (original text + metadata, no vectors). */
311
+ exportMemories(userId?: string): Promise<Memory[]>;
312
+ /** Check connectivity AND that the API key works. Resolves `true`, or rejects — an
313
+ * `AuthenticationError` (bad key), `PaymentRequiredError` (valid key but no card /
314
+ * depleted balance), or `APIConnectionError` (unreachable). Makes one metered request. */
315
+ ping(): Promise<boolean>;
262
316
  /** Available models: `[{ id, name, available, memory }, ...]`. Needs no API key. */
263
317
  listModels(): Promise<ModelInfo[]>;
264
318
  /** Create a store — the `userId` you read and write under. Stores are explicit:
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.11";
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. */
@@ -48,17 +53,77 @@ const MODEL_RE = /^[A-Za-z0-9._-]+$/;
48
53
  function maskKey(key) {
49
54
  return key.length > 12 ? `${key.slice(0, 4)}...${key.slice(-4)}` : "***";
50
55
  }
56
+ function parseRateLimit(h) {
57
+ const num = (name) => {
58
+ const v = h.get(name);
59
+ if (v == null || v.trim() === "")
60
+ return null; // Number("") is 0 — treat blank as absent
61
+ const n = Number(v);
62
+ return Number.isFinite(n) ? n : null;
63
+ };
64
+ const limit = num("X-RateLimit-Limit");
65
+ const remaining = num("X-RateLimit-Remaining");
66
+ const reset = num("X-RateLimit-Reset");
67
+ if (limit === null && remaining === null && reset === null)
68
+ return null;
69
+ return { limit, remaining, reset };
70
+ }
51
71
  export class WosError extends Error {
52
72
  status;
53
73
  /** The server's id for the request when it sent one — include it when contacting support. */
54
74
  requestId;
55
75
  constructor(status, message, requestId) {
56
76
  super(`[${status}] ${message}${requestId ? ` (request_id: ${requestId})` : ""}`);
57
- this.name = "WosError";
77
+ this.name = new.target.name; // the concrete subclass name (RateLimitError, …)
58
78
  this.status = status;
59
79
  this.requestId = requestId;
60
80
  }
61
81
  }
82
+ // Typed subclasses so callers can branch on the failure — `catch (e) { if (e instanceof
83
+ // RateLimitError) … }`. Each is a WosError, so a broad `instanceof WosError` still works.
84
+ /** The request never got a response (DNS/TLS/timeout/connection). `status` is 0. */
85
+ export class APIConnectionError extends WosError {
86
+ }
87
+ /** 400 — the request was malformed (bad arguments). */
88
+ export class BadRequestError extends WosError {
89
+ }
90
+ /** 401 — the API key is missing, wrong, or revoked. */
91
+ export class AuthenticationError extends WosError {
92
+ }
93
+ /** 402 — no card on file or the balance is depleted. Top up to continue. */
94
+ export class PaymentRequiredError extends WosError {
95
+ }
96
+ /** 403 — the key/model isn't allowed to do this. */
97
+ export class PermissionDeniedError extends WosError {
98
+ }
99
+ /** 404 — the store or resource doesn't exist (create the store first). */
100
+ export class NotFoundError extends WosError {
101
+ }
102
+ /** 409 — a concurrent write to the same store. Retry. */
103
+ export class ConflictError extends WosError {
104
+ }
105
+ /** 429 — too many requests. Back off and retry (the client already retries these). */
106
+ export class RateLimitError extends WosError {
107
+ }
108
+ /** 5xx — the service failed. Safe to retry. */
109
+ export class ServerError extends WosError {
110
+ }
111
+ const STATUS_ERRORS = {
112
+ 400: BadRequestError,
113
+ 401: AuthenticationError,
114
+ 402: PaymentRequiredError,
115
+ 403: PermissionDeniedError,
116
+ 404: NotFoundError,
117
+ 409: ConflictError,
118
+ 429: RateLimitError,
119
+ };
120
+ /** Build the most specific WosError subclass for an HTTP status. */
121
+ export function errorFor(status, message, requestId) {
122
+ const Cls = status === 0
123
+ ? APIConnectionError
124
+ : STATUS_ERRORS[status] ?? (status >= 500 && status < 600 ? ServerError : WosError);
125
+ return new Cls(status, message, requestId);
126
+ }
62
127
  const DEFAULT_USER = "default";
63
128
  /** Wontopos memory client. Store and recall memories, isolated per `userId`. */
64
129
  export class Client {
@@ -70,6 +135,13 @@ export class Client {
70
135
  maxRetries;
71
136
  /** The store every call uses unless one passes `userId`. */
72
137
  defaultUser;
138
+ _rateLimit = null;
139
+ /** Quota from the MOST RECENT call: `{ limit, remaining, reset }` (or `null` before
140
+ * the first call). Read it to self-throttle — the client already retries 429s, but
141
+ * this lets you slow down before hitting the wall. */
142
+ get rateLimit() {
143
+ return this._rateLimit;
144
+ }
73
145
  constructor(opts) {
74
146
  // Trim the key and reject inner whitespace — a stray newline from a file or
75
147
  // env var otherwise turns into a mystery 401 (or a mangled header).
@@ -86,7 +158,11 @@ export class Client {
86
158
  throw new Error(`invalid model name: ${JSON.stringify(this.model)} (letters, digits, '.', '_', '-' only)`);
87
159
  }
88
160
  this.defaultUser = opts.userId || DEFAULT_USER;
89
- 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;
90
166
  this.headers = {
91
167
  "X-API-Key": this.apiKey,
92
168
  "Content-Type": "application/json",
@@ -98,8 +174,9 @@ export class Client {
98
174
  // An API key on plain HTTP travels readable by anyone on the path. Loopback
99
175
  // is fine (local dev / self-hosted on the same box); anything else gets a
100
176
  // warning, not an error, so private-network gateways keep working.
101
- const host = this.base.replace(/^https?:\/\//, "").split(/[/:]/)[0].toLowerCase();
102
- 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)) {
103
180
  console.warn("wontopos: baseUrl uses plain HTTP on a non-local host, so the API key travels unencrypted. Use https://.");
104
181
  }
105
182
  }
@@ -107,10 +184,13 @@ export class Client {
107
184
  * keys out of source code. Node/Bun/Deno-with-env only. */
108
185
  static fromEnv(opts = {}) {
109
186
  const env = globalThis?.process?.env;
110
- const key = env?.WONTOPOS_API_KEY ?? env?.WOS_API_KEY;
111
- 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)
112
191
  throw new Error("set WONTOPOS_API_KEY (or WOS_API_KEY) in the environment");
113
- 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 });
114
194
  }
115
195
  /** Never expose the key: `JSON.stringify(client)` gets the redacted view. */
116
196
  toJSON() {
@@ -177,7 +257,9 @@ export class Client {
177
257
  // ----- read -----
178
258
  /** Semantic search over a store's memories. Returns memories, most relevant first. */
179
259
  async search(query, userId, limit = 10, opts = {}) {
180
- 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 });
181
263
  return (r.memories ?? []);
182
264
  }
183
265
  /** One-call LLM context: short-term turns + long-term matches + surrounding context. */
@@ -215,6 +297,40 @@ export class Client {
215
297
  body.cursor = opts.cursor;
216
298
  return this.post("/api/v1/memory/list", body);
217
299
  }
300
+ /** Async-iterate every stored memory in a store, paging under the hood — no cursor
301
+ * bookkeeping. Original text + metadata only, never vectors.
302
+ *
303
+ * for await (const m of mem.iterMemories()) console.log(m.id, m.content);
304
+ */
305
+ async *iterMemories(userId, opts = {}) {
306
+ let cursor;
307
+ const seen = new Set();
308
+ for (;;) {
309
+ const page = await this.listMemories(userId, { limit: opts.pageSize ?? 100, cursor });
310
+ for (const m of page.memories ?? [])
311
+ yield m;
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))
315
+ break;
316
+ seen.add(next);
317
+ cursor = next;
318
+ }
319
+ }
320
+ /** Collect ALL of a store's memories into an array (original text + metadata, no vectors). */
321
+ async exportMemories(userId) {
322
+ const out = [];
323
+ for await (const m of this.iterMemories(userId))
324
+ out.push(m);
325
+ return out;
326
+ }
327
+ /** Check connectivity AND that the API key works. Resolves `true`, or rejects — an
328
+ * `AuthenticationError` (bad key), `PaymentRequiredError` (valid key but no card /
329
+ * depleted balance), or `APIConnectionError` (unreachable). Makes one metered request. */
330
+ async ping() {
331
+ await this.request("GET", "/api/v1/memory/collections");
332
+ return true;
333
+ }
218
334
  // ----- models -----
219
335
  /** Available models: `[{ id, name, available, memory }, ...]`. Needs no API key. */
220
336
  async listModels() {
@@ -236,8 +352,8 @@ export class Client {
236
352
  }
237
353
  /** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
238
354
  deleteStore(userId) {
239
- if (!userId)
240
- 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.");
241
357
  return this.request("DELETE", "/api/v1/memory/collection", { user_id: userId });
242
358
  }
243
359
  // ----- speakers (who said it) -----
@@ -269,8 +385,8 @@ export class Client {
269
385
  /** Delete ALL memories for a store (GDPR erase). `userId` is required on purpose -
270
386
  * this is destructive, so it never falls back to the default store. */
271
387
  deleteAll(userId) {
272
- if (!userId)
273
- 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.");
274
390
  return this.post("/api/v1/memory/forget", { user_id: userId });
275
391
  }
276
392
  // ----- internal -----
@@ -283,6 +399,10 @@ export class Client {
283
399
  const s = Number(retryAfter);
284
400
  if (Number.isFinite(s) && s >= 0)
285
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()));
286
406
  }
287
407
  return Math.min(8_000, 500 * 2 ** attempt) + Math.random() * 250;
288
408
  }
@@ -342,19 +462,38 @@ export class Client {
342
462
  await new Promise((r) => setTimeout(r, this.backoffMs(attempt)));
343
463
  continue;
344
464
  }
345
- throw new WosError(0, timedOut ? `request timed out after ${this.timeoutMs}ms` : `network error: ${e?.message ?? e}`);
465
+ throw new APIConnectionError(0, timedOut ? `request timed out after ${this.timeoutMs}ms` : `network error: ${e?.message ?? e}`);
346
466
  }
347
- clearTimeout(timer);
348
- 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);
349
471
  const retryAfter = res.headers.get("Retry-After");
350
472
  void res.body?.cancel();
351
473
  await new Promise((r) => setTimeout(r, this.backoffMs(attempt, retryAfter)));
352
474
  continue;
353
475
  }
354
476
  if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
477
+ clearTimeout(timer);
355
478
  throw new WosError(res.status, "unexpected redirect — refused (the API key never follows a redirect). Check baseUrl: exact host, https://.");
356
479
  }
357
- const text = await this.readCapped(res);
480
+ this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
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
+ }
358
497
  if (!res.ok) {
359
498
  // Server returns either Anthropic-style envelope
360
499
  // {"type":"error","error":{"type":...,"message":...,"request_id":...}}
@@ -379,7 +518,7 @@ export class Client {
379
518
  catch {
380
519
  /* keep raw text */
381
520
  }
382
- throw new WosError(res.status, msg, requestId);
521
+ throw errorFor(res.status, msg, requestId);
383
522
  }
384
523
  return text ? JSON.parse(text) : null;
385
524
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wontopos",
3
- "version": "2.2.11",
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",