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.
@@ -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
- /** 5xx — the service failed. Safe to retry. */
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 (e.g. { event_date: "2026-03-01T00:00:00Z" } — RFC3339).
288
- * `metadata.speaker`: "me" = the assistant's own words, or a person's name (up to 50 per store). */
289
- add(content: string, userId?: string, metadata?: Record<string, unknown>): Promise<StoreResult>;
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>): Promise<StoreResult>;
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
- addBulk(content: string, userId?: string, category?: string, timestamp?: string): Promise<StatusResult>;
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.23";
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
- /** 5xx — the service failed. Safe to retry. */
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
- return userId || this.defaultUser;
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 (e.g. { event_date: "2026-03-01T00:00:00Z" } — RFC3339).
345
- * `metadata.speaker`: "me" = the assistant's own words, or a person's name (up to 50 per store). */
346
- add(content, userId, metadata = {}) {
347
- return this.post("/api/v1/memory/store", { user_id: this.uid(userId), content, metadata });
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
- addBulk(content, userId, category = "general", timestamp) {
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
- if (!text)
683
- return {};
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wontopos",
3
- "version": "2.2.23",
3
+ "version": "2.2.25",
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",