wontopos 2.2.23 → 2.2.24

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.
@@ -107,7 +107,18 @@ export interface Memory {
107
107
  /** `add` / `store` → `{ id, status }` (`status: "stored" | "stored (async)" | "duplicate"`). */
108
108
  export interface StoreResult {
109
109
  id?: string;
110
+ /** `"stored"`, or `"duplicate"` when nothing was saved. */
110
111
  status?: string;
112
+ /**
113
+ * Present when `status` is `"duplicate"`: the id of the memory this write collided with.
114
+ *
115
+ * The match is by MEANING, not by exact text — a genuinely new fact that only varies a
116
+ * detail of one already stored ("allergic to shellfish" next to "allergic to peanuts")
117
+ * lands here too. So a duplicate is not always a harmless no-op: read this id, and
118
+ * either merge the two into one sentence or `update()` the existing memory. Without it
119
+ * a dropped write gave the caller nothing to act on.
120
+ */
121
+ duplicate_of?: string;
111
122
  /** Present when the store was a duplicate and something (e.g. a speaker tag) was dropped. */
112
123
  note?: string;
113
124
  [key: string]: unknown;
@@ -183,6 +194,19 @@ export interface ModelInfo {
183
194
  /** "shared" — reads the common memory pool; "isolated" — its own dedicated store. */
184
195
  memory: "shared" | "isolated";
185
196
  }
197
+ /** One entry of the engram / delivery-form catalogue (`listEngrams`). */
198
+ export interface EngramInfo {
199
+ name: string;
200
+ description: string;
201
+ [key: string]: unknown;
202
+ }
203
+ /** `listEngrams` → what the SELECTED model can run. `forms` is empty on models
204
+ * without delivery forms; `note` explains an empty `engrams`. */
205
+ export interface EngramCatalog {
206
+ engrams: EngramInfo[];
207
+ forms: EngramInfo[];
208
+ note?: string;
209
+ }
186
210
  /** `createStore` / `deleteStore` → `{ user_id, status }`. */
187
211
  export interface StoreOpResult {
188
212
  user_id?: string;
@@ -215,6 +239,27 @@ export interface SpeakersList {
215
239
  limit?: number;
216
240
  [key: string]: unknown;
217
241
  }
242
+ /**
243
+ * Narrow a search to part of a store — applied BEFORE ranking, so the results are
244
+ * the best matches *within* the filter, not a filtered top-N.
245
+ *
246
+ * Retrieval itself stays purely semantic (no keyword matching), so these behave
247
+ * identically in every language. Unlisted keys are dropped by the API rather than
248
+ * rejected, so a typo silently widens the search — spell them exactly.
249
+ */
250
+ export interface SearchFilters {
251
+ /** Only these categories (the `category` you see on `listMemories` results). */
252
+ categories?: string[];
253
+ /** Ingestion-time window, as the API stores it. Plain strings, matched as given. */
254
+ time_from?: string;
255
+ time_to?: string;
256
+ /** WHEN THE CONTENT HAPPENED (`metadata.event_date`), not when it was written —
257
+ * this is the one you usually want. RFC3339, or a plain `YYYY-MM-DD`. */
258
+ event_from?: string;
259
+ event_to?: string;
260
+ /** Drop matches the engine scored below this importance (0–1). */
261
+ min_importance?: number;
262
+ }
218
263
  /** Known `search` options; extra fields pass through to the API untouched. */
219
264
  export interface SearchOptions {
220
265
  /** Recall caching: repeated/extended queries within the TTL bill at 0.1×. */
@@ -223,8 +268,29 @@ export interface SearchOptions {
223
268
  };
224
269
  /** Recall only this person's words ("me" or a registered name). */
225
270
  speaker?: string;
271
+ /** Restrict the search to part of the store — see {@link SearchFilters}. */
272
+ filters?: SearchFilters;
226
273
  [key: string]: unknown;
227
274
  }
275
+ /**
276
+ * Per-call options for a write.
277
+ *
278
+ * `idempotencyKey` makes repeating THIS EXACT write safe: the API replays the first
279
+ * response instead of storing again, for 10 minutes, and answers 422 if the same key
280
+ * arrives with a different body. Use it when a retry is your own (a job that died and
281
+ * was re-run, a queue that redelivers) — the SDK never retries writes by itself,
282
+ * because without a key it cannot know whether the first attempt landed.
283
+ *
284
+ * The key must be UNIQUE PER LOGICAL WRITE — derive it from the thing being stored
285
+ * (`` `import:${row.id}` ``), never a constant, or the second write replays the first
286
+ * and is silently lost. Format: 1-128 chars of `[A-Za-z0-9._:-]`, checked locally.
287
+ *
288
+ * The window is in-memory on the API, so a deploy or restart clears it early. It is a
289
+ * guard against a retry storm, not a durable ledger.
290
+ */
291
+ export interface WriteOptions {
292
+ idempotencyKey?: string;
293
+ }
228
294
  export interface ClientOptions {
229
295
  apiKey: string;
230
296
  /** API base URL (defaults to the hosted service). */
@@ -284,17 +350,25 @@ export declare class Client {
284
350
  withTimeout(timeoutMs: number): Client;
285
351
  /** A client with a different retry budget (everything else kept). 0 disables retries. */
286
352
  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>;
353
+ /** Store one memory. `metadata` optional — e.g. `{ event_date: "2026-03-01" }` for when the
354
+ * content actually happened. A plain date works (read as UTC midnight); a full RFC3339
355
+ * timestamp also works. The API used to demand the full form and answered a bare date with
356
+ * an opaque 400, which is why older examples here show the long one.
357
+ * `metadata.speaker`: "me" = the assistant's own words, or a person's name (up to 50 per store).
358
+ *
359
+ * `opts.idempotencyKey`: see {@link WriteOptions} — pass one to make YOUR retry
360
+ * of this exact write safe to repeat. */
361
+ add(content: string, userId?: string, metadata?: Record<string, unknown>, opts?: WriteOptions): Promise<StoreResult>;
290
362
  /** 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>;
363
+ store(content: string, userId?: string, metadata?: Record<string, unknown>, opts?: WriteOptions): Promise<StoreResult>;
292
364
  /** 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>;
365
+ addTurn(userMsg: string, assistantMsg: string, userId?: string, opts?: WriteOptions): Promise<StatusResult>;
366
+ /** Bulk-ingest a large blob of text (chunked + embedded server-side). For backfilling.
367
+ * The call most worth an `opts.idempotencyKey`: a backfill that dies halfway and is
368
+ * re-run would otherwise re-chunk and re-bill the whole blob. */
369
+ addBulk(content: string, userId?: string, category?: string, timestamp?: string, opts?: WriteOptions): Promise<StatusResult>;
296
370
  /** 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>;
371
+ update(oldMemoryId: string, newContent: string, userId?: string, opts?: WriteOptions): Promise<UpdateResult>;
298
372
  /** Semantic search over a store's memories. Returns memories, most relevant first. */
299
373
  search(query: string, userId?: string, limit?: number, opts?: SearchOptions): Promise<Memory[]>;
300
374
  /** Search a self-memory model (Scroll 1.2+): both lanes from ONE call. Returns
@@ -357,6 +431,19 @@ export declare class Client {
357
431
  ping(): Promise<boolean>;
358
432
  /** Available models: `[{ id, name, available, memory }, ...]`. Needs no API key. */
359
433
  listModels(): Promise<ModelInfo[]>;
434
+ /**
435
+ * The engrams (and delivery forms) the selected model can actually run.
436
+ *
437
+ * `engram()` could always RUN one, but there was no way to ASK what exists — so a
438
+ * caller had to hard-code names from the docs, and every engram we shipped after
439
+ * their code was written stayed invisible to them. The MCP server hit exactly this
440
+ * and stopped hard-coding for the same reason. The service is the authority: what
441
+ * comes back depends on the model (delivery forms need Scroll 1.2+), so pass
442
+ * `withModel()` if you want another model's catalogue.
443
+ *
444
+ * const { engrams, forms } = await mem.listEngrams();
445
+ */
446
+ listEngrams(): Promise<EngramCatalog>;
360
447
  /** Create a store — the `userId` you read and write under. Stores are explicit:
361
448
  * a store must exist before you `add` to or `search` it, otherwise those calls
362
449
  * return 404. Idempotent. Every account starts with a `default` store.
@@ -380,6 +467,10 @@ export declare class Client {
380
467
  * this is destructive, so it never falls back to the default store. */
381
468
  deleteAll(userId: string): Promise<StatusResult>;
382
469
  private post;
470
+ /** Validate an idempotency key BEFORE the network. The API answers a malformed
471
+ * key with a 400, which on a retry path reads as "my write failed" when in fact
472
+ * it was never attempted — cheaper and clearer to reject it here. */
473
+ private idemHeader;
383
474
  /** Seconds→ms to sleep before retry `attempt` (0-based). Honors Retry-After. */
384
475
  private backoffMs;
385
476
  /** 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.24";
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,12 @@ 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
+ /** Statuses that legitimately carry NO body (RFC 9110). Everything else must
78
+ * answer with a JSON object — see the empty-body check in `request`. */
79
+ const NO_BODY_STATUS = new Set([204, 205, 304]);
74
80
  /** Paging backstop: bound the walk so a fresh-cursor-forever server can't loop us. */
75
81
  const MAX_PAGES = 1_000_000;
76
82
  /** Error codes that only occur while ESTABLISHING a connection — the request
@@ -341,29 +347,37 @@ export class Client {
341
347
  // ----- write -----
342
348
  // `userId` is optional in every call below - omit it to use the client's default
343
349
  // 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 });
350
+ /** Store one memory. `metadata` optional — e.g. `{ event_date: "2026-03-01" }` for when the
351
+ * content actually happened. A plain date works (read as UTC midnight); a full RFC3339
352
+ * timestamp also works. The API used to demand the full form and answered a bare date with
353
+ * an opaque 400, which is why older examples here show the long one.
354
+ * `metadata.speaker`: "me" = the assistant's own words, or a person's name (up to 50 per store).
355
+ *
356
+ * `opts.idempotencyKey`: see {@link WriteOptions} — pass one to make YOUR retry
357
+ * of this exact write safe to repeat. */
358
+ add(content, userId, metadata = {}, opts = {}) {
359
+ return this.post("/api/v1/memory/store", { user_id: this.uid(userId), content, metadata }, opts.idempotencyKey);
348
360
  }
349
361
  /** 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);
362
+ store(content, userId, metadata = {}, opts = {}) {
363
+ return this.add(content, userId, metadata, opts);
352
364
  }
353
365
  /** 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 });
366
+ addTurn(userMsg, assistantMsg, userId, opts = {}) {
367
+ return this.post("/api/v1/memory/store-turn", { user_id: this.uid(userId), user_msg: userMsg, assistant_msg: assistantMsg }, opts.idempotencyKey);
356
368
  }
357
- /** Bulk-ingest a large blob of text (chunked + embedded server-side). For backfilling. */
358
- addBulk(content, userId, category = "general", timestamp) {
369
+ /** Bulk-ingest a large blob of text (chunked + embedded server-side). For backfilling.
370
+ * The call most worth an `opts.idempotencyKey`: a backfill that dies halfway and is
371
+ * re-run would otherwise re-chunk and re-bill the whole blob. */
372
+ addBulk(content, userId, category = "general", timestamp, opts = {}) {
359
373
  const body = { user_id: this.uid(userId), content, category };
360
374
  if (timestamp)
361
375
  body.timestamp = timestamp;
362
- return this.post("/api/v1/memory/bulk-store", body);
376
+ return this.post("/api/v1/memory/bulk-store", body, opts.idempotencyKey);
363
377
  }
364
378
  /** 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 });
379
+ update(oldMemoryId, newContent, userId, opts = {}) {
380
+ return this.post("/api/v1/memory/supersede", { user_id: this.uid(userId), old_memory_id: oldMemoryId, new_content: newContent }, opts.idempotencyKey);
367
381
  }
368
382
  // ----- read -----
369
383
  /** Semantic search over a store's memories. Returns memories, most relevant first. */
@@ -473,6 +487,29 @@ export class Client {
473
487
  const data = await this.request("GET", "/api/v1/models");
474
488
  return asRecords(data.models);
475
489
  }
490
+ // ----- engrams -----
491
+ /**
492
+ * The engrams (and delivery forms) the selected model can actually run.
493
+ *
494
+ * `engram()` could always RUN one, but there was no way to ASK what exists — so a
495
+ * caller had to hard-code names from the docs, and every engram we shipped after
496
+ * their code was written stayed invisible to them. The MCP server hit exactly this
497
+ * and stopped hard-coding for the same reason. The service is the authority: what
498
+ * comes back depends on the model (delivery forms need Scroll 1.2+), so pass
499
+ * `withModel()` if you want another model's catalogue.
500
+ *
501
+ * const { engrams, forms } = await mem.listEngrams();
502
+ */
503
+ async listEngrams() {
504
+ const data = await this.request("GET", "/api/v1/engram");
505
+ return {
506
+ engrams: asRecords(data.engrams),
507
+ forms: asRecords(data.forms),
508
+ // Present when the selected model has no engram support — the service explains
509
+ // rather than returning a bare empty list.
510
+ note: typeof data.note === "string" ? data.note : undefined,
511
+ };
512
+ }
476
513
  // ----- stores -----
477
514
  /** Create a store — the `userId` you read and write under. Stores are explicit:
478
515
  * a store must exist before you `add` to or `search` it, otherwise those calls
@@ -526,8 +563,19 @@ export class Client {
526
563
  return this.post("/api/v1/memory/forget", { user_id: userId });
527
564
  }
528
565
  // ----- internal -----
529
- post(path, body) {
530
- return this.request("POST", path, body);
566
+ post(path, body, idempotencyKey) {
567
+ return this.request("POST", path, body, idempotencyKey);
568
+ }
569
+ /** Validate an idempotency key BEFORE the network. The API answers a malformed
570
+ * key with a 400, which on a retry path reads as "my write failed" when in fact
571
+ * it was never attempted — cheaper and clearer to reject it here. */
572
+ idemHeader(key) {
573
+ if (key === undefined)
574
+ return undefined;
575
+ if (!IDEMPOTENCY_KEY_RE.test(key)) {
576
+ throw new Error(`invalid idempotencyKey: ${JSON.stringify(key)} — 1-128 chars of [A-Za-z0-9._:-]`);
577
+ }
578
+ return { "Idempotency-Key": key };
531
579
  }
532
580
  /** Seconds→ms to sleep before retry `attempt` (0-based). Honors Retry-After. */
533
581
  backoffMs(attempt, retryAfter) {
@@ -573,7 +621,8 @@ export class Client {
573
621
  }
574
622
  return new TextDecoder().decode(buf);
575
623
  }
576
- async request(method, path, body) {
624
+ async request(method, path, body, idempotencyKey) {
625
+ const extraHeaders = this.idemHeader(idempotencyKey);
577
626
  const attempts = this.maxRetries + 1;
578
627
  // Debug logging shows the path WITHOUT its query string — a query can carry
579
628
  // a store id (listSpeakers), and logs must never carry data.
@@ -586,7 +635,7 @@ export class Client {
586
635
  try {
587
636
  res = await fetch(`${this.base}${path}`, {
588
637
  method,
589
- headers: this.headers,
638
+ headers: extraHeaders ? { ...this.headers, ...extraHeaders } : this.headers,
590
639
  body: body === undefined ? undefined : JSON.stringify(body),
591
640
  signal: ctrl.signal,
592
641
  // Never follow a redirect: fetch would forward the API key to
@@ -679,8 +728,15 @@ export class Client {
679
728
  msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
680
729
  throw errorFor(res.status, msg, requestId);
681
730
  }
682
- if (!text)
683
- return {};
731
+ // An empty body is only legal when the STATUS says there is no body. This
732
+ // used to accept any empty 2xx, so an `add()` answered by a truncating proxy
733
+ // returned `{}` and read as a successful write with no id — while the Python
734
+ // and Rust SDKs raised on the same response. Same rule in all three now.
735
+ if (!text) {
736
+ if (NO_BODY_STATUS.has(res.status))
737
+ return {};
738
+ throw new WosError(res.status, "empty response body — expected a JSON object");
739
+ }
684
740
  let data;
685
741
  try {
686
742
  data = JSON.parse(text);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wontopos",
3
- "version": "2.2.23",
3
+ "version": "2.2.24",
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",