wontopos 2.2.7 → 2.2.9

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,55 @@ 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)`.
72
+
73
+ ## Security
74
+
75
+ Built in, none of it configurable off:
76
+
77
+ - **Redirects refused** — a 3xx is an error, so the key never follows one to
78
+ another host.
79
+ - **Response size cap** — anything over 64MB is refused instead of buffered.
80
+ - **Key hygiene** — keys are trimmed (a stray newline from an env var otherwise
81
+ becomes a mystery 401) and inner whitespace is rejected; model names are
82
+ validated before they reach a header.
83
+ - **`Client.fromEnv()`** reads `WONTOPOS_API_KEY` (or `WOS_API_KEY`) — keep
84
+ keys out of source code.
85
+ - TLS certificate verification is never touched (runtime defaults; Node 18+
86
+ floors TLS at 1.2). Plain-HTTP base URLs on non-local hosts warn.
87
+ Zero runtime dependencies.
88
+
89
+ ## Typed responses
90
+
91
+ Every method returns a documented shape — `StoreResult`, `UpdateResult`,
92
+ `RecallResult`, `EngramResult`, `StatsResult`, `SpeakersList`, `Memory[]`, ... —
93
+ each with an index signature, so new server fields flow through without an SDK
94
+ update. `search` options are typed too (`SearchOptions`: `cache_control`,
95
+ `speaker`, plus pass-through).
96
+
97
+ ```ts
98
+ const hits: Memory[] = await mem.search("what did Bob say?", "alice", 10, { speaker: "Bob" });
99
+ console.log(hits[0].speaker, hits[0].time_bucket); // typed: string | undefined
100
+ ```
58
101
 
59
102
  ## Errors
60
103
 
104
+ Any non-2xx response throws `WosError` with `status`, `message`, and — when the
105
+ server sent one — `requestId` (include it when contacting support).
106
+
61
107
  ```ts
62
108
  import { Client, WosError } from "wontopos";
63
109
 
@@ -66,8 +112,8 @@ try {
66
112
  } catch (e) {
67
113
  if (e instanceof WosError) {
68
114
  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);
115
+ else if (e.status === 429) console.error("Rate limited"); // already retried twice by then
116
+ else console.error(e.status, e.message, e.requestId);
71
117
  }
72
118
  }
73
119
  ```
@@ -81,7 +127,21 @@ const mem = new Client({ apiKey: "...", baseUrl: "https://wos.your-host.com" });
81
127
  ## Links
82
128
 
83
129
  - Homepage: <https://wontopos.com>
84
- - API reference: <https://wontopos.com/why> (Developers tab)
85
- - Repo: <https://github.com/Irina1920/Wos_API>
130
+ - API reference: <https://wontopos.com/en/why> (Developers tab)
131
+
132
+ ## Changelog
133
+
134
+ - **2.2.9** — typed responses for every method (index-signature friendly) +
135
+ typed `SearchOptions`; security round 2: 64MB response cap, key/model-name
136
+ hygiene, `Client.fromEnv()`.
137
+ - **2.2.8** — reliability + hardening: automatic retries (429/502/503 + connection
138
+ errors, backoff + jitter, honors `Retry-After`), redirects refused, key masked in
139
+ `toJSON`/inspect, `WosError.requestId`, guard against `delete()` without
140
+ `memoryId`, plain-HTTP warning, `User-Agent`.
141
+ - **2.2.7** — speakers: `addSpeaker` / `listSpeakers` / `removeSpeaker`.
142
+ - **2.2.6** — docs: `userId` = store, clarified everywhere.
143
+ - **2.2.5** — docs: speakers + recall caching sections.
144
+ - **2.2.4** — Python/TypeScript/Rust converge on one version; all three now
145
+ release in lockstep (same version, same surface). Patch releases are additive.
86
146
 
87
147
  License: MIT.
@@ -21,15 +21,56 @@
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
+ * Tune with `maxRetries` (0 disables) and `timeoutMs`.
28
+ *
29
+ * Security posture (not configurable off): redirects are refused so the API key
30
+ * can never follow one to another host; responses over 64MB are refused; the key
31
+ * is masked in `toJSON`/inspect output. TLS certificate verification is the
32
+ * runtime's (never disabled by this SDK; Node 18+ floors TLS at 1.2). Prefer
33
+ * `Client.fromEnv()` over keys in source code.
24
34
  */
25
35
  export declare class WosError extends Error {
26
36
  readonly status: number;
27
- constructor(status: number, message: string);
37
+ /** The server's id for the request when it sent one — include it when contacting support. */
38
+ readonly requestId?: string;
39
+ constructor(status: number, message: string, requestId?: string);
40
+ }
41
+ export interface Usage {
42
+ input_tokens?: number;
43
+ output_tokens?: number;
44
+ [key: string]: unknown;
28
45
  }
29
46
  export interface Memory {
30
47
  id?: string;
31
48
  content?: string;
32
49
  score?: number;
50
+ /** Month bucket the memory belongs to (e.g. "2026-07"). */
51
+ time_bucket?: string;
52
+ /** WHO said it: "me" (the assistant) or a registered person's name. Absent = untagged. */
53
+ speaker?: string;
54
+ [key: string]: unknown;
55
+ }
56
+ /** `add` / `store` → `{ id, status }` (`status: "stored" | "stored (async)" | "duplicate"`). */
57
+ export interface StoreResult {
58
+ id?: string;
59
+ status?: string;
60
+ /** Present when the store was a duplicate and something (e.g. a speaker tag) was dropped. */
61
+ note?: string;
62
+ [key: string]: unknown;
63
+ }
64
+ /** `addTurn` / `addBulk` / `delete` / `deleteAll` → `{ status }`. */
65
+ export interface StatusResult {
66
+ status?: string;
67
+ [key: string]: unknown;
68
+ }
69
+ /** `update` (supersede) → old and new memory ids. */
70
+ export interface UpdateResult {
71
+ old_memory_id?: string;
72
+ new_memory_id?: string;
73
+ status?: string;
33
74
  [key: string]: unknown;
34
75
  }
35
76
  export interface RecallResult {
@@ -47,6 +88,28 @@ export interface RecallResult {
47
88
  };
48
89
  [key: string]: unknown;
49
90
  }
91
+ /** `engram` → the merged multi-hop result. */
92
+ export interface EngramResult {
93
+ engram?: string;
94
+ form?: string;
95
+ user_id?: string;
96
+ hops?: number;
97
+ count?: number;
98
+ memories?: Memory[];
99
+ usage?: Usage;
100
+ [key: string]: unknown;
101
+ }
102
+ export interface HistoryTurn {
103
+ user_msg?: string;
104
+ assistant_msg?: string;
105
+ timestamp?: string;
106
+ [key: string]: unknown;
107
+ }
108
+ export interface StatsResult {
109
+ total_memories?: number;
110
+ short_term_turns?: number;
111
+ [key: string]: unknown;
112
+ }
50
113
  export interface ModelInfo {
51
114
  id: string;
52
115
  name: string;
@@ -54,17 +117,62 @@ export interface ModelInfo {
54
117
  /** "shared" — reads the common memory pool; "isolated" — its own dedicated store. */
55
118
  memory: "shared" | "isolated";
56
119
  }
120
+ /** `createStore` / `deleteStore` → `{ user_id, status }`. */
121
+ export interface StoreOpResult {
122
+ user_id?: string;
123
+ status?: string;
124
+ [key: string]: unknown;
125
+ }
126
+ export interface StoreInfo {
127
+ user_id: string;
128
+ created_at: string;
129
+ [key: string]: unknown;
130
+ }
131
+ /** `addSpeaker` / `removeSpeaker` → `{ user_id, speaker, status }`. */
132
+ export interface SpeakerOpResult {
133
+ user_id?: string;
134
+ speaker?: string;
135
+ status?: string;
136
+ [key: string]: unknown;
137
+ }
138
+ export interface SpeakerInfo {
139
+ speaker: string;
140
+ memories: number;
141
+ created_at?: string;
142
+ [key: string]: unknown;
143
+ }
144
+ /** `listSpeakers` → registered people with per-person memory counts. */
145
+ export interface SpeakersList {
146
+ speakers: SpeakerInfo[];
147
+ count?: number;
148
+ /** The registration cap for this store (50 to start). */
149
+ limit?: number;
150
+ [key: string]: unknown;
151
+ }
152
+ /** Known `search` options; extra fields pass through to the API untouched. */
153
+ export interface SearchOptions {
154
+ /** Recall caching: repeated/extended queries within the TTL bill at 0.1×. */
155
+ cache_control?: {
156
+ ttl: "5m" | "1h";
157
+ };
158
+ /** Recall only this person's words ("me" or a registered name). */
159
+ speaker?: string;
160
+ [key: string]: unknown;
161
+ }
57
162
  export interface ClientOptions {
58
163
  apiKey: string;
59
164
  /** API base URL (defaults to the hosted service). */
60
165
  baseUrl?: string;
61
- /** Per-request timeout in ms (default 30000). */
166
+ /** Per-request timeout in ms, applied to each retry attempt (default 30000). */
62
167
  timeoutMs?: number;
63
168
  /** Default model for every call (sent as `X-WOS-Model`). See `listModels()`. */
64
169
  model?: string;
65
170
  /** Default store for every call. Override per call by passing `userId`. Defaults
66
171
  * to the account's built-in `default` store. */
67
172
  userId?: string;
173
+ /** How many times to retry transient failures (429/502/503 and connection
174
+ * errors) before throwing. 0 disables retries (default 2). */
175
+ maxRetries?: number;
68
176
  }
69
177
  /** Wontopos memory client. Store and recall memories, isolated per `userId`. */
70
178
  export declare class Client {
@@ -73,11 +181,18 @@ export declare class Client {
73
181
  private readonly timeoutMs;
74
182
  private readonly apiKey;
75
183
  private readonly model;
184
+ private readonly maxRetries;
76
185
  /** The store every call uses unless one passes `userId`. */
77
186
  private readonly defaultUser;
78
187
  constructor(opts: ClientOptions);
188
+ /** A client whose key comes from `WONTOPOS_API_KEY` (or `WOS_API_KEY`) — keeps
189
+ * keys out of source code. Node/Bun/Deno-with-env only. */
190
+ static fromEnv(opts?: Omit<ClientOptions, "apiKey">): Client;
191
+ /** Never expose the key: `JSON.stringify(client)` gets the redacted view. */
192
+ toJSON(): Record<string, string>;
79
193
  /** Resolve a call's store: the explicit userId, else the client default. */
80
194
  private uid;
195
+ private clone;
81
196
  /**
82
197
  * A client that uses `model` (sent as `X-WOS-Model`). Use it to set the default
83
198
  * (`new Client({ apiKey, model })`) or to override one call
@@ -88,53 +203,55 @@ export declare class Client {
88
203
  withUser(userId: string): Client;
89
204
  /** Store one memory. `metadata` optional (e.g. { event_date: "2026-03-01" }).
90
205
  * `metadata.speaker`: "me" = the assistant's own words, or a person's name (up to 50 per store). */
91
- add(content: string, userId?: string, metadata?: Record<string, unknown>): Promise<any>;
206
+ add(content: string, userId?: string, metadata?: Record<string, unknown>): Promise<StoreResult>;
92
207
  /** Alias of `add` — store one memory. Same surface as the Python SDK's `store`. */
93
- store(content: string, userId?: string, metadata?: Record<string, unknown>): Promise<any>;
208
+ store(content: string, userId?: string, metadata?: Record<string, unknown>): Promise<StoreResult>;
94
209
  /** Store a conversation turn (user + assistant). Payload first, userId last — same shape as add/search. */
95
- addTurn(userMsg: string, assistantMsg: string, userId?: string): Promise<any>;
210
+ addTurn(userMsg: string, assistantMsg: string, userId?: string): Promise<StatusResult>;
96
211
  /** Bulk-ingest a large blob of text (chunked + embedded server-side). For backfilling. */
97
- addBulk(content: string, userId?: string, category?: string, timestamp?: string): Promise<any>;
212
+ addBulk(content: string, userId?: string, category?: string, timestamp?: string): Promise<StatusResult>;
98
213
  /** Supersede an old memory with new content. Payload first, userId last — same shape as add/search. */
99
- update(oldMemoryId: string, newContent: string, userId?: string): Promise<any>;
214
+ update(oldMemoryId: string, newContent: string, userId?: string): Promise<UpdateResult>;
100
215
  /** Semantic search over a store's memories. Returns memories, most relevant first. */
101
- search(query: string, userId?: string, limit?: number, opts?: Record<string, unknown>): Promise<Memory[]>;
216
+ search(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<Memory[]>;
102
217
  /** One-call LLM context: short-term turns + long-term matches + surrounding context. */
103
218
  recall(query: string, userId?: string): Promise<RecallResult>;
104
219
  /** Run a built-in engram ("deep_recall" | "timeline" | "gather"). Returns the merged result. */
105
- engram(name: string, query: string, userId?: string): Promise<any>;
220
+ engram(name: string, query: string, userId?: string): Promise<EngramResult>;
106
221
  /** Recent conversation turns (short-term memory). */
107
- history(userId?: string): Promise<unknown[]>;
222
+ history(userId?: string): Promise<HistoryTurn[]>;
108
223
  /** Memory counts for a store: { total_memories, short_term_turns }. */
109
- stats(userId?: string): Promise<any>;
224
+ stats(userId?: string): Promise<StatsResult>;
110
225
  /** Available models: `[{ id, name, available, memory }, ...]`. Needs no API key. */
111
226
  listModels(): Promise<ModelInfo[]>;
112
227
  /** Create a store — the `userId` you read and write under. Stores are explicit:
113
228
  * a store must exist before you `add` to or `search` it, otherwise those calls
114
229
  * return 404. Idempotent. Every account starts with a `default` store.
115
230
  * Returns `{ user_id, status }` (`status` is `"created"` or `"exists"`). */
116
- createStore(userId?: string): Promise<any>;
231
+ createStore(userId?: string): Promise<StoreOpResult>;
117
232
  /** List your stores: `[{ user_id, created_at }, ...]` (`default` first). */
118
- listStores(): Promise<Array<{
119
- user_id: string;
120
- created_at: string;
121
- }>>;
233
+ listStores(): Promise<StoreInfo[]>;
122
234
  /** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
123
- deleteStore(userId: string): Promise<any>;
235
+ deleteStore(userId: string): Promise<StoreOpResult>;
124
236
  /** Register a person for this store. Speakers are explicit: register once, then
125
237
  * store with `{ speaker }`. `"me"` (the assistant itself) never needs
126
238
  * registration. A store registers up to 50 people to start. */
127
- addSpeaker(speaker: string, userId?: string): Promise<any>;
239
+ addSpeaker(speaker: string, userId?: string): Promise<SpeakerOpResult>;
128
240
  /** The store's registered people, each with its memory count. */
129
- listSpeakers(userId?: string): Promise<any>;
241
+ listSpeakers(userId?: string): Promise<SpeakersList>;
130
242
  /** Unregister a person. Their memories stay; the name tag goes. */
131
- removeSpeaker(speaker: string, userId?: string): Promise<any>;
243
+ removeSpeaker(speaker: string, userId?: string): Promise<SpeakerOpResult>;
132
244
  /** Delete a single memory by id. `userId` optional (uses the default store). */
133
- delete(userId: string | undefined, memoryId: string): Promise<any>;
245
+ delete(userId: string | undefined, memoryId: string): Promise<StatusResult>;
134
246
  /** Delete ALL memories for a store (GDPR erase). `userId` is required on purpose -
135
247
  * this is destructive, so it never falls back to the default store. */
136
- deleteAll(userId: string): Promise<any>;
248
+ deleteAll(userId: string): Promise<StatusResult>;
137
249
  private post;
250
+ /** Seconds→ms to sleep before retry `attempt` (0-based). Honors Retry-After. */
251
+ private backoffMs;
252
+ /** Read the body with a hard size cap, so a broken/hostile endpoint can't
253
+ * make the process buffer gigabytes. */
254
+ private readCapped;
138
255
  private request;
139
256
  }
140
257
  /** Back-compat alias: older code used `WME`. */
package/dist/wontopos.js CHANGED
@@ -21,15 +21,42 @@
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
+ * Tune with `maxRetries` (0 disables) and `timeoutMs`.
28
+ *
29
+ * Security posture (not configurable off): redirects are refused so the API key
30
+ * can never follow one to another host; responses over 64MB are refused; the key
31
+ * is masked in `toJSON`/inspect output. TLS certificate verification is the
32
+ * runtime's (never disabled by this SDK; Node 18+ floors TLS at 1.2). Prefer
33
+ * `Client.fromEnv()` over keys in source code.
24
34
  */
35
+ const VERSION = "2.2.9";
36
+ const USER_AGENT = `wontopos-node/${VERSION}`;
25
37
  const DEFAULT_BASE_URL = "https://api.wontopos.com";
26
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]);
41
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
42
+ /** Refuse to buffer absurd responses (real ones are a few KB) — protects the
43
+ * process if a self-hosted baseUrl points somewhere broken or hostile. */
44
+ const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
45
+ /** Model names travel in a header — only header-safe characters. */
46
+ const MODEL_RE = /^[A-Za-z0-9._-]+$/;
47
+ /** `wos-abc...wxyz` — enough to tell keys apart, never enough to use. */
48
+ function maskKey(key) {
49
+ return key.length > 12 ? `${key.slice(0, 4)}...${key.slice(-4)}` : "***";
50
+ }
27
51
  export class WosError extends Error {
28
52
  status;
29
- constructor(status, message) {
30
- super(`[${status}] ${message}`);
53
+ /** The server's id for the request when it sent one — include it when contacting support. */
54
+ requestId;
55
+ constructor(status, message, requestId) {
56
+ super(`[${status}] ${message}${requestId ? ` (request_id: ${requestId})` : ""}`);
31
57
  this.name = "WosError";
32
58
  this.status = status;
59
+ this.requestId = requestId;
33
60
  }
34
61
  }
35
62
  const DEFAULT_USER = "default";
@@ -40,35 +67,85 @@ export class Client {
40
67
  timeoutMs;
41
68
  apiKey;
42
69
  model;
70
+ maxRetries;
43
71
  /** The store every call uses unless one passes `userId`. */
44
72
  defaultUser;
45
73
  constructor(opts) {
46
- if (!opts?.apiKey)
74
+ // Trim the key and reject inner whitespace — a stray newline from a file or
75
+ // env var otherwise turns into a mystery 401 (or a mangled header).
76
+ const key = (opts?.apiKey ?? "").trim();
77
+ if (!key)
47
78
  throw new Error("apiKey is required");
48
- this.apiKey = opts.apiKey;
79
+ if (/\s/.test(key))
80
+ throw new Error("apiKey contains whitespace - check for a stray newline or paste error");
81
+ this.apiKey = key;
49
82
  this.base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
50
83
  this.timeoutMs = opts.timeoutMs ?? 30_000;
51
84
  this.model = opts.model ?? DEFAULT_MODEL;
85
+ if (this.model && !MODEL_RE.test(this.model)) {
86
+ throw new Error(`invalid model name: ${JSON.stringify(this.model)} (letters, digits, '.', '_', '-' only)`);
87
+ }
52
88
  this.defaultUser = opts.userId || DEFAULT_USER;
53
- this.headers = { "X-API-Key": opts.apiKey, "Content-Type": "application/json" };
89
+ this.maxRetries = Math.max(0, opts.maxRetries ?? 2);
90
+ this.headers = {
91
+ "X-API-Key": this.apiKey,
92
+ "Content-Type": "application/json",
93
+ // Browsers silently drop User-Agent (forbidden header) — harmless.
94
+ "User-Agent": USER_AGENT,
95
+ };
54
96
  if (this.model)
55
97
  this.headers["X-WOS-Model"] = this.model;
98
+ // An API key on plain HTTP travels readable by anyone on the path. Loopback
99
+ // is fine (local dev / self-hosted on the same box); anything else gets a
100
+ // 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)) {
103
+ console.warn("wontopos: baseUrl uses plain HTTP on a non-local host, so the API key travels unencrypted. Use https://.");
104
+ }
105
+ }
106
+ /** A client whose key comes from `WONTOPOS_API_KEY` (or `WOS_API_KEY`) — keeps
107
+ * keys out of source code. Node/Bun/Deno-with-env only. */
108
+ static fromEnv(opts = {}) {
109
+ const env = globalThis?.process?.env;
110
+ const key = env?.WONTOPOS_API_KEY ?? env?.WOS_API_KEY;
111
+ if (!key || !key.trim())
112
+ throw new Error("set WONTOPOS_API_KEY (or WOS_API_KEY) in the environment");
113
+ return new Client({ apiKey: key, ...opts });
114
+ }
115
+ /** Never expose the key: `JSON.stringify(client)` gets the redacted view. */
116
+ toJSON() {
117
+ return { baseUrl: this.base, model: this.model, userId: this.defaultUser, apiKey: maskKey(this.apiKey) };
118
+ }
119
+ /** Never expose the key: Node's `console.log(client)` gets the redacted view. */
120
+ [Symbol.for("nodejs.util.inspect.custom")]() {
121
+ return `Client(${this.base}, model=${this.model}, userId=${this.defaultUser}, apiKey=${maskKey(this.apiKey)})`;
56
122
  }
57
123
  /** Resolve a call's store: the explicit userId, else the client default. */
58
124
  uid(userId) {
59
125
  return userId || this.defaultUser;
60
126
  }
127
+ clone(overrides) {
128
+ return new Client({
129
+ apiKey: this.apiKey,
130
+ baseUrl: this.base,
131
+ timeoutMs: this.timeoutMs,
132
+ model: this.model,
133
+ userId: this.defaultUser,
134
+ maxRetries: this.maxRetries,
135
+ ...overrides,
136
+ });
137
+ }
61
138
  /**
62
139
  * A client that uses `model` (sent as `X-WOS-Model`). Use it to set the default
63
140
  * (`new Client({ apiKey, model })`) or to override one call
64
141
  * (`client.withModel("tablet-1").recall(...)`).
65
142
  */
66
143
  withModel(model) {
67
- return new Client({ apiKey: this.apiKey, baseUrl: this.base, timeoutMs: this.timeoutMs, model, userId: this.defaultUser });
144
+ return this.clone({ model });
68
145
  }
69
146
  /** A client bound to `userId` as its default store (everything else kept). */
70
147
  withUser(userId) {
71
- return new Client({ apiKey: this.apiKey, baseUrl: this.base, timeoutMs: this.timeoutMs, model: this.model, userId });
148
+ return this.clone({ userId });
72
149
  }
73
150
  // ----- write -----
74
151
  // `userId` is optional in every call below - omit it to use the client's default
@@ -123,10 +200,7 @@ export class Client {
123
200
  // ----- models -----
124
201
  /** Available models: `[{ id, name, available, memory }, ...]`. Needs no API key. */
125
202
  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();
203
+ const data = await this.request("GET", "/api/v1/models");
130
204
  return (data.models ?? []);
131
205
  }
132
206
  // ----- stores -----
@@ -144,6 +218,8 @@ export class Client {
144
218
  }
145
219
  /** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
146
220
  deleteStore(userId) {
221
+ if (!userId)
222
+ throw new Error("userId is required — deleteStore never falls back to the default store.");
147
223
  return this.request("DELETE", "/api/v1/memory/collection", { user_id: userId });
148
224
  }
149
225
  // ----- speakers (who said it) -----
@@ -154,8 +230,9 @@ export class Client {
154
230
  return this.post("/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
155
231
  }
156
232
  /** The store's registered people, each with its memory count. */
157
- listSpeakers(userId) {
158
- return this.request("GET", `/api/v1/memory/speakers?user_id=${encodeURIComponent(this.uid(userId))}`);
233
+ async listSpeakers(userId) {
234
+ const r = await this.request("GET", `/api/v1/memory/speakers?user_id=${encodeURIComponent(this.uid(userId))}`);
235
+ return { speakers: [], ...r };
159
236
  }
160
237
  /** Unregister a person. Their memories stay; the name tag goes. */
161
238
  removeSpeaker(speaker, userId) {
@@ -164,58 +241,131 @@ export class Client {
164
241
  // ----- delete -----
165
242
  /** Delete a single memory by id. `userId` optional (uses the default store). */
166
243
  delete(userId, memoryId) {
244
+ // Guard: without a memory_id the API's forget endpoint means "delete the
245
+ // whole store". An undefined/"" slipping in here must never become a wipe.
246
+ if (!memoryId || typeof memoryId !== "string") {
247
+ throw new Error("memoryId is required. To delete every memory in a store, call deleteAll(userId) explicitly.");
248
+ }
167
249
  return this.post("/api/v1/memory/forget", { user_id: this.uid(userId), memory_id: memoryId });
168
250
  }
169
251
  /** Delete ALL memories for a store (GDPR erase). `userId` is required on purpose -
170
252
  * this is destructive, so it never falls back to the default store. */
171
253
  deleteAll(userId) {
254
+ if (!userId)
255
+ throw new Error("userId is required for deleteAll — it never falls back to the default store.");
172
256
  return this.post("/api/v1/memory/forget", { user_id: userId });
173
257
  }
174
258
  // ----- internal -----
175
259
  post(path, body) {
176
260
  return this.request("POST", path, body);
177
261
  }
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
- });
262
+ /** Seconds→ms to sleep before retry `attempt` (0-based). Honors Retry-After. */
263
+ backoffMs(attempt, retryAfter) {
264
+ if (retryAfter) {
265
+ const s = Number(retryAfter);
266
+ if (Number.isFinite(s) && s >= 0)
267
+ return Math.min(30_000, s * 1000);
189
268
  }
190
- catch (e) {
191
- throw new WosError(0, `network error: ${e?.message ?? e}`);
269
+ return Math.min(8_000, 500 * 2 ** attempt) + Math.random() * 250;
270
+ }
271
+ /** Read the body with a hard size cap, so a broken/hostile endpoint can't
272
+ * make the process buffer gigabytes. */
273
+ async readCapped(res) {
274
+ const cl = res.headers.get("content-length");
275
+ if (cl && Number(cl) > MAX_RESPONSE_BYTES) {
276
+ throw new WosError(0, `response too large (${cl} bytes) — refusing to buffer it`);
192
277
  }
193
- finally {
194
- clearTimeout(timer);
278
+ if (!res.body)
279
+ return res.text();
280
+ const reader = res.body.getReader();
281
+ const parts = [];
282
+ let size = 0;
283
+ for (;;) {
284
+ const { done, value } = await reader.read();
285
+ if (done)
286
+ break;
287
+ size += value.byteLength;
288
+ if (size > MAX_RESPONSE_BYTES) {
289
+ void reader.cancel();
290
+ throw new WosError(0, "response too large — refusing to buffer it");
291
+ }
292
+ parts.push(value);
293
+ }
294
+ const buf = new Uint8Array(size);
295
+ let off = 0;
296
+ for (const p of parts) {
297
+ buf.set(p, off);
298
+ off += p.byteLength;
195
299
  }
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();
300
+ return new TextDecoder().decode(buf);
301
+ }
302
+ async request(method, path, body) {
303
+ const attempts = this.maxRetries + 1;
304
+ for (let attempt = 0; attempt < attempts; attempt++) {
305
+ const ctrl = new AbortController();
306
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
307
+ let res;
201
308
  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;
309
+ res = await fetch(`${this.base}${path}`, {
310
+ method,
311
+ headers: this.headers,
312
+ body: body === undefined ? undefined : JSON.stringify(body),
313
+ signal: ctrl.signal,
314
+ // Never follow a redirect: fetch would forward the API key to
315
+ // wherever a 3xx points. The API never legitimately redirects.
316
+ redirect: "manual",
317
+ });
318
+ }
319
+ catch (e) {
320
+ clearTimeout(timer);
321
+ // Timeouts are ambiguous (the write may have landed) — don't retry those.
322
+ const timedOut = e?.name === "AbortError" || ctrl.signal.aborted;
323
+ if (!timedOut && attempt + 1 < attempts) {
324
+ await new Promise((r) => setTimeout(r, this.backoffMs(attempt)));
325
+ continue;
211
326
  }
327
+ throw new WosError(0, timedOut ? `request timed out after ${this.timeoutMs}ms` : `network error: ${e?.message ?? e}`);
212
328
  }
213
- catch {
214
- /* keep raw text */
329
+ clearTimeout(timer);
330
+ if (RETRY_STATUSES.has(res.status) && attempt + 1 < attempts) {
331
+ const retryAfter = res.headers.get("Retry-After");
332
+ void res.body?.cancel();
333
+ await new Promise((r) => setTimeout(r, this.backoffMs(attempt, retryAfter)));
334
+ continue;
335
+ }
336
+ if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
337
+ throw new WosError(res.status, "unexpected redirect — refused (the API key never follows a redirect). Check baseUrl: exact host, https://.");
338
+ }
339
+ const text = await this.readCapped(res);
340
+ if (!res.ok) {
341
+ // Server returns either Anthropic-style envelope
342
+ // {"type":"error","error":{"type":...,"message":...,"request_id":...}}
343
+ // or simple {"error":"reason"}. Fall back to raw text.
344
+ let msg = text;
345
+ let requestId;
346
+ try {
347
+ const data = JSON.parse(text);
348
+ if (data && typeof data === "object") {
349
+ const err = data.error;
350
+ if (err && typeof err === "object") {
351
+ msg = err.message ?? err.type ?? msg;
352
+ if (typeof err.request_id === "string")
353
+ requestId = err.request_id;
354
+ }
355
+ else if (typeof err === "string")
356
+ msg = err;
357
+ else if (typeof data.message === "string")
358
+ msg = data.message;
359
+ }
360
+ }
361
+ catch {
362
+ /* keep raw text */
363
+ }
364
+ throw new WosError(res.status, msg, requestId);
215
365
  }
216
- throw new WosError(res.status, msg);
366
+ return text ? JSON.parse(text) : null;
217
367
  }
218
- return res.json();
368
+ throw new WosError(0, "retries exhausted"); // unreachable; keeps the compiler happy
219
369
  }
220
370
  }
221
371
  /** 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.9",
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": {