@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/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