wontopos 2.2.7 → 2.2.8

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 CHANGED
@@ -55,9 +55,26 @@ const hits = await mem.search("...the conversation so far...", "alice", 10,
55
55
  { cache_control: { ttl: "5m" } }); // or "1h"
56
56
  ```
57
57
 
58
+ ## Reliability
59
+
60
+ Built in, no configuration needed:
61
+
62
+ - **Automatic retries** — 429 / 502 / 503 and connection errors retry twice with
63
+ exponential backoff + jitter, honoring the server's `Retry-After`. Tune with
64
+ `new Client({ maxRetries })`; `0` disables.
65
+ - **Redirects refused** — the API key never follows a 3xx to another host.
66
+ - **Timeouts** — 30s per attempt by default (`timeoutMs`).
67
+ - **Key never in logs** — `JSON.stringify(client)` and Node's `console.log(client)`
68
+ print a masked key.
69
+ - **Wipe guard** — `delete()` without a `memoryId` throws instead of silently
70
+ meaning "delete everything"; wiping a store is only ever the explicit
71
+ `deleteAll(userId)` / `deleteStore(userId)`.
58
72
 
59
73
  ## Errors
60
74
 
75
+ Any non-2xx response throws `WosError` with `status`, `message`, and — when the
76
+ server sent one — `requestId` (include it when contacting support).
77
+
61
78
  ```ts
62
79
  import { Client, WosError } from "wontopos";
63
80
 
@@ -66,8 +83,8 @@ try {
66
83
  } catch (e) {
67
84
  if (e instanceof WosError) {
68
85
  if (e.status === 401) console.error("API key invalid or revoked");
69
- else if (e.status === 429) console.error("Rate limited");
70
- else console.error(e.status, e.message);
86
+ else if (e.status === 429) console.error("Rate limited"); // already retried twice by then
87
+ else console.error(e.status, e.message, e.requestId);
71
88
  }
72
89
  }
73
90
  ```
@@ -81,7 +98,18 @@ const mem = new Client({ apiKey: "...", baseUrl: "https://wos.your-host.com" });
81
98
  ## Links
82
99
 
83
100
  - Homepage: <https://wontopos.com>
84
- - API reference: <https://wontopos.com/why> (Developers tab)
85
- - Repo: <https://github.com/Irina1920/Wos_API>
101
+ - API reference: <https://wontopos.com/en/why> (Developers tab)
102
+
103
+ ## Changelog
104
+
105
+ - **2.2.8** — reliability + hardening: automatic retries (429/502/503 + connection
106
+ errors, backoff + jitter, honors `Retry-After`), redirects refused, key masked in
107
+ `toJSON`/inspect, `WosError.requestId`, guard against `delete()` without
108
+ `memoryId`, plain-HTTP warning, `User-Agent`.
109
+ - **2.2.7** — speakers: `addSpeaker` / `listSpeakers` / `removeSpeaker`.
110
+ - **2.2.6** — docs: `userId` = store, clarified everywhere.
111
+ - **2.2.5** — docs: speakers + recall caching sections.
112
+ - **2.2.4** — Python/TypeScript/Rust converge on one version; all three now
113
+ release in lockstep (same version, same surface). Patch releases are additive.
86
114
 
87
115
  License: MIT.
@@ -21,15 +21,24 @@
21
21
  *
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
+ *
25
+ * Reliability: every call retries transient failures (429 / 502 / 503 and
26
+ * connection errors) with exponential backoff + jitter, honoring `Retry-After`.
27
+ * Redirects are refused so the API key can never follow one to another host.
28
+ * Tune with `maxRetries` (0 disables) and `timeoutMs`.
24
29
  */
25
30
  export declare class WosError extends Error {
26
31
  readonly status: number;
27
- constructor(status: number, message: string);
32
+ /** The server's id for the request when it sent one — include it when contacting support. */
33
+ readonly requestId?: string;
34
+ constructor(status: number, message: string, requestId?: string);
28
35
  }
29
36
  export interface Memory {
30
37
  id?: string;
31
38
  content?: string;
32
39
  score?: number;
40
+ /** WHO said it: "me" (the assistant) or a registered person's name. Absent = untagged. */
41
+ speaker?: string;
33
42
  [key: string]: unknown;
34
43
  }
35
44
  export interface RecallResult {
@@ -58,13 +67,16 @@ export interface ClientOptions {
58
67
  apiKey: string;
59
68
  /** API base URL (defaults to the hosted service). */
60
69
  baseUrl?: string;
61
- /** Per-request timeout in ms (default 30000). */
70
+ /** Per-request timeout in ms, applied to each retry attempt (default 30000). */
62
71
  timeoutMs?: number;
63
72
  /** Default model for every call (sent as `X-WOS-Model`). See `listModels()`. */
64
73
  model?: string;
65
74
  /** Default store for every call. Override per call by passing `userId`. Defaults
66
75
  * to the account's built-in `default` store. */
67
76
  userId?: string;
77
+ /** How many times to retry transient failures (429/502/503 and connection
78
+ * errors) before throwing. 0 disables retries (default 2). */
79
+ maxRetries?: number;
68
80
  }
69
81
  /** Wontopos memory client. Store and recall memories, isolated per `userId`. */
70
82
  export declare class Client {
@@ -73,11 +85,15 @@ export declare class Client {
73
85
  private readonly timeoutMs;
74
86
  private readonly apiKey;
75
87
  private readonly model;
88
+ private readonly maxRetries;
76
89
  /** The store every call uses unless one passes `userId`. */
77
90
  private readonly defaultUser;
78
91
  constructor(opts: ClientOptions);
92
+ /** Never expose the key: `JSON.stringify(client)` gets the redacted view. */
93
+ toJSON(): Record<string, string>;
79
94
  /** Resolve a call's store: the explicit userId, else the client default. */
80
95
  private uid;
96
+ private clone;
81
97
  /**
82
98
  * A client that uses `model` (sent as `X-WOS-Model`). Use it to set the default
83
99
  * (`new Client({ apiKey, model })`) or to override one call
@@ -135,6 +151,8 @@ export declare class Client {
135
151
  * this is destructive, so it never falls back to the default store. */
136
152
  deleteAll(userId: string): Promise<any>;
137
153
  private post;
154
+ /** Seconds→ms to sleep before retry `attempt` (0-based). Honors Retry-After. */
155
+ private backoffMs;
138
156
  private request;
139
157
  }
140
158
  /** Back-compat alias: older code used `WME`. */
package/dist/wontopos.js CHANGED
@@ -21,15 +21,32 @@
21
21
  *
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
+ *
25
+ * Reliability: every call retries transient failures (429 / 502 / 503 and
26
+ * connection errors) with exponential backoff + jitter, honoring `Retry-After`.
27
+ * Redirects are refused so the API key can never follow one to another host.
28
+ * Tune with `maxRetries` (0 disables) and `timeoutMs`.
24
29
  */
30
+ const VERSION = "2.2.8";
31
+ const USER_AGENT = `wontopos-node/${VERSION}`;
25
32
  const DEFAULT_BASE_URL = "https://api.wontopos.com";
26
33
  const DEFAULT_MODEL = "tablet-1";
34
+ /** Statuses where the request was NOT processed, so retrying is always safe. */
35
+ const RETRY_STATUSES = new Set([429, 502, 503]);
36
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
37
+ /** `wos-abc...wxyz` — enough to tell keys apart, never enough to use. */
38
+ function maskKey(key) {
39
+ return key.length > 12 ? `${key.slice(0, 4)}...${key.slice(-4)}` : "***";
40
+ }
27
41
  export class WosError extends Error {
28
42
  status;
29
- constructor(status, message) {
30
- super(`[${status}] ${message}`);
43
+ /** The server's id for the request when it sent one — include it when contacting support. */
44
+ requestId;
45
+ constructor(status, message, requestId) {
46
+ super(`[${status}] ${message}${requestId ? ` (request_id: ${requestId})` : ""}`);
31
47
  this.name = "WosError";
32
48
  this.status = status;
49
+ this.requestId = requestId;
33
50
  }
34
51
  }
35
52
  const DEFAULT_USER = "default";
@@ -40,6 +57,7 @@ export class Client {
40
57
  timeoutMs;
41
58
  apiKey;
42
59
  model;
60
+ maxRetries;
43
61
  /** The store every call uses unless one passes `userId`. */
44
62
  defaultUser;
45
63
  constructor(opts) {
@@ -50,25 +68,57 @@ export class Client {
50
68
  this.timeoutMs = opts.timeoutMs ?? 30_000;
51
69
  this.model = opts.model ?? DEFAULT_MODEL;
52
70
  this.defaultUser = opts.userId || DEFAULT_USER;
53
- this.headers = { "X-API-Key": opts.apiKey, "Content-Type": "application/json" };
71
+ this.maxRetries = Math.max(0, opts.maxRetries ?? 2);
72
+ this.headers = {
73
+ "X-API-Key": opts.apiKey,
74
+ "Content-Type": "application/json",
75
+ // Browsers silently drop User-Agent (forbidden header) — harmless.
76
+ "User-Agent": USER_AGENT,
77
+ };
54
78
  if (this.model)
55
79
  this.headers["X-WOS-Model"] = this.model;
80
+ // An API key on plain HTTP travels readable by anyone on the path. Loopback
81
+ // is fine (local dev / self-hosted on the same box); anything else gets a
82
+ // warning, not an error, so private-network gateways keep working.
83
+ const host = this.base.replace(/^https?:\/\//, "").split(/[/:]/)[0].toLowerCase();
84
+ if (this.base.startsWith("http://") && !LOOPBACK_HOSTS.has(host)) {
85
+ console.warn("wontopos: baseUrl uses plain HTTP on a non-local host, so the API key travels unencrypted. Use https://.");
86
+ }
87
+ }
88
+ /** Never expose the key: `JSON.stringify(client)` gets the redacted view. */
89
+ toJSON() {
90
+ return { baseUrl: this.base, model: this.model, userId: this.defaultUser, apiKey: maskKey(this.apiKey) };
91
+ }
92
+ /** Never expose the key: Node's `console.log(client)` gets the redacted view. */
93
+ [Symbol.for("nodejs.util.inspect.custom")]() {
94
+ return `Client(${this.base}, model=${this.model}, userId=${this.defaultUser}, apiKey=${maskKey(this.apiKey)})`;
56
95
  }
57
96
  /** Resolve a call's store: the explicit userId, else the client default. */
58
97
  uid(userId) {
59
98
  return userId || this.defaultUser;
60
99
  }
100
+ clone(overrides) {
101
+ return new Client({
102
+ apiKey: this.apiKey,
103
+ baseUrl: this.base,
104
+ timeoutMs: this.timeoutMs,
105
+ model: this.model,
106
+ userId: this.defaultUser,
107
+ maxRetries: this.maxRetries,
108
+ ...overrides,
109
+ });
110
+ }
61
111
  /**
62
112
  * A client that uses `model` (sent as `X-WOS-Model`). Use it to set the default
63
113
  * (`new Client({ apiKey, model })`) or to override one call
64
114
  * (`client.withModel("tablet-1").recall(...)`).
65
115
  */
66
116
  withModel(model) {
67
- return new Client({ apiKey: this.apiKey, baseUrl: this.base, timeoutMs: this.timeoutMs, model, userId: this.defaultUser });
117
+ return this.clone({ model });
68
118
  }
69
119
  /** A client bound to `userId` as its default store (everything else kept). */
70
120
  withUser(userId) {
71
- return new Client({ apiKey: this.apiKey, baseUrl: this.base, timeoutMs: this.timeoutMs, model: this.model, userId });
121
+ return this.clone({ userId });
72
122
  }
73
123
  // ----- write -----
74
124
  // `userId` is optional in every call below - omit it to use the client's default
@@ -123,10 +173,7 @@ export class Client {
123
173
  // ----- models -----
124
174
  /** Available models: `[{ id, name, available, memory }, ...]`. Needs no API key. */
125
175
  async listModels() {
126
- const res = await fetch(`${this.base}/api/v1/models`);
127
- if (!res.ok)
128
- throw new WosError(res.status, await res.text());
129
- const data = await res.json();
176
+ const data = await this.request("GET", "/api/v1/models");
130
177
  return (data.models ?? []);
131
178
  }
132
179
  // ----- stores -----
@@ -144,6 +191,8 @@ export class Client {
144
191
  }
145
192
  /** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
146
193
  deleteStore(userId) {
194
+ if (!userId)
195
+ throw new Error("userId is required — deleteStore never falls back to the default store.");
147
196
  return this.request("DELETE", "/api/v1/memory/collection", { user_id: userId });
148
197
  }
149
198
  // ----- speakers (who said it) -----
@@ -164,58 +213,97 @@ export class Client {
164
213
  // ----- delete -----
165
214
  /** Delete a single memory by id. `userId` optional (uses the default store). */
166
215
  delete(userId, memoryId) {
216
+ // Guard: without a memory_id the API's forget endpoint means "delete the
217
+ // whole store". An undefined/"" slipping in here must never become a wipe.
218
+ if (!memoryId || typeof memoryId !== "string") {
219
+ throw new Error("memoryId is required. To delete every memory in a store, call deleteAll(userId) explicitly.");
220
+ }
167
221
  return this.post("/api/v1/memory/forget", { user_id: this.uid(userId), memory_id: memoryId });
168
222
  }
169
223
  /** Delete ALL memories for a store (GDPR erase). `userId` is required on purpose -
170
224
  * this is destructive, so it never falls back to the default store. */
171
225
  deleteAll(userId) {
226
+ if (!userId)
227
+ throw new Error("userId is required for deleteAll — it never falls back to the default store.");
172
228
  return this.post("/api/v1/memory/forget", { user_id: userId });
173
229
  }
174
230
  // ----- internal -----
175
231
  post(path, body) {
176
232
  return this.request("POST", path, body);
177
233
  }
178
- async request(method, path, body) {
179
- const ctrl = new AbortController();
180
- const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
181
- let res;
182
- try {
183
- res = await fetch(`${this.base}${path}`, {
184
- method,
185
- headers: this.headers,
186
- body: body === undefined ? undefined : JSON.stringify(body),
187
- signal: ctrl.signal,
188
- });
234
+ /** Seconds→ms to sleep before retry `attempt` (0-based). Honors Retry-After. */
235
+ backoffMs(attempt, retryAfter) {
236
+ if (retryAfter) {
237
+ const s = Number(retryAfter);
238
+ if (Number.isFinite(s) && s >= 0)
239
+ return Math.min(30_000, s * 1000);
189
240
  }
190
- catch (e) {
191
- throw new WosError(0, `network error: ${e?.message ?? e}`);
192
- }
193
- finally {
194
- clearTimeout(timer);
195
- }
196
- if (!res.ok) {
197
- // Server returns either Anthropic-style envelope
198
- // {"type":"error","error":{"type":...,"message":...,"request_id":...}}
199
- // or simple {"error":"reason"}. Fall back to raw text.
200
- let msg = await res.text();
241
+ return Math.min(8_000, 500 * 2 ** attempt) + Math.random() * 250;
242
+ }
243
+ async request(method, path, body) {
244
+ const attempts = this.maxRetries + 1;
245
+ for (let attempt = 0; attempt < attempts; attempt++) {
246
+ const ctrl = new AbortController();
247
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
248
+ let res;
201
249
  try {
202
- const data = JSON.parse(msg);
203
- if (data && typeof data === "object") {
204
- const err = data.error;
205
- if (err && typeof err === "object")
206
- msg = err.message ?? err.type ?? msg;
207
- else if (typeof err === "string")
208
- msg = err;
209
- else if (typeof data.message === "string")
210
- msg = data.message;
250
+ res = await fetch(`${this.base}${path}`, {
251
+ method,
252
+ headers: this.headers,
253
+ body: body === undefined ? undefined : JSON.stringify(body),
254
+ signal: ctrl.signal,
255
+ // Never follow a redirect: fetch would forward the API key to
256
+ // wherever a 3xx points. The API never legitimately redirects.
257
+ redirect: "manual",
258
+ });
259
+ }
260
+ catch (e) {
261
+ clearTimeout(timer);
262
+ // Timeouts are ambiguous (the write may have landed) — don't retry those.
263
+ const timedOut = e?.name === "AbortError" || ctrl.signal.aborted;
264
+ if (!timedOut && attempt + 1 < attempts) {
265
+ await new Promise((r) => setTimeout(r, this.backoffMs(attempt)));
266
+ continue;
211
267
  }
268
+ throw new WosError(0, timedOut ? `request timed out after ${this.timeoutMs}ms` : `network error: ${e?.message ?? e}`);
212
269
  }
213
- catch {
214
- /* keep raw text */
270
+ clearTimeout(timer);
271
+ if (RETRY_STATUSES.has(res.status) && attempt + 1 < attempts) {
272
+ await new Promise((r) => setTimeout(r, this.backoffMs(attempt, res.headers.get("Retry-After"))));
273
+ continue;
274
+ }
275
+ if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
276
+ throw new WosError(res.status, "unexpected redirect — refused (the API key never follows a redirect). Check baseUrl: exact host, https://.");
277
+ }
278
+ if (!res.ok) {
279
+ // Server returns either Anthropic-style envelope
280
+ // {"type":"error","error":{"type":...,"message":...,"request_id":...}}
281
+ // or simple {"error":"reason"}. Fall back to raw text.
282
+ let msg = await res.text();
283
+ let requestId;
284
+ try {
285
+ const data = JSON.parse(msg);
286
+ if (data && typeof data === "object") {
287
+ const err = data.error;
288
+ if (err && typeof err === "object") {
289
+ msg = err.message ?? err.type ?? msg;
290
+ if (typeof err.request_id === "string")
291
+ requestId = err.request_id;
292
+ }
293
+ else if (typeof err === "string")
294
+ msg = err;
295
+ else if (typeof data.message === "string")
296
+ msg = data.message;
297
+ }
298
+ }
299
+ catch {
300
+ /* keep raw text */
301
+ }
302
+ throw new WosError(res.status, msg, requestId);
215
303
  }
216
- throw new WosError(res.status, msg);
304
+ return res.json();
217
305
  }
218
- return res.json();
306
+ throw new WosError(0, "retries exhausted"); // unreachable; keeps the compiler happy
219
307
  }
220
308
  }
221
309
  /** Back-compat alias: older code used `WME`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wontopos",
3
- "version": "2.2.7",
3
+ "version": "2.2.8",
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",
@@ -14,14 +14,6 @@
14
14
  "context"
15
15
  ],
16
16
  "homepage": "https://wontopos.com",
17
- "repository": {
18
- "type": "git",
19
- "url": "git+https://github.com/Irina1920/Wos_API.git",
20
- "directory": "sdk/typescript"
21
- },
22
- "bugs": {
23
- "url": "https://github.com/Irina1920/Wos_API/issues"
24
- },
25
17
  "license": "MIT",
26
18
  "author": "Wontopos",
27
19
  "type": "module",
@@ -45,6 +37,7 @@
45
37
  },
46
38
  "scripts": {
47
39
  "build": "tsc",
40
+ "test": "npm run build && node --test test/client.test.mjs",
48
41
  "prepublishOnly": "npm run build"
49
42
  },
50
43
  "devDependencies": {