wontopos 2.2.23 → 2.2.25
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/dist/wontopos.d.ts +124 -9
- package/dist/wontopos.js +162 -22
- package/package.json +1 -1
package/dist/wontopos.d.ts
CHANGED
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
* runtime's (never disabled by this SDK; Node 18+ floors TLS at 1.2). Prefer
|
|
38
38
|
* `Client.fromEnv()` over keys in source code.
|
|
39
39
|
*/
|
|
40
|
+
/** Test hook — the warn-once set is process-global by design. */
|
|
41
|
+
export declare function _resetStoreIdWarnings(): void;
|
|
40
42
|
/** Quota from a response's `X-RateLimit-*` headers, or `null` if absent. */
|
|
41
43
|
export interface RateLimit {
|
|
42
44
|
limit: number | null;
|
|
@@ -73,7 +75,18 @@ export declare class ConflictError extends WosError {
|
|
|
73
75
|
/** 429 — too many requests. Back off and retry (the client already retries these). */
|
|
74
76
|
export declare class RateLimitError extends WosError {
|
|
75
77
|
}
|
|
76
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* 5xx — the service failed.
|
|
80
|
+
*
|
|
81
|
+
* `502` / `503` are transient: the client already retries them for calls where a
|
|
82
|
+
* retry cannot double-process a write, and retrying yourself is reasonable.
|
|
83
|
+
*
|
|
84
|
+
* `501` is NOT transient. It means the engine behind the model you selected does
|
|
85
|
+
* not implement that endpoint at all (the error carries `model` and `endpoint`).
|
|
86
|
+
* Retrying can never succeed — pick a model that supports it (`listModels`) or
|
|
87
|
+
* drop the call. This class used to say "safe to retry" for the whole 5xx range,
|
|
88
|
+
* which sent callers into a loop that could not end.
|
|
89
|
+
*/
|
|
77
90
|
export declare class ServerError extends WosError {
|
|
78
91
|
}
|
|
79
92
|
export interface Usage {
|
|
@@ -107,9 +120,24 @@ export interface Memory {
|
|
|
107
120
|
/** `add` / `store` → `{ id, status }` (`status: "stored" | "stored (async)" | "duplicate"`). */
|
|
108
121
|
export interface StoreResult {
|
|
109
122
|
id?: string;
|
|
123
|
+
/** `"stored"`, or `"duplicate"` when nothing was saved. */
|
|
110
124
|
status?: string;
|
|
125
|
+
/**
|
|
126
|
+
* Present when `status` is `"duplicate"`: the id of the memory this write collided with.
|
|
127
|
+
*
|
|
128
|
+
* The match is by MEANING, not by exact text — a genuinely new fact that only varies a
|
|
129
|
+
* detail of one already stored ("allergic to shellfish" next to "allergic to peanuts")
|
|
130
|
+
* lands here too. So a duplicate is not always a harmless no-op: read this id, and
|
|
131
|
+
* either merge the two into one sentence or `update()` the existing memory. Without it
|
|
132
|
+
* a dropped write gave the caller nothing to act on.
|
|
133
|
+
*/
|
|
134
|
+
duplicate_of?: string;
|
|
111
135
|
/** Present when the store was a duplicate and something (e.g. a speaker tag) was dropped. */
|
|
112
136
|
note?: string;
|
|
137
|
+
/** `true` when this response was REPLAYED from a previous request with the same
|
|
138
|
+
* `idempotencyKey` — nothing new was stored. Absent on a fresh write. Read it to
|
|
139
|
+
* tell "my retry landed" from "my retry was a no-op". */
|
|
140
|
+
replayed?: boolean;
|
|
113
141
|
[key: string]: unknown;
|
|
114
142
|
}
|
|
115
143
|
/** `addTurn` / `addBulk` / `delete` / `deleteAll` → `{ status }`. */
|
|
@@ -183,10 +211,28 @@ export interface ModelInfo {
|
|
|
183
211
|
/** "shared" — reads the common memory pool; "isolated" — its own dedicated store. */
|
|
184
212
|
memory: "shared" | "isolated";
|
|
185
213
|
}
|
|
214
|
+
/** One entry of the engram / delivery-form catalogue (`listEngrams`). */
|
|
215
|
+
export interface EngramInfo {
|
|
216
|
+
name: string;
|
|
217
|
+
description: string;
|
|
218
|
+
[key: string]: unknown;
|
|
219
|
+
}
|
|
220
|
+
/** `listEngrams` → what the SELECTED model can run. `forms` is empty on models
|
|
221
|
+
* without delivery forms; `note` explains an empty `engrams`. */
|
|
222
|
+
export interface EngramCatalog {
|
|
223
|
+
engrams: EngramInfo[];
|
|
224
|
+
forms: EngramInfo[];
|
|
225
|
+
note?: string;
|
|
226
|
+
}
|
|
186
227
|
/** `createStore` / `deleteStore` → `{ user_id, status }`. */
|
|
187
228
|
export interface StoreOpResult {
|
|
229
|
+
/** The store id the API actually used — NOT necessarily the one you sent.
|
|
230
|
+
* Ids are normalized (see the `note`), so compare this against your input. */
|
|
188
231
|
user_id?: string;
|
|
189
232
|
status?: string;
|
|
233
|
+
/** Present when the API changed your id, explaining how. Read it: two of your
|
|
234
|
+
* end users can land in one store if their ids differ only by punctuation. */
|
|
235
|
+
note?: string;
|
|
190
236
|
[key: string]: unknown;
|
|
191
237
|
}
|
|
192
238
|
export interface StoreInfo {
|
|
@@ -215,6 +261,29 @@ export interface SpeakersList {
|
|
|
215
261
|
limit?: number;
|
|
216
262
|
[key: string]: unknown;
|
|
217
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Narrow a search to part of a store — applied BEFORE ranking, so the results are
|
|
266
|
+
* the best matches *within* the filter, not a filtered top-N.
|
|
267
|
+
*
|
|
268
|
+
* Retrieval itself stays purely semantic (no keyword matching), so these behave
|
|
269
|
+
* identically in every language. Unlisted keys are dropped by the API rather than
|
|
270
|
+
* rejected, so a typo silently widens the search — spell them exactly.
|
|
271
|
+
*/
|
|
272
|
+
export interface SearchFilters {
|
|
273
|
+
/** Only these categories (the `category` you see on `listMemories` results). */
|
|
274
|
+
categories?: string[];
|
|
275
|
+
/** Ingestion-time window, as the API stores it. Plain strings, matched as given. */
|
|
276
|
+
time_from?: string;
|
|
277
|
+
time_to?: string;
|
|
278
|
+
/** WHEN THE CONTENT HAPPENED (`metadata.event_date`), not when it was written —
|
|
279
|
+
* this is the one you usually want. RFC3339, or a plain `YYYY-MM-DD`. */
|
|
280
|
+
event_from?: string;
|
|
281
|
+
event_to?: string;
|
|
282
|
+
/** Drop matches the engine scored below this importance (0–1). */
|
|
283
|
+
min_importance?: number;
|
|
284
|
+
}
|
|
285
|
+
/** Test hook — the warn-once set is process-global by design. */
|
|
286
|
+
export declare function _resetFilterWarnings(): void;
|
|
218
287
|
/** Known `search` options; extra fields pass through to the API untouched. */
|
|
219
288
|
export interface SearchOptions {
|
|
220
289
|
/** Recall caching: repeated/extended queries within the TTL bill at 0.1×. */
|
|
@@ -223,8 +292,29 @@ export interface SearchOptions {
|
|
|
223
292
|
};
|
|
224
293
|
/** Recall only this person's words ("me" or a registered name). */
|
|
225
294
|
speaker?: string;
|
|
295
|
+
/** Restrict the search to part of the store — see {@link SearchFilters}. */
|
|
296
|
+
filters?: SearchFilters;
|
|
226
297
|
[key: string]: unknown;
|
|
227
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Per-call options for a write.
|
|
301
|
+
*
|
|
302
|
+
* `idempotencyKey` makes repeating THIS EXACT write safe: the API replays the first
|
|
303
|
+
* response instead of storing again, for 10 minutes, and answers 422 if the same key
|
|
304
|
+
* arrives with a different body. Use it when a retry is your own (a job that died and
|
|
305
|
+
* was re-run, a queue that redelivers) — the SDK never retries writes by itself,
|
|
306
|
+
* because without a key it cannot know whether the first attempt landed.
|
|
307
|
+
*
|
|
308
|
+
* The key must be UNIQUE PER LOGICAL WRITE — derive it from the thing being stored
|
|
309
|
+
* (`` `import:${row.id}` ``), never a constant, or the second write replays the first
|
|
310
|
+
* and is silently lost. Format: 1-128 chars of `[A-Za-z0-9._:-]`, checked locally.
|
|
311
|
+
*
|
|
312
|
+
* The window is in-memory on the API, so a deploy or restart clears it early. It is a
|
|
313
|
+
* guard against a retry storm, not a durable ledger.
|
|
314
|
+
*/
|
|
315
|
+
export interface WriteOptions {
|
|
316
|
+
idempotencyKey?: string;
|
|
317
|
+
}
|
|
228
318
|
export interface ClientOptions {
|
|
229
319
|
apiKey: string;
|
|
230
320
|
/** API base URL (defaults to the hosted service). */
|
|
@@ -284,17 +374,25 @@ export declare class Client {
|
|
|
284
374
|
withTimeout(timeoutMs: number): Client;
|
|
285
375
|
/** A client with a different retry budget (everything else kept). 0 disables retries. */
|
|
286
376
|
withRetries(maxRetries: number): Client;
|
|
287
|
-
/** Store one memory. `metadata` optional
|
|
288
|
-
*
|
|
289
|
-
|
|
377
|
+
/** Store one memory. `metadata` optional — e.g. `{ event_date: "2026-03-01" }` for when the
|
|
378
|
+
* content actually happened. A plain date works (read as UTC midnight); a full RFC3339
|
|
379
|
+
* timestamp also works. The API used to demand the full form and answered a bare date with
|
|
380
|
+
* an opaque 400, which is why older examples here show the long one.
|
|
381
|
+
* `metadata.speaker`: "me" = the assistant's own words, or a person's name (up to 50 per store).
|
|
382
|
+
*
|
|
383
|
+
* `opts.idempotencyKey`: see {@link WriteOptions} — pass one to make YOUR retry
|
|
384
|
+
* of this exact write safe to repeat. */
|
|
385
|
+
add(content: string, userId?: string, metadata?: Record<string, unknown>, opts?: WriteOptions): Promise<StoreResult>;
|
|
290
386
|
/** Alias of `add` — store one memory. Same surface as the Python SDK's `store`. */
|
|
291
|
-
store(content: string, userId?: string, metadata?: Record<string, unknown
|
|
387
|
+
store(content: string, userId?: string, metadata?: Record<string, unknown>, opts?: WriteOptions): Promise<StoreResult>;
|
|
292
388
|
/** Store a conversation turn (user + assistant). Payload first, userId last — same shape as add/search. */
|
|
293
|
-
addTurn(userMsg: string, assistantMsg: string, userId?: string): Promise<StatusResult>;
|
|
294
|
-
/** Bulk-ingest a large blob of text (chunked + embedded server-side). For backfilling.
|
|
295
|
-
|
|
389
|
+
addTurn(userMsg: string, assistantMsg: string, userId?: string, opts?: WriteOptions): Promise<StatusResult>;
|
|
390
|
+
/** Bulk-ingest a large blob of text (chunked + embedded server-side). For backfilling.
|
|
391
|
+
* The call most worth an `opts.idempotencyKey`: a backfill that dies halfway and is
|
|
392
|
+
* re-run would otherwise re-chunk and re-bill the whole blob. */
|
|
393
|
+
addBulk(content: string, userId?: string, category?: string, timestamp?: string, opts?: WriteOptions): Promise<StatusResult>;
|
|
296
394
|
/** Supersede an old memory with new content. Payload first, userId last — same shape as add/search. */
|
|
297
|
-
update(oldMemoryId: string, newContent: string, userId?: string): Promise<UpdateResult>;
|
|
395
|
+
update(oldMemoryId: string, newContent: string, userId?: string, opts?: WriteOptions): Promise<UpdateResult>;
|
|
298
396
|
/** Semantic search over a store's memories. Returns memories, most relevant first. */
|
|
299
397
|
search(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<Memory[]>;
|
|
300
398
|
/** Search a self-memory model (Scroll 1.2+): both lanes from ONE call. Returns
|
|
@@ -357,6 +455,19 @@ export declare class Client {
|
|
|
357
455
|
ping(): Promise<boolean>;
|
|
358
456
|
/** Available models: `[{ id, name, available, memory }, ...]`. Needs no API key. */
|
|
359
457
|
listModels(): Promise<ModelInfo[]>;
|
|
458
|
+
/**
|
|
459
|
+
* The engrams (and delivery forms) the selected model can actually run.
|
|
460
|
+
*
|
|
461
|
+
* `engram()` could always RUN one, but there was no way to ASK what exists — so a
|
|
462
|
+
* caller had to hard-code names from the docs, and every engram we shipped after
|
|
463
|
+
* their code was written stayed invisible to them. The MCP server hit exactly this
|
|
464
|
+
* and stopped hard-coding for the same reason. The service is the authority: what
|
|
465
|
+
* comes back depends on the model (delivery forms need Scroll 1.2+), so pass
|
|
466
|
+
* `withModel()` if you want another model's catalogue.
|
|
467
|
+
*
|
|
468
|
+
* const { engrams, forms } = await mem.listEngrams();
|
|
469
|
+
*/
|
|
470
|
+
listEngrams(): Promise<EngramCatalog>;
|
|
360
471
|
/** Create a store — the `userId` you read and write under. Stores are explicit:
|
|
361
472
|
* a store must exist before you `add` to or `search` it, otherwise those calls
|
|
362
473
|
* return 404. Idempotent. Every account starts with a `default` store.
|
|
@@ -380,6 +491,10 @@ export declare class Client {
|
|
|
380
491
|
* this is destructive, so it never falls back to the default store. */
|
|
381
492
|
deleteAll(userId: string): Promise<StatusResult>;
|
|
382
493
|
private post;
|
|
494
|
+
/** Validate an idempotency key BEFORE the network. The API answers a malformed
|
|
495
|
+
* key with a 400, which on a retry path reads as "my write failed" when in fact
|
|
496
|
+
* it was never attempted — cheaper and clearer to reject it here. */
|
|
497
|
+
private idemHeader;
|
|
383
498
|
/** Seconds→ms to sleep before retry `attempt` (0-based). Honors Retry-After. */
|
|
384
499
|
private backoffMs;
|
|
385
500
|
/** Read the body with a hard size cap, so a broken/hostile endpoint can't
|
package/dist/wontopos.js
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* runtime's (never disabled by this SDK; Node 18+ floors TLS at 1.2). Prefer
|
|
38
38
|
* `Client.fromEnv()` over keys in source code.
|
|
39
39
|
*/
|
|
40
|
-
const VERSION = "2.2.
|
|
40
|
+
const VERSION = "2.2.25";
|
|
41
41
|
/** Runtime info helps support debug a report ("node 18 on Windows...") —
|
|
42
42
|
* platform only, never anything identifying. Browsers have no `process` (and
|
|
43
43
|
* silently drop the UA header anyway). */
|
|
@@ -71,6 +71,46 @@ const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
|
71
71
|
const MODEL_RE = /^[A-Za-z0-9._-]+$/;
|
|
72
72
|
/** Cap a server-controlled error message so a hostile body can't blow up a log. */
|
|
73
73
|
const MAX_ERR_MSG = 4096;
|
|
74
|
+
/** What the API accepts as an `Idempotency-Key`. Checked client-side so a bad key
|
|
75
|
+
* fails before the request instead of coming back as a 400 mid-retry. */
|
|
76
|
+
const IDEMPOTENCY_KEY_RE = /^[A-Za-z0-9._:\-]{1,128}$/;
|
|
77
|
+
/**
|
|
78
|
+
* The API normalizes a store id: lowercased, and every character outside
|
|
79
|
+
* `[a-z0-9_]` becomes `_`. So `Alice.Smith`, `alice-smith` and `alice_smith` are
|
|
80
|
+
* ALL the same store.
|
|
81
|
+
*
|
|
82
|
+
* That is a data-exposure hazard for the most common way this SDK is used — one
|
|
83
|
+
* store per end user. Two accounts whose ids differ only by punctuation or case
|
|
84
|
+
* (`bob.lee@x.com` / `bob-lee@x.com`) silently share every memory, and nothing in
|
|
85
|
+
* the response says so: the note only appears when `createStore` creates one, and
|
|
86
|
+
* an app that reuses an existing store never sees it.
|
|
87
|
+
*
|
|
88
|
+
* We cannot refuse the id — the API accepts it, and callers may have written it
|
|
89
|
+
* this way for a year. So we say it once, loudly, at the moment it happens.
|
|
90
|
+
*/
|
|
91
|
+
function normalizeStoreId(id) {
|
|
92
|
+
return id.toLowerCase().replace(/[^a-z0-9_]/g, "_");
|
|
93
|
+
}
|
|
94
|
+
const warnedStoreIds = new Set();
|
|
95
|
+
function warnIfStoreIdCollapses(id) {
|
|
96
|
+
if (!id || warnedStoreIds.has(id))
|
|
97
|
+
return;
|
|
98
|
+
const normalized = normalizeStoreId(id);
|
|
99
|
+
if (normalized === id)
|
|
100
|
+
return;
|
|
101
|
+
warnedStoreIds.add(id);
|
|
102
|
+
console.warn(`wontopos: store id ${JSON.stringify(id)} is stored as ${JSON.stringify(normalized)} ` +
|
|
103
|
+
`(lowercased, and anything outside [a-z0-9_] becomes "_"). Ids that differ only by case or ` +
|
|
104
|
+
`punctuation share ONE store and therefore one set of memories — if these ids come from your ` +
|
|
105
|
+
`end users, normalize them yourself first so two people can never collide.`);
|
|
106
|
+
}
|
|
107
|
+
/** Test hook — the warn-once set is process-global by design. */
|
|
108
|
+
export function _resetStoreIdWarnings() {
|
|
109
|
+
warnedStoreIds.clear();
|
|
110
|
+
}
|
|
111
|
+
/** Statuses that legitimately carry NO body (RFC 9110). Everything else must
|
|
112
|
+
* answer with a JSON object — see the empty-body check in `request`. */
|
|
113
|
+
const NO_BODY_STATUS = new Set([204, 205, 304]);
|
|
74
114
|
/** Paging backstop: bound the walk so a fresh-cursor-forever server can't loop us. */
|
|
75
115
|
const MAX_PAGES = 1_000_000;
|
|
76
116
|
/** Error codes that only occur while ESTABLISHING a connection — the request
|
|
@@ -202,7 +242,18 @@ export class ConflictError extends WosError {
|
|
|
202
242
|
/** 429 — too many requests. Back off and retry (the client already retries these). */
|
|
203
243
|
export class RateLimitError extends WosError {
|
|
204
244
|
}
|
|
205
|
-
/**
|
|
245
|
+
/**
|
|
246
|
+
* 5xx — the service failed.
|
|
247
|
+
*
|
|
248
|
+
* `502` / `503` are transient: the client already retries them for calls where a
|
|
249
|
+
* retry cannot double-process a write, and retrying yourself is reasonable.
|
|
250
|
+
*
|
|
251
|
+
* `501` is NOT transient. It means the engine behind the model you selected does
|
|
252
|
+
* not implement that endpoint at all (the error carries `model` and `endpoint`).
|
|
253
|
+
* Retrying can never succeed — pick a model that supports it (`listModels`) or
|
|
254
|
+
* drop the call. This class used to say "safe to retry" for the whole 5xx range,
|
|
255
|
+
* which sent callers into a loop that could not end.
|
|
256
|
+
*/
|
|
206
257
|
export class ServerError extends WosError {
|
|
207
258
|
}
|
|
208
259
|
const STATUS_ERRORS = {
|
|
@@ -222,6 +273,34 @@ function errorFor(status, message, requestId) {
|
|
|
222
273
|
: STATUS_ERRORS[status] ?? (status >= 500 && status < 600 ? ServerError : WosError);
|
|
223
274
|
return new Cls(status, message, requestId);
|
|
224
275
|
}
|
|
276
|
+
/** The filter keys the API acts on. Anything else it DROPS silently, so a typo
|
|
277
|
+
* widens the search instead of failing — we warn rather than reject, because the
|
|
278
|
+
* API may grow a key before this package is updated. */
|
|
279
|
+
const KNOWN_FILTER_KEYS = new Set([
|
|
280
|
+
"categories",
|
|
281
|
+
"event_from",
|
|
282
|
+
"event_to",
|
|
283
|
+
"time_from",
|
|
284
|
+
"time_to",
|
|
285
|
+
"min_importance",
|
|
286
|
+
]);
|
|
287
|
+
const warnedFilterKeys = new Set();
|
|
288
|
+
function warnOnUnknownFilters(filters) {
|
|
289
|
+
if (!filters || typeof filters !== "object" || Array.isArray(filters))
|
|
290
|
+
return;
|
|
291
|
+
for (const k of Object.keys(filters)) {
|
|
292
|
+
if (KNOWN_FILTER_KEYS.has(k) || warnedFilterKeys.has(k))
|
|
293
|
+
continue;
|
|
294
|
+
warnedFilterKeys.add(k);
|
|
295
|
+
console.warn(`wontopos: unknown search filter ${JSON.stringify(k)} — the API drops keys it does not know, ` +
|
|
296
|
+
`so this filter has NO effect and the search is wider than you think. ` +
|
|
297
|
+
`Known keys: ${[...KNOWN_FILTER_KEYS].join(", ")}.`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/** Test hook — the warn-once set is process-global by design. */
|
|
301
|
+
export function _resetFilterWarnings() {
|
|
302
|
+
warnedFilterKeys.clear();
|
|
303
|
+
}
|
|
225
304
|
const DEFAULT_USER = "default";
|
|
226
305
|
/** Wontopos memory client. Store and recall memories, isolated per `userId`. */
|
|
227
306
|
export class Client {
|
|
@@ -304,7 +383,9 @@ export class Client {
|
|
|
304
383
|
}
|
|
305
384
|
/** Resolve a call's store: the explicit userId, else the client default. */
|
|
306
385
|
uid(userId) {
|
|
307
|
-
|
|
386
|
+
const id = userId || this.defaultUser;
|
|
387
|
+
warnIfStoreIdCollapses(id);
|
|
388
|
+
return id;
|
|
308
389
|
}
|
|
309
390
|
clone(overrides) {
|
|
310
391
|
return new Client({
|
|
@@ -341,35 +422,44 @@ export class Client {
|
|
|
341
422
|
// ----- write -----
|
|
342
423
|
// `userId` is optional in every call below - omit it to use the client's default
|
|
343
424
|
// store (set via `new Client({ apiKey, userId })` or `withUser`).
|
|
344
|
-
/** Store one memory. `metadata` optional
|
|
345
|
-
*
|
|
346
|
-
|
|
347
|
-
|
|
425
|
+
/** Store one memory. `metadata` optional — e.g. `{ event_date: "2026-03-01" }` for when the
|
|
426
|
+
* content actually happened. A plain date works (read as UTC midnight); a full RFC3339
|
|
427
|
+
* timestamp also works. The API used to demand the full form and answered a bare date with
|
|
428
|
+
* an opaque 400, which is why older examples here show the long one.
|
|
429
|
+
* `metadata.speaker`: "me" = the assistant's own words, or a person's name (up to 50 per store).
|
|
430
|
+
*
|
|
431
|
+
* `opts.idempotencyKey`: see {@link WriteOptions} — pass one to make YOUR retry
|
|
432
|
+
* of this exact write safe to repeat. */
|
|
433
|
+
add(content, userId, metadata = {}, opts = {}) {
|
|
434
|
+
return this.post("/api/v1/memory/store", { user_id: this.uid(userId), content, metadata }, opts.idempotencyKey);
|
|
348
435
|
}
|
|
349
436
|
/** Alias of `add` — store one memory. Same surface as the Python SDK's `store`. */
|
|
350
|
-
store(content, userId, metadata = {}) {
|
|
351
|
-
return this.add(content, userId, metadata);
|
|
437
|
+
store(content, userId, metadata = {}, opts = {}) {
|
|
438
|
+
return this.add(content, userId, metadata, opts);
|
|
352
439
|
}
|
|
353
440
|
/** Store a conversation turn (user + assistant). Payload first, userId last — same shape as add/search. */
|
|
354
|
-
addTurn(userMsg, assistantMsg, userId) {
|
|
355
|
-
return this.post("/api/v1/memory/store-turn", { user_id: this.uid(userId), user_msg: userMsg, assistant_msg: assistantMsg });
|
|
441
|
+
addTurn(userMsg, assistantMsg, userId, opts = {}) {
|
|
442
|
+
return this.post("/api/v1/memory/store-turn", { user_id: this.uid(userId), user_msg: userMsg, assistant_msg: assistantMsg }, opts.idempotencyKey);
|
|
356
443
|
}
|
|
357
|
-
/** Bulk-ingest a large blob of text (chunked + embedded server-side). For backfilling.
|
|
358
|
-
|
|
444
|
+
/** Bulk-ingest a large blob of text (chunked + embedded server-side). For backfilling.
|
|
445
|
+
* The call most worth an `opts.idempotencyKey`: a backfill that dies halfway and is
|
|
446
|
+
* re-run would otherwise re-chunk and re-bill the whole blob. */
|
|
447
|
+
addBulk(content, userId, category = "general", timestamp, opts = {}) {
|
|
359
448
|
const body = { user_id: this.uid(userId), content, category };
|
|
360
449
|
if (timestamp)
|
|
361
450
|
body.timestamp = timestamp;
|
|
362
|
-
return this.post("/api/v1/memory/bulk-store", body);
|
|
451
|
+
return this.post("/api/v1/memory/bulk-store", body, opts.idempotencyKey);
|
|
363
452
|
}
|
|
364
453
|
/** Supersede an old memory with new content. Payload first, userId last — same shape as add/search. */
|
|
365
|
-
update(oldMemoryId, newContent, userId) {
|
|
366
|
-
return this.post("/api/v1/memory/supersede", { user_id: this.uid(userId), old_memory_id: oldMemoryId, new_content: newContent });
|
|
454
|
+
update(oldMemoryId, newContent, userId, opts = {}) {
|
|
455
|
+
return this.post("/api/v1/memory/supersede", { user_id: this.uid(userId), old_memory_id: oldMemoryId, new_content: newContent }, opts.idempotencyKey);
|
|
367
456
|
}
|
|
368
457
|
// ----- read -----
|
|
369
458
|
/** Semantic search over a store's memories. Returns memories, most relevant first. */
|
|
370
459
|
async search(query, userId, limit = 10, opts = {}) {
|
|
371
460
|
// Reserved fields win over ...opts: an app that forwards untrusted input as
|
|
372
461
|
// opts must not be able to override the store (user_id), query, or limit.
|
|
462
|
+
warnOnUnknownFilters(opts.filters);
|
|
373
463
|
const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
|
|
374
464
|
return mergeLanes(r);
|
|
375
465
|
}
|
|
@@ -379,6 +469,7 @@ export class Client {
|
|
|
379
469
|
* speaker "me"), kept separate so the reader never confuses who said what. On a model
|
|
380
470
|
* without a self lane, `self_memories` is `[]`. `userId` optional (default store). */
|
|
381
471
|
async searchSelf(query, userId, limit = 10, opts = {}) {
|
|
472
|
+
warnOnUnknownFilters(opts.filters);
|
|
382
473
|
const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
|
|
383
474
|
return { memories: asRecords(r.memories), self_memories: asRecords(r.self_memories) };
|
|
384
475
|
}
|
|
@@ -473,6 +564,29 @@ export class Client {
|
|
|
473
564
|
const data = await this.request("GET", "/api/v1/models");
|
|
474
565
|
return asRecords(data.models);
|
|
475
566
|
}
|
|
567
|
+
// ----- engrams -----
|
|
568
|
+
/**
|
|
569
|
+
* The engrams (and delivery forms) the selected model can actually run.
|
|
570
|
+
*
|
|
571
|
+
* `engram()` could always RUN one, but there was no way to ASK what exists — so a
|
|
572
|
+
* caller had to hard-code names from the docs, and every engram we shipped after
|
|
573
|
+
* their code was written stayed invisible to them. The MCP server hit exactly this
|
|
574
|
+
* and stopped hard-coding for the same reason. The service is the authority: what
|
|
575
|
+
* comes back depends on the model (delivery forms need Scroll 1.2+), so pass
|
|
576
|
+
* `withModel()` if you want another model's catalogue.
|
|
577
|
+
*
|
|
578
|
+
* const { engrams, forms } = await mem.listEngrams();
|
|
579
|
+
*/
|
|
580
|
+
async listEngrams() {
|
|
581
|
+
const data = await this.request("GET", "/api/v1/engram");
|
|
582
|
+
return {
|
|
583
|
+
engrams: asRecords(data.engrams),
|
|
584
|
+
forms: asRecords(data.forms),
|
|
585
|
+
// Present when the selected model has no engram support — the service explains
|
|
586
|
+
// rather than returning a bare empty list.
|
|
587
|
+
note: typeof data.note === "string" ? data.note : undefined,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
476
590
|
// ----- stores -----
|
|
477
591
|
/** Create a store — the `userId` you read and write under. Stores are explicit:
|
|
478
592
|
* a store must exist before you `add` to or `search` it, otherwise those calls
|
|
@@ -526,8 +640,19 @@ export class Client {
|
|
|
526
640
|
return this.post("/api/v1/memory/forget", { user_id: userId });
|
|
527
641
|
}
|
|
528
642
|
// ----- internal -----
|
|
529
|
-
post(path, body) {
|
|
530
|
-
return this.request("POST", path, body);
|
|
643
|
+
post(path, body, idempotencyKey) {
|
|
644
|
+
return this.request("POST", path, body, idempotencyKey);
|
|
645
|
+
}
|
|
646
|
+
/** Validate an idempotency key BEFORE the network. The API answers a malformed
|
|
647
|
+
* key with a 400, which on a retry path reads as "my write failed" when in fact
|
|
648
|
+
* it was never attempted — cheaper and clearer to reject it here. */
|
|
649
|
+
idemHeader(key) {
|
|
650
|
+
if (key === undefined)
|
|
651
|
+
return undefined;
|
|
652
|
+
if (!IDEMPOTENCY_KEY_RE.test(key)) {
|
|
653
|
+
throw new Error(`invalid idempotencyKey: ${JSON.stringify(key)} — 1-128 chars of [A-Za-z0-9._:-]`);
|
|
654
|
+
}
|
|
655
|
+
return { "Idempotency-Key": key };
|
|
531
656
|
}
|
|
532
657
|
/** Seconds→ms to sleep before retry `attempt` (0-based). Honors Retry-After. */
|
|
533
658
|
backoffMs(attempt, retryAfter) {
|
|
@@ -573,7 +698,8 @@ export class Client {
|
|
|
573
698
|
}
|
|
574
699
|
return new TextDecoder().decode(buf);
|
|
575
700
|
}
|
|
576
|
-
async request(method, path, body) {
|
|
701
|
+
async request(method, path, body, idempotencyKey) {
|
|
702
|
+
const extraHeaders = this.idemHeader(idempotencyKey);
|
|
577
703
|
const attempts = this.maxRetries + 1;
|
|
578
704
|
// Debug logging shows the path WITHOUT its query string — a query can carry
|
|
579
705
|
// a store id (listSpeakers), and logs must never carry data.
|
|
@@ -586,7 +712,7 @@ export class Client {
|
|
|
586
712
|
try {
|
|
587
713
|
res = await fetch(`${this.base}${path}`, {
|
|
588
714
|
method,
|
|
589
|
-
headers: this.headers,
|
|
715
|
+
headers: extraHeaders ? { ...this.headers, ...extraHeaders } : this.headers,
|
|
590
716
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
591
717
|
signal: ctrl.signal,
|
|
592
718
|
// Never follow a redirect: fetch would forward the API key to
|
|
@@ -679,8 +805,15 @@ export class Client {
|
|
|
679
805
|
msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
|
|
680
806
|
throw errorFor(res.status, msg, requestId);
|
|
681
807
|
}
|
|
682
|
-
|
|
683
|
-
|
|
808
|
+
// An empty body is only legal when the STATUS says there is no body. This
|
|
809
|
+
// used to accept any empty 2xx, so an `add()` answered by a truncating proxy
|
|
810
|
+
// returned `{}` and read as a successful write with no id — while the Python
|
|
811
|
+
// and Rust SDKs raised on the same response. Same rule in all three now.
|
|
812
|
+
if (!text) {
|
|
813
|
+
if (NO_BODY_STATUS.has(res.status))
|
|
814
|
+
return {};
|
|
815
|
+
throw new WosError(res.status, "empty response body — expected a JSON object");
|
|
816
|
+
}
|
|
684
817
|
let data;
|
|
685
818
|
try {
|
|
686
819
|
data = JSON.parse(text);
|
|
@@ -697,6 +830,13 @@ export class Client {
|
|
|
697
830
|
const got = data === null ? "null" : Array.isArray(data) ? "array" : typeof data;
|
|
698
831
|
throw new WosError(res.status, `expected a JSON object in the response, got ${got}`);
|
|
699
832
|
}
|
|
833
|
+
// 멱등키를 쓴 쓰기가 **저장된 것인지 재생된 것인지**를 호출자가 알아야 한다.
|
|
834
|
+
// 서버는 `Idempotent-Replayed: true` 로 말해주는데 지금까지 SDK 가 삼켰다 —
|
|
835
|
+
// 멱등키를 쓰는 사람이 가장 알고 싶은 사실이 그것이다(재시도가 실제로
|
|
836
|
+
// 저장을 일으켰나, 아니면 첫 응답이 되풀이된 것인가).
|
|
837
|
+
if (res.headers.get("Idempotent-Replayed") === "true") {
|
|
838
|
+
data.replayed = true;
|
|
839
|
+
}
|
|
700
840
|
return data;
|
|
701
841
|
}
|
|
702
842
|
throw new WosError(0, "retries exhausted"); // unreachable; keeps the compiler happy
|
package/package.json
CHANGED