@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,977 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { generateId as generateTimedId, sha256Hex16, stableMemoryId } from "../../util/ids";
3
+ import { currentEmbeddingModel, embed } from "../embeddings";
4
+ import { getMnemopiRuntimeOptions, withMnemopiRuntimeOptions } from "../runtime-options";
5
+ import { cosineSimilarity as vectorCosineSimilarity } from "../vector-math";
6
+ import type { BeamMemoryState, JsonValue, Metadata } from "./types";
7
+
8
+ export type Vector = number[];
9
+
10
+ export type HybridWeights = readonly [vecWeight: number, ftsWeight: number, importanceWeight: number];
11
+
12
+ export interface VectorDistanceResult {
13
+ rowid: number;
14
+ distance: number;
15
+ }
16
+
17
+ export interface WorkingVectorResult {
18
+ id: string;
19
+ sim: number;
20
+ }
21
+
22
+ export interface FtsRankResult {
23
+ rowid: number;
24
+ rank: number;
25
+ }
26
+
27
+ export interface WorkingFtsRankResult {
28
+ id: string;
29
+ rank: number;
30
+ }
31
+
32
+ const DEFAULT_RECENCY_HALFLIFE_HOURS = 72;
33
+ const DEFAULT_WEIGHTS: HybridWeights = [0.5, 0.3, 0.2];
34
+ const TS_CACHE_MAX = 2000;
35
+ const moduleTimestampCache = new Map<string, Date>();
36
+
37
+ const FACT_MATCH_STOPWORDS = new Set([
38
+ "a",
39
+ "an",
40
+ "and",
41
+ "are",
42
+ "as",
43
+ "at",
44
+ "be",
45
+ "by",
46
+ "can",
47
+ "could",
48
+ "did",
49
+ "do",
50
+ "does",
51
+ "for",
52
+ "from",
53
+ "had",
54
+ "has",
55
+ "have",
56
+ "how",
57
+ "i",
58
+ "in",
59
+ "is",
60
+ "it",
61
+ "its",
62
+ "me",
63
+ "my",
64
+ "of",
65
+ "on",
66
+ "or",
67
+ "our",
68
+ "related",
69
+ "should",
70
+ "that",
71
+ "the",
72
+ "their",
73
+ "there",
74
+ "this",
75
+ "to",
76
+ "totally",
77
+ "unrelated",
78
+ "use",
79
+ "uses",
80
+ "was",
81
+ "we",
82
+ "what",
83
+ "when",
84
+ "where",
85
+ "which",
86
+ "who",
87
+ "why",
88
+ "with",
89
+ "you",
90
+ "your",
91
+ ]);
92
+
93
+ const RECALL_SYNONYMS: Readonly<Record<string, readonly string[]>> = {
94
+ branding: ["brand", "positioning", "identity", "wording"],
95
+ preference: ["prefer", "prefers", "want", "wants", "reject", "rejects", "avoid", "grounded"],
96
+ professional: ["software", "builder"],
97
+ url: ["link", "profile"],
98
+ current: ["now", "live", "latest"],
99
+ feeling: ["feel", "feels"],
100
+ imposter: ["self-doubt", "doubt", "insecure"],
101
+ };
102
+
103
+ const RECALL_TOKEN_RE = /[a-z0-9][a-z0-9_.:/+-]*/g;
104
+ const SPLIT_TOKEN_RE = /[_:/.-]+/g;
105
+ const WORD_RE = /[\p{L}\p{N}_]+/gu;
106
+
107
+ function envNumber(name: string, fallback: number): number {
108
+ const raw = process.env[name];
109
+ if (raw === undefined || raw.trim() === "") return fallback;
110
+ const value = Number(raw);
111
+ return Number.isFinite(value) ? value : fallback;
112
+ }
113
+
114
+ function clamp01(value: number): number {
115
+ if (!Number.isFinite(value)) return 0;
116
+ if (value < 0) return 0;
117
+ if (value > 1) return 1;
118
+ return value;
119
+ }
120
+
121
+ function asFiniteNonNegative(value: number): number {
122
+ return Number.isFinite(value) && value > 0 ? value : 0;
123
+ }
124
+
125
+ function isCjkChar(ch: string): boolean {
126
+ return (
127
+ (ch >= "\u4e00" && ch <= "\u9fff") || (ch >= "\u3040" && ch <= "\u30ff") || (ch >= "\uac00" && ch <= "\ud7af")
128
+ );
129
+ }
130
+
131
+ function tableExists(db: Database, table: string): boolean {
132
+ try {
133
+ return (
134
+ db
135
+ .query("SELECT 1 FROM sqlite_master WHERE type IN ('table','virtual table') AND name = ? LIMIT 1")
136
+ .get(table) !== null
137
+ );
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ function rowValue<T>(row: unknown, key: string): T | undefined {
144
+ if (row && typeof row === "object" && key in row) return (row as Record<string, T>)[key];
145
+ return undefined;
146
+ }
147
+
148
+ function timestampCacheFor(beam?: Pick<BeamMemoryState, "caches"> | null): Map<string, Date> {
149
+ return beam?.caches?.timestampParse ?? moduleTimestampCache;
150
+ }
151
+
152
+ export function generateId(content: string, now: Date = new Date()): string {
153
+ return generateTimedId(content, now);
154
+ }
155
+
156
+ export function generateStableId(content: string, source = ""): string {
157
+ return stableMemoryId(content, source);
158
+ }
159
+
160
+ export function normalizeWeights(
161
+ vecWeight: number | null | undefined,
162
+ ftsWeight: number | null | undefined,
163
+ importanceWeight: number | null | undefined,
164
+ ): HybridWeights {
165
+ let vw = Math.max(0, vecWeight ?? envNumber("PROMETHEUS_MEMORY_VEC_WEIGHT", DEFAULT_WEIGHTS[0]));
166
+ let fw = Math.max(0, ftsWeight ?? envNumber("PROMETHEUS_MEMORY_FTS_WEIGHT", DEFAULT_WEIGHTS[1]));
167
+ let iw = Math.max(0, importanceWeight ?? envNumber("PROMETHEUS_MEMORY_IMPORTANCE_WEIGHT", DEFAULT_WEIGHTS[2]));
168
+ if (!Number.isFinite(vw)) vw = 0;
169
+ if (!Number.isFinite(fw)) fw = 0;
170
+ if (!Number.isFinite(iw)) iw = 0;
171
+ const total = vw + fw + iw;
172
+ if (total === 0) return DEFAULT_WEIGHTS;
173
+ return [vw / total, fw / total, iw / total];
174
+ }
175
+
176
+ export function normalizeImportance(importance: number | null | undefined, fallback = 0.5): number {
177
+ return clamp01(importance ?? fallback);
178
+ }
179
+
180
+ export function normalizeDateUtc(dt: Date): Date {
181
+ const time = dt.getTime();
182
+ if (!Number.isFinite(time)) throw new RangeError("Invalid Date");
183
+ return new Date(time);
184
+ }
185
+
186
+ export function parseIsoDateTimeUtc(value: string): Date {
187
+ const normalized = value.endsWith("Z") ? value : value.replace(/Z$/, "+00:00");
188
+ const dt = new Date(normalized);
189
+ if (!Number.isFinite(dt.getTime())) throw new RangeError(`Invalid ISO datetime: ${value}`);
190
+ return dt;
191
+ }
192
+
193
+ export function parseQueryTime(queryTime?: string | Date | null): Date {
194
+ if (queryTime == null) return new Date();
195
+ if (queryTime instanceof Date) return normalizeDateUtc(queryTime);
196
+ try {
197
+ return parseIsoDateTimeUtc(queryTime);
198
+ } catch {
199
+ return parseIsoDateTimeUtc(`${queryTime}T00:00:00`);
200
+ }
201
+ }
202
+
203
+ export function parseTimestampFast(
204
+ ts: string | null | undefined,
205
+ beam?: Pick<BeamMemoryState, "caches"> | null,
206
+ ): Date | null {
207
+ if (!ts) return null;
208
+ const cache = timestampCacheFor(beam);
209
+ const cached = cache.get(ts);
210
+ if (cached !== undefined) return cached;
211
+ let parsed: Date;
212
+ try {
213
+ parsed = parseIsoDateTimeUtc(ts);
214
+ } catch {
215
+ return null;
216
+ }
217
+ if (cache.size >= TS_CACHE_MAX) cache.clear();
218
+ cache.set(ts, parsed);
219
+ return parsed;
220
+ }
221
+
222
+ export function recencyDecay(
223
+ timestamp: string | null | undefined,
224
+ halflifeHours = DEFAULT_RECENCY_HALFLIFE_HOURS,
225
+ now: Date = new Date(),
226
+ ): number {
227
+ if (!timestamp) return 0.5;
228
+ const halflife = asFiniteNonNegative(halflifeHours);
229
+ if (halflife === 0) return 0.5;
230
+ const ts = parseTimestampFast(timestamp);
231
+ if (ts === null) return 0.5;
232
+ const ageHours = (now.getTime() - ts.getTime()) / 3_600_000;
233
+ return Math.exp(-ageHours / halflife);
234
+ }
235
+
236
+ export function temporalBoost(
237
+ memoryTimestamp: string | null | undefined,
238
+ queryTime: Date | string,
239
+ halflifeHours = 24,
240
+ beam?: Pick<BeamMemoryState, "caches"> | null,
241
+ ): number {
242
+ const ts = parseTimestampFast(memoryTimestamp, beam);
243
+ if (ts === null) return 0;
244
+ const query = parseQueryTime(queryTime);
245
+ const effectiveTs = ts.getTime() > query.getTime() ? query : ts;
246
+ const halflife = asFiniteNonNegative(halflifeHours);
247
+ if (halflife === 0) return effectiveTs.getTime() === query.getTime() ? 1 : 0;
248
+ const hoursDelta = (query.getTime() - effectiveTs.getTime()) / 3_600_000;
249
+ return Math.exp(-hoursDelta / halflife);
250
+ }
251
+
252
+ export function recallTokens(text: string): string[] {
253
+ const out: string[] = [];
254
+ for (const match of text.toLowerCase().matchAll(RECALL_TOKEN_RE)) {
255
+ const token = match[0] ?? "";
256
+ if (token.length >= 3 && !FACT_MATCH_STOPWORDS.has(token) && !/^\d+$/.test(token)) out.push(token);
257
+ }
258
+ return out;
259
+ }
260
+
261
+ export function expandedQueryTokens(tokens: readonly string[]): string[] {
262
+ const expanded: string[] = [];
263
+ const seen = new Set<string>();
264
+ for (const token of tokens) {
265
+ const synonyms = RECALL_SYNONYMS[token] ?? [];
266
+ for (const candidate of [token, ...synonyms]) {
267
+ if (!seen.has(candidate)) {
268
+ seen.add(candidate);
269
+ expanded.push(candidate);
270
+ }
271
+ }
272
+ }
273
+ return expanded;
274
+ }
275
+
276
+ export function minimumRecallRelevance(queryTokens: readonly string[]): number {
277
+ if (queryTokens.length >= 4) return 0.3;
278
+ if (queryTokens.length === 3) return 0.5;
279
+ return 0.15;
280
+ }
281
+
282
+ export function factMatchTokens(text: string): Set<string> {
283
+ return new Set(recallTokens(text));
284
+ }
285
+
286
+ export function containsSpacelessCjk(text: string): boolean {
287
+ return hasCjk(text);
288
+ }
289
+
290
+ export function hasCjk(text: string): boolean {
291
+ for (const ch of text) if (isCjkChar(ch)) return true;
292
+ return false;
293
+ }
294
+
295
+ export function cjkFtsTerms(text: string): string[] {
296
+ const chars = Array.from(text).filter(isCjkChar);
297
+ if (chars.length === 0) return [];
298
+ const terms: string[] = [];
299
+ const seen = new Set<string>();
300
+ for (const ch of chars) {
301
+ if (!seen.has(ch)) {
302
+ seen.add(ch);
303
+ terms.push(ch);
304
+ }
305
+ }
306
+ for (let i = 0; i < chars.length - 1; i += 1) {
307
+ const left = chars[i];
308
+ const right = chars[i + 1];
309
+ if (left === undefined || right === undefined) continue;
310
+ const bigram = left + right;
311
+ if (!seen.has(bigram)) {
312
+ seen.add(bigram);
313
+ terms.push(`"${bigram}"`);
314
+ }
315
+ }
316
+ return terms;
317
+ }
318
+
319
+ export function lexicalRelevance(queryTokens: readonly string[], content: string, queryLower = ""): number {
320
+ const contentLower = content.toLowerCase();
321
+ const queryCjk = new Set(Array.from(queryLower).filter(isCjkChar));
322
+ if (queryTokens.length === 0 && queryCjk.size === 0) return 0;
323
+
324
+ const contentTokens = new Set(recallTokens(contentLower));
325
+ for (const token of Array.from(contentTokens)) {
326
+ for (const part of token.split(SPLIT_TOKEN_RE)) {
327
+ if (part.length >= 3 && !FACT_MATCH_STOPWORDS.has(part) && !/^\d+$/.test(part)) contentTokens.add(part);
328
+ }
329
+ }
330
+ if (contentTokens.size === 0 && queryCjk.size === 0) return 0;
331
+
332
+ let exact = 0;
333
+ let partial = 0;
334
+ for (const token of queryTokens) {
335
+ if (contentTokens.has(token)) {
336
+ exact += 1;
337
+ continue;
338
+ }
339
+ const synonyms = RECALL_SYNONYMS[token] ?? [];
340
+ if (synonyms.some(syn => contentTokens.has(syn))) {
341
+ partial += 0.75;
342
+ continue;
343
+ }
344
+ if (
345
+ token.length >= 4 &&
346
+ Array.from(contentTokens).some(
347
+ contentToken => contentToken.length >= 4 && (token.includes(contentToken) || contentToken.includes(token)),
348
+ )
349
+ ) {
350
+ partial += 0.4;
351
+ }
352
+ }
353
+
354
+ const fullMatch = queryLower !== "" && contentLower.includes(queryLower) ? 1 : 0;
355
+ let score = (exact + partial + fullMatch) / Math.max(queryTokens.length, 1);
356
+ if (score === 0 && queryCjk.size > 0) {
357
+ const contentCjk = new Set(Array.from(contentLower).filter(isCjkChar));
358
+ let overlap = 0;
359
+ for (const ch of queryCjk) if (contentCjk.has(ch)) overlap += 1;
360
+ score = overlap / queryCjk.size;
361
+ }
362
+ return Math.min(score, 1);
363
+ }
364
+
365
+ export function strictFactMatches(query: string, factText: string): boolean {
366
+ const queryLower = query.toLowerCase().trim();
367
+ const factLower = factText.toLowerCase().trim();
368
+ if (!queryLower || !factLower) return false;
369
+ if (factLower.includes(queryLower)) return true;
370
+ const queryTokens = factMatchTokens(queryLower);
371
+ const factTokens = factMatchTokens(factLower);
372
+ if (queryTokens.size === 0 || factTokens.size === 0) return false;
373
+ const overlap = Array.from(queryTokens).filter(token => factTokens.has(token));
374
+ if (overlap.length >= 2) return true;
375
+ const token = overlap[0];
376
+ if (token === undefined) return false;
377
+ if (token.length >= 8 && /[./:_-]/.test(token)) return true;
378
+ return token.length >= 5;
379
+ }
380
+
381
+ export function ftsQueryTerms(query: string): string[] {
382
+ const terms: string[] = [];
383
+ for (const term of expandedQueryTokens(recallTokens(query))) {
384
+ const escaped = term.replaceAll('"', '""').trim();
385
+ if (escaped) terms.push(`"${escaped}"`);
386
+ }
387
+ return terms;
388
+ }
389
+
390
+ export function buildFtsQuery(query: string): string {
391
+ return ftsQueryTerms(query).join(" OR ");
392
+ }
393
+
394
+ function cjkCharsForSearch(query: string): string[] {
395
+ return Array.from(new Set(Array.from(query).filter(isCjkChar))).sort();
396
+ }
397
+
398
+ export function cjkLikeSearch(
399
+ db: Database,
400
+ query: string,
401
+ k = 20,
402
+ working = false,
403
+ ): Array<FtsRankResult | WorkingFtsRankResult> {
404
+ const cjkChars = cjkCharsForSearch(query);
405
+ if (cjkChars.length === 0) return [];
406
+ const table = working ? "working_memory" : "episodic_memory";
407
+ const idColumn = working ? "id" : "rowid";
408
+ const conditions = cjkChars.map(() => "content LIKE ? ESCAPE '\\'").join(" OR ");
409
+ try {
410
+ const rows = db
411
+ .query(`SELECT ${idColumn}, content FROM ${table} WHERE ${conditions} LIMIT ?`)
412
+ .all(...cjkChars.map(ch => `%${ch}%`), k * 5) as Record<string, unknown>[];
413
+ const scored: Array<{ id: string | number; score: number }> = [];
414
+ for (const row of rows) {
415
+ const content = String(row.content ?? "");
416
+ let hits = 0;
417
+ for (const ch of cjkChars) if (content.includes(ch)) hits += 1;
418
+ const score = hits / Math.max(cjkChars.length, 1);
419
+ if (score > 0) scored.push({ id: row[idColumn] as string | number, score });
420
+ }
421
+ scored.sort((a, b) => b.score - a.score);
422
+ return scored
423
+ .slice(0, Math.max(0, Math.trunc(k)))
424
+ .map(row =>
425
+ working ? { id: String(row.id), rank: -row.score } : { rowid: Number(row.id), rank: -row.score },
426
+ );
427
+ } catch {
428
+ return [];
429
+ }
430
+ }
431
+
432
+ export function ftsSearch(db: Database, query: string, k = 20): FtsRankResult[] {
433
+ const ftsQuery = buildFtsQuery(query);
434
+ if (!ftsQuery) return hasCjk(query) ? (cjkLikeSearch(db, query, k, false) as FtsRankResult[]) : [];
435
+ try {
436
+ const rows = db
437
+ .query("SELECT rowid, rank FROM fts_episodes WHERE fts_episodes MATCH ? ORDER BY rank, rowid LIMIT ?")
438
+ .all(ftsQuery, k) as Record<string, unknown>[];
439
+ if (rows.length === 0 && hasCjk(query)) return cjkLikeSearch(db, query, k, false) as FtsRankResult[];
440
+ return rows.map(row => ({ rowid: Number(row.rowid), rank: Number(row.rank) }));
441
+ } catch {
442
+ return [];
443
+ }
444
+ }
445
+
446
+ export function ftsSearchWorking(db: Database, query: string, k = 20): WorkingFtsRankResult[] {
447
+ const ftsQuery = buildFtsQuery(query);
448
+ if (!ftsQuery) return hasCjk(query) ? (cjkLikeSearch(db, query, k, true) as WorkingFtsRankResult[]) : [];
449
+ try {
450
+ const rows = db
451
+ .query("SELECT id, rank FROM fts_working WHERE fts_working MATCH ? ORDER BY rank, id LIMIT ?")
452
+ .all(ftsQuery, k) as Record<string, unknown>[];
453
+ if (rows.length === 0 && hasCjk(query)) return cjkLikeSearch(db, query, k, true) as WorkingFtsRankResult[];
454
+ return rows.map(row => ({ id: String(row.id), rank: Number(row.rank) }));
455
+ } catch {
456
+ return [];
457
+ }
458
+ }
459
+
460
+ export function encodeVector(embedding: readonly number[]): string {
461
+ return JSON.stringify(embedding);
462
+ }
463
+
464
+ export function decodeVector(value: string | null | undefined): Vector | null {
465
+ if (!value) return null;
466
+ try {
467
+ const parsed = JSON.parse(value) as unknown;
468
+ if (!Array.isArray(parsed)) return null;
469
+ const vector: number[] = [];
470
+ for (const item of parsed) {
471
+ if (typeof item !== "number" || !Number.isFinite(item)) return null;
472
+ vector.push(item);
473
+ }
474
+ return vector;
475
+ } catch {
476
+ return null;
477
+ }
478
+ }
479
+
480
+ export function vecAvailable(db: Database): boolean {
481
+ return tableExists(db, "vec_episodes");
482
+ }
483
+
484
+ export function effectiveVecType(db: Database): "float32" | "int8" | "bit" {
485
+ if (!vecAvailable(db)) return "float32";
486
+ try {
487
+ const row = db.query("SELECT sql FROM sqlite_master WHERE type='table' AND name='vec_episodes'").get() as {
488
+ sql?: string;
489
+ } | null;
490
+ const sql = row?.sql ?? "";
491
+ if (sql.includes("int8")) return "int8";
492
+ if (sql.includes("bit")) return "bit";
493
+ } catch {
494
+ return "float32";
495
+ }
496
+ return "float32";
497
+ }
498
+
499
+ export function vecInsert(db: Database, rowid: number, embedding: readonly number[]): void {
500
+ const vecType = effectiveVecType(db);
501
+ const embJson = encodeVector(embedding);
502
+ if (vecType === "bit") {
503
+ db.query("INSERT INTO vec_episodes(rowid, embedding) VALUES (?, vec_quantize_binary(?))").run(rowid, embJson);
504
+ } else if (vecType === "int8") {
505
+ db.query("INSERT INTO vec_episodes(rowid, embedding) VALUES (?, vec_quantize_int8(?, 'unit'))").run(
506
+ rowid,
507
+ embJson,
508
+ );
509
+ } else {
510
+ db.query("INSERT INTO vec_episodes(rowid, embedding) VALUES (?, ?)").run(rowid, embJson);
511
+ }
512
+ }
513
+
514
+ export function vecSearch(db: Database, embedding: readonly number[], k = 20): VectorDistanceResult[] {
515
+ const vecType = effectiveVecType(db);
516
+ const embJson = encodeVector(embedding);
517
+ const limit = Math.max(0, Math.trunc(k));
518
+ try {
519
+ let rows: Record<string, unknown>[];
520
+ if (vecType === "bit") {
521
+ rows = db
522
+ .query(
523
+ `SELECT rowid, distance FROM vec_episodes WHERE embedding MATCH vec_quantize_binary(?) ORDER BY distance LIMIT ${limit}`,
524
+ )
525
+ .all(embJson) as Record<string, unknown>[];
526
+ } else if (vecType === "int8") {
527
+ rows = db
528
+ .query(
529
+ `SELECT rowid, distance FROM vec_episodes WHERE embedding MATCH vec_quantize_int8(?, "unit") AND k=${limit} ORDER BY distance`,
530
+ )
531
+ .all(embJson) as Record<string, unknown>[];
532
+ } else {
533
+ rows = db
534
+ .query(`SELECT rowid, distance FROM vec_episodes WHERE embedding MATCH ? ORDER BY distance LIMIT ${limit}`)
535
+ .all(embJson) as Record<string, unknown>[];
536
+ }
537
+ return rows.map(row => ({ rowid: Number(row.rowid), distance: Number(row.distance) }));
538
+ } catch {
539
+ return [];
540
+ }
541
+ }
542
+
543
+ export function inMemoryVecSearch(db: Database, queryEmbedding: readonly number[], k = 20): VectorDistanceResult[] {
544
+ if (queryEmbedding.length === 0) return [];
545
+ try {
546
+ const rows = db
547
+ .query(`
548
+ SELECT em.rowid, me.memory_id, me.embedding_json
549
+ FROM memory_embeddings me
550
+ JOIN episodic_memory em ON me.memory_id = em.id
551
+ LIMIT 10000
552
+ `)
553
+ .all() as Record<string, unknown>[];
554
+ const results: VectorDistanceResult[] = [];
555
+ for (const row of rows) {
556
+ const vec = decodeVector(String(row.embedding_json ?? ""));
557
+ if (vec === null) continue;
558
+ const sim = vectorCosineSimilarity(queryEmbedding, vec);
559
+ if (sim === 0 && (queryEmbedding.every(n => n === 0) || vec.every(n => n === 0))) continue;
560
+ results.push({ rowid: Number(row.rowid), distance: 1 - sim });
561
+ }
562
+ results.sort((a, b) => a.distance - b.distance || a.rowid - b.rowid);
563
+ return results.slice(0, Math.max(0, Math.trunc(k)));
564
+ } catch {
565
+ return [];
566
+ }
567
+ }
568
+
569
+ export function workingMemoryVecSearch(
570
+ db: Database,
571
+ queryEmbedding: readonly number[],
572
+ k = 20,
573
+ now: Date = new Date(),
574
+ ): WorkingVectorResult[] {
575
+ if (queryEmbedding.length === 0) return [];
576
+ try {
577
+ const limit = process.env.PROMETHEUS_MEMORY_BEAM_MODE ? 500_000 : 50_000;
578
+ const rows = db
579
+ .query(`
580
+ SELECT wm.id, me.embedding_json
581
+ FROM memory_embeddings me
582
+ JOIN working_memory wm ON me.memory_id = wm.id
583
+ WHERE wm.superseded_by IS NULL
584
+ AND (wm.valid_until IS NULL OR wm.valid_until > ?)
585
+ LIMIT ?
586
+ `)
587
+ .all(now.toISOString(), limit) as Record<string, unknown>[];
588
+ const results: WorkingVectorResult[] = [];
589
+ for (const row of rows) {
590
+ const vec = decodeVector(String(row.embedding_json ?? ""));
591
+ if (vec === null) continue;
592
+ const sim = vectorCosineSimilarity(queryEmbedding, vec);
593
+ if (sim === 0 && (queryEmbedding.every(n => n === 0) || vec.every(n => n === 0))) continue;
594
+ results.push({ id: String(row.id), sim });
595
+ }
596
+ results.sort((a, b) => b.sim - a.sim || a.id.localeCompare(b.id));
597
+ return results.slice(0, Math.max(0, Math.trunc(k)));
598
+ } catch {
599
+ return [];
600
+ }
601
+ }
602
+
603
+ export function normalizeMetadata(input: unknown): Metadata {
604
+ if (input == null) return {};
605
+ if (typeof input === "string") {
606
+ try {
607
+ return normalizeMetadata(JSON.parse(input) as unknown);
608
+ } catch {
609
+ return {};
610
+ }
611
+ }
612
+ if (typeof input !== "object" || Array.isArray(input)) return {};
613
+ const out: Metadata = {};
614
+ for (const key in input) {
615
+ const normalized = normalizeJsonValue((input as Record<string, unknown>)[key]);
616
+ if (normalized !== undefined) out[key] = normalized;
617
+ }
618
+ return out;
619
+ }
620
+
621
+ function normalizeJsonValue(value: unknown): JsonValue | undefined {
622
+ if (value == null || typeof value === "string" || typeof value === "boolean") return value;
623
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
624
+ if (Array.isArray(value)) {
625
+ const out: JsonValue[] = [];
626
+ for (const item of value) {
627
+ const normalized = normalizeJsonValue(item);
628
+ if (normalized !== undefined) out.push(normalized);
629
+ }
630
+ return out;
631
+ }
632
+ if (typeof value === "object") {
633
+ const out: Record<string, JsonValue> = {};
634
+ for (const key in value) {
635
+ const normalized = normalizeJsonValue((value as Record<string, unknown>)[key]);
636
+ if (normalized !== undefined) out[key] = normalized;
637
+ }
638
+ return out;
639
+ }
640
+ return undefined;
641
+ }
642
+
643
+ export function metadataJson(input: unknown): string {
644
+ return JSON.stringify(normalizeMetadata(input));
645
+ }
646
+
647
+ export function detectLanguage(text: string): string {
648
+ if (!text) return "en";
649
+ const lower = text.toLowerCase();
650
+ const cyrillic = "абвгдеёжзийклмнопрстуфхцчшщъыьэюя";
651
+ let russianChars = 0;
652
+ for (const ch of lower) if (cyrillic.includes(ch)) russianChars += 1;
653
+ if (russianChars >= 5) return "ru";
654
+ if (russianChars >= 2) {
655
+ const ruMarkers = new Set([
656
+ "я",
657
+ "ты",
658
+ "он",
659
+ "она",
660
+ "оно",
661
+ "мы",
662
+ "вы",
663
+ "они",
664
+ "не",
665
+ "на",
666
+ "в",
667
+ "с",
668
+ "по",
669
+ "для",
670
+ "что",
671
+ "как",
672
+ "это",
673
+ "так",
674
+ "но",
675
+ "да",
676
+ "нет",
677
+ "уже",
678
+ "ещё",
679
+ "мой",
680
+ "твой",
681
+ "наш",
682
+ "ваш",
683
+ "этот",
684
+ "тот",
685
+ ]);
686
+ if (intersectionCount(words(lower), ruMarkers) >= 2) return "ru";
687
+ }
688
+ if (["ä", "ö", "ü", "ß"].some(ch => lower.includes(ch))) return "de";
689
+ const germanMarkers = new Set([
690
+ "ich",
691
+ "du",
692
+ "wir",
693
+ "ist",
694
+ "nicht",
695
+ "für",
696
+ "und",
697
+ "der",
698
+ "die",
699
+ "das",
700
+ "ein",
701
+ "eine",
702
+ "kein",
703
+ "keine",
704
+ "mein",
705
+ "meine",
706
+ "dann",
707
+ "auch",
708
+ "immer",
709
+ "nie",
710
+ "niemals",
711
+ "mag",
712
+ "will",
713
+ "möchte",
714
+ "kann",
715
+ "kannst",
716
+ "können",
717
+ "habe",
718
+ "hast",
719
+ "hat",
720
+ "haben",
721
+ "bin",
722
+ "bist",
723
+ "sind",
724
+ "seid",
725
+ "einen",
726
+ "einer",
727
+ "eines",
728
+ "dem",
729
+ "den",
730
+ "beim",
731
+ "zum",
732
+ "zur",
733
+ "nach",
734
+ "mit",
735
+ "von",
736
+ "bei",
737
+ "aus",
738
+ "auf",
739
+ "vor",
740
+ "aber",
741
+ "oder",
742
+ "weil",
743
+ "denn",
744
+ "dass",
745
+ "sehr",
746
+ "schon",
747
+ "noch",
748
+ "mal",
749
+ "man",
750
+ "nur",
751
+ "wenn",
752
+ "wie",
753
+ "als",
754
+ "doch",
755
+ "gerne",
756
+ "gern",
757
+ "lieber",
758
+ "einfach",
759
+ "eigentlich",
760
+ "vielleicht",
761
+ "natürlich",
762
+ "genau",
763
+ "bereits",
764
+ "eben",
765
+ ]);
766
+ const textWords = words(lower);
767
+ if (intersectionCount(textWords, germanMarkers) >= 2) return "de";
768
+ if (["ñ", "á", "é", "í", "ó", "ú", "ü", "¿", "¡"].some(ch => lower.includes(ch))) return "es";
769
+ const spanishMarkers = new Set([
770
+ "y",
771
+ "de",
772
+ "por",
773
+ "con",
774
+ "para",
775
+ "que",
776
+ "qué",
777
+ "como",
778
+ "el",
779
+ "la",
780
+ "lo",
781
+ "los",
782
+ "las",
783
+ "un",
784
+ "una",
785
+ "del",
786
+ "este",
787
+ "esta",
788
+ "esto",
789
+ "ese",
790
+ "esa",
791
+ "eso",
792
+ "aquel",
793
+ "mi",
794
+ "mis",
795
+ "tu",
796
+ "tus",
797
+ "su",
798
+ "sus",
799
+ "es",
800
+ "está",
801
+ "son",
802
+ "hay",
803
+ "tiene",
804
+ "puede",
805
+ "más",
806
+ "no",
807
+ "también",
808
+ "si",
809
+ "ya",
810
+ "nunca",
811
+ "he",
812
+ "se",
813
+ "me",
814
+ "te",
815
+ "le",
816
+ "a",
817
+ "yo",
818
+ "ante",
819
+ "bajo",
820
+ "contra",
821
+ "desde",
822
+ "en",
823
+ "entre",
824
+ "hacia",
825
+ "hasta",
826
+ "según",
827
+ "sin",
828
+ "sobre",
829
+ "tras",
830
+ "todo",
831
+ "toda",
832
+ "cada",
833
+ "muy",
834
+ "pero",
835
+ "siempre",
836
+ "usa",
837
+ "hacer",
838
+ "antes",
839
+ "recuerda",
840
+ "evita",
841
+ ]);
842
+ if (intersectionCount(textWords, spanishMarkers) >= 2) return "es";
843
+ if (["à", "è", "é", "ì", "ò", "ù"].some(ch => lower.includes(ch))) {
844
+ const italianMarkers = new Set([
845
+ "e",
846
+ "il",
847
+ "la",
848
+ "i",
849
+ "le",
850
+ "di",
851
+ "che",
852
+ "non",
853
+ "un",
854
+ "una",
855
+ "per",
856
+ "è",
857
+ "in",
858
+ "sono",
859
+ "mi",
860
+ "ha",
861
+ "ma",
862
+ "lo",
863
+ "se",
864
+ "su",
865
+ "con",
866
+ "da",
867
+ "come",
868
+ "questo",
869
+ "quello",
870
+ "anche",
871
+ "o",
872
+ "ho",
873
+ "ci",
874
+ "si",
875
+ "perché",
876
+ "perche",
877
+ "quando",
878
+ "chi",
879
+ "dove",
880
+ "molto",
881
+ "del",
882
+ "della",
883
+ "delle",
884
+ "dei",
885
+ "degli",
886
+ "nel",
887
+ "nella",
888
+ "sul",
889
+ "sulla",
890
+ "sui",
891
+ "sulle",
892
+ "al",
893
+ "alla",
894
+ "agli",
895
+ "alle",
896
+ ]);
897
+ if (intersectionCount(textWords, italianMarkers) >= 2) return "it";
898
+ }
899
+ return "en";
900
+ }
901
+
902
+ function words(text: string): Set<string> {
903
+ return new Set(Array.from(text.matchAll(WORD_RE), match => match[0] ?? ""));
904
+ }
905
+
906
+ function intersectionCount(left: ReadonlySet<string>, right: ReadonlySet<string>): number {
907
+ let count = 0;
908
+ for (const item of left) if (right.has(item)) count += 1;
909
+ return count;
910
+ }
911
+
912
+ export function memoryRowMetadata(row: unknown): Metadata {
913
+ return normalizeMetadata(rowValue<unknown>(row, "metadata_json") ?? rowValue<unknown>(row, "metadata"));
914
+ }
915
+ export {
916
+ cosineSimilarity,
917
+ hammingDistance,
918
+ informationTheoreticScore,
919
+ maximallyInformativeBinarization,
920
+ quantizeInt8,
921
+ } from "../binary-vectors";
922
+ export { sha256Hex16 };
923
+
924
+ /** Identifies one freshly stored memory whose embedding still needs to be derived. */
925
+ export interface EmbedItem {
926
+ readonly memoryId: string;
927
+ readonly content: string;
928
+ }
929
+
930
+ async function runEmbedding(beam: BeamMemoryState, items: readonly EmbedItem[]): Promise<void> {
931
+ try {
932
+ const matrix = await embed(items.map(item => item.content));
933
+ if (matrix === null) return;
934
+ const model = currentEmbeddingModel();
935
+ const insertEmbedding = beam.db.prepare(
936
+ "INSERT OR REPLACE INTO memory_embeddings(memory_id, embedding_json, model) VALUES (?, ?, ?)",
937
+ );
938
+ const insertMany = beam.db.transaction((rows: readonly EmbedItem[]) => {
939
+ for (let i = 0; i < rows.length; i += 1) {
940
+ const vector = matrix[i];
941
+ const item = rows[i];
942
+ if (vector === undefined || item === undefined) continue;
943
+ insertEmbedding.run(item.memoryId, JSON.stringify(Array.from(vector)), model);
944
+ }
945
+ });
946
+ insertMany(items);
947
+ } catch {
948
+ // Background embedding generation is best-effort: a failing provider, a closed DB
949
+ // during shutdown, or a transient API error must never disrupt the synchronous
950
+ // remember()/consolidate() that scheduled it. Production recall silently degrades
951
+ // to FTS-only for the affected rows, which is the same shape as a misconfigured
952
+ // provider.
953
+ }
954
+ }
955
+
956
+ /**
957
+ * Schedule background embedding generation for one or more freshly stored memories.
958
+ *
959
+ * Mirrors the `scheduleFactExtraction` pattern in `beam/store.ts`: `remember()`,
960
+ * `rememberBatch()`, and `consolidateToEpisodic()` are synchronous, but `embed()` is
961
+ * async (it may hit an HTTP provider), so the task is fired-and-forgotten and tracked
962
+ * on `beam.pendingExtractions` so tests and graceful shutdown can drain it via
963
+ * `flushExtractions()`. The active runtime options (provider, model, API URL/key) are
964
+ * captured here and re-entered inside the task because the `AsyncLocalStorage` scope
965
+ * set by `Mnemopi.#withRuntimeOptions` has already exited by the time the task runs.
966
+ */
967
+ export function scheduleEmbedding(beam: BeamMemoryState, items: readonly EmbedItem[]): void {
968
+ const cleaned = items.filter(item => item.content.trim() !== "");
969
+ if (cleaned.length === 0) return;
970
+ const runtimeOptions = getMnemopiRuntimeOptions();
971
+ const task = withMnemopiRuntimeOptions(runtimeOptions, () => runEmbedding(beam, cleaned));
972
+ const pending = beam.pendingExtractions;
973
+ if (pending !== undefined) {
974
+ pending.add(task);
975
+ void task.finally(() => pending.delete(task));
976
+ }
977
+ }