@theokit/sdk-cache 0.1.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 +27 -0
- package/LICENSE +201 -0
- package/README.md +93 -0
- package/dist/index.cjs +663 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +153 -0
- package/dist/index.d.ts +153 -0
- package/dist/index.js +658 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
import { definePlugin } from '@theokit/sdk';
|
|
2
|
+
import { PersistenceSchema, atomicWriteText } from '@theokit/sdk/internal/persistence';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { createHash } from 'crypto';
|
|
5
|
+
import { createRequire } from 'module';
|
|
6
|
+
import { readFile, mkdir } from 'fs/promises';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
|
|
9
|
+
// src/cache.ts
|
|
10
|
+
|
|
11
|
+
// src/internal/embed-helper.ts
|
|
12
|
+
async function embedOrDegrade(embedder, prompt, store, span, context) {
|
|
13
|
+
try {
|
|
14
|
+
const result = await embedder.embed([prompt]);
|
|
15
|
+
return result[0];
|
|
16
|
+
} catch (err) {
|
|
17
|
+
store.incrementEmbedderFailures();
|
|
18
|
+
const action = context === "lookup" ? "degrading to miss" : "skipping cache write";
|
|
19
|
+
console.warn(
|
|
20
|
+
`[cache] embedder failed during ${context}, ${action}:`,
|
|
21
|
+
err instanceof Error ? err.message : err
|
|
22
|
+
);
|
|
23
|
+
span.setAttribute("cache.bypass_reason", "embedder_failure");
|
|
24
|
+
return void 0;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function computeCacheKey(p) {
|
|
28
|
+
const normalized = p.prompt.trim().replace(/\s+/g, " ").toLowerCase();
|
|
29
|
+
const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
30
|
+
return `${p.namespace}:${p.embedderId}:${p.modelId}:${hash}`;
|
|
31
|
+
}
|
|
32
|
+
var noopSpan = {
|
|
33
|
+
setAttribute: () => noopSpan,
|
|
34
|
+
end: () => void 0
|
|
35
|
+
};
|
|
36
|
+
var tracerCache = /* @__PURE__ */ new Map();
|
|
37
|
+
function getTracer(name, version = "1.0.0") {
|
|
38
|
+
const cached = tracerCache.get(name);
|
|
39
|
+
if (cached !== void 0) return cached.tracer ?? void 0;
|
|
40
|
+
try {
|
|
41
|
+
const r = createRequire(import.meta.url);
|
|
42
|
+
const otel = r("@opentelemetry/api");
|
|
43
|
+
if (otel.trace?.getTracer === void 0) {
|
|
44
|
+
tracerCache.set(name, { tracer: null });
|
|
45
|
+
return void 0;
|
|
46
|
+
}
|
|
47
|
+
const tracer = otel.trace.getTracer(name, version);
|
|
48
|
+
tracerCache.set(name, { tracer });
|
|
49
|
+
return tracer;
|
|
50
|
+
} catch {
|
|
51
|
+
tracerCache.set(name, { tracer: null });
|
|
52
|
+
return void 0;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
var TRACER_NAME = "@theokit/sdk/cache";
|
|
56
|
+
function startCacheLookupSpan(info) {
|
|
57
|
+
const tracer = getTracer(TRACER_NAME);
|
|
58
|
+
if (tracer === void 0) return noopSpan;
|
|
59
|
+
return tracer.startSpan("cache.lookup", {
|
|
60
|
+
attributes: {
|
|
61
|
+
"cache.namespace": info.namespace,
|
|
62
|
+
"cache.embedder_id": info.embedderId
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function startCacheStoreSpan(info) {
|
|
67
|
+
const tracer = getTracer(TRACER_NAME);
|
|
68
|
+
if (tracer === void 0) return noopSpan;
|
|
69
|
+
return tracer.startSpan("cache.store", {
|
|
70
|
+
attributes: {
|
|
71
|
+
"cache.namespace": info.namespace,
|
|
72
|
+
"cache.embedder_id": info.embedderId
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/internal/lookup.ts
|
|
78
|
+
async function performLookup(p) {
|
|
79
|
+
const span = startCacheLookupSpan({
|
|
80
|
+
namespace: p.namespace,
|
|
81
|
+
embedderId: p.embedder.id
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
if (p.prompt.trim().length === 0) {
|
|
85
|
+
p.store.incrementMisses();
|
|
86
|
+
span.setAttribute("cache.bypass_reason", "empty_prompt");
|
|
87
|
+
return { cached: false };
|
|
88
|
+
}
|
|
89
|
+
if (p.ttl.exclude?.test(p.prompt)) {
|
|
90
|
+
p.store.incrementExcluded();
|
|
91
|
+
span.setAttribute("cache.bypass_reason", "exclude_regex");
|
|
92
|
+
return { cached: false };
|
|
93
|
+
}
|
|
94
|
+
const key = computeCacheKey({
|
|
95
|
+
namespace: p.namespace,
|
|
96
|
+
embedderId: p.embedder.id,
|
|
97
|
+
modelId: p.modelId,
|
|
98
|
+
prompt: p.prompt
|
|
99
|
+
});
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
const kv = p.store.kvGet(key, now);
|
|
102
|
+
if (kv !== void 0) {
|
|
103
|
+
p.store.incrementKvHits();
|
|
104
|
+
span.setAttribute("cache.hit", "kv");
|
|
105
|
+
span.setAttribute("cache.ttl_remaining_s", Math.floor((kv.expiresAt - now) / 1e3));
|
|
106
|
+
return { cached: true, response: kv.response, source: "kv" };
|
|
107
|
+
}
|
|
108
|
+
const vec = await embedOrDegrade(p.embedder, p.prompt, p.store, span, "lookup");
|
|
109
|
+
if (vec === void 0) return { cached: false };
|
|
110
|
+
const match = p.store.semanticSearch(vec, p.threshold, p.embedder.id, p.namespace, now);
|
|
111
|
+
if (match !== void 0) {
|
|
112
|
+
p.store.incrementSemanticHits();
|
|
113
|
+
span.setAttribute("cache.hit", "semantic");
|
|
114
|
+
span.setAttribute("cache.distance", match.distance);
|
|
115
|
+
span.setAttribute("cache.ttl_remaining_s", Math.floor((match.entry.expiresAt - now) / 1e3));
|
|
116
|
+
return {
|
|
117
|
+
cached: true,
|
|
118
|
+
response: match.entry.response,
|
|
119
|
+
source: "semantic",
|
|
120
|
+
distance: match.distance
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
p.store.incrementMisses();
|
|
124
|
+
span.setAttribute("cache.hit", "miss");
|
|
125
|
+
return { cached: false };
|
|
126
|
+
} finally {
|
|
127
|
+
span.end();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/internal/cosine.ts
|
|
132
|
+
function cosineDistance(a, b) {
|
|
133
|
+
if (a.length !== b.length) {
|
|
134
|
+
throw new Error(`cosineDistance: dim mismatch (${a.length} vs ${b.length})`);
|
|
135
|
+
}
|
|
136
|
+
let dot = 0;
|
|
137
|
+
let normA = 0;
|
|
138
|
+
let normB = 0;
|
|
139
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
140
|
+
const av = a[i];
|
|
141
|
+
const bv = b[i];
|
|
142
|
+
dot += av * bv;
|
|
143
|
+
normA += av * av;
|
|
144
|
+
normB += bv * bv;
|
|
145
|
+
}
|
|
146
|
+
if (normA === 0 || normB === 0) return 1;
|
|
147
|
+
return 1 - dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/internal/store.ts
|
|
151
|
+
var InMemoryCacheStore = class {
|
|
152
|
+
constructor(maxEntries) {
|
|
153
|
+
this.maxEntries = maxEntries;
|
|
154
|
+
}
|
|
155
|
+
maxEntries;
|
|
156
|
+
map = /* @__PURE__ */ new Map();
|
|
157
|
+
counters = {
|
|
158
|
+
kvHits: 0,
|
|
159
|
+
semanticHits: 0,
|
|
160
|
+
misses: 0,
|
|
161
|
+
excluded: 0,
|
|
162
|
+
evicted: 0,
|
|
163
|
+
embedderFailures: 0
|
|
164
|
+
};
|
|
165
|
+
kvGet(key, now) {
|
|
166
|
+
const e = this.map.get(key);
|
|
167
|
+
if (e === void 0) return void 0;
|
|
168
|
+
if (e.expiresAt <= now) {
|
|
169
|
+
this.map.delete(key);
|
|
170
|
+
this.counters.evicted += 1;
|
|
171
|
+
return void 0;
|
|
172
|
+
}
|
|
173
|
+
this.map.delete(key);
|
|
174
|
+
this.map.set(key, { ...e, accessedAt: now, accessCount: e.accessCount + 1 });
|
|
175
|
+
return this.map.get(key);
|
|
176
|
+
}
|
|
177
|
+
isEligibleForSearch(e, embedderId, namespace, dim, now) {
|
|
178
|
+
if (e.embedderId !== embedderId) return false;
|
|
179
|
+
if (e.namespace !== namespace) return false;
|
|
180
|
+
if (e.vector.length !== dim) return false;
|
|
181
|
+
if (e.expiresAt <= now) return false;
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
semanticSearch(vector, threshold, embedderId, namespace, now) {
|
|
185
|
+
let best;
|
|
186
|
+
for (const e of this.map.values()) {
|
|
187
|
+
if (!this.isEligibleForSearch(e, embedderId, namespace, vector.length, now)) continue;
|
|
188
|
+
const d = cosineDistance(e.vector, vector);
|
|
189
|
+
if (d <= threshold && (best === void 0 || d < best.distance)) {
|
|
190
|
+
best = { entry: e, distance: d };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (best !== void 0) {
|
|
194
|
+
this.map.delete(best.entry.key);
|
|
195
|
+
this.map.set(best.entry.key, {
|
|
196
|
+
...best.entry,
|
|
197
|
+
accessedAt: now,
|
|
198
|
+
accessCount: best.entry.accessCount + 1
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return best;
|
|
202
|
+
}
|
|
203
|
+
set(entry) {
|
|
204
|
+
if (this.map.has(entry.key)) {
|
|
205
|
+
this.map.delete(entry.key);
|
|
206
|
+
}
|
|
207
|
+
this.map.set(entry.key, entry);
|
|
208
|
+
while (this.map.size > this.maxEntries) {
|
|
209
|
+
const oldestKey = this.map.keys().next().value;
|
|
210
|
+
if (oldestKey === void 0) break;
|
|
211
|
+
this.map.delete(oldestKey);
|
|
212
|
+
this.counters.evicted += 1;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
delete(key) {
|
|
216
|
+
this.map.delete(key);
|
|
217
|
+
}
|
|
218
|
+
async clear() {
|
|
219
|
+
this.map.clear();
|
|
220
|
+
}
|
|
221
|
+
stats() {
|
|
222
|
+
return {
|
|
223
|
+
entries: this.map.size,
|
|
224
|
+
kvHits: this.counters.kvHits,
|
|
225
|
+
semanticHits: this.counters.semanticHits,
|
|
226
|
+
misses: this.counters.misses,
|
|
227
|
+
excluded: this.counters.excluded,
|
|
228
|
+
evicted: this.counters.evicted,
|
|
229
|
+
embedderFailures: this.counters.embedderFailures
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
evictExpired(now) {
|
|
233
|
+
let count = 0;
|
|
234
|
+
for (const [k, e] of this.map.entries()) {
|
|
235
|
+
if (e.expiresAt <= now) {
|
|
236
|
+
this.map.delete(k);
|
|
237
|
+
count += 1;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
this.counters.evicted += count;
|
|
241
|
+
return count;
|
|
242
|
+
}
|
|
243
|
+
loadAll(entries) {
|
|
244
|
+
for (const e of entries) this.map.set(e.key, e);
|
|
245
|
+
}
|
|
246
|
+
dump() {
|
|
247
|
+
return [...this.map.values()];
|
|
248
|
+
}
|
|
249
|
+
/** Internal helpers for counters (used by lookup/store handlers). */
|
|
250
|
+
incrementKvHits() {
|
|
251
|
+
this.counters.kvHits += 1;
|
|
252
|
+
}
|
|
253
|
+
incrementSemanticHits() {
|
|
254
|
+
this.counters.semanticHits += 1;
|
|
255
|
+
}
|
|
256
|
+
incrementMisses() {
|
|
257
|
+
this.counters.misses += 1;
|
|
258
|
+
}
|
|
259
|
+
incrementExcluded() {
|
|
260
|
+
this.counters.excluded += 1;
|
|
261
|
+
}
|
|
262
|
+
incrementEmbedderFailures() {
|
|
263
|
+
this.counters.embedderFailures += 1;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// src/types/cache.ts
|
|
268
|
+
var CacheEmbedderError = class extends Error {
|
|
269
|
+
name = "CacheEmbedderError";
|
|
270
|
+
cause;
|
|
271
|
+
constructor(message, cause) {
|
|
272
|
+
super(`Cache embedder failed: ${message}`);
|
|
273
|
+
if (cause !== void 0) this.cause = cause;
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
var CacheInvalidTtlError = class extends Error {
|
|
277
|
+
constructor(input) {
|
|
278
|
+
super(
|
|
279
|
+
`Invalid TTL value: "${String(input)}". Expected number (seconds) or string like "1h" / "30m" / "7d".`
|
|
280
|
+
);
|
|
281
|
+
this.input = input;
|
|
282
|
+
}
|
|
283
|
+
input;
|
|
284
|
+
name = "CacheInvalidTtlError";
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// src/internal/ttl.ts
|
|
288
|
+
var TTL_PATTERN = /^(\d+)\s*(s|m|h|d|w)$/i;
|
|
289
|
+
function parseTtlMs(input) {
|
|
290
|
+
if (typeof input === "number") {
|
|
291
|
+
if (!Number.isFinite(input) || input < 0) {
|
|
292
|
+
throw new CacheInvalidTtlError(input);
|
|
293
|
+
}
|
|
294
|
+
return Math.floor(input * 1e3);
|
|
295
|
+
}
|
|
296
|
+
const trimmed = input.trim();
|
|
297
|
+
const m = TTL_PATTERN.exec(trimmed);
|
|
298
|
+
if (m === null) {
|
|
299
|
+
throw new CacheInvalidTtlError(input);
|
|
300
|
+
}
|
|
301
|
+
const value = Number(m[1]);
|
|
302
|
+
const unit = m[2].toLowerCase();
|
|
303
|
+
switch (unit) {
|
|
304
|
+
case "s":
|
|
305
|
+
return value * 1e3;
|
|
306
|
+
case "m":
|
|
307
|
+
return value * 6e4;
|
|
308
|
+
case "h":
|
|
309
|
+
return value * 36e5;
|
|
310
|
+
case "d":
|
|
311
|
+
return value * 864e5;
|
|
312
|
+
case "w":
|
|
313
|
+
return value * 6048e5;
|
|
314
|
+
/* c8 ignore next 2 */
|
|
315
|
+
default:
|
|
316
|
+
throw new CacheInvalidTtlError(input);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/internal/store-handler.ts
|
|
321
|
+
async function performStore(p) {
|
|
322
|
+
const span = startCacheStoreSpan({
|
|
323
|
+
namespace: p.namespace,
|
|
324
|
+
embedderId: p.embedder.id
|
|
325
|
+
});
|
|
326
|
+
try {
|
|
327
|
+
if (p.prompt.trim().length === 0) {
|
|
328
|
+
span.setAttribute("cache.bypass_reason", "empty_prompt");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (p.response.length === 0) {
|
|
332
|
+
span.setAttribute("cache.bypass_reason", "empty_response");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (p.usedTools === true) {
|
|
336
|
+
span.setAttribute("cache.bypass_reason", "used_tools");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (p.ttl.exclude?.test(p.prompt)) {
|
|
340
|
+
span.setAttribute("cache.bypass_reason", "exclude_regex");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const key = computeCacheKey({
|
|
344
|
+
namespace: p.namespace,
|
|
345
|
+
embedderId: p.embedder.id,
|
|
346
|
+
modelId: p.modelId,
|
|
347
|
+
prompt: p.prompt
|
|
348
|
+
});
|
|
349
|
+
const vec = await embedOrDegrade(p.embedder, p.prompt, p.store, span, "store");
|
|
350
|
+
if (vec === void 0) return;
|
|
351
|
+
const now = Date.now();
|
|
352
|
+
const ttlMs = parseTtlMs(p.ttl.default);
|
|
353
|
+
p.store.set({
|
|
354
|
+
key,
|
|
355
|
+
namespace: p.namespace,
|
|
356
|
+
embedderId: p.embedder.id,
|
|
357
|
+
modelId: p.modelId,
|
|
358
|
+
prompt: p.prompt,
|
|
359
|
+
response: p.response,
|
|
360
|
+
vector: vec,
|
|
361
|
+
createdAt: now,
|
|
362
|
+
expiresAt: now + ttlMs,
|
|
363
|
+
accessedAt: now,
|
|
364
|
+
accessCount: 0
|
|
365
|
+
});
|
|
366
|
+
span.setAttribute("cache.stored", true);
|
|
367
|
+
} finally {
|
|
368
|
+
span.end();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
var FLUSH_DEBOUNCE_MS = 200;
|
|
372
|
+
var JsonFileCacheStore = class {
|
|
373
|
+
constructor(dir, namespace, maxEntries) {
|
|
374
|
+
this.dir = dir;
|
|
375
|
+
this.namespace = namespace;
|
|
376
|
+
this.inner = new InMemoryCacheStore(maxEntries);
|
|
377
|
+
}
|
|
378
|
+
dir;
|
|
379
|
+
namespace;
|
|
380
|
+
inner;
|
|
381
|
+
flushTimer;
|
|
382
|
+
dirty = false;
|
|
383
|
+
/** Hydrate from disk. EC-7: corrupt file → empty cache. */
|
|
384
|
+
async hydrate() {
|
|
385
|
+
const file = this.filePath();
|
|
386
|
+
try {
|
|
387
|
+
const raw = await readFile(file, "utf8");
|
|
388
|
+
let parsed;
|
|
389
|
+
try {
|
|
390
|
+
parsed = JSON.parse(raw);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
console.warn(
|
|
393
|
+
`[cache] corrupt snapshot at ${file}, starting fresh:`,
|
|
394
|
+
err instanceof Error ? err.message : err
|
|
395
|
+
);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (parsed._schemaVersion !== 1) {
|
|
399
|
+
console.warn(
|
|
400
|
+
`[cache] unsupported schema v${parsed._schemaVersion} at ${file}, starting fresh`
|
|
401
|
+
);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const valid = parsed.entries.filter((e) => e.namespace === this.namespace);
|
|
405
|
+
this.inner.loadAll(valid);
|
|
406
|
+
} catch (err) {
|
|
407
|
+
if (err.code === "ENOENT") return;
|
|
408
|
+
console.warn(`[cache] failed to read ${file}:`, err instanceof Error ? err.message : err);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
kvGet(key, now) {
|
|
412
|
+
return this.inner.kvGet(key, now);
|
|
413
|
+
}
|
|
414
|
+
semanticSearch(vector, threshold, embedderId, namespace, now) {
|
|
415
|
+
return this.inner.semanticSearch(vector, threshold, embedderId, namespace, now);
|
|
416
|
+
}
|
|
417
|
+
set(entry) {
|
|
418
|
+
this.inner.set(entry);
|
|
419
|
+
this.markDirty();
|
|
420
|
+
}
|
|
421
|
+
delete(key) {
|
|
422
|
+
this.inner.delete(key);
|
|
423
|
+
this.markDirty();
|
|
424
|
+
}
|
|
425
|
+
async clear() {
|
|
426
|
+
await this.inner.clear();
|
|
427
|
+
this.markDirty();
|
|
428
|
+
await this.flush();
|
|
429
|
+
}
|
|
430
|
+
stats() {
|
|
431
|
+
return this.inner.stats();
|
|
432
|
+
}
|
|
433
|
+
evictExpired(now) {
|
|
434
|
+
const n = this.inner.evictExpired(now);
|
|
435
|
+
if (n > 0) this.markDirty();
|
|
436
|
+
return n;
|
|
437
|
+
}
|
|
438
|
+
/** Force write the current snapshot. Called on shutdown / clear. */
|
|
439
|
+
async flush() {
|
|
440
|
+
if (this.flushTimer !== void 0) {
|
|
441
|
+
clearTimeout(this.flushTimer);
|
|
442
|
+
this.flushTimer = void 0;
|
|
443
|
+
}
|
|
444
|
+
if (!this.dirty) return;
|
|
445
|
+
await this.writeSnapshot();
|
|
446
|
+
this.dirty = false;
|
|
447
|
+
}
|
|
448
|
+
/** Counters proxied to inner. */
|
|
449
|
+
incrementKvHits() {
|
|
450
|
+
this.inner.incrementKvHits();
|
|
451
|
+
}
|
|
452
|
+
incrementSemanticHits() {
|
|
453
|
+
this.inner.incrementSemanticHits();
|
|
454
|
+
}
|
|
455
|
+
incrementMisses() {
|
|
456
|
+
this.inner.incrementMisses();
|
|
457
|
+
}
|
|
458
|
+
incrementExcluded() {
|
|
459
|
+
this.inner.incrementExcluded();
|
|
460
|
+
}
|
|
461
|
+
incrementEmbedderFailures() {
|
|
462
|
+
this.inner.incrementEmbedderFailures();
|
|
463
|
+
}
|
|
464
|
+
filePath() {
|
|
465
|
+
return join(this.dir, `${this.namespace}.json`);
|
|
466
|
+
}
|
|
467
|
+
markDirty() {
|
|
468
|
+
this.dirty = true;
|
|
469
|
+
if (this.flushTimer === void 0) {
|
|
470
|
+
this.flushTimer = setTimeout(() => {
|
|
471
|
+
this.flushTimer = void 0;
|
|
472
|
+
void this.flush().catch(
|
|
473
|
+
(err) => console.warn(`[cache] debounced flush failed:`, err instanceof Error ? err.message : err)
|
|
474
|
+
);
|
|
475
|
+
}, FLUSH_DEBOUNCE_MS);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
async writeSnapshot() {
|
|
479
|
+
await mkdir(this.dir, { recursive: true });
|
|
480
|
+
const snapshot = {
|
|
481
|
+
_schemaVersion: 1,
|
|
482
|
+
namespace: this.namespace,
|
|
483
|
+
entries: this.inner.dump()
|
|
484
|
+
};
|
|
485
|
+
const serialized = JSON.stringify(snapshot);
|
|
486
|
+
await atomicWriteText(this.filePath(), serialized);
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
// src/cache.ts
|
|
491
|
+
var CacheSemanticOptionsSchema = z.object({
|
|
492
|
+
embedder: z.unknown().refine(
|
|
493
|
+
(v) => {
|
|
494
|
+
if (v === null || typeof v !== "object") return false;
|
|
495
|
+
const o = v;
|
|
496
|
+
return typeof o.id === "string" && typeof o.embed === "function" && typeof o.dimension === "number";
|
|
497
|
+
},
|
|
498
|
+
{ message: "embedder must be a CacheEmbedderRuntime with { id, dimension, embed }" }
|
|
499
|
+
),
|
|
500
|
+
threshold: z.number().min(0).max(2).optional(),
|
|
501
|
+
ttl: z.object({
|
|
502
|
+
default: z.union([z.string(), z.number()]),
|
|
503
|
+
exclude: z.instanceof(RegExp).optional()
|
|
504
|
+
}).optional(),
|
|
505
|
+
namespace: z.string().min(1).max(64).optional(),
|
|
506
|
+
modelId: z.string().min(1).max(128).optional(),
|
|
507
|
+
maxEntries: z.number().int().min(1).max(1e6).optional(),
|
|
508
|
+
persistence: PersistenceSchema
|
|
509
|
+
});
|
|
510
|
+
var DEFAULT_THRESHOLD = 0.85;
|
|
511
|
+
var DEFAULT_TTL = { default: "1h" };
|
|
512
|
+
var DEFAULT_NAMESPACE = "global";
|
|
513
|
+
var DEFAULT_MAX_ENTRIES = 1e3;
|
|
514
|
+
var Cache = class _Cache {
|
|
515
|
+
constructor(embedder, threshold, ttl, namespace, modelId, store) {
|
|
516
|
+
this.embedder = embedder;
|
|
517
|
+
this.threshold = threshold;
|
|
518
|
+
this.ttl = ttl;
|
|
519
|
+
this.namespace = namespace;
|
|
520
|
+
this.modelId = modelId;
|
|
521
|
+
this.store = store;
|
|
522
|
+
}
|
|
523
|
+
embedder;
|
|
524
|
+
threshold;
|
|
525
|
+
ttl;
|
|
526
|
+
namespace;
|
|
527
|
+
modelId;
|
|
528
|
+
store;
|
|
529
|
+
_plugin;
|
|
530
|
+
static semantic(options) {
|
|
531
|
+
CacheSemanticOptionsSchema.parse(options);
|
|
532
|
+
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
|
|
533
|
+
const ttl = options.ttl ?? DEFAULT_TTL;
|
|
534
|
+
const namespace = options.namespace ?? DEFAULT_NAMESPACE;
|
|
535
|
+
const modelId = options.modelId ?? "unknown";
|
|
536
|
+
const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
537
|
+
const store = createStore(namespace, maxEntries, options.persistence);
|
|
538
|
+
return new _Cache(options.embedder, threshold, ttl, namespace, modelId, store);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* EC-4 absorbed: memoized so repeated `asPlugin()` calls return the SAME
|
|
542
|
+
* plugin descriptor — no duplicate hook registration.
|
|
543
|
+
*/
|
|
544
|
+
asPlugin() {
|
|
545
|
+
if (this._plugin !== void 0) return this._plugin;
|
|
546
|
+
const cache = this;
|
|
547
|
+
this._plugin = definePlugin({
|
|
548
|
+
name: `cache-semantic-${this.namespace}`,
|
|
549
|
+
version: "1.0.0",
|
|
550
|
+
kind: "general",
|
|
551
|
+
register(ctx) {
|
|
552
|
+
ctx.on("pre_user_send", async (rawCtx) => {
|
|
553
|
+
const c = rawCtx;
|
|
554
|
+
const result = await performLookup({
|
|
555
|
+
prompt: c.prompt,
|
|
556
|
+
store: cache.store,
|
|
557
|
+
embedder: cache.embedder,
|
|
558
|
+
threshold: cache.threshold,
|
|
559
|
+
ttl: cache.ttl,
|
|
560
|
+
namespace: cache.namespace,
|
|
561
|
+
modelId: cache.modelId
|
|
562
|
+
});
|
|
563
|
+
if (result.cached === true) {
|
|
564
|
+
const wrapped = {
|
|
565
|
+
recalledContext: result.response
|
|
566
|
+
};
|
|
567
|
+
return wrapped;
|
|
568
|
+
}
|
|
569
|
+
const miss = {};
|
|
570
|
+
return miss;
|
|
571
|
+
});
|
|
572
|
+
ctx.on("post_assistant_reply", async (rawCtx) => {
|
|
573
|
+
const c = rawCtx;
|
|
574
|
+
await performStore({
|
|
575
|
+
prompt: c.prompt,
|
|
576
|
+
response: c.reply,
|
|
577
|
+
usedTools: false,
|
|
578
|
+
store: cache.store,
|
|
579
|
+
embedder: cache.embedder,
|
|
580
|
+
ttl: cache.ttl,
|
|
581
|
+
namespace: cache.namespace,
|
|
582
|
+
modelId: cache.modelId
|
|
583
|
+
});
|
|
584
|
+
return void 0;
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
return this._plugin;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Explicit cache lookup — callers that want true LLM short-circuit
|
|
592
|
+
* call this BEFORE `agent.send()`, then dispatch to the LLM only on miss.
|
|
593
|
+
*
|
|
594
|
+
* v1 plugin mode provides recall + context-inject (LLM still called).
|
|
595
|
+
* v1.x will add transparent short-circuit via an agent-loop refactor.
|
|
596
|
+
*/
|
|
597
|
+
async consult(prompt) {
|
|
598
|
+
const result = await performLookup({
|
|
599
|
+
prompt,
|
|
600
|
+
store: this.store,
|
|
601
|
+
embedder: this.embedder,
|
|
602
|
+
threshold: this.threshold,
|
|
603
|
+
ttl: this.ttl,
|
|
604
|
+
namespace: this.namespace,
|
|
605
|
+
modelId: this.modelId
|
|
606
|
+
});
|
|
607
|
+
if (result.cached === true) {
|
|
608
|
+
return {
|
|
609
|
+
hit: true,
|
|
610
|
+
response: result.response ?? "",
|
|
611
|
+
source: result.source ?? "kv",
|
|
612
|
+
...result.distance !== void 0 ? { distance: result.distance } : {}
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
return { hit: false };
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Explicit cache store — pair with `consult()` to manually feed the
|
|
619
|
+
* cache after dispatching the LLM call yourself.
|
|
620
|
+
*/
|
|
621
|
+
async remember(prompt, response, opts) {
|
|
622
|
+
await performStore({
|
|
623
|
+
prompt,
|
|
624
|
+
response,
|
|
625
|
+
usedTools: opts?.usedTools === true,
|
|
626
|
+
store: this.store,
|
|
627
|
+
embedder: this.embedder,
|
|
628
|
+
ttl: this.ttl,
|
|
629
|
+
namespace: this.namespace,
|
|
630
|
+
modelId: this.modelId
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
/** Stats snapshot — primary observable for dogfood verification. */
|
|
634
|
+
stats() {
|
|
635
|
+
return this.store.stats();
|
|
636
|
+
}
|
|
637
|
+
/** Clear all entries (and flush to disk if JSON backend). */
|
|
638
|
+
async clear() {
|
|
639
|
+
await this.store.clear();
|
|
640
|
+
}
|
|
641
|
+
/** Force-evict expired entries. Returns count removed. */
|
|
642
|
+
evictExpired(now = Date.now()) {
|
|
643
|
+
return this.store.evictExpired(now);
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
function createStore(namespace, maxEntries, persistence) {
|
|
647
|
+
if (persistence?.backend === "json") {
|
|
648
|
+
const dir = persistence.dir;
|
|
649
|
+
const store = new JsonFileCacheStore(dir, namespace, maxEntries);
|
|
650
|
+
void store.hydrate();
|
|
651
|
+
return store;
|
|
652
|
+
}
|
|
653
|
+
return new InMemoryCacheStore(maxEntries);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export { Cache, CacheEmbedderError, CacheInvalidTtlError };
|
|
657
|
+
//# sourceMappingURL=index.js.map
|
|
658
|
+
//# sourceMappingURL=index.js.map
|