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.
- package/README.md +165 -24
- package/dist/cache/semanticCache.d.ts +54 -22
- package/dist/cache/semanticCache.js +230 -86
- package/dist/cache/semanticCache.js.map +1 -1
- package/dist/cost/budgetEnforcer.d.ts +108 -0
- package/dist/cost/budgetEnforcer.js +295 -0
- package/dist/cost/budgetEnforcer.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -1
- package/dist/routing/providerHealth.d.ts +154 -0
- package/dist/routing/providerHealth.js +371 -0
- package/dist/routing/providerHealth.js.map +1 -0
- package/dist/sdk.d.ts +124 -0
- package/dist/sdk.js +109 -100
- package/docs/UPDATE_TOPICS.md +15 -0
- package/package.json +164 -3
- package/src/cache/semanticCache.ts +293 -103
- package/src/cost/budgetEnforcer.ts +358 -0
- package/src/index.ts +2 -0
- package/src/routing/providerHealth.ts +483 -0
- package/test/test_budgetEnforcer.ts +310 -0
- package/test/test_providerHealth.ts +523 -0
- package/test/test_semanticCache.ts +507 -0
|
@@ -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
|
|
5
|
+
* for semantically similar queries using cosine similarity on embeddings.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
140
|
+
* Compute cosine similarity between two vectors.
|
|
48
141
|
*/
|
|
49
|
-
function
|
|
50
|
-
return
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
*
|
|
58
|
-
* Pads with spaces so short words still produce trigrams.
|
|
161
|
+
* Normalize text for hashing: lowercase, collapse whitespace.
|
|
59
162
|
*/
|
|
60
|
-
function
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
*
|
|
71
|
-
* |A ∩ B| / |A ∪ B|
|
|
171
|
+
* Simple hash for cache key (deterministic, fast).
|
|
72
172
|
*/
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
for (
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
193
|
+
private ttlMs: number;
|
|
194
|
+
private maxEntries: number;
|
|
92
195
|
private hits = 0;
|
|
93
196
|
private misses = 0;
|
|
94
197
|
|
|
95
|
-
constructor(
|
|
96
|
-
this.
|
|
97
|
-
this.
|
|
98
|
-
this.
|
|
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
|
|
228
|
+
* Returns the best match above the similarity threshold, or { hit: false }.
|
|
104
229
|
*/
|
|
105
|
-
async get(query: string): Promise<
|
|
230
|
+
async get(query: string): Promise<SemanticCacheGetResult> {
|
|
106
231
|
const now = Date.now();
|
|
107
|
-
|
|
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
|
|
244
|
+
let bestSimilarity = 0;
|
|
111
245
|
|
|
112
|
-
for (const entry of this.entries) {
|
|
113
|
-
// Skip expired
|
|
114
|
-
if (now > entry.
|
|
246
|
+
for (const entry of this.entries.values()) {
|
|
247
|
+
// Skip expired entries
|
|
248
|
+
if (now > entry.createdAt + entry.ttl) continue;
|
|
115
249
|
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
118
|
-
|
|
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 &&
|
|
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
|
-
|
|
264
|
+
hit: true,
|
|
128
265
|
response: bestEntry.response,
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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(
|
|
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
|
|
302
|
+
// Evict expired entries
|
|
146
303
|
this.evictExpired();
|
|
147
304
|
|
|
148
|
-
// Evict oldest if at capacity
|
|
149
|
-
if (this.entries.
|
|
150
|
-
this.
|
|
305
|
+
// Evict oldest if at capacity (LRU)
|
|
306
|
+
if (this.entries.size >= this.maxEntries) {
|
|
307
|
+
this.evictLRU();
|
|
151
308
|
}
|
|
152
309
|
|
|
153
|
-
// Check if
|
|
154
|
-
|
|
155
|
-
|
|
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.
|
|
161
|
-
existing.
|
|
162
|
-
existing.
|
|
163
|
-
existing.
|
|
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
|
-
|
|
168
|
-
|
|
325
|
+
const entry: CacheEntry = {
|
|
326
|
+
key,
|
|
327
|
+
embedding,
|
|
169
328
|
response,
|
|
170
|
-
metadata,
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
396
|
+
* Evict the least recently used entry.
|
|
208
397
|
*/
|
|
209
|
-
private
|
|
210
|
-
if (this.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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;
|