wontopos 2.2.24 → 2.2.26

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 CHANGED
@@ -52,7 +52,13 @@ registration and never counts against it.
52
52
  ## Recall caching
53
53
 
54
54
  Opt in per search and repeated or extended queries reuse the previous result
55
- at 10% of the normal rate (Tablet and Scroll models):
55
+ at 10% of the normal rate (Tablet and Scroll models).
56
+
57
+ It is not free to turn on: the FIRST call writes the cache and bills the query
58
+ tokens at 2x for a `5m` TTL, 3x for `1h`. Only hits inside the TTL bill at 0.1x.
59
+ So it pays for a query you repeat or extend, and costs more for one you issue
60
+ once — do not switch it on globally. Any write to the store invalidates its cache
61
+ at once, so a hit can never predate a new memory.
56
62
 
57
63
  ```ts
58
64
  const hits = await mem.search("...the conversation so far...", "alice", 10,
@@ -74,6 +80,27 @@ Built in, no configuration needed:
74
80
  meaning "delete everything"; wiping a store is only ever the explicit
75
81
  `deleteAll(userId)` / `deleteStore(userId)`.
76
82
 
83
+ ### Behind a corporate proxy
84
+
85
+ Node's global `fetch` (undici) ignores `HTTP_PROXY` / `HTTPS_PROXY`, so behind an
86
+ egress proxy this SDK could not connect at all — while the Python and Rust SDKs went
87
+ through, because `requests` and `reqwest` both read those variables. Pass a
88
+ proxy-aware `fetch` and the three behave the same:
89
+
90
+ ```ts
91
+ import { ProxyAgent } from "undici";
92
+
93
+ const agent = new ProxyAgent(process.env.HTTPS_PROXY!);
94
+ const mem = new Client({
95
+ apiKey: process.env.WONTOPOS_API_KEY!,
96
+ fetch: (url, init) => fetch(url, { ...init, dispatcher: agent } as any),
97
+ });
98
+ ```
99
+
100
+ The same option is how you add instrumentation or drive the client without a
101
+ network in tests. It replaces the transport, not the client's rules — retries,
102
+ timeouts, redirect refusal and the response cap all still apply.
103
+
77
104
  ## Security
78
105
 
79
106
  Built in, none of it configurable off:
@@ -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 {
@@ -121,6 +134,10 @@ export interface StoreResult {
121
134
  duplicate_of?: string;
122
135
  /** Present when the store was a duplicate and something (e.g. a speaker tag) was dropped. */
123
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;
124
141
  [key: string]: unknown;
125
142
  }
126
143
  /** `addTurn` / `addBulk` / `delete` / `deleteAll` → `{ status }`. */
@@ -209,8 +226,13 @@ export interface EngramCatalog {
209
226
  }
210
227
  /** `createStore` / `deleteStore` → `{ user_id, status }`. */
211
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. */
212
231
  user_id?: string;
213
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;
214
236
  [key: string]: unknown;
215
237
  }
216
238
  export interface StoreInfo {
@@ -260,9 +282,13 @@ export interface SearchFilters {
260
282
  /** Drop matches the engine scored below this importance (0–1). */
261
283
  min_importance?: number;
262
284
  }
285
+ /** Test hook — the warn-once set is process-global by design. */
286
+ export declare function _resetFilterWarnings(): void;
263
287
  /** Known `search` options; extra fields pass through to the API untouched. */
264
288
  export interface SearchOptions {
265
- /** Recall caching: repeated/extended queries within the TTL bill at 0.1×. */
289
+ /** Recall caching. A hit inside the TTL bills at 0.1× — but the FIRST call
290
+ * writes the cache and bills the query tokens at 2× (`5m`) or 3× (`1h`), so
291
+ * this only pays for a query you repeat or extend. Do not enable it globally. */
266
292
  cache_control?: {
267
293
  ttl: "5m" | "1h";
268
294
  };
@@ -312,6 +338,26 @@ export interface ClientOptions {
312
338
  * key on an options object is not an error in TS at runtime). Both names work;
313
339
  * `maxRetries` wins if somehow both are given. */
314
340
  retries?: number;
341
+ /** The `fetch` used for every request (default: the global `fetch`).
342
+ *
343
+ * This is the seam for anything the runtime cannot express through options.
344
+ * The one that matters in practice is a corporate egress proxy: Node's global
345
+ * fetch (undici) IGNORES `HTTP_PROXY` / `HTTPS_PROXY`, so behind such a proxy
346
+ * this SDK could not connect at all — while the Python and Rust SDKs went
347
+ * through, because `requests` and `reqwest` both read those variables. Pass a
348
+ * proxy-aware fetch and the three behave the same again:
349
+ *
350
+ * import { ProxyAgent } from "undici";
351
+ * const agent = new ProxyAgent(process.env.HTTPS_PROXY!);
352
+ * const mem = new Client({
353
+ * apiKey,
354
+ * fetch: (url, init) => fetch(url, { ...init, dispatcher: agent } as any),
355
+ * });
356
+ *
357
+ * It is also how you add instrumentation or drive the client in a test without
358
+ * a network. Retries, timeouts, redirect refusal and the size cap all still
359
+ * apply — this replaces the transport, not the client's rules. */
360
+ fetch?: typeof fetch;
315
361
  }
316
362
  /** Wontopos memory client. Store and recall memories, isolated per `userId`. */
317
363
  export declare class Client {
@@ -321,6 +367,9 @@ export declare class Client {
321
367
  private readonly apiKey;
322
368
  private readonly model;
323
369
  private readonly maxRetries;
370
+ /** Transport. Held as a field (not read off `globalThis` per call) so a caller
371
+ * who passes one gets it for every request, including from cloned clients. */
372
+ private readonly fetchImpl;
324
373
  /** The store every call uses unless one passes `userId`. */
325
374
  private readonly defaultUser;
326
375
  private _rateLimit;
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.24";
40
+ const VERSION = "2.2.26";
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). */
@@ -74,6 +74,40 @@ const MAX_ERR_MSG = 4096;
74
74
  /** What the API accepts as an `Idempotency-Key`. Checked client-side so a bad key
75
75
  * fails before the request instead of coming back as a 400 mid-retry. */
76
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
+ }
77
111
  /** Statuses that legitimately carry NO body (RFC 9110). Everything else must
78
112
  * answer with a JSON object — see the empty-body check in `request`. */
79
113
  const NO_BODY_STATUS = new Set([204, 205, 304]);
@@ -208,7 +242,18 @@ export class ConflictError extends WosError {
208
242
  /** 429 — too many requests. Back off and retry (the client already retries these). */
209
243
  export class RateLimitError extends WosError {
210
244
  }
211
- /** 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
+ */
212
257
  export class ServerError extends WosError {
213
258
  }
214
259
  const STATUS_ERRORS = {
@@ -228,6 +273,34 @@ function errorFor(status, message, requestId) {
228
273
  : STATUS_ERRORS[status] ?? (status >= 500 && status < 600 ? ServerError : WosError);
229
274
  return new Cls(status, message, requestId);
230
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
+ }
231
304
  const DEFAULT_USER = "default";
232
305
  /** Wontopos memory client. Store and recall memories, isolated per `userId`. */
233
306
  export class Client {
@@ -237,6 +310,9 @@ export class Client {
237
310
  apiKey;
238
311
  model;
239
312
  maxRetries;
313
+ /** Transport. Held as a field (not read off `globalThis` per call) so a caller
314
+ * who passes one gets it for every request, including from cloned clients. */
315
+ fetchImpl;
240
316
  /** The store every call uses unless one passes `userId`. */
241
317
  defaultUser;
242
318
  _rateLimit = null;
@@ -271,6 +347,17 @@ export class Client {
271
347
  // "retries exhausted"; Infinity would retry forever.
272
348
  const mr = opts.maxRetries ?? opts.retries;
273
349
  this.maxRetries = typeof mr === "number" && Number.isFinite(mr) ? Math.max(0, Math.floor(mr)) : 2;
350
+ // Bind the global so `fetch` is not called as a method of `globalThis`, which
351
+ // throws "Illegal invocation" on some runtimes. A caller-supplied fetch is
352
+ // taken as given — it is already whatever they meant to hand us.
353
+ const f = opts.fetch;
354
+ if (f !== undefined && typeof f !== "function") {
355
+ throw new Error("fetch must be a function (a fetch-compatible transport)");
356
+ }
357
+ this.fetchImpl = f ?? (globalThis.fetch ? globalThis.fetch.bind(globalThis) : undefined);
358
+ if (typeof this.fetchImpl !== "function") {
359
+ throw new Error("no fetch available in this runtime — pass one as `fetch` (Node 18+ has it built in; older Node needs undici)");
360
+ }
274
361
  this.headers = {
275
362
  "X-API-Key": this.apiKey,
276
363
  "Content-Type": "application/json",
@@ -310,7 +397,9 @@ export class Client {
310
397
  }
311
398
  /** Resolve a call's store: the explicit userId, else the client default. */
312
399
  uid(userId) {
313
- return userId || this.defaultUser;
400
+ const id = userId || this.defaultUser;
401
+ warnIfStoreIdCollapses(id);
402
+ return id;
314
403
  }
315
404
  clone(overrides) {
316
405
  return new Client({
@@ -320,6 +409,10 @@ export class Client {
320
409
  model: this.model,
321
410
  userId: this.defaultUser,
322
411
  maxRetries: this.maxRetries,
412
+ // Carry the transport across clones. Without this, `withModel(...)` and
413
+ // friends would silently fall back to the global fetch and a proxy-bound
414
+ // client would stop connecting the moment it was cloned.
415
+ fetch: this.fetchImpl,
323
416
  ...overrides,
324
417
  });
325
418
  }
@@ -384,6 +477,7 @@ export class Client {
384
477
  async search(query, userId, limit = 10, opts = {}) {
385
478
  // Reserved fields win over ...opts: an app that forwards untrusted input as
386
479
  // opts must not be able to override the store (user_id), query, or limit.
480
+ warnOnUnknownFilters(opts.filters);
387
481
  const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
388
482
  return mergeLanes(r);
389
483
  }
@@ -393,6 +487,7 @@ export class Client {
393
487
  * speaker "me"), kept separate so the reader never confuses who said what. On a model
394
488
  * without a self lane, `self_memories` is `[]`. `userId` optional (default store). */
395
489
  async searchSelf(query, userId, limit = 10, opts = {}) {
490
+ warnOnUnknownFilters(opts.filters);
396
491
  const r = await this.post("/api/v1/memory/search", { ...opts, user_id: this.uid(userId), query, max_results: limit });
397
492
  return { memories: asRecords(r.memories), self_memories: asRecords(r.self_memories) };
398
493
  }
@@ -633,7 +728,7 @@ export class Client {
633
728
  const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
634
729
  let res;
635
730
  try {
636
- res = await fetch(`${this.base}${path}`, {
731
+ res = await this.fetchImpl(`${this.base}${path}`, {
637
732
  method,
638
733
  headers: extraHeaders ? { ...this.headers, ...extraHeaders } : this.headers,
639
734
  body: body === undefined ? undefined : JSON.stringify(body),
@@ -753,6 +848,13 @@ export class Client {
753
848
  const got = data === null ? "null" : Array.isArray(data) ? "array" : typeof data;
754
849
  throw new WosError(res.status, `expected a JSON object in the response, got ${got}`);
755
850
  }
851
+ // 멱등키를 쓴 쓰기가 **저장된 것인지 재생된 것인지**를 호출자가 알아야 한다.
852
+ // 서버는 `Idempotent-Replayed: true` 로 말해주는데 지금까지 SDK 가 삼켰다 —
853
+ // 멱등키를 쓰는 사람이 가장 알고 싶은 사실이 그것이다(재시도가 실제로
854
+ // 저장을 일으켰나, 아니면 첫 응답이 되풀이된 것인가).
855
+ if (res.headers.get("Idempotent-Replayed") === "true") {
856
+ data.replayed = true;
857
+ }
756
858
  return data;
757
859
  }
758
860
  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.24",
3
+ "version": "2.2.26",
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",