@hbarefoot/engram 1.0.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.
@@ -0,0 +1,350 @@
1
+ import { generateEmbedding, cosineSimilarity } from '../embed/index.js';
2
+ import { searchMemories, getMemoriesWithEmbeddings, updateAccessStats } from './store.js';
3
+ import { parseTimeFilter, formatTimestamp } from '../utils/time.js';
4
+ import * as logger from '../utils/logger.js';
5
+
6
+ /**
7
+ * Recall memories using hybrid search (embedding similarity + FTS + recency)
8
+ * @param {Database} db - SQLite database instance
9
+ * @param {string} query - Search query
10
+ * @param {Object} options - Search options
11
+ * @param {number} [options.limit=5] - Maximum results to return
12
+ * @param {string} [options.category] - Filter by category
13
+ * @param {string} [options.namespace] - Filter by namespace
14
+ * @param {number} [options.threshold=0.3] - Minimum relevance score
15
+ * @param {Object} [options.time_filter] - Temporal filter
16
+ * @param {string} [options.time_filter.after] - Start time (ISO or relative)
17
+ * @param {string} [options.time_filter.before] - End time (ISO or relative)
18
+ * @param {string} [options.time_filter.period] - Period shorthand
19
+ * @param {string} modelsPath - Path to models directory
20
+ * @returns {Promise<Object[]>} Array of relevant memories with scores
21
+ */
22
+ export async function recallMemories(db, query, options = {}, modelsPath) {
23
+ const {
24
+ limit = 5,
25
+ category,
26
+ namespace,
27
+ threshold = 0.3,
28
+ time_filter
29
+ } = options;
30
+
31
+ // Parse time filter if provided
32
+ const timeRange = parseTimeFilter(time_filter);
33
+
34
+ logger.debug('Recalling memories', { query, limit, category, namespace, threshold, timeRange });
35
+
36
+ try {
37
+ // Step 1: Generate embedding for the query
38
+ let queryEmbedding;
39
+ try {
40
+ queryEmbedding = await generateEmbedding(query, modelsPath);
41
+ logger.debug('Query embedding generated', { dimensions: queryEmbedding.length });
42
+ } catch (error) {
43
+ logger.warn('Failed to generate query embedding, falling back to FTS only', { error: error.message });
44
+ // Fall back to FTS-only search
45
+ return await fallbackToFTS(db, query, { limit, category, namespace });
46
+ }
47
+
48
+ // Step 2: Fetch candidate memories
49
+ const candidates = await fetchCandidates(db, query, namespace, timeRange);
50
+ logger.debug('Fetched candidates', { count: candidates.length });
51
+
52
+ if (candidates.length === 0) {
53
+ logger.debug('No candidates found');
54
+ return [];
55
+ }
56
+
57
+ // Step 3: Score each candidate
58
+ const scored = candidates.map(memory => {
59
+ const scores = calculateScores(memory, queryEmbedding, candidates);
60
+
61
+ return {
62
+ ...memory,
63
+ score: scores.final,
64
+ scoreBreakdown: scores
65
+ };
66
+ });
67
+
68
+ // Step 4: Filter by category if specified
69
+ let filtered = scored;
70
+ if (category) {
71
+ filtered = filtered.filter(m => m.category === category);
72
+ }
73
+
74
+ // Step 5: Filter by threshold
75
+ filtered = filtered.filter(m => m.score >= threshold);
76
+
77
+ // Step 6: Sort by score descending
78
+ filtered.sort((a, b) => b.score - a.score);
79
+
80
+ // Step 7: Take top N
81
+ const results = filtered.slice(0, limit);
82
+
83
+ // Step 8: Update access stats for returned memories
84
+ if (results.length > 0) {
85
+ const ids = results.map(m => m.id);
86
+ updateAccessStats(db, ids);
87
+ }
88
+
89
+ // Add time range metadata if temporal filter was used
90
+ if (timeRange) {
91
+ results.timeRange = {
92
+ after: formatTimestamp(timeRange.start),
93
+ before: formatTimestamp(timeRange.end),
94
+ description: timeRange.description
95
+ };
96
+ results.totalInRange = candidates.length;
97
+ }
98
+
99
+ logger.info('Memories recalled', {
100
+ query,
101
+ returned: results.length,
102
+ avgScore: results.length > 0
103
+ ? (results.reduce((sum, m) => sum + m.score, 0) / results.length).toFixed(3)
104
+ : 0,
105
+ timeRange: timeRange?.description
106
+ });
107
+
108
+ return results;
109
+ } catch (error) {
110
+ logger.error('Error during recall', { error: error.message });
111
+ // Fallback to FTS if anything fails
112
+ return await fallbackToFTS(db, query, { limit, category, namespace, timeRange });
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Fetch candidate memories for scoring
118
+ * @param {Database} db - SQLite database instance
119
+ * @param {string} query - Search query
120
+ * @param {string} [namespace] - Optional namespace filter
121
+ * @param {Object} [timeRange] - Optional time range filter
122
+ * @returns {Promise<Object[]>} Candidate memories
123
+ */
124
+ async function fetchCandidates(db, query, namespace, timeRange) {
125
+ const candidates = new Map();
126
+
127
+ // Time filter function
128
+ const withinTimeRange = (memory) => {
129
+ if (!timeRange) return true;
130
+ const createdAt = memory.created_at;
131
+ return createdAt >= timeRange.start && createdAt <= timeRange.end;
132
+ };
133
+
134
+ // Fetch FTS matches (top 20)
135
+ try {
136
+ let ftsResults = searchMemories(db, query, 20);
137
+
138
+ // Filter by namespace if specified
139
+ if (namespace) {
140
+ ftsResults = ftsResults.filter(m => m.namespace === namespace);
141
+ }
142
+
143
+ // Filter by time range if specified
144
+ ftsResults = ftsResults.filter(withinTimeRange);
145
+
146
+ for (const memory of ftsResults) {
147
+ memory.fromFTS = true;
148
+ candidates.set(memory.id, memory);
149
+ }
150
+ logger.debug('FTS candidates', { count: ftsResults.length });
151
+ } catch (error) {
152
+ logger.warn('FTS search failed', { error: error.message });
153
+ }
154
+
155
+ // Fetch all memories with embeddings in namespace
156
+ let embeddedMemories = getMemoriesWithEmbeddings(db, namespace);
157
+
158
+ // Filter by time range if specified
159
+ embeddedMemories = embeddedMemories.filter(withinTimeRange);
160
+
161
+ for (const memory of embeddedMemories) {
162
+ if (!candidates.has(memory.id)) {
163
+ memory.fromFTS = false;
164
+ candidates.set(memory.id, memory);
165
+ } else {
166
+ // Mark that this memory was also found via FTS
167
+ candidates.get(memory.id).fromFTS = true;
168
+ }
169
+ }
170
+ logger.debug('Embedding candidates', { count: embeddedMemories.length });
171
+
172
+ return Array.from(candidates.values());
173
+ }
174
+
175
+ /**
176
+ * Calculate all component scores for a memory
177
+ * @param {Object} memory - Memory to score
178
+ * @param {Float32Array} queryEmbedding - Query embedding
179
+ * @param {Object[]} allCandidates - All candidate memories (for FTS boost calculation)
180
+ * @returns {Object} Scores object
181
+ */
182
+ function calculateScores(memory, queryEmbedding, allCandidates) {
183
+ // Similarity score (cosine similarity if embedding exists)
184
+ let similarity = 0;
185
+ if (memory.embedding && queryEmbedding) {
186
+ similarity = cosineSimilarity(queryEmbedding, memory.embedding);
187
+ }
188
+
189
+ // Recency score based on last access
190
+ const recency = calculateRecencyScore(memory);
191
+
192
+ // Confidence score (from memory)
193
+ const confidence = memory.confidence || 0.8;
194
+
195
+ // Access score (how often this memory has been recalled)
196
+ const access = Math.min(memory.access_count / 10, 1.0);
197
+
198
+ // Feedback score - normalize from [-1, 1] to [0, 1]
199
+ const feedbackScore = memory.feedback_score || 0;
200
+ const feedback = (feedbackScore + 1) / 2;
201
+
202
+ // FTS boost (if memory appeared in FTS results)
203
+ const ftsBoost = memory.fromFTS ? 0.1 : 0;
204
+
205
+ // Final score calculation with feedback component
206
+ // Adjusted weights: similarity=0.45, recency=0.15, confidence=0.15, access=0.05, feedback=0.10
207
+ const final = (similarity * 0.45) + (recency * 0.15) + (confidence * 0.15) + (access * 0.05) + (feedback * 0.10) + ftsBoost;
208
+
209
+ return {
210
+ similarity,
211
+ recency,
212
+ confidence,
213
+ access,
214
+ feedback,
215
+ feedbackRaw: feedbackScore,
216
+ ftsBoost,
217
+ final
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Calculate recency score based on last access time and decay rate
223
+ * @param {Object} memory - Memory object
224
+ * @returns {number} Recency score (0-1)
225
+ */
226
+ function calculateRecencyScore(memory) {
227
+ const now = Date.now();
228
+ const lastAccessed = memory.last_accessed || memory.created_at;
229
+ const daysSinceAccess = (now - lastAccessed) / (1000 * 60 * 60 * 24);
230
+ const decayRate = memory.decay_rate || 0.01;
231
+
232
+ // Recency score decreases over time based on decay rate
233
+ const recency = 1 / (1 + daysSinceAccess * decayRate);
234
+
235
+ return Math.max(0, Math.min(1, recency));
236
+ }
237
+
238
+ /**
239
+ * Fallback to FTS-only search when embeddings fail
240
+ * @param {Database} db - SQLite database instance
241
+ * @param {string} query - Search query
242
+ * @param {Object} options - Search options
243
+ * @returns {Promise<Object[]>} Search results
244
+ */
245
+ async function fallbackToFTS(db, query, options = {}) {
246
+ const { limit = 5, category, namespace, timeRange } = options;
247
+
248
+ logger.warn('Using FTS-only fallback search');
249
+
250
+ try {
251
+ let results = searchMemories(db, query, limit * 2); // Get more to filter
252
+
253
+ // Filter by category if specified
254
+ if (category) {
255
+ results = results.filter(m => m.category === category);
256
+ }
257
+
258
+ // Filter by namespace if specified
259
+ if (namespace) {
260
+ results = results.filter(m => m.namespace === namespace);
261
+ }
262
+
263
+ // Filter by time range if specified
264
+ if (timeRange) {
265
+ results = results.filter(m =>
266
+ m.created_at >= timeRange.start && m.created_at <= timeRange.end
267
+ );
268
+ }
269
+
270
+ // Take top N
271
+ results = results.slice(0, limit);
272
+
273
+ // Add a basic score based on position
274
+ results = results.map((memory, index) => ({
275
+ ...memory,
276
+ score: 1.0 - (index * 0.1), // Simple position-based score
277
+ scoreBreakdown: {
278
+ similarity: 0,
279
+ recency: 0,
280
+ confidence: memory.confidence,
281
+ access: 0,
282
+ ftsBoost: 1.0 - (index * 0.1),
283
+ final: 1.0 - (index * 0.1)
284
+ }
285
+ }));
286
+
287
+ // Update access stats
288
+ if (results.length > 0) {
289
+ const ids = results.map(m => m.id);
290
+ updateAccessStats(db, ids);
291
+ }
292
+
293
+ // Add time range metadata if temporal filter was used
294
+ if (timeRange) {
295
+ results.timeRange = {
296
+ after: formatTimestamp(timeRange.start),
297
+ before: formatTimestamp(timeRange.end),
298
+ description: timeRange.description
299
+ };
300
+ }
301
+
302
+ logger.info('FTS fallback recall complete', { returned: results.length });
303
+
304
+ return results;
305
+ } catch (error) {
306
+ logger.error('FTS fallback failed', { error: error.message });
307
+ return [];
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Format recall results for display
313
+ * @param {Object[]} memories - Recalled memories with scores
314
+ * @returns {string} Formatted text output
315
+ */
316
+ export function formatRecallResults(memories) {
317
+ if (memories.length === 0) {
318
+ const noResultsMsg = 'No relevant memories found.';
319
+ if (memories.timeRange) {
320
+ return `${noResultsMsg}\n\nTime range: ${memories.timeRange.description} (${memories.timeRange.after} to ${memories.timeRange.before})`;
321
+ }
322
+ return noResultsMsg;
323
+ }
324
+
325
+ let header = `Found ${memories.length} relevant ${memories.length === 1 ? 'memory' : 'memories'}`;
326
+
327
+ // Add time range info if present
328
+ if (memories.timeRange) {
329
+ header += ` from ${memories.timeRange.description}`;
330
+ if (memories.totalInRange !== undefined) {
331
+ header += ` (${memories.totalInRange} total in range)`;
332
+ }
333
+ }
334
+
335
+ const lines = [`${header}:\n`];
336
+
337
+ memories.forEach((memory, index) => {
338
+ const num = index + 1;
339
+ const category = memory.category;
340
+ const confidence = memory.confidence.toFixed(2);
341
+ const id = memory.id.substring(0, 8);
342
+ const score = memory.score ? ` (score: ${memory.score.toFixed(3)})` : '';
343
+
344
+ lines.push(`[${num}] (${category}, confidence: ${confidence}, id: ${id})${score}`);
345
+ lines.push(memory.content);
346
+ lines.push('');
347
+ });
348
+
349
+ return lines.join('\n');
350
+ }