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 +64 -4
- package/dist/wontopos.d.ts +139 -22
- package/dist/wontopos.js +196 -46
- package/package.json +2 -9
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
|
-
|
|
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.
|
package/dist/wontopos.d.ts
CHANGED
|
@@ -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
|
-
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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?:
|
|
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<
|
|
220
|
+
engram(name: string, query: string, userId?: string): Promise<EngramResult>;
|
|
106
221
|
/** Recent conversation turns (short-term memory). */
|
|
107
|
-
history(userId?: string): Promise<
|
|
222
|
+
history(userId?: string): Promise<HistoryTurn[]>;
|
|
108
223
|
/** Memory counts for a store: { total_memories, short_term_turns }. */
|
|
109
|
-
stats(userId?: string): Promise<
|
|
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<
|
|
231
|
+
createStore(userId?: string): Promise<StoreOpResult>;
|
|
117
232
|
/** List your stores: `[{ user_id, created_at }, ...]` (`default` first). */
|
|
118
|
-
listStores(): Promise<
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
366
|
+
return text ? JSON.parse(text) : null;
|
|
217
367
|
}
|
|
218
|
-
|
|
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.
|
|
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": {
|