wontopos 2.2.8 → 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
@@ -70,6 +70,35 @@ Built in, no configuration needed:
70
70
  meaning "delete everything"; wiping a store is only ever the explicit
71
71
  `deleteAll(userId)` / `deleteStore(userId)`.
72
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
+ ```
101
+
73
102
  ## Errors
74
103
 
75
104
  Any non-2xx response throws `WosError` with `status`, `message`, and — when the
@@ -102,6 +131,9 @@ const mem = new Client({ apiKey: "...", baseUrl: "https://wos.your-host.com" });
102
131
 
103
132
  ## Changelog
104
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()`.
105
137
  - **2.2.8** — reliability + hardening: automatic retries (429/502/503 + connection
106
138
  errors, backoff + jitter, honors `Retry-After`), redirects refused, key masked in
107
139
  `toJSON`/inspect, `WosError.requestId`, guard against `delete()` without
@@ -24,8 +24,13 @@
24
24
  *
25
25
  * Reliability: every call retries transient failures (429 / 502 / 503 and
26
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
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.
29
34
  */
30
35
  export declare class WosError extends Error {
31
36
  readonly status: number;
@@ -33,14 +38,41 @@ export declare class WosError extends Error {
33
38
  readonly requestId?: string;
34
39
  constructor(status: number, message: string, requestId?: string);
35
40
  }
41
+ export interface Usage {
42
+ input_tokens?: number;
43
+ output_tokens?: number;
44
+ [key: string]: unknown;
45
+ }
36
46
  export interface Memory {
37
47
  id?: string;
38
48
  content?: string;
39
49
  score?: number;
50
+ /** Month bucket the memory belongs to (e.g. "2026-07"). */
51
+ time_bucket?: string;
40
52
  /** WHO said it: "me" (the assistant) or a registered person's name. Absent = untagged. */
41
53
  speaker?: string;
42
54
  [key: string]: unknown;
43
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;
74
+ [key: string]: unknown;
75
+ }
44
76
  export interface RecallResult {
45
77
  short_term: {
46
78
  turns: unknown[];
@@ -56,6 +88,28 @@ export interface RecallResult {
56
88
  };
57
89
  [key: string]: unknown;
58
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
+ }
59
113
  export interface ModelInfo {
60
114
  id: string;
61
115
  name: string;
@@ -63,6 +117,48 @@ export interface ModelInfo {
63
117
  /** "shared" — reads the common memory pool; "isolated" — its own dedicated store. */
64
118
  memory: "shared" | "isolated";
65
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
+ }
66
162
  export interface ClientOptions {
67
163
  apiKey: string;
68
164
  /** API base URL (defaults to the hosted service). */
@@ -89,6 +185,9 @@ export declare class Client {
89
185
  /** The store every call uses unless one passes `userId`. */
90
186
  private readonly defaultUser;
91
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;
92
191
  /** Never expose the key: `JSON.stringify(client)` gets the redacted view. */
93
192
  toJSON(): Record<string, string>;
94
193
  /** Resolve a call's store: the explicit userId, else the client default. */
@@ -104,55 +203,55 @@ export declare class Client {
104
203
  withUser(userId: string): Client;
105
204
  /** Store one memory. `metadata` optional (e.g. { event_date: "2026-03-01" }).
106
205
  * `metadata.speaker`: "me" = the assistant's own words, or a person's name (up to 50 per store). */
107
- add(content: string, userId?: string, metadata?: Record<string, unknown>): Promise<any>;
206
+ add(content: string, userId?: string, metadata?: Record<string, unknown>): Promise<StoreResult>;
108
207
  /** Alias of `add` — store one memory. Same surface as the Python SDK's `store`. */
109
- store(content: string, userId?: string, metadata?: Record<string, unknown>): Promise<any>;
208
+ store(content: string, userId?: string, metadata?: Record<string, unknown>): Promise<StoreResult>;
110
209
  /** Store a conversation turn (user + assistant). Payload first, userId last — same shape as add/search. */
111
- addTurn(userMsg: string, assistantMsg: string, userId?: string): Promise<any>;
210
+ addTurn(userMsg: string, assistantMsg: string, userId?: string): Promise<StatusResult>;
112
211
  /** Bulk-ingest a large blob of text (chunked + embedded server-side). For backfilling. */
113
- addBulk(content: string, userId?: string, category?: string, timestamp?: string): Promise<any>;
212
+ addBulk(content: string, userId?: string, category?: string, timestamp?: string): Promise<StatusResult>;
114
213
  /** Supersede an old memory with new content. Payload first, userId last — same shape as add/search. */
115
- update(oldMemoryId: string, newContent: string, userId?: string): Promise<any>;
214
+ update(oldMemoryId: string, newContent: string, userId?: string): Promise<UpdateResult>;
116
215
  /** Semantic search over a store's memories. Returns memories, most relevant first. */
117
- 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[]>;
118
217
  /** One-call LLM context: short-term turns + long-term matches + surrounding context. */
119
218
  recall(query: string, userId?: string): Promise<RecallResult>;
120
219
  /** Run a built-in engram ("deep_recall" | "timeline" | "gather"). Returns the merged result. */
121
- engram(name: string, query: string, userId?: string): Promise<any>;
220
+ engram(name: string, query: string, userId?: string): Promise<EngramResult>;
122
221
  /** Recent conversation turns (short-term memory). */
123
- history(userId?: string): Promise<unknown[]>;
222
+ history(userId?: string): Promise<HistoryTurn[]>;
124
223
  /** Memory counts for a store: { total_memories, short_term_turns }. */
125
- stats(userId?: string): Promise<any>;
224
+ stats(userId?: string): Promise<StatsResult>;
126
225
  /** Available models: `[{ id, name, available, memory }, ...]`. Needs no API key. */
127
226
  listModels(): Promise<ModelInfo[]>;
128
227
  /** Create a store — the `userId` you read and write under. Stores are explicit:
129
228
  * a store must exist before you `add` to or `search` it, otherwise those calls
130
229
  * return 404. Idempotent. Every account starts with a `default` store.
131
230
  * Returns `{ user_id, status }` (`status` is `"created"` or `"exists"`). */
132
- createStore(userId?: string): Promise<any>;
231
+ createStore(userId?: string): Promise<StoreOpResult>;
133
232
  /** List your stores: `[{ user_id, created_at }, ...]` (`default` first). */
134
- listStores(): Promise<Array<{
135
- user_id: string;
136
- created_at: string;
137
- }>>;
233
+ listStores(): Promise<StoreInfo[]>;
138
234
  /** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
139
- deleteStore(userId: string): Promise<any>;
235
+ deleteStore(userId: string): Promise<StoreOpResult>;
140
236
  /** Register a person for this store. Speakers are explicit: register once, then
141
237
  * store with `{ speaker }`. `"me"` (the assistant itself) never needs
142
238
  * registration. A store registers up to 50 people to start. */
143
- addSpeaker(speaker: string, userId?: string): Promise<any>;
239
+ addSpeaker(speaker: string, userId?: string): Promise<SpeakerOpResult>;
144
240
  /** The store's registered people, each with its memory count. */
145
- listSpeakers(userId?: string): Promise<any>;
241
+ listSpeakers(userId?: string): Promise<SpeakersList>;
146
242
  /** Unregister a person. Their memories stay; the name tag goes. */
147
- removeSpeaker(speaker: string, userId?: string): Promise<any>;
243
+ removeSpeaker(speaker: string, userId?: string): Promise<SpeakerOpResult>;
148
244
  /** Delete a single memory by id. `userId` optional (uses the default store). */
149
- delete(userId: string | undefined, memoryId: string): Promise<any>;
245
+ delete(userId: string | undefined, memoryId: string): Promise<StatusResult>;
150
246
  /** Delete ALL memories for a store (GDPR erase). `userId` is required on purpose -
151
247
  * this is destructive, so it never falls back to the default store. */
152
- deleteAll(userId: string): Promise<any>;
248
+ deleteAll(userId: string): Promise<StatusResult>;
153
249
  private post;
154
250
  /** Seconds→ms to sleep before retry `attempt` (0-based). Honors Retry-After. */
155
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;
156
255
  private request;
157
256
  }
158
257
  /** Back-compat alias: older code used `WME`. */
package/dist/wontopos.js CHANGED
@@ -24,16 +24,26 @@
24
24
  *
25
25
  * Reliability: every call retries transient failures (429 / 502 / 503 and
26
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
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.
29
34
  */
30
- const VERSION = "2.2.8";
35
+ const VERSION = "2.2.9";
31
36
  const USER_AGENT = `wontopos-node/${VERSION}`;
32
37
  const DEFAULT_BASE_URL = "https://api.wontopos.com";
33
38
  const DEFAULT_MODEL = "tablet-1";
34
39
  /** Statuses where the request was NOT processed, so retrying is always safe. */
35
40
  const RETRY_STATUSES = new Set([429, 502, 503]);
36
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._-]+$/;
37
47
  /** `wos-abc...wxyz` — enough to tell keys apart, never enough to use. */
38
48
  function maskKey(key) {
39
49
  return key.length > 12 ? `${key.slice(0, 4)}...${key.slice(-4)}` : "***";
@@ -61,16 +71,24 @@ export class Client {
61
71
  /** The store every call uses unless one passes `userId`. */
62
72
  defaultUser;
63
73
  constructor(opts) {
64
- 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)
65
78
  throw new Error("apiKey is required");
66
- 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;
67
82
  this.base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
68
83
  this.timeoutMs = opts.timeoutMs ?? 30_000;
69
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
+ }
70
88
  this.defaultUser = opts.userId || DEFAULT_USER;
71
89
  this.maxRetries = Math.max(0, opts.maxRetries ?? 2);
72
90
  this.headers = {
73
- "X-API-Key": opts.apiKey,
91
+ "X-API-Key": this.apiKey,
74
92
  "Content-Type": "application/json",
75
93
  // Browsers silently drop User-Agent (forbidden header) — harmless.
76
94
  "User-Agent": USER_AGENT,
@@ -85,6 +103,15 @@ export class Client {
85
103
  console.warn("wontopos: baseUrl uses plain HTTP on a non-local host, so the API key travels unencrypted. Use https://.");
86
104
  }
87
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
+ }
88
115
  /** Never expose the key: `JSON.stringify(client)` gets the redacted view. */
89
116
  toJSON() {
90
117
  return { baseUrl: this.base, model: this.model, userId: this.defaultUser, apiKey: maskKey(this.apiKey) };
@@ -203,8 +230,9 @@ export class Client {
203
230
  return this.post("/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
204
231
  }
205
232
  /** The store's registered people, each with its memory count. */
206
- listSpeakers(userId) {
207
- 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 };
208
236
  }
209
237
  /** Unregister a person. Their memories stay; the name tag goes. */
210
238
  removeSpeaker(speaker, userId) {
@@ -240,6 +268,37 @@ export class Client {
240
268
  }
241
269
  return Math.min(8_000, 500 * 2 ** attempt) + Math.random() * 250;
242
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`);
277
+ }
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;
299
+ }
300
+ return new TextDecoder().decode(buf);
301
+ }
243
302
  async request(method, path, body) {
244
303
  const attempts = this.maxRetries + 1;
245
304
  for (let attempt = 0; attempt < attempts; attempt++) {
@@ -269,20 +328,23 @@ export class Client {
269
328
  }
270
329
  clearTimeout(timer);
271
330
  if (RETRY_STATUSES.has(res.status) && attempt + 1 < attempts) {
272
- await new Promise((r) => setTimeout(r, this.backoffMs(attempt, res.headers.get("Retry-After"))));
331
+ const retryAfter = res.headers.get("Retry-After");
332
+ void res.body?.cancel();
333
+ await new Promise((r) => setTimeout(r, this.backoffMs(attempt, retryAfter)));
273
334
  continue;
274
335
  }
275
336
  if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
276
337
  throw new WosError(res.status, "unexpected redirect — refused (the API key never follows a redirect). Check baseUrl: exact host, https://.");
277
338
  }
339
+ const text = await this.readCapped(res);
278
340
  if (!res.ok) {
279
341
  // Server returns either Anthropic-style envelope
280
342
  // {"type":"error","error":{"type":...,"message":...,"request_id":...}}
281
343
  // or simple {"error":"reason"}. Fall back to raw text.
282
- let msg = await res.text();
344
+ let msg = text;
283
345
  let requestId;
284
346
  try {
285
- const data = JSON.parse(msg);
347
+ const data = JSON.parse(text);
286
348
  if (data && typeof data === "object") {
287
349
  const err = data.error;
288
350
  if (err && typeof err === "object") {
@@ -301,7 +363,7 @@ export class Client {
301
363
  }
302
364
  throw new WosError(res.status, msg, requestId);
303
365
  }
304
- return res.json();
366
+ return text ? JSON.parse(text) : null;
305
367
  }
306
368
  throw new WosError(0, "retries exhausted"); // unreachable; keeps the compiler happy
307
369
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wontopos",
3
- "version": "2.2.8",
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",