adaptive-memory-multi-model-router 2.5.5 → 2.7.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.
@@ -1,83 +1,184 @@
1
1
  /**
2
- * A3M Router - Semantic Cache
2
+ * A3M Router - Semantic Cache (Embedding-based)
3
3
  *
4
4
  * Stores previous query->response pairs and returns cached responses
5
- * for semantically similar queries using character n-gram Jaccard similarity.
5
+ * for semantically similar queries using cosine similarity on embeddings.
6
6
  *
7
- * No external embedding API needed. Trigram overlap catches paraphrases like:
8
- * "What is Python?" ≈ "Tell me about Python" ≈ "Explain Python"
9
- * "Write a sort fn" ≈ "Create a sorting fn" ≈ "How to sort an array"
7
+ * Supports embedders: nomic (via Ollama), openai, or local Ollama.
8
+ * Uses nomic-embed-text by default via local Ollama.
10
9
  */
11
10
 
12
11
  // ============================================================
13
12
  // Types
14
13
  // ============================================================
15
14
 
16
- export interface CachedResponse {
17
- query: string;
18
- response: string;
19
- metadata?: any;
20
- cachedAt: number;
21
- hitCount: number;
15
+ export type EmbedderType = 'nomic' | 'openai' | 'local';
16
+
17
+ export interface SemanticCacheConfig {
18
+ similarityThreshold: number; // cosine similarity, e.g., 0.92
19
+ ttlSeconds: number; // default TTL in seconds
20
+ maxEntries?: number; // LRU cache size limit (default: 1000)
21
+ embedder?: EmbedderType; // which embedder to use (default: 'nomic')
22
+ embedderUrl?: string; // custom embedder URL
23
+ embedderApiKey?: string; // API key for external embedder
22
24
  }
23
25
 
24
- export interface SemanticCacheOptions {
25
- maxSize?: number; // Max entries (default: 1000)
26
- similarityThreshold?: number; // 0-1, min similarity for hit (default: 0.92)
27
- ttl?: number; // TTL in ms (default: 3600000 = 1 hour)
26
+ export interface CacheEntry {
27
+ key: string; // hash of normalized query
28
+ embedding: number[];
29
+ response: string;
30
+ provider: string;
31
+ model: string;
32
+ cost: number;
33
+ createdAt: number;
34
+ ttl: number; // TTL in ms
35
+ hitCount: number;
36
+ lastAccessedAt: number; // for LRU tracking
28
37
  }
29
38
 
30
- interface CacheEntry extends CachedResponse {
31
- trigrams: Set<string>;
32
- expiresAt: number;
39
+ export interface SemanticCacheGetResult {
40
+ hit: boolean;
41
+ response?: string;
42
+ provider?: string;
43
+ model?: string;
44
+ cost?: number;
45
+ similarity?: number;
33
46
  }
34
47
 
35
48
  export interface SemanticCacheStats {
49
+ size: number;
36
50
  hits: number;
37
51
  misses: number;
38
52
  hitRate: number;
39
- size: number;
40
53
  }
41
54
 
42
55
  // ============================================================
43
- // N-gram utilities
56
+ // Embedding Service
57
+ // ============================================================
58
+
59
+ interface Embedder {
60
+ embed(text: string): Promise<number[]>;
61
+ }
62
+
63
+ class NomicEmbedder implements Embedder {
64
+ constructor(private url: string = 'http://127.0.0.1:11434/api/embeddings') {}
65
+
66
+ async embed(text: string): Promise<number[]> {
67
+ const response = await fetch(this.url, {
68
+ method: 'POST',
69
+ headers: { 'Content-Type': 'application/json' },
70
+ body: JSON.stringify({
71
+ model: 'nomic-embed-text',
72
+ prompt: text,
73
+ }),
74
+ });
75
+
76
+ if (!response.ok) {
77
+ throw new Error(`Nomic embedder error: ${response.status} ${response.statusText}`);
78
+ }
79
+
80
+ const data = await response.json() as { embedding: number[] };
81
+ return data.embedding;
82
+ }
83
+ }
84
+
85
+ class OpenAIEmbedder implements Embedder {
86
+ constructor(private apiKey: string, private url: string = 'https://api.openai.com/v1/embeddings') {}
87
+
88
+ async embed(text: string): Promise<number[]> {
89
+ const response = await fetch(this.url, {
90
+ method: 'POST',
91
+ headers: {
92
+ 'Content-Type': 'application/json',
93
+ 'Authorization': `Bearer ${this.apiKey}`,
94
+ },
95
+ body: JSON.stringify({
96
+ model: 'text-embedding-3-small',
97
+ input: text,
98
+ }),
99
+ });
100
+
101
+ if (!response.ok) {
102
+ throw new Error(`OpenAI embedder error: ${response.status} ${response.statusText}`);
103
+ }
104
+
105
+ const data = await response.json() as { data: Array<{ embedding: number[] }> };
106
+ return data.data[0]?.embedding ?? [];
107
+ }
108
+ }
109
+
110
+ class LocalOllamaEmbedder implements Embedder {
111
+ constructor(
112
+ private baseUrl: string = 'http://127.0.0.1:11434/api/embeddings',
113
+ private model: string = 'nomic-embed-text'
114
+ ) {}
115
+
116
+ async embed(text: string): Promise<number[]> {
117
+ const response = await fetch(this.baseUrl, {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/json' },
120
+ body: JSON.stringify({
121
+ model: this.model,
122
+ prompt: text,
123
+ }),
124
+ });
125
+
126
+ if (!response.ok) {
127
+ throw new Error(`Local Ollama embedder error: ${response.status} ${response.statusText}`);
128
+ }
129
+
130
+ const data = await response.json() as { embedding: number[] };
131
+ return data.embedding;
132
+ }
133
+ }
134
+
135
+ // ============================================================
136
+ // Utilities
44
137
  // ============================================================
45
138
 
46
139
  /**
47
- * Normalize text: lowercase, collapse whitespace, strip punctuation edges.
140
+ * Compute cosine similarity between two vectors.
48
141
  */
49
- function normalize(text: string): string {
50
- return text
51
- .toLowerCase()
52
- .replace(/\s+/g, " ")
53
- .trim();
142
+ function cosineSimilarity(a: number[], b: number[]): number {
143
+ if (a.length !== b.length) return 0;
144
+ if (a.length === 0) return 0;
145
+
146
+ let dotProduct = 0;
147
+ let normA = 0;
148
+ let normB = 0;
149
+
150
+ for (let i = 0; i < a.length; i++) {
151
+ dotProduct += a[i] * b[i];
152
+ normA += a[i] * a[i];
153
+ normB += b[i] * b[i];
154
+ }
155
+
156
+ const denominator = Math.sqrt(normA) * Math.sqrt(normB);
157
+ return denominator === 0 ? 0 : dotProduct / denominator;
54
158
  }
55
159
 
56
160
  /**
57
- * Extract character trigrams from text.
58
- * Pads with spaces so short words still produce trigrams.
161
+ * Normalize text for hashing: lowercase, collapse whitespace.
59
162
  */
60
- function extractTrigrams(text: string): Set<string> {
61
- const normalized = " " + normalize(text) + " ";
62
- const trigrams = new Set<string>();
63
- for (let i = 0; i <= normalized.length - 3; i++) {
64
- trigrams.add(normalized.substring(i, i + 3));
65
- }
66
- return trigrams;
163
+ function normalize(text: string): string {
164
+ return text
165
+ .toLowerCase()
166
+ .replace(/\s+/g, ' ')
167
+ .trim();
67
168
  }
68
169
 
69
170
  /**
70
- * Compute Jaccard similarity between two sets.
71
- * |A ∩ B| / |A ∪ B|
171
+ * Simple hash for cache key (deterministic, fast).
72
172
  */
73
- function jaccard(a: Set<string>, b: Set<string>): number {
74
- if (a.size === 0 && b.size === 0) return 1.0;
75
- let intersection = 0;
76
- for (const item of a) {
77
- if (b.has(item)) intersection++;
173
+ function hashKey(text: string): string {
174
+ let hash = 0;
175
+ const normalized = normalize(text);
176
+ for (let i = 0; i < normalized.length; i++) {
177
+ const char = normalized.charCodeAt(i);
178
+ hash = ((hash << 5) - hash) + char;
179
+ hash = hash & hash; // Convert to 32-bit integer
78
180
  }
79
- const union = a.size + b.size - intersection;
80
- return union === 0 ? 0 : intersection / union;
181
+ return hash.toString(36) + '_' + normalized.slice(0, 20);
81
182
  }
82
183
 
83
184
  // ============================================================
@@ -85,101 +186,176 @@ function jaccard(a: Set<string>, b: Set<string>): number {
85
186
  // ============================================================
86
187
 
87
188
  export class SemanticCache {
88
- private entries: CacheEntry[] = [];
89
- private maxSize: number;
189
+ private entries: Map<string, CacheEntry> = new Map();
190
+ private accessOrder: string[] = []; // track LRU order
191
+ private embedder: Embedder;
90
192
  private similarityThreshold: number;
91
- private ttl: number;
193
+ private ttlMs: number;
194
+ private maxEntries: number;
92
195
  private hits = 0;
93
196
  private misses = 0;
94
197
 
95
- constructor(options?: SemanticCacheOptions) {
96
- this.maxSize = options?.maxSize ?? 1000;
97
- this.similarityThreshold = options?.similarityThreshold ?? 0.92;
98
- this.ttl = options?.ttl ?? 3600000; // 1 hour
198
+ constructor(config: SemanticCacheConfig) {
199
+ this.similarityThreshold = config.similarityThreshold;
200
+ this.ttlMs = (config.ttlSeconds || 3600) * 1000;
201
+ this.maxEntries = config.maxEntries || 1000;
202
+
203
+ // Initialize embedder based on config
204
+ switch (config.embedder) {
205
+ case 'openai':
206
+ if (!config.embedderApiKey) {
207
+ throw new Error('OpenAI embedder requires apiKey in config.embedderApiKey');
208
+ }
209
+ this.embedder = new OpenAIEmbedder(config.embedderApiKey, config.embedderUrl);
210
+ break;
211
+ case 'local':
212
+ this.embedder = new LocalOllamaEmbedder(
213
+ config.embedderUrl || 'http://127.0.0.1:11434/api/embeddings',
214
+ 'nomic-embed-text'
215
+ );
216
+ break;
217
+ case 'nomic':
218
+ default:
219
+ this.embedder = new NomicEmbedder(
220
+ config.embedderUrl || 'http://127.0.0.1:11434/api/embeddings'
221
+ );
222
+ break;
223
+ }
99
224
  }
100
225
 
101
226
  /**
102
227
  * Get cached response for a semantically similar query.
103
- * Returns the best match above the similarity threshold, or null.
228
+ * Returns the best match above the similarity threshold, or { hit: false }.
104
229
  */
105
- async get(query: string): Promise<CachedResponse | null> {
230
+ async get(query: string): Promise<SemanticCacheGetResult> {
106
231
  const now = Date.now();
107
- const queryTrigrams = extractTrigrams(query);
232
+
233
+ // Generate embedding for the query
234
+ let queryEmbedding: number[];
235
+ try {
236
+ queryEmbedding = await this.embedder.embed(query);
237
+ } catch (error) {
238
+ console.warn('SemanticCache: Failed to generate embedding for query:', error);
239
+ this.misses++;
240
+ return { hit: false };
241
+ }
108
242
 
109
243
  let bestEntry: CacheEntry | null = null;
110
- let bestScore = 0;
244
+ let bestSimilarity = 0;
111
245
 
112
- for (const entry of this.entries) {
113
- // Skip expired
114
- if (now > entry.expiresAt) continue;
246
+ for (const entry of this.entries.values()) {
247
+ // Skip expired entries
248
+ if (now > entry.createdAt + entry.ttl) continue;
115
249
 
116
- const score = jaccard(queryTrigrams, entry.trigrams);
117
- if (score > bestScore) {
118
- bestScore = score;
250
+ const similarity = cosineSimilarity(queryEmbedding, entry.embedding);
251
+ if (similarity > bestSimilarity) {
252
+ bestSimilarity = similarity;
119
253
  bestEntry = entry;
120
254
  }
121
255
  }
122
256
 
123
- if (bestEntry && bestScore >= this.similarityThreshold) {
257
+ if (bestEntry && bestSimilarity >= this.similarityThreshold) {
124
258
  this.hits++;
125
259
  bestEntry.hitCount++;
260
+ bestEntry.lastAccessedAt = now;
261
+ this.updateAccessOrder(bestEntry.key);
262
+
126
263
  return {
127
- query: bestEntry.query,
264
+ hit: true,
128
265
  response: bestEntry.response,
129
- metadata: bestEntry.metadata,
130
- cachedAt: bestEntry.cachedAt,
131
- hitCount: bestEntry.hitCount,
266
+ provider: bestEntry.provider,
267
+ model: bestEntry.model,
268
+ cost: bestEntry.cost,
269
+ similarity: bestSimilarity,
132
270
  };
133
271
  }
134
272
 
135
273
  this.misses++;
136
- return null;
274
+ return { hit: false };
137
275
  }
138
276
 
139
277
  /**
140
278
  * Store a query->response pair in the cache.
141
279
  */
142
- async set(query: string, response: string, metadata?: any): Promise<void> {
280
+ async set(
281
+ query: string,
282
+ response: string,
283
+ metadata: {
284
+ provider: string;
285
+ model: string;
286
+ cost: number;
287
+ ttl?: number;
288
+ }
289
+ ): Promise<void> {
143
290
  const now = Date.now();
291
+ const key = hashKey(query);
292
+
293
+ // Generate embedding
294
+ let embedding: number[];
295
+ try {
296
+ embedding = await this.embedder.embed(query);
297
+ } catch (error) {
298
+ console.warn('SemanticCache: Failed to generate embedding for set:', error);
299
+ return;
300
+ }
144
301
 
145
- // Evict expired entries first
302
+ // Evict expired entries
146
303
  this.evictExpired();
147
304
 
148
- // Evict oldest if at capacity
149
- if (this.entries.length >= this.maxSize) {
150
- this.evictOldest();
305
+ // Evict oldest if at capacity (LRU)
306
+ if (this.entries.size >= this.maxEntries) {
307
+ this.evictLRU();
151
308
  }
152
309
 
153
- // Check if an exact-match entry already exists and update it
154
- const normalized = normalize(query);
155
- const existing = this.entries.find(
156
- (e) => normalize(e.query) === normalized && now <= e.expiresAt
157
- );
158
- if (existing) {
310
+ // Check if entry already exists and update it
311
+ if (this.entries.has(key)) {
312
+ const existing = this.entries.get(key)!;
159
313
  existing.response = response;
160
- existing.metadata = metadata;
161
- existing.cachedAt = now;
162
- existing.expiresAt = now + this.ttl;
163
- existing.trigrams = extractTrigrams(query);
314
+ existing.embedding = embedding;
315
+ existing.provider = metadata.provider;
316
+ existing.model = metadata.model;
317
+ existing.cost = metadata.cost;
318
+ existing.createdAt = now;
319
+ existing.ttl = (metadata.ttl !== undefined ? metadata.ttl : this.ttlMs / 1000) * 1000;
320
+ existing.lastAccessedAt = now;
321
+ existing.hitCount = existing.hitCount; // preserve hit count
164
322
  return;
165
323
  }
166
324
 
167
- this.entries.push({
168
- query,
325
+ const entry: CacheEntry = {
326
+ key,
327
+ embedding,
169
328
  response,
170
- metadata,
171
- cachedAt: now,
172
- expiresAt: now + this.ttl,
329
+ provider: metadata.provider,
330
+ model: metadata.model,
331
+ cost: metadata.cost,
332
+ createdAt: now,
333
+ ttl: (metadata.ttl !== undefined ? metadata.ttl : this.ttlMs / 1000) * 1000,
173
334
  hitCount: 0,
174
- trigrams: extractTrigrams(query),
175
- });
335
+ lastAccessedAt: now,
336
+ };
337
+
338
+ this.entries.set(key, entry);
339
+ this.accessOrder.push(key);
340
+ }
341
+
342
+ /**
343
+ * Delete a specific query from the cache.
344
+ */
345
+ async delete(query: string): Promise<void> {
346
+ const key = hashKey(query);
347
+ if (this.entries.has(key)) {
348
+ this.entries.delete(key);
349
+ this.accessOrder = this.accessOrder.filter(k => k !== key);
350
+ }
176
351
  }
177
352
 
178
353
  /**
179
354
  * Clear all cache entries.
180
355
  */
181
356
  clear(): void {
182
- this.entries = [];
357
+ this.entries.clear();
358
+ this.accessOrder = [];
183
359
  }
184
360
 
185
361
  /**
@@ -188,34 +364,48 @@ export class SemanticCache {
188
364
  getStats(): SemanticCacheStats {
189
365
  const total = this.hits + this.misses;
190
366
  return {
367
+ size: this.entries.size,
191
368
  hits: this.hits,
192
369
  misses: this.misses,
193
370
  hitRate: total > 0 ? this.hits / total : 0,
194
- size: this.entries.length,
195
371
  };
196
372
  }
197
373
 
374
+ /**
375
+ * Update access order for LRU tracking.
376
+ */
377
+ private updateAccessOrder(key: string): void {
378
+ this.accessOrder = this.accessOrder.filter(k => k !== key);
379
+ this.accessOrder.push(key);
380
+ }
381
+
198
382
  /**
199
383
  * Purge expired entries.
200
384
  */
201
385
  private evictExpired(): void {
202
386
  const now = Date.now();
203
- this.entries = this.entries.filter((e) => now <= e.expiresAt);
387
+ for (const [key, entry] of this.entries.entries()) {
388
+ if (now > entry.createdAt + entry.ttl) {
389
+ this.entries.delete(key);
390
+ this.accessOrder = this.accessOrder.filter(k => k !== key);
391
+ }
392
+ }
204
393
  }
205
394
 
206
395
  /**
207
- * Evict the oldest (by cachedAt) entry.
396
+ * Evict the least recently used entry.
208
397
  */
209
- private evictOldest(): void {
210
- if (this.entries.length === 0) return;
211
- let oldestIdx = 0;
212
- let oldestTime = Infinity;
213
- for (let i = 0; i < this.entries.length; i++) {
214
- if (this.entries[i].cachedAt < oldestTime) {
215
- oldestTime = this.entries[i].cachedAt;
216
- oldestIdx = i;
217
- }
398
+ private evictLRU(): void {
399
+ if (this.accessOrder.length === 0) return;
400
+ const lruKey = this.accessOrder.shift();
401
+ if (lruKey && this.entries.has(lruKey)) {
402
+ this.entries.delete(lruKey);
218
403
  }
219
- this.entries.splice(oldestIdx, 1);
220
404
  }
221
405
  }
406
+
407
+ // ============================================================
408
+ // Exports
409
+ // ============================================================
410
+
411
+ export default SemanticCache;