@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,1100 @@
1
+ import { normalizedRecallWeights, temporalHalflifeHours } from "../../config";
2
+ import { embedQuery } from "../embeddings";
3
+ import { mmrRerank } from "../mmr";
4
+ import { adjustWeights, classifyIntent } from "../query-intent";
5
+ import { getSynonyms, normalizeQuery } from "../synonyms";
6
+ import { extractTemporal } from "../temporal-parser";
7
+ import { cosineSimilarity } from "../vector-math";
8
+ import type { BeamMemoryState, RecallEnhancedOptions, RecallOptions, RecallResult } from "./types";
9
+
10
+ type DbValue = string | number | null | Uint8Array;
11
+ type Row = Record<string, unknown>;
12
+ type TierLabel = "working" | "episodic";
13
+
14
+ type RecallOptionsInternal = RecallOptions & {
15
+ source?: string | null;
16
+ topic?: string | null;
17
+ veracity?: string | null;
18
+ memoryType?: string | null;
19
+ temporalWeight?: number;
20
+ temporalHalflife?: number;
21
+ vecWeight?: number;
22
+ ftsWeight?: number;
23
+ importanceWeight?: number;
24
+ queryEmbedding?: readonly number[] | null;
25
+ useSynonyms?: boolean;
26
+ useIntent?: boolean;
27
+ useMmr?: boolean;
28
+ mmrLambda?: number;
29
+ ignoreSessionScope?: boolean;
30
+ currentSensitive?: boolean;
31
+ updateRecallCounts?: boolean;
32
+ };
33
+
34
+ type CandidateSignals = {
35
+ fts: number;
36
+ ftsMatched: boolean;
37
+ dense: number;
38
+ keyword: number;
39
+ candidateSource: "fts" | "vec" | "fallback";
40
+ };
41
+
42
+ type MemoryCandidate = {
43
+ row: Row;
44
+ tierLabel: TierLabel;
45
+ signals: CandidateSignals;
46
+ };
47
+
48
+ type FactRecallResult = RecallResult & {
49
+ fact_id?: string;
50
+ subject?: string;
51
+ predicate?: string;
52
+ };
53
+
54
+ type RecallMmrItem = {
55
+ readonly content?: string;
56
+ readonly score?: number;
57
+ readonly result: RecallResult;
58
+ readonly [key: string]: unknown;
59
+ };
60
+
61
+ const VERACITY_WEIGHTS: Record<string, number> = {
62
+ stated: 1.0,
63
+ true: 1.0,
64
+ likely_true: 1.0,
65
+ unknown: 0.8,
66
+ inferred: 0.7,
67
+ imported: 0.6,
68
+ tool: 0.5,
69
+ false: 0,
70
+ };
71
+
72
+ const DEFAULT_LIMIT = 500;
73
+ const STOP_WORDS = new Set([
74
+ "a",
75
+ "an",
76
+ "and",
77
+ "are",
78
+ "as",
79
+ "at",
80
+ "be",
81
+ "by",
82
+ "for",
83
+ "from",
84
+ "how",
85
+ "i",
86
+ "in",
87
+ "is",
88
+ "it",
89
+ "of",
90
+ "on",
91
+ "or",
92
+ "that",
93
+ "the",
94
+ "this",
95
+ "to",
96
+ "was",
97
+ "what",
98
+ "when",
99
+ "where",
100
+ "who",
101
+ "with",
102
+ ]);
103
+
104
+ function nowIso(): string {
105
+ return new Date().toISOString();
106
+ }
107
+
108
+ function asNumber(value: unknown, fallback = 0): number {
109
+ const n = typeof value === "number" ? value : Number(value);
110
+ return Number.isFinite(n) ? n : fallback;
111
+ }
112
+
113
+ function asString(value: unknown): string {
114
+ return typeof value === "string" ? value : "";
115
+ }
116
+
117
+ function asNullableString(value: unknown): string | null {
118
+ return typeof value === "string" ? value : null;
119
+ }
120
+
121
+ function round4(value: number): number {
122
+ return Math.round(value * 10000) / 10000;
123
+ }
124
+
125
+ function clamp01(value: number): number {
126
+ if (value <= 0) return 0;
127
+ if (value >= 1) return 1;
128
+ return value;
129
+ }
130
+
131
+ function tokenize(text: string): string[] {
132
+ const lowered = text.toLowerCase();
133
+ const matches = lowered.match(/[\p{L}\p{N}_]+/gu) ?? [];
134
+ const tokens: string[] = [];
135
+ for (const token of matches) {
136
+ if (token.length === 0 || STOP_WORDS.has(token)) continue;
137
+ tokens.push(token);
138
+ }
139
+ return tokens;
140
+ }
141
+
142
+ function recallSynonyms(token: string, useSynonyms: boolean): string[] {
143
+ if (!useSynonyms) return [token];
144
+ const variants = getSynonyms(token);
145
+ switch (token) {
146
+ case "branding":
147
+ return [...variants, "positioning", "wording", "headline"];
148
+ case "preference":
149
+ case "prefer":
150
+ case "preferred":
151
+ return [...variants, "wants", "want", "prefers"];
152
+ default:
153
+ return variants;
154
+ }
155
+ }
156
+
157
+ function expandedTokens(query: string, useSynonyms = true): string[] {
158
+ const seen = new Set<string>();
159
+ for (const token of tokenize(query)) {
160
+ for (const variant of recallSynonyms(token, useSynonyms)) {
161
+ for (const part of tokenize(variant)) seen.add(part);
162
+ }
163
+ }
164
+ return [...seen];
165
+ }
166
+
167
+ function expandedTokenGroups(query: string, useSynonyms = true): string[][] {
168
+ const groups: string[][] = [];
169
+ for (const token of tokenize(query)) {
170
+ const seen = new Set<string>();
171
+ for (const variant of recallSynonyms(token, useSynonyms)) {
172
+ for (const part of tokenize(variant)) seen.add(part);
173
+ }
174
+ if (seen.size > 0) groups.push([...seen]);
175
+ }
176
+ return groups;
177
+ }
178
+
179
+ function contentMatchesToken(contentLower: string, contentTokens: ReadonlySet<string>, token: string): boolean {
180
+ if (contentTokens.has(token) || contentLower.includes(token)) return true;
181
+ for (const contentToken of contentTokens) {
182
+ if (
183
+ contentToken.length >= 4 &&
184
+ token.length >= 4 &&
185
+ (contentToken.includes(token) || token.includes(contentToken))
186
+ ) {
187
+ return true;
188
+ }
189
+ }
190
+ return false;
191
+ }
192
+
193
+ function lexicalGroupRelevance(
194
+ queryGroups: readonly (readonly string[])[],
195
+ content: string,
196
+ normalizedQuery: string,
197
+ ): number {
198
+ if (queryGroups.length === 0) return 0;
199
+ const contentLower = content.toLowerCase();
200
+ if (queryGroups.length > 1 && normalizedQuery.length > 0 && contentLower.includes(normalizedQuery)) return 1;
201
+ const contentTokens = new Set(tokenize(contentLower));
202
+ let exact = 0;
203
+ let partial = 0;
204
+ for (const group of queryGroups) {
205
+ let matched = false;
206
+ for (const token of group) {
207
+ if (contentMatchesToken(contentLower, contentTokens, token)) {
208
+ matched = true;
209
+ break;
210
+ }
211
+ }
212
+ if (matched) exact += 1;
213
+ else {
214
+ for (const token of group) {
215
+ for (const contentToken of contentTokens) {
216
+ if (
217
+ contentToken.length >= 4 &&
218
+ token.length >= 4 &&
219
+ (contentToken.includes(token) || token.includes(contentToken))
220
+ ) {
221
+ partial += 1;
222
+ matched = true;
223
+ break;
224
+ }
225
+ }
226
+ if (matched) break;
227
+ }
228
+ }
229
+ }
230
+ if (queryGroups.length === 1) {
231
+ if (exact === 0 && partial === 0) return 0;
232
+ const token = queryGroups[0]?.[0] ?? "";
233
+ let count = 0;
234
+ let offset = 0;
235
+ while (token.length > 0) {
236
+ const idx = contentLower.indexOf(token, offset);
237
+ if (idx < 0) break;
238
+ count += 1;
239
+ offset = idx + token.length;
240
+ }
241
+ return clamp01(0.7 + Math.min(Math.max(count - 1, 0), 3) * 0.1);
242
+ }
243
+ return clamp01((exact + partial * 0.5) / queryGroups.length);
244
+ }
245
+
246
+ function queryAsksCurrent(query: string): boolean {
247
+ return /\b(?:now|current|currently|latest|recent|today|active|present)\b/i.test(query);
248
+ }
249
+
250
+ function currentContentAdjustment(content: string, currentSensitive: boolean): number {
251
+ if (!currentSensitive) return 1;
252
+ const lowered = content.toLowerCase();
253
+ let factor = 1;
254
+ if (/\b(?:current|currently|latest|now|active|present)\b/.test(lowered)) factor *= 1.35;
255
+ if (/\b(?:was|previous|previously|legacy|old|stale|former|deprecated)\b/.test(lowered)) factor *= 0.72;
256
+ return factor;
257
+ }
258
+
259
+ function minimumRelevance(tokens: readonly string[]): number {
260
+ if (tokens.length <= 1) return 0.08;
261
+ if (tokens.length === 2) return 0.18;
262
+ if (tokens.length === 3) return 0.34;
263
+ return 0.22;
264
+ }
265
+
266
+ function lexicalRelevance(queryTokens: readonly string[], content: string, normalizedQuery: string): number {
267
+ if (queryTokens.length === 0) return 0;
268
+ const contentLower = content.toLowerCase();
269
+ if (queryTokens.length > 1 && normalizedQuery.length > 0 && contentLower.includes(normalizedQuery)) return 1;
270
+ if (queryTokens.length === 1) {
271
+ const token = queryTokens[0] ?? "";
272
+ if (token.length === 0 || !contentLower.includes(token)) return 0;
273
+ let count = 0;
274
+ let offset = 0;
275
+ while (true) {
276
+ const idx = contentLower.indexOf(token, offset);
277
+ if (idx < 0) break;
278
+ count += 1;
279
+ offset = idx + token.length;
280
+ }
281
+ return clamp01(0.7 + Math.min(Math.max(count - 1, 0), 3) * 0.1);
282
+ }
283
+ const contentTokens = new Set(tokenize(contentLower));
284
+ let exact = 0;
285
+ let partial = 0;
286
+ for (const token of queryTokens) {
287
+ if (contentTokens.has(token) || contentLower.includes(token)) {
288
+ exact += 1;
289
+ continue;
290
+ }
291
+ for (const contentToken of contentTokens) {
292
+ if (
293
+ contentToken.length >= 4 &&
294
+ token.length >= 4 &&
295
+ (contentToken.includes(token) || token.includes(contentToken))
296
+ ) {
297
+ partial += 1;
298
+ break;
299
+ }
300
+ }
301
+ }
302
+ return clamp01((exact + partial * 0.5) / queryTokens.length);
303
+ }
304
+
305
+ function recencyDecay(timestamp: unknown, halfLifeHours = 72): number {
306
+ const raw = asString(timestamp);
307
+ if (raw.length === 0) return 0;
308
+ const parsed = Date.parse(raw);
309
+ if (!Number.isFinite(parsed)) return 0;
310
+ const ageHours = Math.max(0, (Date.now() - parsed) / 3_600_000);
311
+ return Math.exp(-ageHours / Math.max(halfLifeHours, 0.001));
312
+ }
313
+
314
+ export function parseQueryTime(value: RecallOptionsInternal["queryTime"]): Date {
315
+ if (value == null) return new Date();
316
+ if (value instanceof Date) {
317
+ if (!Number.isFinite(value.getTime())) throw new RangeError("Invalid query time");
318
+ return value;
319
+ }
320
+ if (typeof value === "string") {
321
+ const normalized = /^\d{4}-\d{2}-\d{2}$/.test(value)
322
+ ? `${value}T00:00:00.000Z`
323
+ : /(?:Z|[+-]\d{2}:?\d{2})$/.test(value)
324
+ ? value
325
+ : `${value}Z`;
326
+ const parsed = new Date(normalized);
327
+ if (Number.isFinite(parsed.getTime())) return parsed;
328
+ }
329
+ throw new TypeError("queryTime must be null, an ISO date string, or a valid Date");
330
+ }
331
+
332
+ export function temporalBoost(timestamp: unknown, queryTime: Date, halfLifeHours: number): number {
333
+ const raw = asString(timestamp);
334
+ if (raw.length === 0) return 0;
335
+ const parsed = Date.parse(raw);
336
+ if (!Number.isFinite(parsed)) return 0;
337
+ const distanceHours = Math.max(0, queryTime.getTime() - parsed) / 3_600_000;
338
+ return Math.exp(-distanceHours / Math.max(halfLifeHours, 0.001));
339
+ }
340
+
341
+ function inferTemporalOptions(query: string, options: RecallOptionsInternal): RecallOptionsInternal {
342
+ const copy: RecallOptionsInternal = { ...options };
343
+ const info = extractTemporal(query, options.queryTime ?? undefined);
344
+ if (info.event_date !== null) {
345
+ copy.queryTime ??= info.event_date;
346
+ copy.temporalWeight ??= 0.35;
347
+ }
348
+ return copy;
349
+ }
350
+
351
+ function ftsPhrase(token: string): string {
352
+ return `"${token.replaceAll('"', '""')}"`;
353
+ }
354
+
355
+ function ftsQuery(query: string, useSynonyms = true): string {
356
+ const tokens = expandedTokens(query, useSynonyms).slice(0, 12);
357
+ if (tokens.length === 0) return ftsPhrase(query.trim());
358
+ return tokens.map(ftsPhrase).join(" OR ");
359
+ }
360
+
361
+ function placeholders(count: number): string {
362
+ return new Array<string>(count).fill("?").join(",");
363
+ }
364
+
365
+ function queryAll(beam: BeamMemoryState, sql: string, params: readonly DbValue[] = []): Row[] {
366
+ return beam.db.query(sql).all(...params) as Row[];
367
+ }
368
+
369
+ function queryGet(beam: BeamMemoryState, sql: string, params: readonly DbValue[] = []): Row | null {
370
+ return (beam.db.query(sql).get(...params) as Row | null) ?? null;
371
+ }
372
+
373
+ function tableExists(beam: BeamMemoryState, table: string): boolean {
374
+ return (
375
+ queryGet(beam, "SELECT 1 FROM sqlite_master WHERE type IN ('table', 'virtual table') AND name = ?", [table]) !==
376
+ null
377
+ );
378
+ }
379
+
380
+ function factsHaveScopeColumn(beam: BeamMemoryState): boolean {
381
+ const rows = queryAll(beam, "PRAGMA table_info(facts)");
382
+ return rows.some(row => asString(row.name) === "scope");
383
+ }
384
+
385
+ function factVisibilityWhere(beam: BeamMemoryState, tableAlias: string): { where: string; params: DbValue[] } {
386
+ const prefix = tableAlias.length === 0 ? "" : `${tableAlias}.`;
387
+ if (factsHaveScopeColumn(beam)) {
388
+ return { where: `(${prefix}session_id = ? OR ${prefix}scope = 'global')`, params: [beam.sessionId] };
389
+ }
390
+ return { where: `${prefix}session_id = ?`, params: [beam.sessionId] };
391
+ }
392
+
393
+ function buildWhere(
394
+ beam: BeamMemoryState,
395
+ tableAlias: string,
396
+ options: RecallOptionsInternal,
397
+ ): { where: string; params: DbValue[] } {
398
+ const prefix = tableAlias.length === 0 ? "" : `${tableAlias}.`;
399
+ const clauses = [`(${prefix}valid_until IS NULL OR ${prefix}valid_until > ?)`, `${prefix}superseded_by IS NULL`];
400
+ const params: DbValue[] = [nowIso()];
401
+ const channelId = options.channelId ?? null;
402
+ const authorId = options.authorId ?? null;
403
+ const authorType = options.authorType ?? null;
404
+ if (options.ignoreSessionScope === true) {
405
+ clauses.push("1=1");
406
+ } else if (channelId !== null && channelId !== "") {
407
+ clauses.push(`(${prefix}session_id = ? OR ${prefix}scope = 'global' OR ${prefix}channel_id = ?)`);
408
+ params.push(beam.sessionId, channelId);
409
+ } else if (authorId !== null || authorType !== null) {
410
+ clauses.push("1=1");
411
+ } else {
412
+ clauses.push(`(${prefix}session_id = ? OR ${prefix}scope = 'global')`);
413
+ params.push(beam.sessionId);
414
+ }
415
+ if (options.fromDate !== undefined && options.fromDate !== null) {
416
+ clauses.push(`${prefix}timestamp >= ?`);
417
+ params.push(`${options.fromDate}T00:00:00`);
418
+ }
419
+ if (options.toDate !== undefined && options.toDate !== null) {
420
+ clauses.push(`${prefix}timestamp <= ?`);
421
+ params.push(`${options.toDate}T23:59:59`);
422
+ }
423
+ if (options.source) {
424
+ clauses.push(`${prefix}source = ?`);
425
+ params.push(options.source);
426
+ }
427
+ if (options.topic) {
428
+ clauses.push(`${prefix}source = ?`);
429
+ params.push(options.topic);
430
+ }
431
+ if (options.veracity) {
432
+ clauses.push(`${prefix}veracity = ?`);
433
+ params.push(options.veracity);
434
+ }
435
+ if (options.memoryType) {
436
+ clauses.push(`${prefix}memory_type = ?`);
437
+ params.push(options.memoryType);
438
+ }
439
+ if (authorId !== null) {
440
+ clauses.push(`${prefix}author_id = ?`);
441
+ params.push(authorId);
442
+ }
443
+ if (authorType !== null) {
444
+ clauses.push(`${prefix}author_type = ?`);
445
+ params.push(authorType);
446
+ }
447
+ if (channelId !== null && channelId !== "") {
448
+ clauses.push(`${prefix}channel_id = ?`);
449
+ params.push(channelId);
450
+ }
451
+ return { where: clauses.join(" AND "), params };
452
+ }
453
+
454
+ const MEMORY_COLUMNS =
455
+ "id, content, source, timestamp, session_id, importance, metadata_json, veracity, memory_type, recall_count, last_recalled, valid_until, superseded_by, scope, author_id, author_type, channel_id, event_date, event_date_precision, temporal_tags";
456
+ const EPISODIC_COLUMNS = `${MEMORY_COLUMNS}, rowid, summary_of, tier`;
457
+
458
+ function ftsRows(
459
+ beam: BeamMemoryState,
460
+ table: "fts_working" | "fts_episodes",
461
+ query: string,
462
+ limit: number,
463
+ useSynonyms = true,
464
+ ): Row[] {
465
+ if (!tableExists(beam, table)) return [];
466
+ try {
467
+ if (table === "fts_working") {
468
+ return queryAll(beam, "SELECT id, rank FROM fts_working WHERE fts_working MATCH ? ORDER BY rank, id LIMIT ?", [
469
+ ftsQuery(query, useSynonyms),
470
+ limit,
471
+ ]);
472
+ }
473
+ return queryAll(
474
+ beam,
475
+ "SELECT rowid, rank FROM fts_episodes WHERE fts_episodes MATCH ? ORDER BY rank, rowid LIMIT ?",
476
+ [ftsQuery(query, useSynonyms), limit],
477
+ );
478
+ } catch {
479
+ return [];
480
+ }
481
+ }
482
+
483
+ function normalizeRanks(rows: readonly Row[], key: string): Map<string | number, number> {
484
+ const out = new Map<string | number, number>();
485
+ if (rows.length === 0) return out;
486
+ let min = Number.POSITIVE_INFINITY;
487
+ let max = Number.NEGATIVE_INFINITY;
488
+ for (const row of rows) {
489
+ const rank = asNumber(row.rank, 0);
490
+ if (rank < min) min = rank;
491
+ if (rank > max) max = rank;
492
+ }
493
+ const range = max === min ? 1 : max - min;
494
+ for (const row of rows) {
495
+ const id = row[key] as string | number | undefined;
496
+ if (id === undefined) continue;
497
+ out.set(id, 1 - (asNumber(row.rank, 0) - min) / range);
498
+ }
499
+ return out;
500
+ }
501
+
502
+ function parseEmbedding(raw: unknown): number[] | null {
503
+ if (typeof raw !== "string") return null;
504
+ try {
505
+ const parsed = JSON.parse(raw) as unknown;
506
+ if (!Array.isArray(parsed)) return null;
507
+ const vector = new Array<number>(parsed.length);
508
+ for (let i = 0; i < parsed.length; i += 1) {
509
+ const value = Number(parsed[i]);
510
+ if (!Number.isFinite(value)) return null;
511
+ vector[i] = value;
512
+ }
513
+ return vector;
514
+ } catch {
515
+ return null;
516
+ }
517
+ }
518
+
519
+ function vectorSimilarities(
520
+ beam: BeamMemoryState,
521
+ memoryIds: readonly string[],
522
+ queryEmbedding: readonly number[] | null | undefined,
523
+ ): Map<string, number> {
524
+ const out = new Map<string, number>();
525
+ if (
526
+ queryEmbedding == null ||
527
+ queryEmbedding.length === 0 ||
528
+ memoryIds.length === 0 ||
529
+ !tableExists(beam, "memory_embeddings")
530
+ ) {
531
+ return out;
532
+ }
533
+ for (let offset = 0; offset < memoryIds.length; offset += 500) {
534
+ const chunk = memoryIds.slice(offset, offset + 500);
535
+ const rows = queryAll(
536
+ beam,
537
+ `SELECT memory_id, embedding_json FROM memory_embeddings WHERE memory_id IN (${placeholders(chunk.length)})`,
538
+ chunk,
539
+ );
540
+ for (const row of rows) {
541
+ const vector = parseEmbedding(row.embedding_json);
542
+ const id = asString(row.memory_id);
543
+ if (vector !== null && id.length > 0) out.set(id, Math.max(0, cosineSimilarity(queryEmbedding, vector)));
544
+ }
545
+ }
546
+ return out;
547
+ }
548
+
549
+ function allVisibleIds(
550
+ beam: BeamMemoryState,
551
+ table: "working_memory" | "episodic_memory",
552
+ options: RecallOptionsInternal,
553
+ ): string[] {
554
+ const { where, params } = buildWhere(beam, "", options);
555
+ const rows = queryAll(beam, `SELECT id FROM ${table} WHERE ${where} ORDER BY timestamp DESC LIMIT ?`, [
556
+ ...params,
557
+ DEFAULT_LIMIT,
558
+ ]);
559
+ return rows.map(row => asString(row.id)).filter(Boolean);
560
+ }
561
+
562
+ function fetchCandidates(
563
+ beam: BeamMemoryState,
564
+ tierLabel: TierLabel,
565
+ idsOrRowids: readonly (string | number)[],
566
+ ftsScores: Map<string | number, number>,
567
+ vecScores: Map<string, number>,
568
+ options: RecallOptionsInternal,
569
+ ): MemoryCandidate[] {
570
+ if (idsOrRowids.length === 0) return [];
571
+ const table = tierLabel === "working" ? "working_memory" : "episodic_memory";
572
+ const keyColumn = tierLabel === "working" ? "id" : "rowid";
573
+ const columns = tierLabel === "working" ? MEMORY_COLUMNS : EPISODIC_COLUMNS;
574
+ const { where, params } = buildWhere(beam, "m", options);
575
+ const rows = queryAll(
576
+ beam,
577
+ `SELECT ${columns
578
+ .split(", ")
579
+ .map(column => `m.${column}`)
580
+ .join(", ")} FROM ${table} m WHERE m.${keyColumn} IN (${placeholders(idsOrRowids.length)}) AND ${where}`,
581
+ [...idsOrRowids, ...params],
582
+ );
583
+ const out: MemoryCandidate[] = [];
584
+ for (const row of rows) {
585
+ const rowKey = tierLabel === "working" ? asString(row.id) : asNumber(row.rowid);
586
+ const id = asString(row.id);
587
+ const fts = ftsScores.get(rowKey) ?? 0;
588
+ const ftsMatched = ftsScores.has(rowKey);
589
+ const dense = vecScores.get(id) ?? 0;
590
+ out.push({
591
+ row,
592
+ tierLabel,
593
+ signals: {
594
+ fts,
595
+ ftsMatched,
596
+ dense,
597
+ keyword: 0,
598
+ candidateSource: ftsMatched ? "fts" : dense > 0 ? "vec" : "fallback",
599
+ },
600
+ });
601
+ }
602
+ return out;
603
+ }
604
+
605
+ function fallbackCandidates(
606
+ beam: BeamMemoryState,
607
+ tierLabel: TierLabel,
608
+ options: RecallOptionsInternal,
609
+ ): MemoryCandidate[] {
610
+ const table = tierLabel === "working" ? "working_memory" : "episodic_memory";
611
+ const columns = tierLabel === "working" ? MEMORY_COLUMNS : EPISODIC_COLUMNS;
612
+ const { where, params } = buildWhere(beam, "", options);
613
+ const rows = queryAll(beam, `SELECT ${columns} FROM ${table} WHERE ${where} ORDER BY timestamp DESC LIMIT ?`, [
614
+ ...params,
615
+ Math.min(DEFAULT_LIMIT, 2000),
616
+ ]);
617
+ return rows.map(row => ({
618
+ row,
619
+ tierLabel,
620
+ signals: { fts: 0, ftsMatched: false, dense: 0, keyword: 0, candidateSource: "fallback" },
621
+ }));
622
+ }
623
+
624
+ function scoreCandidate(
625
+ candidate: MemoryCandidate,
626
+ queryTokens: readonly string[],
627
+ queryGroups: readonly (readonly string[])[],
628
+ normalizedQueryLower: string,
629
+ weights: readonly [number, number, number],
630
+ options: RecallOptionsInternal,
631
+ ): RecallResult | null {
632
+ const content = asString(candidate.row.content);
633
+ const lexical =
634
+ queryGroups.length > 0
635
+ ? lexicalGroupRelevance(queryGroups, content, normalizedQueryLower)
636
+ : lexicalRelevance(queryTokens, content, normalizedQueryLower);
637
+ const minRel = minimumRelevance(queryTokens);
638
+ if (lexical < minRel && candidate.signals.dense < 0.65) return null;
639
+ const [vecWeight, ftsWeight, importanceWeight] = weights;
640
+ const importance = asNumber(candidate.row.importance, 0.5);
641
+ const decay =
642
+ options.queryTime == null
643
+ ? recencyDecay(candidate.row.timestamp, 72)
644
+ : temporalBoost(candidate.row.timestamp, parseQueryTime(options.queryTime), 72);
645
+ const keyword = Math.max(lexical, candidate.signals.fts * 0.6);
646
+ let baseScore: number;
647
+ if (candidate.tierLabel === "episodic") {
648
+ baseScore = Math.max(
649
+ candidate.signals.dense * vecWeight + candidate.signals.fts * ftsWeight + importance * importanceWeight,
650
+ lexical * 0.8,
651
+ );
652
+ } else {
653
+ const kwShare = (1 - importanceWeight) * 0.6;
654
+ baseScore = keyword * kwShare + importance * importanceWeight + keyword * keyword * 0.08;
655
+ if (candidate.signals.dense > 0) baseScore = baseScore * 0.8 + candidate.signals.dense * 0.2;
656
+ }
657
+ let score = baseScore * (0.7 + 0.3 * decay);
658
+ const temporalWeight = options.temporalWeight ?? 0;
659
+ let temporalScore = 0;
660
+ if (temporalWeight > 0) {
661
+ temporalScore = temporalBoost(
662
+ candidate.row.timestamp,
663
+ parseQueryTime(options.queryTime),
664
+ options.temporalHalflife ?? temporalHalflifeHours(),
665
+ );
666
+ const eventBoost = temporalBoost(
667
+ candidate.row.event_date,
668
+ parseQueryTime(options.queryTime),
669
+ (options.temporalHalflife ?? temporalHalflifeHours()) * 2,
670
+ );
671
+ temporalScore = Math.max(temporalScore, eventBoost);
672
+ score *= 1 + temporalWeight * temporalScore;
673
+ }
674
+ const veracity = asString(candidate.row.veracity) || "unknown";
675
+ const veracityWeight = VERACITY_WEIGHTS[veracity] ?? VERACITY_WEIGHTS.unknown ?? 0.8;
676
+ const degradationTier = candidate.tierLabel === "episodic" ? asNumber(candidate.row.tier, 1) : undefined;
677
+ if (candidate.tierLabel === "episodic") {
678
+ const tierWeight = degradationTier === 1 ? 1 : degradationTier === 2 ? 0.85 : 0.7;
679
+ score *= tierWeight;
680
+ }
681
+ score *= veracityWeight * currentContentAdjustment(content, options.currentSensitive === true);
682
+ const result: RecallResult = {
683
+ ...candidate.row,
684
+ id: asString(candidate.row.id),
685
+ content: content.slice(0, 500),
686
+ source: asNullableString(candidate.row.source),
687
+ timestamp: asNullableString(candidate.row.timestamp),
688
+ importance,
689
+ score: round4(score),
690
+ rank: candidate.signals.fts,
691
+ tier: candidate.tierLabel,
692
+ tier_label: candidate.tierLabel,
693
+ degradation_tier: degradationTier,
694
+ keyword_score: round4(lexical),
695
+ dense_score: round4(candidate.signals.dense),
696
+ fts_score: round4(candidate.signals.fts),
697
+ importance_score: round4(importance),
698
+ recency_score: round4(decay),
699
+ temporal_score: round4(temporalScore),
700
+ recall_count: asNumber(candidate.row.recall_count, 0),
701
+ last_recalled: asNullableString(candidate.row.last_recalled),
702
+ explanation: explain(candidate.tierLabel, candidate.signals, lexical, temporalScore),
703
+ voice_scores: {
704
+ vec: round4(candidate.signals.dense),
705
+ fts: round4(candidate.signals.fts),
706
+ keyword: round4(lexical),
707
+ importance: round4(importance),
708
+ recency_decay: round4(decay),
709
+ temporal: round4(temporalScore),
710
+ },
711
+ };
712
+ return result;
713
+ }
714
+
715
+ function explain(tierLabel: TierLabel, signals: CandidateSignals, lexical: number, temporalScore: number): string {
716
+ const parts: string[] = [tierLabel, signals.candidateSource];
717
+ if (lexical > 0) parts.push(`keyword=${round4(lexical)}`);
718
+ if (signals.dense > 0) parts.push(`dense=${round4(signals.dense)}`);
719
+ if (temporalScore > 0) parts.push(`temporal=${round4(temporalScore)}`);
720
+ return parts.join(" ");
721
+ }
722
+
723
+ function dedupeResults(results: readonly RecallResult[]): RecallResult[] {
724
+ const seen = new Set<string>();
725
+ const out: RecallResult[] = [];
726
+ for (const result of results) {
727
+ const key = `${result.tier_label ?? ""}:${result.id}`;
728
+ if (seen.has(key)) continue;
729
+ seen.add(key);
730
+ out.push(result);
731
+ }
732
+ return out;
733
+ }
734
+
735
+ function dedupCrossTierSummaryLinks(beam: BeamMemoryState, results: readonly RecallResult[]): RecallResult[] {
736
+ const episodicIds = results
737
+ .filter(result => (result.tier_label ?? result.tier) === "episodic")
738
+ .map(result => result.id)
739
+ .filter(id => id.length > 0);
740
+ if (episodicIds.length === 0) return [...results];
741
+
742
+ const workingScores = new Map<string, number>();
743
+ const episodicScores = new Map<string, number>();
744
+ for (const result of results) {
745
+ const tier = result.tier_label ?? result.tier;
746
+ if (tier === "working") workingScores.set(result.id, result.score ?? 0);
747
+ else if (tier === "episodic") episodicScores.set(result.id, result.score ?? 0);
748
+ }
749
+ if (workingScores.size === 0 || episodicScores.size === 0) return [...results];
750
+
751
+ const summaryRows = queryAll(
752
+ beam,
753
+ `SELECT id, summary_of FROM episodic_memory WHERE id IN (${placeholders(episodicIds.length)})`,
754
+ episodicIds,
755
+ );
756
+ const dropWorking = new Set<string>();
757
+ const dropEpisodic = new Set<string>();
758
+ for (const row of summaryRows) {
759
+ const episodicId = asString(row.id);
760
+ const episodicScore = episodicScores.get(episodicId);
761
+ if (episodicScore === undefined) continue;
762
+ const covered = asString(row.summary_of)
763
+ .split(",")
764
+ .map(id => id.trim())
765
+ .filter(id => id.length > 0 && workingScores.has(id));
766
+ if (covered.length === 0) continue;
767
+ dropEpisodic.add(episodicId);
768
+ }
769
+ if (dropWorking.size === 0 && dropEpisodic.size === 0) return [...results];
770
+ return results.filter(result => {
771
+ const tier = result.tier_label ?? result.tier;
772
+ if (tier === "working") return !dropWorking.has(result.id);
773
+ if (tier === "episodic") return !dropEpisodic.has(result.id);
774
+ return true;
775
+ });
776
+ }
777
+
778
+ function rerankRecallResults(results: readonly RecallResult[], lambdaParam: number, topK: number): RecallResult[] {
779
+ const items: RecallMmrItem[] = results.map(result => ({
780
+ content: result.content,
781
+ score: result.score,
782
+ result,
783
+ }));
784
+ return mmrRerank(items, lambdaParam, topK).map(item => item.result);
785
+ }
786
+
787
+ function updateRecallCounts(
788
+ beam: BeamMemoryState,
789
+ results: readonly RecallResult[],
790
+ options: RecallOptionsInternal,
791
+ ): void {
792
+ const timestamp = nowIso();
793
+ for (const tierLabel of ["working", "episodic"] as const) {
794
+ const ids = results.filter(r => r.tier_label === tierLabel).map(r => r.id);
795
+ if (ids.length === 0) continue;
796
+ const table = tierLabel === "working" ? "working_memory" : "episodic_memory";
797
+ const { where, params } = buildWhere(beam, "", options);
798
+ beam.db.run(
799
+ `UPDATE ${table} SET recall_count = COALESCE(recall_count, 0) + 1, last_recalled = ? WHERE id IN (${placeholders(ids.length)}) AND ${where}`,
800
+ [timestamp, ...ids, ...params],
801
+ );
802
+ }
803
+ }
804
+
805
+ function collectMemoryCandidates(
806
+ beam: BeamMemoryState,
807
+ query: string,
808
+ topK: number,
809
+ options: RecallOptionsInternal,
810
+ ): MemoryCandidate[] {
811
+ const limit = Math.max(topK * 3, 50);
812
+ const useSynonyms = options.useSynonyms !== false;
813
+ const wmFtsRows = options.includeWorking === false ? [] : ftsRows(beam, "fts_working", query, limit, useSynonyms);
814
+ const emFtsRows = ftsRows(beam, "fts_episodes", query, limit, useSynonyms);
815
+ const wmFts = normalizeRanks(wmFtsRows, "id");
816
+ const emFts = normalizeRanks(emFtsRows, "rowid");
817
+
818
+ let wmIds = [...wmFts.keys()].filter((id): id is string => typeof id === "string");
819
+ let emRowids = [...emFts.keys()].filter((id): id is number => typeof id === "number");
820
+ const queryEmbedding = options.queryEmbedding ?? null;
821
+ let wmVec = new Map<string, number>();
822
+ let emVec = new Map<string, number>();
823
+ if (queryEmbedding !== null && queryEmbedding !== undefined) {
824
+ const allWmIds = options.includeWorking === false ? [] : allVisibleIds(beam, "working_memory", options);
825
+ const allEmIds = allVisibleIds(beam, "episodic_memory", options);
826
+ wmVec = vectorSimilarities(beam, allWmIds, queryEmbedding);
827
+ emVec = vectorSimilarities(beam, allEmIds, queryEmbedding);
828
+ wmIds = [
829
+ ...new Set([
830
+ ...wmIds,
831
+ ...[...wmVec.entries()]
832
+ .sort((a, b) => b[1] - a[1])
833
+ .slice(0, limit)
834
+ .map(([id]) => id),
835
+ ]),
836
+ ];
837
+ const emIds = [...emVec.entries()]
838
+ .sort((a, b) => b[1] - a[1])
839
+ .slice(0, limit)
840
+ .map(([id]) => id);
841
+ if (emIds.length > 0) {
842
+ const rows = queryAll(
843
+ beam,
844
+ `SELECT rowid, id FROM episodic_memory WHERE id IN (${placeholders(emIds.length)})`,
845
+ emIds,
846
+ );
847
+ emRowids = [...new Set([...emRowids, ...rows.map(row => asNumber(row.rowid)).filter(n => n > 0)])];
848
+ }
849
+ }
850
+
851
+ const candidates: MemoryCandidate[] = [];
852
+ if (wmIds.length > 0) candidates.push(...fetchCandidates(beam, "working", wmIds, wmFts, wmVec, options));
853
+ else if (options.includeWorking !== false) candidates.push(...fallbackCandidates(beam, "working", options));
854
+ if (emRowids.length > 0) candidates.push(...fetchCandidates(beam, "episodic", emRowids, emFts, emVec, options));
855
+ else candidates.push(...fallbackCandidates(beam, "episodic", options));
856
+ if (candidates.length === 0) return candidates;
857
+ void useSynonyms;
858
+ return candidates;
859
+ }
860
+
861
+ export async function recall(
862
+ beam: BeamMemoryState,
863
+ query: string,
864
+ topK = 40,
865
+ options: RecallOptionsInternal = {},
866
+ ): Promise<RecallResult[]> {
867
+ if (topK <= 0) return [];
868
+ const temporalOptions = inferTemporalOptions(query, options);
869
+ if (queryAsksCurrent(query)) {
870
+ temporalOptions.queryTime ??= options.queryTime ?? new Date();
871
+ temporalOptions.temporalWeight ??= 0.45;
872
+ temporalOptions.currentSensitive = true;
873
+ }
874
+ if (temporalOptions.queryEmbedding === undefined) {
875
+ // Honour `null` (explicit "no embedding"); `undefined` means "derive from query text".
876
+ // `embedQuery()` returns null when embeddings are disabled or no provider is configured,
877
+ // so this is a no-op when the user has not wired one up. Float32Array → number[]
878
+ // because RecallOptions exposes the narrower public shape.
879
+ const derived = query.length > 0 ? await embedQuery(query) : null;
880
+ temporalOptions.queryEmbedding = derived === null ? null : Array.from(derived);
881
+ }
882
+ let weights = normalizedRecallWeights(
883
+ options.vecWeight ?? beam.config.vecWeight,
884
+ options.ftsWeight ?? beam.config.ftsWeight,
885
+ options.importanceWeight ?? beam.config.importanceWeight,
886
+ );
887
+ if (options.useIntent === true) {
888
+ const intent = classifyIntent(query);
889
+ weights = adjustWeights(weights[0], weights[1], weights[2], intent);
890
+ }
891
+ const useSynonyms = options.useSynonyms !== false;
892
+ const tokens = expandedTokens(query, useSynonyms);
893
+ const tokenGroups = expandedTokenGroups(query, useSynonyms);
894
+ const normalized = normalizeQuery(query).toLowerCase();
895
+ const candidates = collectMemoryCandidates(beam, query, topK, temporalOptions);
896
+ const scored: RecallResult[] = [];
897
+ for (const candidate of candidates) {
898
+ const result = scoreCandidate(candidate, tokens, tokenGroups, normalized, weights, temporalOptions);
899
+ if (result !== null) scored.push(result);
900
+ }
901
+ scored.sort((left, right) => (right.score ?? 0) - (left.score ?? 0));
902
+ let finalResults = dedupCrossTierSummaryLinks(beam, dedupeResults(scored));
903
+ if (query.length > 0 && tokens.length >= 4 && finalResults.length > topK)
904
+ finalResults = diversifyByCoverage(finalResults, tokens, topK);
905
+ if (options.useMmr === true && finalResults.length > 1) {
906
+ finalResults = rerankRecallResults(finalResults, options.mmrLambda ?? 0.7, topK);
907
+ } else {
908
+ finalResults = finalResults.slice(0, topK);
909
+ }
910
+ if (temporalOptions.updateRecallCounts !== false) updateRecallCounts(beam, finalResults, temporalOptions);
911
+ return finalResults;
912
+ }
913
+
914
+ function diversifyByCoverage(
915
+ results: readonly RecallResult[],
916
+ tokens: readonly string[],
917
+ topK: number,
918
+ ): RecallResult[] {
919
+ const selected: RecallResult[] = [];
920
+ const covered = new Set<string>();
921
+ const pool = [...results];
922
+ const querySet = new Set(tokens);
923
+ while (pool.length > 0 && selected.length < topK) {
924
+ let bestIdx = 0;
925
+ let bestScore = Number.NEGATIVE_INFINITY;
926
+ for (let i = 0; i < pool.length; i += 1) {
927
+ const row = pool[i];
928
+ if (row === undefined) continue;
929
+ let additions = 0;
930
+ for (const token of tokenize(row.content)) {
931
+ if (querySet.has(token) && !covered.has(token)) additions += 1;
932
+ }
933
+ const score = (row.score ?? 0) + 0.06 * additions;
934
+ if (score > bestScore) {
935
+ bestScore = score;
936
+ bestIdx = i;
937
+ }
938
+ }
939
+ const picked = pool.splice(bestIdx, 1)[0];
940
+ if (picked === undefined) break;
941
+ selected.push(picked);
942
+ for (const token of tokenize(picked.content)) if (querySet.has(token)) covered.add(token);
943
+ }
944
+ return selected;
945
+ }
946
+
947
+ export async function recallEnhanced(
948
+ beam: BeamMemoryState,
949
+ query: string,
950
+ topK = 40,
951
+ options: RecallEnhancedOptions & RecallOptionsInternal = {},
952
+ ): Promise<RecallResult[]> {
953
+ const useSynonyms = options.useSynonyms !== false;
954
+ const enhancedOptions: RecallOptionsInternal = {
955
+ ...options,
956
+ useSynonyms,
957
+ useIntent: options.useIntent !== false,
958
+ useMmr: options.useMmr !== false,
959
+ };
960
+ const results = await recall(beam, query, Math.max(topK * 2, topK), {
961
+ ...enhancedOptions,
962
+ updateRecallCounts: false,
963
+ });
964
+ if (options.includeFacts === true) {
965
+ const facts = factRecall(beam, query, Math.min(3, topK));
966
+ results.push(...facts);
967
+ }
968
+ results.sort((left, right) => (right.score ?? 0) - (left.score ?? 0));
969
+ const finalResults = rerankRecallResults(results, options.mmrLambda ?? 0.7, topK);
970
+ if (enhancedOptions.updateRecallCounts !== false) updateRecallCounts(beam, finalResults, enhancedOptions);
971
+ return finalResults;
972
+ }
973
+
974
+ function sandwichOrder(results: readonly RecallResult[]): {
975
+ high: RecallResult[];
976
+ medium: RecallResult[];
977
+ closing: RecallResult[];
978
+ } {
979
+ const scored = [...results].sort((left, right) => (right.score ?? 0) - (left.score ?? 0));
980
+ const high = scored.filter(r => (r.score ?? 0) > 0.7).slice(0, 3);
981
+ const medium = scored.filter(r => (r.score ?? 0) > 0.3 && (r.score ?? 0) <= 0.7).slice(0, 5);
982
+ const closing = scored.filter(r => !high.includes(r)).slice(0, 3);
983
+ return { high, medium, closing: closing.length > 0 ? closing : high.slice(0, 2) };
984
+ }
985
+
986
+ function factLine(result: RecallResult): string {
987
+ const content = result.content.slice(0, 200).trim();
988
+ const ts = typeof result.timestamp === "string" && result.timestamp.length > 0 ? result.timestamp.slice(0, 10) : "?";
989
+ const source = result.source ?? "unknown";
990
+ const score = result.score ?? result.importance ?? 0;
991
+ return `${content} (${ts}, ${source}, c:${score.toFixed(1)})`;
992
+ }
993
+
994
+ export function formatContext(beam: BeamMemoryState, results: readonly RecallResult[], format = "bullet"): string {
995
+ void beam;
996
+ const sandwich = sandwichOrder(results);
997
+ if (format === "json") {
998
+ return JSON.stringify(
999
+ {
1000
+ top_facts: sandwich.high.map(factLine),
1001
+ supporting_context: sandwich.medium.map(factLine),
1002
+ recent_memories: sandwich.closing.map(factLine),
1003
+ total_memories: sandwich.high.length + sandwich.medium.length + sandwich.closing.length,
1004
+ },
1005
+ null,
1006
+ 2,
1007
+ );
1008
+ }
1009
+ const lines = ["## Top Facts"];
1010
+ for (const result of sandwich.high) lines.push(`- ${factLine(result)}`);
1011
+ if (sandwich.medium.length > 0) {
1012
+ lines.push("", "## Supporting Context");
1013
+ for (const result of sandwich.medium) lines.push(`- ${factLine(result)}`);
1014
+ }
1015
+ if (sandwich.closing.length > 0) {
1016
+ lines.push("", "## Recent Signals");
1017
+ for (const result of sandwich.closing) lines.push(`- ${factLine(result)}`);
1018
+ }
1019
+ lines.push(`\n_(${sandwich.high.length + sandwich.medium.length + sandwich.closing.length} memories retrieved)_`);
1020
+ return lines.join("\n");
1021
+ }
1022
+
1023
+ export function factRecall(beam: BeamMemoryState, query: string, topK = 30): FactRecallResult[] {
1024
+ if (topK <= 0 || !tableExists(beam, "facts")) return [];
1025
+ let matched: Row[] = [];
1026
+ if (tableExists(beam, "fts_facts")) {
1027
+ try {
1028
+ const visibility = factVisibilityWhere(beam, "facts");
1029
+ matched = queryAll(
1030
+ beam,
1031
+ `SELECT fts_facts.rowid, fts_facts.rank
1032
+ FROM fts_facts
1033
+ JOIN facts ON facts.rowid = fts_facts.rowid
1034
+ WHERE fts_facts MATCH ? AND ${visibility.where}
1035
+ ORDER BY fts_facts.rank, fts_facts.rowid
1036
+ LIMIT ?`,
1037
+ [ftsQuery(query), ...visibility.params, topK * 3],
1038
+ );
1039
+ } catch {
1040
+ matched = [];
1041
+ }
1042
+ }
1043
+ if (matched.length === 0) {
1044
+ const seen = new Set<number>();
1045
+ for (const token of expandedTokens(query).slice(0, 6)) {
1046
+ const visibility = factVisibilityWhere(beam, "");
1047
+ const rows = queryAll(
1048
+ beam,
1049
+ `SELECT rowid
1050
+ FROM facts
1051
+ WHERE (subject LIKE ? OR predicate LIKE ? OR object LIKE ?) AND ${visibility.where}
1052
+ LIMIT ?`,
1053
+ [`%${token}%`, `%${token}%`, `%${token}%`, ...visibility.params, topK],
1054
+ );
1055
+ for (const row of rows) {
1056
+ const rowid = asNumber(row.rowid);
1057
+ if (rowid > 0 && !seen.has(rowid)) {
1058
+ seen.add(rowid);
1059
+ matched.push({ rowid, rank: 0 });
1060
+ }
1061
+ }
1062
+ }
1063
+ }
1064
+ if (matched.length === 0) return [];
1065
+ const rowids = matched
1066
+ .slice(0, topK)
1067
+ .map(row => asNumber(row.rowid))
1068
+ .filter(rowid => rowid > 0);
1069
+ if (rowids.length === 0) return [];
1070
+ const visibility = factVisibilityWhere(beam, "");
1071
+ const ranks = normalizeRanks(matched, "rowid");
1072
+ const rows = queryAll(
1073
+ beam,
1074
+ `SELECT rowid, fact_id, subject, predicate, object, timestamp, confidence
1075
+ FROM facts
1076
+ WHERE rowid IN (${placeholders(rowids.length)}) AND ${visibility.where}
1077
+ ORDER BY confidence DESC
1078
+ LIMIT ?`,
1079
+ [...rowids, ...visibility.params, topK],
1080
+ );
1081
+ return rows.map(row => {
1082
+ const subject = asString(row.subject);
1083
+ const predicate = asString(row.predicate);
1084
+ const object = asString(row.object);
1085
+ const confidence = asNumber(row.confidence, 0.5);
1086
+ const result: FactRecallResult = {
1087
+ id: asString(row.fact_id),
1088
+ content: object.length > 0 ? object : `${subject} ${predicate}`.trim(),
1089
+ score: round4(confidence * 0.8 + (ranks.get(asNumber(row.rowid)) ?? 0) * 0.2),
1090
+ fact_id: asString(row.fact_id),
1091
+ subject,
1092
+ predicate,
1093
+ timestamp: asNullableString(row.timestamp),
1094
+ tier_label: "fact",
1095
+ tier: "fact",
1096
+ source: "facts",
1097
+ };
1098
+ return result;
1099
+ });
1100
+ }