@prometheus-ai/memory 0.5.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.
Files changed (128) hide show
  1. package/README.md +107 -0
  2. package/dist/types/cli.d.ts +35 -0
  3. package/dist/types/config.d.ts +77 -0
  4. package/dist/types/core/aaak.d.ts +55 -0
  5. package/dist/types/core/annotations.d.ts +75 -0
  6. package/dist/types/core/banks.d.ts +33 -0
  7. package/dist/types/core/beam/consolidate.d.ts +32 -0
  8. package/dist/types/core/beam/helpers.d.ts +76 -0
  9. package/dist/types/core/beam/index.d.ts +59 -0
  10. package/dist/types/core/beam/recall.d.ts +32 -0
  11. package/dist/types/core/beam/schema.d.ts +2 -0
  12. package/dist/types/core/beam/store.d.ts +35 -0
  13. package/dist/types/core/beam/types.d.ts +233 -0
  14. package/dist/types/core/binary-vectors.d.ts +54 -0
  15. package/dist/types/core/chat-normalize.d.ts +13 -0
  16. package/dist/types/core/content-sanitizer.d.ts +18 -0
  17. package/dist/types/core/cost-log.d.ts +13 -0
  18. package/dist/types/core/embeddings.d.ts +44 -0
  19. package/dist/types/core/entities.d.ts +7 -0
  20. package/dist/types/core/episodic-graph.d.ts +89 -0
  21. package/dist/types/core/extraction/client.d.ts +31 -0
  22. package/dist/types/core/extraction/diagnostics.d.ts +51 -0
  23. package/dist/types/core/extraction/prompts.d.ts +2 -0
  24. package/dist/types/core/extraction.d.ts +6 -0
  25. package/dist/types/core/index.d.ts +4 -0
  26. package/dist/types/core/llm-backends.d.ts +21 -0
  27. package/dist/types/core/local-llm.d.ts +15 -0
  28. package/dist/types/core/memory.d.ts +160 -0
  29. package/dist/types/core/migrations/e6-triplestore-split.d.ts +17 -0
  30. package/dist/types/core/migrations/index.d.ts +1 -0
  31. package/dist/types/core/mmr.d.ts +8 -0
  32. package/dist/types/core/orchestrator.d.ts +20 -0
  33. package/dist/types/core/patterns.d.ts +61 -0
  34. package/dist/types/core/plugins.d.ts +109 -0
  35. package/dist/types/core/polyphonic-recall.d.ts +66 -0
  36. package/dist/types/core/query-cache.d.ts +46 -0
  37. package/dist/types/core/query-intent.d.ts +20 -0
  38. package/dist/types/core/recall-diagnostics.d.ts +48 -0
  39. package/dist/types/core/runtime-options.d.ts +68 -0
  40. package/dist/types/core/shmr.d.ts +56 -0
  41. package/dist/types/core/streaming.d.ts +136 -0
  42. package/dist/types/core/synonyms.d.ts +46 -0
  43. package/dist/types/core/temporal-parser.d.ts +16 -0
  44. package/dist/types/core/token-counter.d.ts +8 -0
  45. package/dist/types/core/triples.d.ts +63 -0
  46. package/dist/types/core/typed-memory.d.ts +39 -0
  47. package/dist/types/core/vector-math.d.ts +1 -0
  48. package/dist/types/core/veracity-consolidation.d.ts +60 -0
  49. package/dist/types/core/weibull.d.ts +96 -0
  50. package/dist/types/db.d.ts +16 -0
  51. package/dist/types/diagnose.d.ts +24 -0
  52. package/dist/types/dr/index.d.ts +1 -0
  53. package/dist/types/dr/recovery.d.ts +68 -0
  54. package/dist/types/index.d.ts +5 -0
  55. package/dist/types/mcp-server.d.ts +40 -0
  56. package/dist/types/mcp-tools.d.ts +484 -0
  57. package/dist/types/migrations/e6-triplestore-split.d.ts +1 -0
  58. package/dist/types/migrations/index.d.ts +1 -0
  59. package/dist/types/types.d.ts +145 -0
  60. package/dist/types/util/datetime.d.ts +8 -0
  61. package/dist/types/util/env.d.ts +10 -0
  62. package/dist/types/util/ids.d.ts +3 -0
  63. package/dist/types/util/lru.d.ts +12 -0
  64. package/dist/types/util/regex.d.ts +10 -0
  65. package/package.json +85 -0
  66. package/src/cli.ts +398 -0
  67. package/src/config.ts +326 -0
  68. package/src/core/aaak.ts +142 -0
  69. package/src/core/annotations.ts +457 -0
  70. package/src/core/banks.ts +133 -0
  71. package/src/core/beam/consolidate.ts +965 -0
  72. package/src/core/beam/helpers.ts +977 -0
  73. package/src/core/beam/index.ts +353 -0
  74. package/src/core/beam/recall.ts +1100 -0
  75. package/src/core/beam/schema.ts +423 -0
  76. package/src/core/beam/store.ts +829 -0
  77. package/src/core/beam/types.ts +268 -0
  78. package/src/core/binary-vectors.ts +317 -0
  79. package/src/core/chat-normalize.ts +160 -0
  80. package/src/core/content-sanitizer.ts +136 -0
  81. package/src/core/cost-log.ts +103 -0
  82. package/src/core/embeddings.ts +423 -0
  83. package/src/core/entities.ts +259 -0
  84. package/src/core/episodic-graph.ts +708 -0
  85. package/src/core/extraction/client.ts +162 -0
  86. package/src/core/extraction/diagnostics.ts +193 -0
  87. package/src/core/extraction/prompts.ts +31 -0
  88. package/src/core/extraction.ts +335 -0
  89. package/src/core/index.ts +30 -0
  90. package/src/core/llm-backends.ts +51 -0
  91. package/src/core/local-llm.ts +436 -0
  92. package/src/core/memory.ts +630 -0
  93. package/src/core/migrations/e6-triplestore-split.ts +211 -0
  94. package/src/core/migrations/index.ts +1 -0
  95. package/src/core/mmr.ts +71 -0
  96. package/src/core/orchestrator.ts +62 -0
  97. package/src/core/patterns.ts +484 -0
  98. package/src/core/plugins.ts +375 -0
  99. package/src/core/polyphonic-recall.ts +563 -0
  100. package/src/core/query-cache.ts +354 -0
  101. package/src/core/query-intent.ts +139 -0
  102. package/src/core/recall-diagnostics.ts +157 -0
  103. package/src/core/runtime-options.ts +119 -0
  104. package/src/core/shmr.ts +460 -0
  105. package/src/core/streaming.ts +419 -0
  106. package/src/core/synonyms.ts +197 -0
  107. package/src/core/temporal-parser.ts +363 -0
  108. package/src/core/token-counter.ts +30 -0
  109. package/src/core/triples.ts +454 -0
  110. package/src/core/typed-memory.ts +407 -0
  111. package/src/core/vector-math.ts +23 -0
  112. package/src/core/veracity-consolidation.ts +477 -0
  113. package/src/core/weibull.ts +124 -0
  114. package/src/db.ts +128 -0
  115. package/src/diagnose.ts +174 -0
  116. package/src/dr/index.ts +1 -0
  117. package/src/dr/recovery.ts +405 -0
  118. package/src/index.ts +33 -0
  119. package/src/mcp-server.ts +155 -0
  120. package/src/mcp-tools.ts +970 -0
  121. package/src/migrations/e6-triplestore-split.ts +1 -0
  122. package/src/migrations/index.ts +1 -0
  123. package/src/types.ts +157 -0
  124. package/src/util/datetime.ts +69 -0
  125. package/src/util/env.ts +65 -0
  126. package/src/util/ids.ts +19 -0
  127. package/src/util/lru.ts +48 -0
  128. package/src/util/regex.ts +165 -0
@@ -0,0 +1,354 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { mkdirSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { cosineSimilarity } from "./vector-math";
5
+
6
+ export type QueryCacheResult = Record<string, unknown>;
7
+ export type QueryEmbedding = readonly number[];
8
+
9
+ export interface QueryCacheOptions {
10
+ readonly dbPath?: string | null;
11
+ readonly db_path?: string | null;
12
+ readonly maxSize?: number;
13
+ readonly max_size?: number;
14
+ readonly ttlSeconds?: number;
15
+ readonly ttl_seconds?: number;
16
+ }
17
+
18
+ export interface QueryCacheStats {
19
+ readonly hits: number;
20
+ readonly misses: number;
21
+ readonly hit_rate: number;
22
+ readonly tier1_hits: number;
23
+ readonly tier2_hits: number;
24
+ readonly tier3_hits: number;
25
+ readonly tier4_hits: number;
26
+ readonly size: number;
27
+ readonly max_size: number;
28
+ readonly version: number;
29
+ }
30
+
31
+ interface Tier23Entry {
32
+ readonly embedding: QueryEmbedding;
33
+ readonly results: readonly QueryCacheResult[];
34
+ }
35
+
36
+ interface CacheRow {
37
+ readonly normalized: string;
38
+ readonly embedding_json: string | null;
39
+ readonly results_json: string;
40
+ }
41
+
42
+ type Env = Readonly<Record<string, string | undefined>>;
43
+
44
+ export function isEnhancedRecallEnabled(env: Env = process.env): boolean {
45
+ return env.PROMETHEUS_MEMORY_ENHANCED_RECALL === "1";
46
+ }
47
+
48
+ export function isQueryCacheEnabled(useCache = true, env: Env = process.env): boolean {
49
+ return useCache && isEnhancedRecallEnabled(env);
50
+ }
51
+
52
+ export class QueryCache {
53
+ readonly maxSize: number;
54
+ readonly ttlSeconds: number;
55
+
56
+ #cacheVersion = 0;
57
+ #tier1 = new Map<string, readonly QueryCacheResult[]>();
58
+ #tier23 = new Map<string, Tier23Entry>();
59
+ #tier4 = new Map<string, readonly QueryCacheResult[]>();
60
+ #insertTimes = new Map<string, number>();
61
+ #conn: Database | null = null;
62
+
63
+ hits = 0;
64
+ misses = 0;
65
+ tier1Hits = 0;
66
+ tier2Hits = 0;
67
+ tier3Hits = 0;
68
+ tier4Hits = 0;
69
+
70
+ constructor(options: QueryCacheOptions | string | null = {}, maxSize = 1000, ttlSeconds = 3600) {
71
+ if (typeof options === "string" || options === null) {
72
+ this.maxSize = Math.max(0, Math.trunc(maxSize));
73
+ this.ttlSeconds = Math.max(0, ttlSeconds);
74
+ if (options !== null) this.#initDb(options);
75
+ return;
76
+ }
77
+ this.maxSize = Math.max(0, Math.trunc(options.maxSize ?? options.max_size ?? 1000));
78
+ this.ttlSeconds = Math.max(0, options.ttlSeconds ?? options.ttl_seconds ?? 3600);
79
+ const dbPath = options.dbPath ?? options.db_path;
80
+ if (dbPath !== undefined && dbPath !== null) this.#initDb(dbPath);
81
+ }
82
+
83
+ #initDb(dbPath: string): void {
84
+ if (dbPath !== ":memory:") mkdirSync(dirname(dbPath), { recursive: true });
85
+ const db = new Database(dbPath, { create: true, readwrite: true, strict: true });
86
+ this.#conn = db;
87
+ if (dbPath !== ":memory:") db.exec("PRAGMA journal_mode=WAL");
88
+ db.exec(`
89
+ CREATE TABLE IF NOT EXISTS query_cache (
90
+ normalized TEXT PRIMARY KEY,
91
+ embedding_json TEXT,
92
+ results_json TEXT,
93
+ hit_count INTEGER DEFAULT 0,
94
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
95
+ last_hit TIMESTAMP DEFAULT CURRENT_TIMESTAMP
96
+ );
97
+ CREATE INDEX IF NOT EXISTS idx_cache_hits ON query_cache(hit_count DESC);
98
+ `);
99
+
100
+ try {
101
+ const rows = db.query("SELECT normalized, embedding_json, results_json FROM query_cache").all() as CacheRow[];
102
+ const now = Date.now() / 1000;
103
+ for (const row of rows) {
104
+ try {
105
+ const results = JSON.parse(row.results_json) as QueryCacheResult[];
106
+ this.#rememberKey(row.normalized, now);
107
+ this.#tier1.set(row.normalized, results);
108
+ this.#tier4.set(row.normalized, results);
109
+ if (row.embedding_json !== null) {
110
+ const embedding = JSON.parse(row.embedding_json) as number[];
111
+ this.#tier23.set(row.normalized, { embedding, results });
112
+ }
113
+ } catch {
114
+ // Match Python's best-effort persistence loading: corrupt rows are ignored.
115
+ }
116
+ }
117
+ } catch {
118
+ // Keep an in-memory cache if persistence loading fails after schema setup.
119
+ }
120
+ }
121
+
122
+ invalidate(): void {
123
+ this.#cacheVersion += 1;
124
+ this.#tier1.clear();
125
+ this.#tier23.clear();
126
+ this.#tier4.clear();
127
+ this.#insertTimes.clear();
128
+ if (this.#conn !== null) {
129
+ this.#conn.run("DELETE FROM query_cache");
130
+ }
131
+ }
132
+
133
+ get(query: string, embedding?: QueryEmbedding | null): readonly QueryCacheResult[] | null {
134
+ const normalized = this.normalize(query);
135
+ const now = Date.now() / 1000;
136
+ if (this.#expireIfNeeded(normalized, now)) {
137
+ this.misses += 1;
138
+ return null;
139
+ }
140
+
141
+ const tier1 = this.#tier1.get(normalized);
142
+ if (tier1 !== undefined) {
143
+ this.#touchKey(normalized);
144
+ this.hits += 1;
145
+ this.tier1Hits += 1;
146
+ this.#recordPersistentHit(normalized);
147
+ return tier1;
148
+ }
149
+
150
+ if (embedding !== undefined && embedding !== null && embedding.length !== 0) {
151
+ let bestScore = 0;
152
+ let bestKey: string | null = null;
153
+ for (const [cachedKey, cached] of this.#tier23) {
154
+ if (this.#isExpired(cachedKey, now)) continue;
155
+ const cosine = cosineSimilarity(embedding, cached.embedding);
156
+ if (cosine >= 0.88) {
157
+ bestScore = cosine;
158
+ bestKey = cachedKey;
159
+ break;
160
+ }
161
+ if (cosine >= 0.78) {
162
+ const jaccard = this.jaccardWords(query, cachedKey);
163
+ if (jaccard >= 0.15 && cosine > bestScore) {
164
+ bestScore = cosine;
165
+ bestKey = cachedKey;
166
+ }
167
+ }
168
+ }
169
+ if (bestKey !== null) {
170
+ const entry = this.#tier23.get(bestKey);
171
+ if (entry !== undefined) {
172
+ this.#touchKey(bestKey);
173
+ this.hits += 1;
174
+ if (bestScore >= 0.88) this.tier2Hits += 1;
175
+ else this.tier3Hits += 1;
176
+ this.#recordPersistentHit(bestKey);
177
+ return entry.results;
178
+ }
179
+ }
180
+ }
181
+
182
+ let queryWords: Set<string> | null = null;
183
+ for (const [cachedKey, results] of this.#tier4) {
184
+ if (this.#isExpired(cachedKey, now)) continue;
185
+ queryWords ??= new Set(normalized.split(/\s+/));
186
+ if (queryWords.size === 0) continue;
187
+ let overlap = 0;
188
+ for (const cachedWord of cachedKey.split(/\s+/)) if (queryWords.has(cachedWord)) overlap += 1;
189
+ if (overlap >= queryWords.size * 0.7 && overlap >= 2) {
190
+ this.#touchKey(cachedKey);
191
+ this.hits += 1;
192
+ this.tier4Hits += 1;
193
+ this.#recordPersistentHit(cachedKey);
194
+ return results;
195
+ }
196
+ }
197
+
198
+ this.misses += 1;
199
+ return null;
200
+ }
201
+
202
+ put(query: string, results: readonly QueryCacheResult[], embedding?: QueryEmbedding | null): void {
203
+ if (this.maxSize === 0) return;
204
+ const normalized = this.normalize(query);
205
+ const now = Date.now() / 1000;
206
+ this.#rememberKey(normalized, now);
207
+ this.#tier1.set(normalized, results);
208
+ this.#tier4.set(normalized, results);
209
+ if (embedding !== undefined && embedding !== null && embedding.length !== 0) {
210
+ this.#tier23.set(normalized, { embedding, results });
211
+ } else {
212
+ this.#tier23.delete(normalized);
213
+ }
214
+ this.#putPersistent(normalized, results, embedding);
215
+ this.#evictIfNeeded();
216
+ }
217
+
218
+ close(): void {
219
+ if (this.#conn === null) return;
220
+ this.#conn.close();
221
+ this.#conn = null;
222
+ }
223
+
224
+ get hitRate(): number {
225
+ const total = this.hits + this.misses;
226
+ return total > 0 ? this.hits / total : 0;
227
+ }
228
+
229
+ stats(): QueryCacheStats {
230
+ return {
231
+ hits: this.hits,
232
+ misses: this.misses,
233
+ hit_rate: Math.round(this.hitRate * 1000) / 1000,
234
+ tier1_hits: this.tier1Hits,
235
+ tier2_hits: this.tier2Hits,
236
+ tier3_hits: this.tier3Hits,
237
+ tier4_hits: this.tier4Hits,
238
+ size: this.#tier1.size,
239
+ max_size: this.maxSize,
240
+ version: this.#cacheVersion,
241
+ };
242
+ }
243
+
244
+ normalize(query: string): string {
245
+ const words: string[] = [];
246
+ for (const rawWord of query.split(/\s+/)) {
247
+ if (rawWord.length > 1) words.push(rawWord.toLowerCase());
248
+ }
249
+ return words.sort().join(" ");
250
+ }
251
+
252
+ jaccardWords(queryA: string, queryB: string): number {
253
+ const wordsA = this.#wordSet(queryA);
254
+ const wordsB = this.#wordSet(queryB);
255
+ if (wordsA.size === 0 || wordsB.size === 0) return 0;
256
+ let intersection = 0;
257
+ for (const word of wordsA) if (wordsB.has(word)) intersection += 1;
258
+ return intersection / (wordsA.size + wordsB.size - intersection);
259
+ }
260
+
261
+ #wordSet(query: string): Set<string> {
262
+ const words = new Set<string>();
263
+ for (const rawWord of query.toLowerCase().split(/\s+/)) {
264
+ if (rawWord.length !== 0) words.add(rawWord);
265
+ }
266
+ return words;
267
+ }
268
+
269
+ #rememberKey(key: string, now: number): void {
270
+ this.#insertTimes.delete(key);
271
+ this.#insertTimes.set(key, now);
272
+ }
273
+
274
+ #touchKey(key: string): void {
275
+ const insertTime = this.#insertTimes.get(key);
276
+ if (insertTime !== undefined) {
277
+ this.#insertTimes.delete(key);
278
+ this.#insertTimes.set(key, insertTime);
279
+ }
280
+ this.#touchMap(this.#tier1, key);
281
+ this.#touchMap(this.#tier23, key);
282
+ this.#touchMap(this.#tier4, key);
283
+ }
284
+
285
+ #touchMap<V>(map: Map<string, V>, key: string): void {
286
+ const value = map.get(key);
287
+ if (value === undefined && !map.has(key)) return;
288
+ map.delete(key);
289
+ map.set(key, value as V);
290
+ }
291
+
292
+ #isExpired(key: string, now: number): boolean {
293
+ const insertedAt = this.#insertTimes.get(key);
294
+ return insertedAt !== undefined && now - insertedAt > this.ttlSeconds;
295
+ }
296
+
297
+ #expireIfNeeded(key: string, now: number): boolean {
298
+ if (!this.#isExpired(key, now)) return false;
299
+ this.#deleteKey(key, true);
300
+ return true;
301
+ }
302
+
303
+ #deleteKey(key: string, persistent: boolean): void {
304
+ this.#tier1.delete(key);
305
+ this.#tier23.delete(key);
306
+ this.#tier4.delete(key);
307
+ this.#insertTimes.delete(key);
308
+ if (persistent && this.#conn !== null) this.#conn.run("DELETE FROM query_cache WHERE normalized = ?", [key]);
309
+ }
310
+
311
+ #evictIfNeeded(): void {
312
+ const now = Date.now() / 1000;
313
+ for (const [key, insertedAt] of this.#insertTimes) {
314
+ if (now - insertedAt > this.ttlSeconds) this.#deleteKey(key, true);
315
+ }
316
+ while (this.#tier1.size > this.maxSize) {
317
+ const oldest = this.#tier1.keys().next();
318
+ if (oldest.done) break;
319
+ this.#deleteKey(oldest.value, true);
320
+ }
321
+ }
322
+
323
+ #putPersistent(
324
+ normalized: string,
325
+ results: readonly QueryCacheResult[],
326
+ embedding: QueryEmbedding | null | undefined,
327
+ ): void {
328
+ if (this.#conn === null) return;
329
+ try {
330
+ this.#conn.run(
331
+ "INSERT OR REPLACE INTO query_cache (normalized, embedding_json, results_json) VALUES (?, ?, ?)",
332
+ [
333
+ normalized,
334
+ embedding !== undefined && embedding !== null ? JSON.stringify(embedding) : null,
335
+ JSON.stringify(results),
336
+ ],
337
+ );
338
+ } catch {
339
+ // Persistence is best-effort; in-memory tiers remain authoritative for this process.
340
+ }
341
+ }
342
+
343
+ #recordPersistentHit(normalized: string): void {
344
+ if (this.#conn === null) return;
345
+ try {
346
+ this.#conn.run(
347
+ "UPDATE query_cache SET hit_count = hit_count + 1, last_hit = CURRENT_TIMESTAMP WHERE normalized = ?",
348
+ [normalized],
349
+ );
350
+ } catch {
351
+ // Match Python's best-effort persistence behavior.
352
+ }
353
+ }
354
+ }
@@ -0,0 +1,139 @@
1
+ export type QueryIntentCategory = "temporal" | "factual" | "entity" | "preference" | "procedural" | "general";
2
+
3
+ export interface QueryIntent {
4
+ readonly category: QueryIntentCategory;
5
+ readonly confidence: number;
6
+ readonly signals: QueryIntentCategory[];
7
+ readonly vec_bias: number;
8
+ readonly fts_bias: number;
9
+ readonly importance_bias: number;
10
+ }
11
+
12
+ export interface IntentWeights {
13
+ readonly vec_bias: number;
14
+ readonly fts_bias: number;
15
+ readonly importance_bias: number;
16
+ }
17
+
18
+ type IntentPatternGroup = readonly [QueryIntentCategory, readonly RegExp[]];
19
+
20
+ export const INTENT_PATTERNS: readonly IntentPatternGroup[] = [
21
+ [
22
+ "temporal",
23
+ [
24
+ /\b(when|last|yesterday|today|tomorrow|ago|before|after|since|until|during|recently|lately)\b/,
25
+ /\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/,
26
+ /\b(january|february|march|april|may|june|july|august|september|october|november|december)\b/,
27
+ /\b\d{4}-\d{2}-\d{2}\b/,
28
+ /\b\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b/,
29
+ /\b(this|next|last)\s+(week|month|year|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/,
30
+ /\b\d+\s+(day|week|month|year|hour|minute)s?\s+(ago|from now|later|earlier)\b/,
31
+ ],
32
+ ],
33
+ [
34
+ "factual",
35
+ [
36
+ /\bwhat\s+is\b/,
37
+ /\bwho\s+is\b/,
38
+ /\bwhere\s+is\b/,
39
+ /\b(definition|define|explain|meaning)\b/,
40
+ /\bhow\s+(many|much|long|far)\b/,
41
+ ],
42
+ ],
43
+ [
44
+ "entity",
45
+ [
46
+ /\b(tell\s+me\s+about|what\s+do\s+you\s+know\s+about)\b/,
47
+ /\b(who\s+is|what\s+does)\s+[a-z]+\b/,
48
+ /\b(about|regarding|concerning)\s+[a-z]+\b/,
49
+ ],
50
+ ],
51
+ [
52
+ "preference",
53
+ [
54
+ /\b(prefer|like|dislike|want|hate|love|enjoy|favorite|best|worst)\b/,
55
+ /\b(should\s+i|would\s+you|do\s+you\s+recommend)\b/,
56
+ /\b(choose|pick|select|option|choice|decide)\b/,
57
+ ],
58
+ ],
59
+ [
60
+ "procedural",
61
+ [
62
+ /\bhow\s+(to|do|can|should|would)\b/,
63
+ /\b(step|process|procedure|workflow|guide|tutorial)\b/,
64
+ /\b(setup|install|configure|build|deploy|run|execute|start|stop)\b/,
65
+ ],
66
+ ],
67
+ ] as const;
68
+
69
+ export const INTENT_WEIGHTS: Record<QueryIntentCategory, IntentWeights> = {
70
+ temporal: { vec_bias: 0.6, fts_bias: 1.5, importance_bias: 0.8 },
71
+ factual: { vec_bias: 1.0, fts_bias: 1.2, importance_bias: 0.9 },
72
+ entity: { vec_bias: 1.1, fts_bias: 1.0, importance_bias: 1.3 },
73
+ preference: { vec_bias: 0.9, fts_bias: 0.8, importance_bias: 1.5 },
74
+ procedural: { vec_bias: 1.3, fts_bias: 0.9, importance_bias: 0.7 },
75
+ general: { vec_bias: 1.0, fts_bias: 1.0, importance_bias: 1.0 },
76
+ };
77
+
78
+ export function classifyIntent(query: string): QueryIntent {
79
+ const queryLower = query.toLowerCase();
80
+ let bestIntent: QueryIntentCategory = "general";
81
+ let bestScore = 0.0;
82
+ const signals: QueryIntentCategory[] = [];
83
+
84
+ for (const [category, patterns] of INTENT_PATTERNS) {
85
+ let matches = 0;
86
+ for (const pattern of patterns) {
87
+ if (pattern.test(queryLower)) {
88
+ matches += 1;
89
+ signals.push(category);
90
+ }
91
+ }
92
+
93
+ if (matches > 0) {
94
+ const score = Math.min(0.3 + matches * 0.15, 1.0);
95
+ if (score > bestScore) {
96
+ bestScore = score;
97
+ bestIntent = category;
98
+ }
99
+ }
100
+ }
101
+
102
+ const weights = INTENT_WEIGHTS[bestIntent];
103
+ return {
104
+ category: bestIntent,
105
+ confidence: bestScore,
106
+ signals,
107
+ vec_bias: weights.vec_bias,
108
+ fts_bias: weights.fts_bias,
109
+ importance_bias: weights.importance_bias,
110
+ };
111
+ }
112
+
113
+ export function adjustWeights(
114
+ baseVec = 0.5,
115
+ baseFts = 0.3,
116
+ baseImportance = 0.2,
117
+ intent: QueryIntent | null = null,
118
+ ): [number, number, number] {
119
+ const resolvedIntent = intent ?? {
120
+ category: "general",
121
+ confidence: 0.0,
122
+ signals: [],
123
+ vec_bias: 1.0,
124
+ fts_bias: 1.0,
125
+ importance_bias: 1.0,
126
+ };
127
+ let vecWeight = baseVec * resolvedIntent.vec_bias;
128
+ let ftsWeight = baseFts * resolvedIntent.fts_bias;
129
+ let importanceWeight = baseImportance * resolvedIntent.importance_bias;
130
+
131
+ const total = vecWeight + ftsWeight + importanceWeight;
132
+ if (total > 0) {
133
+ vecWeight /= total;
134
+ ftsWeight /= total;
135
+ importanceWeight /= total;
136
+ }
137
+
138
+ return [vecWeight, ftsWeight, importanceWeight];
139
+ }
@@ -0,0 +1,157 @@
1
+ export const RECALL_TIERS = ["wm_fts", "wm_vec", "wm_fallback", "em_fts", "em_vec", "em_fallback"] as const;
2
+
3
+ export type RecallTier = (typeof RECALL_TIERS)[number];
4
+
5
+ export interface TierStatsSnapshot {
6
+ readonly calls_with_hits: number;
7
+ readonly total_hits: number;
8
+ }
9
+
10
+ export interface RecallDiagnosticsSnapshot {
11
+ readonly created_at: string;
12
+ readonly snapshot_at: string;
13
+ readonly totals: {
14
+ readonly calls: number;
15
+ readonly calls_using_wm_fallback: number;
16
+ readonly calls_using_em_fallback: number;
17
+ readonly calls_truly_empty: number;
18
+ readonly wm_fallback_rate: number;
19
+ readonly em_fallback_rate: number;
20
+ };
21
+ readonly by_tier: Record<RecallTier, TierStatsSnapshot>;
22
+ }
23
+
24
+ interface TierStats {
25
+ callsWithHits: number;
26
+ totalHits: number;
27
+ }
28
+
29
+ function newTierStats(): Record<RecallTier, TierStats> {
30
+ return {
31
+ wm_fts: { callsWithHits: 0, totalHits: 0 },
32
+ wm_vec: { callsWithHits: 0, totalHits: 0 },
33
+ wm_fallback: { callsWithHits: 0, totalHits: 0 },
34
+ em_fts: { callsWithHits: 0, totalHits: 0 },
35
+ em_vec: { callsWithHits: 0, totalHits: 0 },
36
+ em_fallback: { callsWithHits: 0, totalHits: 0 },
37
+ };
38
+ }
39
+
40
+ function isRecallTier(tier: string): tier is RecallTier {
41
+ return (RECALL_TIERS as readonly string[]).includes(tier);
42
+ }
43
+
44
+ export class RecallDiagnostics {
45
+ private tierStats: Record<RecallTier, TierStats>;
46
+ private totalCalls: number;
47
+ private callsUsingWmFallback: number;
48
+ private callsUsingEmFallback: number;
49
+ private callsTrulyEmpty: number;
50
+ private createdAt: string;
51
+
52
+ constructor() {
53
+ this.tierStats = newTierStats();
54
+ this.totalCalls = 0;
55
+ this.callsUsingWmFallback = 0;
56
+ this.callsUsingEmFallback = 0;
57
+ this.callsTrulyEmpty = 0;
58
+ this.createdAt = new Date().toISOString();
59
+ }
60
+
61
+ private static validateTier(tier: string): asserts tier is RecallTier {
62
+ if (!isRecallTier(tier)) {
63
+ throw new Error(`unknown recall tier ${JSON.stringify(tier)}; valid tiers: ${JSON.stringify(RECALL_TIERS)}`);
64
+ }
65
+ }
66
+
67
+ recordTierHits(tier: RecallTier | string, hitCount: number): void {
68
+ RecallDiagnostics.validateTier(tier);
69
+ if (hitCount < 0) throw new Error(`hit_count must be >= 0, got ${hitCount}`);
70
+ const stats = this.tierStats[tier];
71
+ if (hitCount > 0) stats.callsWithHits++;
72
+ stats.totalHits += hitCount;
73
+ }
74
+ recordFallbackUsed(options: { readonly wm?: boolean; readonly em?: boolean } = {}): void {
75
+ if (options.wm === true) this.callsUsingWmFallback++;
76
+ if (options.em === true) this.callsUsingEmFallback++;
77
+ }
78
+ recordCall(options: { readonly trulyEmpty?: boolean; readonly truly_empty?: boolean } = {}): void {
79
+ this.totalCalls++;
80
+ if (options.trulyEmpty === true || options.truly_empty === true) this.callsTrulyEmpty++;
81
+ }
82
+ fallbackRate(): { readonly wm: number; readonly em: number } {
83
+ if (this.totalCalls === 0) return { wm: 0.0, em: 0.0 };
84
+ return {
85
+ wm: Math.min(1.0, this.callsUsingWmFallback / this.totalCalls),
86
+ em: Math.min(1.0, this.callsUsingEmFallback / this.totalCalls),
87
+ };
88
+ }
89
+ snapshot(): RecallDiagnosticsSnapshot {
90
+ const rates = this.fallbackRate();
91
+ const byTier = {} as Record<RecallTier, TierStatsSnapshot>;
92
+ for (const tier of RECALL_TIERS) {
93
+ const stats = this.tierStats[tier];
94
+ byTier[tier] = {
95
+ calls_with_hits: stats.callsWithHits,
96
+ total_hits: stats.totalHits,
97
+ };
98
+ }
99
+ return {
100
+ created_at: this.createdAt,
101
+ snapshot_at: new Date().toISOString(),
102
+ totals: {
103
+ calls: this.totalCalls,
104
+ calls_using_wm_fallback: this.callsUsingWmFallback,
105
+ calls_using_em_fallback: this.callsUsingEmFallback,
106
+ calls_truly_empty: this.callsTrulyEmpty,
107
+ wm_fallback_rate: rates.wm,
108
+ em_fallback_rate: rates.em,
109
+ },
110
+ by_tier: byTier,
111
+ };
112
+ }
113
+
114
+ reset(): void {
115
+ this.tierStats = newTierStats();
116
+ this.totalCalls = 0;
117
+ this.callsUsingWmFallback = 0;
118
+ this.callsUsingEmFallback = 0;
119
+ this.callsTrulyEmpty = 0;
120
+ this.createdAt = new Date().toISOString();
121
+ }
122
+ }
123
+
124
+ let singleton: RecallDiagnostics | undefined;
125
+
126
+ export function getDiagnostics(): RecallDiagnostics {
127
+ if (singleton === undefined) singleton = new RecallDiagnostics();
128
+ return singleton;
129
+ }
130
+ export function getRecallDiagnostics(): RecallDiagnosticsSnapshot {
131
+ return getDiagnostics().snapshot();
132
+ }
133
+ export function resetRecallDiagnostics(): void {
134
+ getDiagnostics().reset();
135
+ }
136
+ export function explainRecallDiagnostics(snapshot: RecallDiagnosticsSnapshot): string[] {
137
+ const explanations: string[] = [];
138
+ const totals = snapshot.totals;
139
+ if (totals.calls === 0) {
140
+ explanations.push("No recall calls have been recorded in this measurement window.");
141
+ return explanations;
142
+ }
143
+ explanations.push(
144
+ `WM fallback used on ${totals.calls_using_wm_fallback}/${totals.calls} calls (${(totals.wm_fallback_rate * 100).toFixed(1)}%).`,
145
+ );
146
+ explanations.push(
147
+ `EM fallback used on ${totals.calls_using_em_fallback}/${totals.calls} calls (${(totals.em_fallback_rate * 100).toFixed(1)}%).`,
148
+ );
149
+ for (const tier of RECALL_TIERS) {
150
+ const stats = snapshot.by_tier[tier];
151
+ explanations.push(`${tier}: ${stats.total_hits} kept hits across ${stats.calls_with_hits} calls with hits.`);
152
+ }
153
+ if (totals.calls_truly_empty > 0) {
154
+ explanations.push(`${totals.calls_truly_empty} calls returned no kept results from any attributed recall path.`);
155
+ }
156
+ return explanations;
157
+ }