@theokit/sdk-cache 0.3.1 → 1.0.0
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/CHANGELOG.md +164 -0
- package/LICENSE +2 -2
- package/README.md +15 -2
- package/dist/index.cjs +144 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +330 -29
- package/dist/index.d.ts +330 -29
- package/dist/index.js +145 -28
- package/dist/index.js.map +1 -1
- package/package.json +14 -6
package/dist/index.d.cts
CHANGED
|
@@ -6,44 +6,170 @@ import { Plugin } from '@theokit/sdk';
|
|
|
6
6
|
*
|
|
7
7
|
* @public
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* How long entries live, and which prompts never become entries at all.
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
9
14
|
interface CacheTTLConfig {
|
|
10
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Lifetime applied to every entry written, evaluated at write time.
|
|
17
|
+
*
|
|
18
|
+
* A NUMBER is SECONDS (`3600` is an hour). A STRING needs a unit suffix — `s`, `m`, `h`, `d`, `w`
|
|
19
|
+
* (`"30m"`, `"1h"`, `"7d"`). A bare numeric string is not a duration: `"3600"` throws
|
|
20
|
+
* {@link CacheInvalidTtlError}, and so do a negative number and an unknown unit. `0` / `"0s"`
|
|
21
|
+
* parses fine and writes entries that are already expired, which disables the cache without
|
|
22
|
+
* disabling the embedding calls.
|
|
23
|
+
*
|
|
24
|
+
* The throw happens on the WRITE, not at `Cache.semantic(...)` — a bad value survives
|
|
25
|
+
* construction and surfaces on the first `remember()` or the first cached assistant reply.
|
|
26
|
+
*/
|
|
11
27
|
readonly default: string | number;
|
|
12
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Prompts matching this regex are never cached — e.g. `/weather|today|now/i` for anything whose
|
|
30
|
+
* answer goes stale.
|
|
31
|
+
*
|
|
32
|
+
* Applies to BOTH directions: a matching prompt is not looked up (it counts as
|
|
33
|
+
* {@link CacheStats.excluded}, not a miss) and not stored. Test it against your real prompts —
|
|
34
|
+
* a regex broad enough to match every question disables the cache while every counter still
|
|
35
|
+
* looks healthy.
|
|
36
|
+
*
|
|
37
|
+
* Bring your own regex object; a `/g` flag is a hazard here because `RegExp.test` is stateful
|
|
38
|
+
* with it and would match every other call.
|
|
39
|
+
*/
|
|
13
40
|
readonly exclude?: RegExp;
|
|
14
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Where cached entries live between process restarts.
|
|
44
|
+
*
|
|
45
|
+
* `"memory"` (the default when {@link CacheSemanticOptions.persistence} is omitted) keeps everything
|
|
46
|
+
* in the process and loses it on exit — right for a request-scoped worker, wrong for a CLI that runs
|
|
47
|
+
* once per invocation and would never see a hit.
|
|
48
|
+
*
|
|
49
|
+
* `"json"` writes the whole entry set, VECTORS INCLUDED, to a file under `dir`. That file grows with
|
|
50
|
+
* `maxEntries` × the embedder's dimension, so a 1000-entry cache over a 1536-dimension embedder is
|
|
51
|
+
* on the order of megabytes, and it is plaintext: every prompt and response is readable. Do not point
|
|
52
|
+
* `dir` at a directory that gets committed.
|
|
53
|
+
*/
|
|
15
54
|
interface CachePersistenceOptions {
|
|
55
|
+
/** `"memory"` for process-local, `"json"` for a file under {@link CachePersistenceOptions.dir}. */
|
|
16
56
|
readonly backend: "memory" | "json";
|
|
17
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Directory holding `<namespace>.json`. REQUIRED when `backend` is `"json"` — omitting it makes
|
|
59
|
+
* `Cache.semantic(...)` throw `ZodError`, it is not silently downgraded to memory.
|
|
60
|
+
*
|
|
61
|
+
* Created recursively on the first write. Writes are atomic but DEBOUNCED by 200 ms, and loading
|
|
62
|
+
* is fire-and-forget, so the file is eventually-consistent with memory in both directions. A
|
|
63
|
+
* corrupt or wrong-schema file is logged and treated as an empty cache — it never blocks startup.
|
|
64
|
+
*/
|
|
18
65
|
readonly dir?: string;
|
|
19
66
|
}
|
|
20
67
|
/**
|
|
21
68
|
* Embedder runtime shape — minimal subset of `EmbeddingRuntime` (D11) the
|
|
22
69
|
* Cache actually uses. Lets tests inject fake embedders without pulling
|
|
23
70
|
* the full memory subsystem.
|
|
71
|
+
*
|
|
72
|
+
* `@theokit/sdk-cache` ships one implementation, `createLexicalEmbedder()`; anything with these
|
|
73
|
+
* four members works, including a wrapper around a provider's embedding endpoint.
|
|
74
|
+
*
|
|
75
|
+
* @public
|
|
24
76
|
*/
|
|
25
77
|
interface CacheEmbedderRuntime {
|
|
78
|
+
/**
|
|
79
|
+
* Stable identity of this embedding SPACE, not of the object.
|
|
80
|
+
*
|
|
81
|
+
* It is part of the exact-match key and of the semantic eligibility filter, so changing it
|
|
82
|
+
* invalidates every existing entry — which is the point: vectors from two different embedders
|
|
83
|
+
* are not comparable, and a shared id would let one embedder's vectors be matched against
|
|
84
|
+
* another's. Version it whenever the model, its parameters or the dimension change.
|
|
85
|
+
*/
|
|
26
86
|
readonly id: string;
|
|
87
|
+
/** Human-facing model name. Recorded for diagnostics; the cache never keys on it. */
|
|
27
88
|
readonly model: string;
|
|
89
|
+
/**
|
|
90
|
+
* Length of the vectors `embed` returns.
|
|
91
|
+
*
|
|
92
|
+
* Entries whose stored vector has a different length are skipped during the semantic scan rather
|
|
93
|
+
* than compared, so a dimension change silently costs you the whole warm cache instead of
|
|
94
|
+
* throwing.
|
|
95
|
+
*/
|
|
28
96
|
readonly dimension: number;
|
|
97
|
+
/**
|
|
98
|
+
* Embed a batch; the cache always passes exactly one text and reads `result[0]`.
|
|
99
|
+
*
|
|
100
|
+
* A rejection is NOT propagated to the caller: the cache logs it, counts it in
|
|
101
|
+
* {@link CacheStats.embedderFailures} and treats the operation as a miss / skipped write. Return
|
|
102
|
+
* a zero vector only if you want it treated as a non-match, since cosine distance against it is
|
|
103
|
+
* defined as 1.0.
|
|
104
|
+
*/
|
|
29
105
|
embed(texts: ReadonlyArray<string>): Promise<number[][]>;
|
|
30
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Configuration for `Cache.semantic(...)`.
|
|
109
|
+
*
|
|
110
|
+
* Only `embedder` is required, and deliberately so: autoselecting one would make an agent start
|
|
111
|
+
* calling an embedding API because a cache was enabled, which is a surprise bill rather than a
|
|
112
|
+
* default. Everything else has a working default.
|
|
113
|
+
*
|
|
114
|
+
* The lookup runs in two stages — an exact key match first, then a vector search — so an identical
|
|
115
|
+
* prompt never pays for an embedding call. Only the second stage consults `threshold`.
|
|
116
|
+
*/
|
|
31
117
|
interface CacheSemanticOptions {
|
|
32
118
|
/** Embedder instance. REQUIRED in v1 — no autoselect (avoids surprise API calls). */
|
|
33
119
|
readonly embedder: CacheEmbedderRuntime;
|
|
34
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Maximum cosine DISTANCE (`1 - cosine similarity`) at which a stored entry counts as a match.
|
|
122
|
+
* Default 0.85. Lower is stricter; 0 requires an identical direction, 1 accepts orthogonal
|
|
123
|
+
* vectors, and the accepted range is 0..2.
|
|
124
|
+
*
|
|
125
|
+
* It is a distance and not a similarity, so raising it LOOSENS matching — 0.85 is already
|
|
126
|
+
* permissive for normalized embeddings and will return semantically unrelated answers if your
|
|
127
|
+
* embedder spreads vectors narrowly. Only the vector stage consults it; an exact-key hit ignores
|
|
128
|
+
* it entirely. Watch {@link CacheStats.semanticHits} when you tune it.
|
|
129
|
+
*/
|
|
35
130
|
readonly threshold?: number;
|
|
36
131
|
/** TTL config. Default `{ default: "1h" }`. */
|
|
37
132
|
readonly ttl?: CacheTTLConfig;
|
|
38
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* Isolation bucket, 1..64 chars. Default `"global"`. Entries never match across namespaces.
|
|
135
|
+
*
|
|
136
|
+
* It is also the plugin name (`cache-semantic-<namespace>`) and, under `"json"` persistence, the
|
|
137
|
+
* FILE name. Two caches sharing a namespace and a `dir` therefore share ONE store rather than
|
|
138
|
+
* racing over one file (#359) — with the consequence that the second one's `maxEntries` is
|
|
139
|
+
* ignored, since the store already exists.
|
|
140
|
+
*/
|
|
39
141
|
readonly namespace?: string;
|
|
40
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* Model id stamped on every entry, and part of both the exact key and the semantic eligibility
|
|
144
|
+
* filter — a response cached for one model is never returned for another.
|
|
145
|
+
*
|
|
146
|
+
* Defaults to the literal string `"unknown"`, which is an ORDINARY value rather than a wildcard:
|
|
147
|
+
* entries written by a cache that defaulted it are visible only to another cache that also
|
|
148
|
+
* defaults it. Set it to the same id you pass to `Agent.create({ model })`.
|
|
149
|
+
*/
|
|
41
150
|
readonly modelId?: string;
|
|
42
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* Ceiling on stored entries. Default 1000. Exceeding it evicts the least recently used entry and
|
|
153
|
+
* increments {@link CacheStats.evicted}.
|
|
154
|
+
*
|
|
155
|
+
* Sizes the JSON snapshot too: the file holds every entry's full embedding, so this multiplied by
|
|
156
|
+
* the embedder dimension is the file's order of magnitude.
|
|
157
|
+
*/
|
|
43
158
|
readonly maxEntries?: number;
|
|
44
159
|
/** Persistence backend. Default in-memory. */
|
|
45
160
|
readonly persistence?: CachePersistenceOptions;
|
|
46
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* One cached prompt/response pair, as `Cache` stores it.
|
|
164
|
+
*
|
|
165
|
+
* Read-only from the outside: entries are produced by the cache, and reach a caller only through a
|
|
166
|
+
* `"json"` persistence dump. `vector` is the embedding of `prompt`, which is what makes the file
|
|
167
|
+
* large; `accessedAt` / `accessCount` are what LRU eviction reads when `maxEntries` is reached.
|
|
168
|
+
*
|
|
169
|
+
* `key` is the exact-match key (a hash of namespace, embedder, model and prompt), so two entries with
|
|
170
|
+
* the same prompt under different models are different entries — a cached answer never crosses a
|
|
171
|
+
* model boundary.
|
|
172
|
+
*/
|
|
47
173
|
interface CacheEntry {
|
|
48
174
|
readonly key: string;
|
|
49
175
|
readonly namespace: string;
|
|
@@ -57,6 +183,24 @@ interface CacheEntry {
|
|
|
57
183
|
readonly accessedAt: number;
|
|
58
184
|
readonly accessCount: number;
|
|
59
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Counters returned by `Cache.stats()`. All monotonic within a process; a `"json"` backend does not
|
|
188
|
+
* restore them, so they count THIS process's traffic, not the file's history.
|
|
189
|
+
*
|
|
190
|
+
* The three miss-shaped counters are distinct on purpose, and the distinction is the point of
|
|
191
|
+
* reading stats at all:
|
|
192
|
+
*
|
|
193
|
+
* - `misses` — looked up, nothing matched. The cache is working and cold. An empty or
|
|
194
|
+
* whitespace-only prompt also lands here, having never been looked up at all.
|
|
195
|
+
* - `excluded` — {@link CacheTTLConfig.exclude} matched the prompt, so no lookup happened. High and
|
|
196
|
+
* unexpected means the regex is too broad.
|
|
197
|
+
* - `embedderFailures` — the embedder threw. The lookup DEGRADES to a miss rather than failing the
|
|
198
|
+
* call, so a broken embedder shows up here as a rising number and nowhere else. A cache that
|
|
199
|
+
* suddenly stops hitting, with this climbing, is an embedder outage — not a cold cache.
|
|
200
|
+
*
|
|
201
|
+
* `kvHits` counts exact-key matches (no embedding call); `semanticHits` counts vector matches.
|
|
202
|
+
* A `semanticHits` of zero with healthy `kvHits` means `threshold` is too strict.
|
|
203
|
+
*/
|
|
60
204
|
interface CacheStats {
|
|
61
205
|
readonly entries: number;
|
|
62
206
|
readonly kvHits: number;
|
|
@@ -66,11 +210,18 @@ interface CacheStats {
|
|
|
66
210
|
readonly evicted: number;
|
|
67
211
|
readonly embedderFailures: number;
|
|
68
212
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
213
|
+
/**
|
|
214
|
+
* A TTL value that could not be parsed, thrown at configuration time rather than on first use.
|
|
215
|
+
*
|
|
216
|
+
* Accepts a number of SECONDS, or a string with a unit suffix `s` / `m` / `h` / `d` / `w`
|
|
217
|
+
* (`"30m"`, `"1h"`, `"7d"`). A bare numeric string is not a duration — `"3600"` is rejected,
|
|
218
|
+
* `3600` is an hour. Also rejected: a negative or non-finite number, and any unit outside that
|
|
219
|
+
* set. `input` carries what was passed, so the message names the offending value rather than the
|
|
220
|
+
* field.
|
|
221
|
+
*
|
|
222
|
+
* "Configuration time" means the first WRITE that applies the TTL, not `Cache.semantic(...)` —
|
|
223
|
+
* `CacheSemanticOptions` is validated for shape, never for TTL parseability.
|
|
224
|
+
*/
|
|
74
225
|
declare class CacheInvalidTtlError extends Error {
|
|
75
226
|
readonly input: string | number;
|
|
76
227
|
readonly name = "CacheInvalidTtlError";
|
|
@@ -83,28 +234,52 @@ declare class CacheInvalidTtlError extends Error {
|
|
|
83
234
|
*
|
|
84
235
|
* Usage:
|
|
85
236
|
*
|
|
86
|
-
* import { Agent
|
|
237
|
+
* import { Agent } from "@theokit/sdk";
|
|
238
|
+
* import { Cache, createLexicalEmbedder } from "@theokit/sdk-cache";
|
|
87
239
|
*
|
|
88
240
|
* const cache = Cache.semantic({
|
|
89
|
-
* embedder:
|
|
241
|
+
* embedder: createLexicalEmbedder(), // or any CacheEmbedderRuntime
|
|
90
242
|
* threshold: 0.85,
|
|
91
243
|
* ttl: { default: "1h", exclude: /weather|today|now/i },
|
|
92
244
|
* namespace: "my-app",
|
|
93
245
|
* modelId: "openai/gpt-4o-mini",
|
|
94
246
|
* });
|
|
95
247
|
*
|
|
248
|
+
* // (a) Plugin mode — the cached answer is INJECTED as context; the LLM is still called.
|
|
96
249
|
* const agent = await Agent.create({
|
|
97
250
|
* model: { id: "openai/gpt-4o-mini" },
|
|
98
251
|
* plugins: [cache.asPlugin()],
|
|
99
|
-
* // ...
|
|
100
252
|
* });
|
|
101
253
|
*
|
|
102
|
-
*
|
|
103
|
-
* await
|
|
254
|
+
* // (b) Explicit mode — this is the one that skips the LLM call.
|
|
255
|
+
* const hit = await cache.consult(prompt);
|
|
256
|
+
* const answer = hit.hit ? hit.response : await callTheModel(prompt);
|
|
257
|
+
* if (!hit.hit) await cache.remember(prompt, answer);
|
|
258
|
+
*
|
|
259
|
+
* The distinction between (a) and (b) is the single most important thing to know about this
|
|
260
|
+
* package — see `Cache.asPlugin`.
|
|
104
261
|
*
|
|
105
262
|
* @public
|
|
106
263
|
*/
|
|
107
264
|
|
|
265
|
+
/**
|
|
266
|
+
* A semantic response cache: an exact-key lookup, then a vector-similarity lookup, over
|
|
267
|
+
* prompt/response pairs the caller has stored.
|
|
268
|
+
*
|
|
269
|
+
* Build one with {@link Cache.semantic}; `new Cache()` is a compile error. One instance owns one
|
|
270
|
+
* store, so two `Cache.semantic(...)` calls never share entries even under the same `namespace`
|
|
271
|
+
* and the same `dir` — the JSON backend will have both instances writing the same file.
|
|
272
|
+
*
|
|
273
|
+
* Two ways to use it, and they do NOT save the same thing:
|
|
274
|
+
*
|
|
275
|
+
* | | {@link Cache.consult} + {@link Cache.remember} | {@link Cache.asPlugin} |
|
|
276
|
+
* |---|---|---|
|
|
277
|
+
* | LLM call on a hit | skipped | still made |
|
|
278
|
+
* | What you save | the whole call | nothing, today |
|
|
279
|
+
* | Who drives it | you | the agent loop |
|
|
280
|
+
*
|
|
281
|
+
* @public
|
|
282
|
+
*/
|
|
108
283
|
declare class Cache {
|
|
109
284
|
private readonly embedder;
|
|
110
285
|
private readonly threshold;
|
|
@@ -112,20 +287,92 @@ declare class Cache {
|
|
|
112
287
|
private readonly namespace;
|
|
113
288
|
private readonly modelId;
|
|
114
289
|
private readonly store;
|
|
290
|
+
private readonly hydrated;
|
|
115
291
|
private _plugin?;
|
|
116
292
|
private constructor();
|
|
293
|
+
/**
|
|
294
|
+
* Resolves once the `"json"` backend has finished reading its snapshot; resolves immediately on
|
|
295
|
+
* the in-memory backend.
|
|
296
|
+
*
|
|
297
|
+
* You rarely need it: `consult` and `remember` await hydration themselves, so a cache is correct
|
|
298
|
+
* without it. It exists for a caller who wants the read charged to startup rather than to the
|
|
299
|
+
* first lookup — and because the code promised it long before it existed (#359).
|
|
300
|
+
*
|
|
301
|
+
* A corrupt or unreadable snapshot resolves normally with an empty cache and a warning on
|
|
302
|
+
* stderr; a cache must not take the process down.
|
|
303
|
+
*/
|
|
304
|
+
ready(): Promise<void>;
|
|
305
|
+
/**
|
|
306
|
+
* Write the pending snapshot to disk now, keeping every entry. No-op on the in-memory backend.
|
|
307
|
+
*
|
|
308
|
+
* Writes are debounced 200ms, so a process that remembers something and exits inside that window
|
|
309
|
+
* persists nothing — precisely the once-per-invocation CLI the `"json"` backend exists for. Call
|
|
310
|
+
* this before exiting. Nothing flushes on teardown: an `exit` handler cannot await, and a library
|
|
311
|
+
* installing a process-level hook is a side effect the caller did not ask for.
|
|
312
|
+
*
|
|
313
|
+
* Until #359 the only public call that wrote the snapshot was `clear()`, which also destroyed
|
|
314
|
+
* everything you wanted to persist.
|
|
315
|
+
*/
|
|
316
|
+
flush(): Promise<void>;
|
|
317
|
+
/**
|
|
318
|
+
* Build a cache. Validates `options` with Zod and THROWS `ZodError` on a bad shape — an
|
|
319
|
+
* `embedder` missing `{ id, dimension, embed }`, a `threshold` outside `0..2`, a `namespace`
|
|
320
|
+
* longer than 64 chars, or `persistence: { backend: "json" }` without a `dir`.
|
|
321
|
+
*
|
|
322
|
+
* Defaults: `threshold` 0.85, `ttl` `{ default: "1h" }`, `namespace` `"global"`, `maxEntries`
|
|
323
|
+
* 1000 (LRU), `persistence` in-memory. `modelId` defaults to the literal string `"unknown"`,
|
|
324
|
+
* which is a real namespace value and not a wildcard: entries stored while `modelId` was
|
|
325
|
+
* defaulted are only ever returned to lookups that also default it.
|
|
326
|
+
*
|
|
327
|
+
* With `persistence: { backend: "json", dir }` the snapshot is read in the background and this
|
|
328
|
+
* call does not await it — but `consult` and `remember` do, so a lookup issued immediately after
|
|
329
|
+
* construction still sees what is on disk. Await {@link Cache.ready} to charge the read to
|
|
330
|
+
* startup instead of to the first lookup. Two caches built with the same `dir` and `namespace`
|
|
331
|
+
* share one store, so they cannot overwrite each other's snapshot; the FIRST one's `maxEntries`
|
|
332
|
+
* is the one that applies.
|
|
333
|
+
*/
|
|
117
334
|
static semantic(options: CacheSemanticOptions): Cache;
|
|
118
335
|
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
336
|
+
* A `Plugin` for `Agent.create({ plugins: [...] })` that reads the cache before each user turn
|
|
337
|
+
* and writes it after each assistant reply.
|
|
338
|
+
*
|
|
339
|
+
* READ THIS BEFORE BUDGETING FOR IT. A hit does NOT skip the model call. The hook returns the
|
|
340
|
+
* cached response as `PreUserSendResult.recalledContext`, which the agent loop injects as a
|
|
341
|
+
* `<memory-context>` block ahead of the prompt — the request still goes to the provider, still
|
|
342
|
+
* costs tokens, and still returns whatever the model makes of that context, which need not be
|
|
343
|
+
* the cached text. Use {@link Cache.consult} / {@link Cache.remember} when the point is to avoid
|
|
344
|
+
* the call.
|
|
345
|
+
*
|
|
346
|
+
* A turn that invoked tools is NOT cached: the store hook reads
|
|
347
|
+
* `PostAssistantReplyContext.usedTools`, which the runtime derives from the run's tool calls
|
|
348
|
+
* (#358). Replaying such an answer would hand a later caller the result of a write that never
|
|
349
|
+
* happened. Until that signal existed the hook passed a literal `false` and cached those turns,
|
|
350
|
+
* despite the package's stated intent.
|
|
351
|
+
*
|
|
352
|
+
* Memoized: repeated calls return the SAME plugin, so registering it twice does not double the
|
|
353
|
+
* hooks.
|
|
121
354
|
*/
|
|
122
355
|
asPlugin(): Plugin;
|
|
123
356
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
357
|
+
* Look a prompt up. Call it BEFORE dispatching to the model and skip the call on a hit — this is
|
|
358
|
+
* the only path in this package that actually avoids an LLM request.
|
|
359
|
+
*
|
|
360
|
+
* ```ts
|
|
361
|
+
* const hit = await cache.consult(prompt);
|
|
362
|
+
* if (hit.hit) return hit.response;
|
|
363
|
+
* ```
|
|
364
|
+
*
|
|
365
|
+
* `source` says which stage matched: `"kv"` is an exact-key match and costs NO embedding call;
|
|
366
|
+
* `"semantic"` means the prompt was embedded and a stored vector came within `threshold`, and
|
|
367
|
+
* only then is `distance` present (cosine distance, so smaller is closer).
|
|
126
368
|
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
369
|
+
* NEVER THROWS on an embedder failure. It degrades to `{ hit: false }`, logs a warning on
|
|
370
|
+
* stderr, and increments {@link CacheStats.embedderFailures} — a cache must not take the request
|
|
371
|
+
* down with it. A cache that has silently stopped hitting is that counter climbing, not a cold
|
|
372
|
+
* cache.
|
|
373
|
+
*
|
|
374
|
+
* An empty or whitespace-only prompt returns `{ hit: false }` and counts as a MISS. A prompt
|
|
375
|
+
* matching {@link CacheTTLConfig.exclude} returns `{ hit: false }` and counts as `excluded`.
|
|
129
376
|
*/
|
|
130
377
|
consult(prompt: string): Promise<{
|
|
131
378
|
hit: false;
|
|
@@ -136,17 +383,51 @@ declare class Cache {
|
|
|
136
383
|
distance?: number;
|
|
137
384
|
}>;
|
|
138
385
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
386
|
+
* Store a prompt/response pair. Pair it with {@link Cache.consult} after you dispatched the model
|
|
387
|
+
* call yourself.
|
|
388
|
+
*
|
|
389
|
+
* Pass `{ usedTools: true }` when the answer came from a run that invoked tools and replaying it
|
|
390
|
+
* would lose the side effects — the write is then skipped entirely.
|
|
391
|
+
*
|
|
392
|
+
* Silently writes nothing when the prompt is empty/whitespace, the response is empty, the prompt
|
|
393
|
+
* matches {@link CacheTTLConfig.exclude}, or the embedder fails (that last case increments
|
|
394
|
+
* {@link CacheStats.embedderFailures}). It resolves in every one of those cases: a resolved
|
|
395
|
+
* promise is not evidence that an entry exists — read {@link CacheStats.entries} if you need
|
|
396
|
+
* that.
|
|
397
|
+
*
|
|
398
|
+
* Writing beyond `maxEntries` evicts the least-recently-used entry. On the `"json"` backend the
|
|
399
|
+
* disk write is DEBOUNCED by 200 ms, so a process that exits right after this resolves loses the
|
|
400
|
+
* entry unless it calls {@link Cache.flush} — which writes the snapshot and keeps every entry.
|
|
401
|
+
* Nothing flushes on teardown.
|
|
141
402
|
*/
|
|
142
403
|
remember(prompt: string, response: string, opts?: {
|
|
143
404
|
usedTools?: boolean;
|
|
144
405
|
}): Promise<void>;
|
|
145
|
-
/**
|
|
406
|
+
/**
|
|
407
|
+
* Counter snapshot for this instance. See {@link CacheStats} for what each counter separates —
|
|
408
|
+
* in particular `misses` vs `excluded` vs `embedderFailures`, which is how you tell a cold cache
|
|
409
|
+
* from a too-broad exclude regex from a broken embedder.
|
|
410
|
+
*
|
|
411
|
+
* Process-local: the `"json"` backend persists entries, never counters, so a restart reports
|
|
412
|
+
* zeros against a warm file.
|
|
413
|
+
*/
|
|
146
414
|
stats(): CacheStats;
|
|
147
|
-
/**
|
|
415
|
+
/**
|
|
416
|
+
* Drop every entry. On the `"json"` backend this also forces the debounced snapshot to disk
|
|
417
|
+
* immediately, so it is the one public call that guarantees the file matches memory.
|
|
418
|
+
*
|
|
419
|
+
* Counters are NOT reset — `stats()` keeps reporting the hits and misses accumulated before the
|
|
420
|
+
* clear, so `entries: 0` alongside a non-zero `kvHits` is expected, not a bug.
|
|
421
|
+
*/
|
|
148
422
|
clear(): Promise<void>;
|
|
149
|
-
/**
|
|
423
|
+
/**
|
|
424
|
+
* Remove every entry whose TTL has elapsed, returning how many were dropped, and add that to
|
|
425
|
+
* {@link CacheStats.evicted}.
|
|
426
|
+
*
|
|
427
|
+
* Optional housekeeping: expired entries are already skipped on lookup and dropped when touched,
|
|
428
|
+
* so this only reclaims memory for entries nobody asks for. `now` exists to make the sweep
|
|
429
|
+
* testable; leave it out in production.
|
|
430
|
+
*/
|
|
150
431
|
evictExpired(now?: number): number;
|
|
151
432
|
}
|
|
152
433
|
|
|
@@ -167,6 +448,26 @@ declare class Cache {
|
|
|
167
448
|
* @public
|
|
168
449
|
*/
|
|
169
450
|
|
|
451
|
+
/**
|
|
452
|
+
* Build the built-in lexical embedder — a zero-dependency, zero-cost `CacheEmbedderRuntime`.
|
|
453
|
+
*
|
|
454
|
+
* ```ts
|
|
455
|
+
* const cache = Cache.semantic({ embedder: createLexicalEmbedder() });
|
|
456
|
+
* ```
|
|
457
|
+
*
|
|
458
|
+
* Matches on SHARED WORDS, not on meaning. "What is the capital of France?" and "Tell me the
|
|
459
|
+
* capital of France" land close together; "capital of France" and "French capital" do not. If you
|
|
460
|
+
* need paraphrase-level hits, wrap a real embedding API instead — that is what
|
|
461
|
+
* `CacheEmbedderRuntime` is for.
|
|
462
|
+
*
|
|
463
|
+
* `dimension` (default 256) is the number of hash buckets. Raising it reduces collisions between
|
|
464
|
+
* unrelated words, which is the failure mode here: a collision makes two unrelated prompts look
|
|
465
|
+
* similar and can serve a wrong cached answer. It is also baked into the returned `id`
|
|
466
|
+
* (`theokit-lexical-v1-d<dimension>`), so CHANGING IT INVALIDATES every entry already stored — the
|
|
467
|
+
* cache keys on the embedder id and skips vectors of a different length.
|
|
468
|
+
*
|
|
469
|
+
* Never rejects, and never calls the network.
|
|
470
|
+
*/
|
|
170
471
|
declare function createLexicalEmbedder(dimension?: number): CacheEmbedderRuntime;
|
|
171
472
|
|
|
172
|
-
export { Cache,
|
|
473
|
+
export { Cache, type CacheEmbedderRuntime, type CacheEntry, CacheInvalidTtlError, type CachePersistenceOptions, type CacheSemanticOptions, type CacheStats, type CacheTTLConfig, createLexicalEmbedder };
|