wontopos 2.2.8 → 2.2.10
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 +35 -0
- package/dist/wontopos.d.ts +134 -22
- package/dist/wontopos.js +73 -11
- package/package.json +1 -1
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,12 @@ const mem = new Client({ apiKey: "...", baseUrl: "https://wos.your-host.com" });
|
|
|
102
131
|
|
|
103
132
|
## Changelog
|
|
104
133
|
|
|
134
|
+
- **2.2.10** — `Memory` fix: the relevance field is `similarity` (not `score`,
|
|
135
|
+
which was never in the response); added typed `importance`, `category`,
|
|
136
|
+
`is_superseded`, `superseded_by`, `created_at`, `event_date`.
|
|
137
|
+
- **2.2.9** — typed responses for every method (index-signature friendly) +
|
|
138
|
+
typed `SearchOptions`; security round 2: 64MB response cap, key/model-name
|
|
139
|
+
hygiene, `Client.fromEnv()`.
|
|
105
140
|
- **2.2.8** — reliability + hardening: automatic retries (429/502/503 + connection
|
|
106
141
|
errors, backoff + jitter, honors `Retry-After`), redirects refused, key masked in
|
|
107
142
|
`toJSON`/inspect, `WosError.requestId`, guard against `delete()` without
|
package/dist/wontopos.d.ts
CHANGED
|
@@ -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,54 @@ 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
|
+
/** Cognitive category, e.g. "general". */
|
|
50
|
+
category?: string;
|
|
51
|
+
/** Semantic relevance to the query — higher is closer. This IS the relevance score; there is no `score` field. */
|
|
52
|
+
similarity?: number;
|
|
53
|
+
/** Learned importance weight of the memory. */
|
|
54
|
+
importance?: number;
|
|
55
|
+
/** Month bucket the memory belongs to (e.g. "2026-07"). Omitted when temporal fields are stripped. */
|
|
56
|
+
time_bucket?: string;
|
|
57
|
+
/** True if a later memory has superseded this one. */
|
|
58
|
+
is_superseded?: boolean;
|
|
59
|
+
/** Id of the memory that superseded this one, or null. */
|
|
60
|
+
superseded_by?: string | null;
|
|
61
|
+
/** When the memory was stored (RFC3339). Omitted when temporal fields are stripped. */
|
|
62
|
+
created_at?: string;
|
|
63
|
+
/** When the content actually happened (RFC3339), if known. */
|
|
64
|
+
event_date?: string;
|
|
40
65
|
/** WHO said it: "me" (the assistant) or a registered person's name. Absent = untagged. */
|
|
41
66
|
speaker?: string;
|
|
42
67
|
[key: string]: unknown;
|
|
43
68
|
}
|
|
69
|
+
/** `add` / `store` → `{ id, status }` (`status: "stored" | "stored (async)" | "duplicate"`). */
|
|
70
|
+
export interface StoreResult {
|
|
71
|
+
id?: string;
|
|
72
|
+
status?: string;
|
|
73
|
+
/** Present when the store was a duplicate and something (e.g. a speaker tag) was dropped. */
|
|
74
|
+
note?: string;
|
|
75
|
+
[key: string]: unknown;
|
|
76
|
+
}
|
|
77
|
+
/** `addTurn` / `addBulk` / `delete` / `deleteAll` → `{ status }`. */
|
|
78
|
+
export interface StatusResult {
|
|
79
|
+
status?: string;
|
|
80
|
+
[key: string]: unknown;
|
|
81
|
+
}
|
|
82
|
+
/** `update` (supersede) → old and new memory ids. */
|
|
83
|
+
export interface UpdateResult {
|
|
84
|
+
old_memory_id?: string;
|
|
85
|
+
new_memory_id?: string;
|
|
86
|
+
status?: string;
|
|
87
|
+
[key: string]: unknown;
|
|
88
|
+
}
|
|
44
89
|
export interface RecallResult {
|
|
45
90
|
short_term: {
|
|
46
91
|
turns: unknown[];
|
|
@@ -56,6 +101,28 @@ export interface RecallResult {
|
|
|
56
101
|
};
|
|
57
102
|
[key: string]: unknown;
|
|
58
103
|
}
|
|
104
|
+
/** `engram` → the merged multi-hop result. */
|
|
105
|
+
export interface EngramResult {
|
|
106
|
+
engram?: string;
|
|
107
|
+
form?: string;
|
|
108
|
+
user_id?: string;
|
|
109
|
+
hops?: number;
|
|
110
|
+
count?: number;
|
|
111
|
+
memories?: Memory[];
|
|
112
|
+
usage?: Usage;
|
|
113
|
+
[key: string]: unknown;
|
|
114
|
+
}
|
|
115
|
+
export interface HistoryTurn {
|
|
116
|
+
user_msg?: string;
|
|
117
|
+
assistant_msg?: string;
|
|
118
|
+
timestamp?: string;
|
|
119
|
+
[key: string]: unknown;
|
|
120
|
+
}
|
|
121
|
+
export interface StatsResult {
|
|
122
|
+
total_memories?: number;
|
|
123
|
+
short_term_turns?: number;
|
|
124
|
+
[key: string]: unknown;
|
|
125
|
+
}
|
|
59
126
|
export interface ModelInfo {
|
|
60
127
|
id: string;
|
|
61
128
|
name: string;
|
|
@@ -63,6 +130,48 @@ export interface ModelInfo {
|
|
|
63
130
|
/** "shared" — reads the common memory pool; "isolated" — its own dedicated store. */
|
|
64
131
|
memory: "shared" | "isolated";
|
|
65
132
|
}
|
|
133
|
+
/** `createStore` / `deleteStore` → `{ user_id, status }`. */
|
|
134
|
+
export interface StoreOpResult {
|
|
135
|
+
user_id?: string;
|
|
136
|
+
status?: string;
|
|
137
|
+
[key: string]: unknown;
|
|
138
|
+
}
|
|
139
|
+
export interface StoreInfo {
|
|
140
|
+
user_id: string;
|
|
141
|
+
created_at: string;
|
|
142
|
+
[key: string]: unknown;
|
|
143
|
+
}
|
|
144
|
+
/** `addSpeaker` / `removeSpeaker` → `{ user_id, speaker, status }`. */
|
|
145
|
+
export interface SpeakerOpResult {
|
|
146
|
+
user_id?: string;
|
|
147
|
+
speaker?: string;
|
|
148
|
+
status?: string;
|
|
149
|
+
[key: string]: unknown;
|
|
150
|
+
}
|
|
151
|
+
export interface SpeakerInfo {
|
|
152
|
+
speaker: string;
|
|
153
|
+
memories: number;
|
|
154
|
+
created_at?: string;
|
|
155
|
+
[key: string]: unknown;
|
|
156
|
+
}
|
|
157
|
+
/** `listSpeakers` → registered people with per-person memory counts. */
|
|
158
|
+
export interface SpeakersList {
|
|
159
|
+
speakers: SpeakerInfo[];
|
|
160
|
+
count?: number;
|
|
161
|
+
/** The registration cap for this store (50 to start). */
|
|
162
|
+
limit?: number;
|
|
163
|
+
[key: string]: unknown;
|
|
164
|
+
}
|
|
165
|
+
/** Known `search` options; extra fields pass through to the API untouched. */
|
|
166
|
+
export interface SearchOptions {
|
|
167
|
+
/** Recall caching: repeated/extended queries within the TTL bill at 0.1×. */
|
|
168
|
+
cache_control?: {
|
|
169
|
+
ttl: "5m" | "1h";
|
|
170
|
+
};
|
|
171
|
+
/** Recall only this person's words ("me" or a registered name). */
|
|
172
|
+
speaker?: string;
|
|
173
|
+
[key: string]: unknown;
|
|
174
|
+
}
|
|
66
175
|
export interface ClientOptions {
|
|
67
176
|
apiKey: string;
|
|
68
177
|
/** API base URL (defaults to the hosted service). */
|
|
@@ -89,6 +198,9 @@ export declare class Client {
|
|
|
89
198
|
/** The store every call uses unless one passes `userId`. */
|
|
90
199
|
private readonly defaultUser;
|
|
91
200
|
constructor(opts: ClientOptions);
|
|
201
|
+
/** A client whose key comes from `WONTOPOS_API_KEY` (or `WOS_API_KEY`) — keeps
|
|
202
|
+
* keys out of source code. Node/Bun/Deno-with-env only. */
|
|
203
|
+
static fromEnv(opts?: Omit<ClientOptions, "apiKey">): Client;
|
|
92
204
|
/** Never expose the key: `JSON.stringify(client)` gets the redacted view. */
|
|
93
205
|
toJSON(): Record<string, string>;
|
|
94
206
|
/** Resolve a call's store: the explicit userId, else the client default. */
|
|
@@ -104,55 +216,55 @@ export declare class Client {
|
|
|
104
216
|
withUser(userId: string): Client;
|
|
105
217
|
/** Store one memory. `metadata` optional (e.g. { event_date: "2026-03-01" }).
|
|
106
218
|
* `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<
|
|
219
|
+
add(content: string, userId?: string, metadata?: Record<string, unknown>): Promise<StoreResult>;
|
|
108
220
|
/** 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<
|
|
221
|
+
store(content: string, userId?: string, metadata?: Record<string, unknown>): Promise<StoreResult>;
|
|
110
222
|
/** Store a conversation turn (user + assistant). Payload first, userId last — same shape as add/search. */
|
|
111
|
-
addTurn(userMsg: string, assistantMsg: string, userId?: string): Promise<
|
|
223
|
+
addTurn(userMsg: string, assistantMsg: string, userId?: string): Promise<StatusResult>;
|
|
112
224
|
/** Bulk-ingest a large blob of text (chunked + embedded server-side). For backfilling. */
|
|
113
|
-
addBulk(content: string, userId?: string, category?: string, timestamp?: string): Promise<
|
|
225
|
+
addBulk(content: string, userId?: string, category?: string, timestamp?: string): Promise<StatusResult>;
|
|
114
226
|
/** 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<
|
|
227
|
+
update(oldMemoryId: string, newContent: string, userId?: string): Promise<UpdateResult>;
|
|
116
228
|
/** Semantic search over a store's memories. Returns memories, most relevant first. */
|
|
117
|
-
search(query: string, userId?: string, limit?: number, opts?:
|
|
229
|
+
search(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<Memory[]>;
|
|
118
230
|
/** One-call LLM context: short-term turns + long-term matches + surrounding context. */
|
|
119
231
|
recall(query: string, userId?: string): Promise<RecallResult>;
|
|
120
232
|
/** Run a built-in engram ("deep_recall" | "timeline" | "gather"). Returns the merged result. */
|
|
121
|
-
engram(name: string, query: string, userId?: string): Promise<
|
|
233
|
+
engram(name: string, query: string, userId?: string): Promise<EngramResult>;
|
|
122
234
|
/** Recent conversation turns (short-term memory). */
|
|
123
|
-
history(userId?: string): Promise<
|
|
235
|
+
history(userId?: string): Promise<HistoryTurn[]>;
|
|
124
236
|
/** Memory counts for a store: { total_memories, short_term_turns }. */
|
|
125
|
-
stats(userId?: string): Promise<
|
|
237
|
+
stats(userId?: string): Promise<StatsResult>;
|
|
126
238
|
/** Available models: `[{ id, name, available, memory }, ...]`. Needs no API key. */
|
|
127
239
|
listModels(): Promise<ModelInfo[]>;
|
|
128
240
|
/** Create a store — the `userId` you read and write under. Stores are explicit:
|
|
129
241
|
* a store must exist before you `add` to or `search` it, otherwise those calls
|
|
130
242
|
* return 404. Idempotent. Every account starts with a `default` store.
|
|
131
243
|
* Returns `{ user_id, status }` (`status` is `"created"` or `"exists"`). */
|
|
132
|
-
createStore(userId?: string): Promise<
|
|
244
|
+
createStore(userId?: string): Promise<StoreOpResult>;
|
|
133
245
|
/** List your stores: `[{ user_id, created_at }, ...]` (`default` first). */
|
|
134
|
-
listStores(): Promise<
|
|
135
|
-
user_id: string;
|
|
136
|
-
created_at: string;
|
|
137
|
-
}>>;
|
|
246
|
+
listStores(): Promise<StoreInfo[]>;
|
|
138
247
|
/** Delete a store and ALL its memories. Returns `{ user_id, status }`. */
|
|
139
|
-
deleteStore(userId: string): Promise<
|
|
248
|
+
deleteStore(userId: string): Promise<StoreOpResult>;
|
|
140
249
|
/** Register a person for this store. Speakers are explicit: register once, then
|
|
141
250
|
* store with `{ speaker }`. `"me"` (the assistant itself) never needs
|
|
142
251
|
* registration. A store registers up to 50 people to start. */
|
|
143
|
-
addSpeaker(speaker: string, userId?: string): Promise<
|
|
252
|
+
addSpeaker(speaker: string, userId?: string): Promise<SpeakerOpResult>;
|
|
144
253
|
/** The store's registered people, each with its memory count. */
|
|
145
|
-
listSpeakers(userId?: string): Promise<
|
|
254
|
+
listSpeakers(userId?: string): Promise<SpeakersList>;
|
|
146
255
|
/** Unregister a person. Their memories stay; the name tag goes. */
|
|
147
|
-
removeSpeaker(speaker: string, userId?: string): Promise<
|
|
256
|
+
removeSpeaker(speaker: string, userId?: string): Promise<SpeakerOpResult>;
|
|
148
257
|
/** Delete a single memory by id. `userId` optional (uses the default store). */
|
|
149
|
-
delete(userId: string | undefined, memoryId: string): Promise<
|
|
258
|
+
delete(userId: string | undefined, memoryId: string): Promise<StatusResult>;
|
|
150
259
|
/** Delete ALL memories for a store (GDPR erase). `userId` is required on purpose -
|
|
151
260
|
* this is destructive, so it never falls back to the default store. */
|
|
152
|
-
deleteAll(userId: string): Promise<
|
|
261
|
+
deleteAll(userId: string): Promise<StatusResult>;
|
|
153
262
|
private post;
|
|
154
263
|
/** Seconds→ms to sleep before retry `attempt` (0-based). Honors Retry-After. */
|
|
155
264
|
private backoffMs;
|
|
265
|
+
/** Read the body with a hard size cap, so a broken/hostile endpoint can't
|
|
266
|
+
* make the process buffer gigabytes. */
|
|
267
|
+
private readCapped;
|
|
156
268
|
private request;
|
|
157
269
|
}
|
|
158
270
|
/** 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.
|
|
35
|
+
const VERSION = "2.2.10";
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
344
|
+
let msg = text;
|
|
283
345
|
let requestId;
|
|
284
346
|
try {
|
|
285
|
-
const data = JSON.parse(
|
|
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
|
|
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