@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.
- package/LICENSE +21 -0
- package/README.md +535 -0
- package/bin/engram.js +421 -0
- package/dashboard/dist/assets/index-BHkLa5w_.css +1 -0
- package/dashboard/dist/assets/index-D9QR_Cnu.js +45 -0
- package/dashboard/dist/index.html +14 -0
- package/dashboard/package.json +21 -0
- package/package.json +76 -0
- package/src/config/index.js +150 -0
- package/src/embed/index.js +249 -0
- package/src/export/static.js +396 -0
- package/src/extract/rules.js +233 -0
- package/src/extract/secrets.js +114 -0
- package/src/index.js +54 -0
- package/src/memory/consolidate.js +420 -0
- package/src/memory/context.js +346 -0
- package/src/memory/feedback.js +197 -0
- package/src/memory/recall.js +350 -0
- package/src/memory/store.js +626 -0
- package/src/server/mcp.js +668 -0
- package/src/server/rest.js +499 -0
- package/src/utils/id.js +9 -0
- package/src/utils/logger.js +79 -0
- package/src/utils/time.js +296 -0
|
@@ -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
|
+
}
|