@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,346 @@
1
+ /**
2
+ * Context generation for AI agents
3
+ * Generates pre-formatted context blocks from memories
4
+ */
5
+
6
+ import { listMemories, getMemoriesWithEmbeddings, updateAccessStats } from './store.js';
7
+ import { generateEmbedding, cosineSimilarity } from '../embed/index.js';
8
+ import * as logger from '../utils/logger.js';
9
+
10
+ /**
11
+ * Estimate token count for text (rough approximation)
12
+ * @param {string} text - Text to estimate
13
+ * @returns {number} Estimated token count
14
+ */
15
+ function estimateTokens(text) {
16
+ return Math.ceil(text.length / 4);
17
+ }
18
+
19
+ /**
20
+ * Generate context from memories
21
+ * @param {Database} db - SQLite database instance
22
+ * @param {Object} options - Context generation options
23
+ * @param {string} [options.query] - Optional query to filter relevant memories
24
+ * @param {string} [options.namespace='default'] - Namespace to pull from
25
+ * @param {number} [options.limit=10] - Maximum memories to include
26
+ * @param {string} [options.format='markdown'] - Output format
27
+ * @param {boolean} [options.include_metadata=false] - Include memory IDs and scores
28
+ * @param {string[]} [options.categories] - Filter by categories
29
+ * @param {number} [options.max_tokens=1000] - Token budget
30
+ * @param {string} modelsPath - Path to embedding models
31
+ * @returns {Promise<Object>} Context object with content and metadata
32
+ */
33
+ export async function generateContext(db, options = {}, modelsPath) {
34
+ const {
35
+ query,
36
+ namespace = 'default',
37
+ limit = 10,
38
+ format = 'markdown',
39
+ include_metadata = false,
40
+ categories,
41
+ max_tokens = 1000
42
+ } = options;
43
+
44
+ logger.info('Generating context', { namespace, limit, format, max_tokens });
45
+
46
+ let memories = [];
47
+
48
+ if (query) {
49
+ // Query-based context: use semantic search
50
+ memories = await getRelevantMemories(db, query, namespace, categories, modelsPath);
51
+ } else {
52
+ // No query: get top memories by access frequency and recency
53
+ memories = getTopMemories(db, namespace, categories);
54
+ }
55
+
56
+ // Limit to max count
57
+ memories = memories.slice(0, Math.min(limit, 25));
58
+
59
+ // Sort by category for grouping, then by score/relevance
60
+ memories = sortMemoriesForContext(memories);
61
+
62
+ // Truncate to fit token budget
63
+ memories = truncateToTokenBudget(memories, max_tokens);
64
+
65
+ // Update access stats
66
+ if (memories.length > 0) {
67
+ updateAccessStats(db, memories.map(m => m.id));
68
+ }
69
+
70
+ // Generate output in requested format
71
+ const content = formatContext(memories, format, include_metadata, namespace);
72
+
73
+ logger.info('Context generated', {
74
+ memories: memories.length,
75
+ format,
76
+ estimatedTokens: estimateTokens(content)
77
+ });
78
+
79
+ return {
80
+ content,
81
+ metadata: {
82
+ namespace,
83
+ count: memories.length,
84
+ format,
85
+ categories: [...new Set(memories.map(m => m.category))],
86
+ estimatedTokens: estimateTokens(content),
87
+ generatedAt: new Date().toISOString()
88
+ }
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Get relevant memories using semantic search
94
+ */
95
+ async function getRelevantMemories(db, query, namespace, categories, modelsPath) {
96
+ try {
97
+ const queryEmbedding = await generateEmbedding(query, modelsPath);
98
+ let memories = getMemoriesWithEmbeddings(db, namespace);
99
+
100
+ // Filter by categories if specified
101
+ if (categories && categories.length > 0) {
102
+ memories = memories.filter(m => categories.includes(m.category));
103
+ }
104
+
105
+ // Filter out low feedback memories
106
+ memories = memories.filter(m => (m.feedback_score || 0) >= -0.3);
107
+
108
+ // Score and sort by similarity
109
+ const scored = memories.map(memory => {
110
+ const similarity = memory.embedding
111
+ ? cosineSimilarity(queryEmbedding, memory.embedding)
112
+ : 0;
113
+ const relevanceScore = calculateRelevanceScore(memory, similarity);
114
+ return { ...memory, relevanceScore, similarity };
115
+ });
116
+
117
+ scored.sort((a, b) => b.relevanceScore - a.relevanceScore);
118
+ return scored;
119
+ } catch (error) {
120
+ logger.warn('Failed to generate embedding for context query, falling back to top memories', {
121
+ error: error.message
122
+ });
123
+ return getTopMemories(db, namespace, categories);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Get top memories by access frequency and recency
129
+ */
130
+ function getTopMemories(db, namespace, categories) {
131
+ let memories = listMemories(db, {
132
+ namespace,
133
+ limit: 100,
134
+ sort: 'access_count DESC, created_at DESC'
135
+ });
136
+
137
+ // Filter by categories if specified
138
+ if (categories && categories.length > 0) {
139
+ memories = memories.filter(m => categories.includes(m.category));
140
+ }
141
+
142
+ // Filter out low feedback memories
143
+ memories = memories.filter(m => (m.feedback_score || 0) >= -0.3);
144
+
145
+ // Score memories
146
+ const scored = memories.map(memory => {
147
+ const relevanceScore = calculateRelevanceScore(memory, 0);
148
+ return { ...memory, relevanceScore };
149
+ });
150
+
151
+ scored.sort((a, b) => b.relevanceScore - a.relevanceScore);
152
+ return scored;
153
+ }
154
+
155
+ /**
156
+ * Calculate relevance score for context inclusion
157
+ */
158
+ function calculateRelevanceScore(memory, similarity = 0) {
159
+ const confidence = memory.confidence || 0.8;
160
+ const accessScore = Math.min(memory.access_count / 10, 1);
161
+ const recencyScore = calculateRecency(memory);
162
+ const feedbackScore = ((memory.feedback_score || 0) + 1) / 2;
163
+
164
+ // Weight components
165
+ return (similarity * 0.4) + (confidence * 0.25) + (accessScore * 0.15) +
166
+ (recencyScore * 0.1) + (feedbackScore * 0.1);
167
+ }
168
+
169
+ /**
170
+ * Calculate recency score
171
+ */
172
+ function calculateRecency(memory) {
173
+ const now = Date.now();
174
+ const lastAccessed = memory.last_accessed || memory.created_at;
175
+ const daysSince = (now - lastAccessed) / (1000 * 60 * 60 * 24);
176
+ return 1 / (1 + daysSince * 0.01);
177
+ }
178
+
179
+ /**
180
+ * Sort memories for context (group by category)
181
+ */
182
+ function sortMemoriesForContext(memories) {
183
+ // Priority order for categories
184
+ const categoryOrder = ['preference', 'fact', 'pattern', 'decision', 'outcome'];
185
+
186
+ return memories.sort((a, b) => {
187
+ const orderA = categoryOrder.indexOf(a.category);
188
+ const orderB = categoryOrder.indexOf(b.category);
189
+ if (orderA !== orderB) {
190
+ return orderA - orderB;
191
+ }
192
+ // Within same category, sort by relevance score
193
+ return (b.relevanceScore || 0) - (a.relevanceScore || 0);
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Truncate memories to fit token budget
199
+ */
200
+ function truncateToTokenBudget(memories, maxTokens) {
201
+ let totalTokens = 100; // Reserve for headers/formatting
202
+ const selected = [];
203
+
204
+ for (const memory of memories) {
205
+ const memoryTokens = estimateTokens(memory.content) + 20; // +20 for metadata
206
+ if (totalTokens + memoryTokens <= maxTokens) {
207
+ selected.push(memory);
208
+ totalTokens += memoryTokens;
209
+ }
210
+ }
211
+
212
+ return selected;
213
+ }
214
+
215
+ /**
216
+ * Format context in requested format
217
+ */
218
+ function formatContext(memories, format, includeMetadata, namespace) {
219
+ switch (format) {
220
+ case 'markdown':
221
+ return formatMarkdown(memories, includeMetadata, namespace);
222
+ case 'xml':
223
+ return formatXML(memories, includeMetadata, namespace);
224
+ case 'json':
225
+ return formatJSON(memories, namespace);
226
+ case 'plain':
227
+ return formatPlain(memories);
228
+ default:
229
+ return formatMarkdown(memories, includeMetadata, namespace);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Format as Markdown
235
+ */
236
+ function formatMarkdown(memories, includeMetadata, namespace) {
237
+ if (memories.length === 0) {
238
+ return '## User Context from Memory\n\nNo memories found.';
239
+ }
240
+
241
+ const lines = ['## User Context from Memory\n'];
242
+
243
+ // Group by category
244
+ const groups = groupByCategory(memories);
245
+
246
+ for (const [category, categoryMemories] of Object.entries(groups)) {
247
+ const categoryTitle = category.charAt(0).toUpperCase() + category.slice(1) + 's';
248
+ lines.push(`**${categoryTitle}:**`);
249
+
250
+ for (const memory of categoryMemories) {
251
+ if (includeMetadata) {
252
+ lines.push(`- ${memory.content} *(id: ${memory.id.substring(0, 8)}, confidence: ${memory.confidence.toFixed(2)})*`);
253
+ } else {
254
+ lines.push(`- ${memory.content}`);
255
+ }
256
+ }
257
+ lines.push('');
258
+ }
259
+
260
+ lines.push('---');
261
+ lines.push(`*${memories.length} memories loaded from namespace "${namespace}" | Generated by Engram*`);
262
+
263
+ return lines.join('\n');
264
+ }
265
+
266
+ /**
267
+ * Format as XML
268
+ */
269
+ function formatXML(memories, includeMetadata, namespace) {
270
+ if (memories.length === 0) {
271
+ return `<engram_context namespace="${namespace}" count="0" />\n`;
272
+ }
273
+
274
+ const lines = [`<engram_context namespace="${namespace}" count="${memories.length}">`];
275
+
276
+ // Group by category
277
+ const groups = groupByCategory(memories);
278
+
279
+ for (const [category, categoryMemories] of Object.entries(groups)) {
280
+ lines.push(` <${category}s>`);
281
+
282
+ for (const memory of categoryMemories) {
283
+ if (includeMetadata) {
284
+ lines.push(` <memory id="${memory.id}" confidence="${memory.confidence.toFixed(2)}">${escapeXML(memory.content)}</memory>`);
285
+ } else {
286
+ lines.push(` <memory>${escapeXML(memory.content)}</memory>`);
287
+ }
288
+ }
289
+
290
+ lines.push(` </${category}s>`);
291
+ }
292
+
293
+ lines.push('</engram_context>');
294
+
295
+ return lines.join('\n');
296
+ }
297
+
298
+ /**
299
+ * Format as JSON
300
+ */
301
+ function formatJSON(memories, namespace) {
302
+ const groups = groupByCategory(memories);
303
+
304
+ return JSON.stringify({
305
+ namespace,
306
+ count: memories.length,
307
+ memories: groups,
308
+ generated_at: new Date().toISOString()
309
+ }, null, 2);
310
+ }
311
+
312
+ /**
313
+ * Format as plain text
314
+ */
315
+ function formatPlain(memories) {
316
+ return memories.map(m => m.content).join('. ');
317
+ }
318
+
319
+ /**
320
+ * Group memories by category
321
+ */
322
+ function groupByCategory(memories) {
323
+ const groups = {};
324
+
325
+ for (const memory of memories) {
326
+ const cat = memory.category || 'fact';
327
+ if (!groups[cat]) {
328
+ groups[cat] = [];
329
+ }
330
+ groups[cat].push(memory);
331
+ }
332
+
333
+ return groups;
334
+ }
335
+
336
+ /**
337
+ * Escape XML special characters
338
+ */
339
+ function escapeXML(text) {
340
+ return text
341
+ .replace(/&/g, '&amp;')
342
+ .replace(/</g, '&lt;')
343
+ .replace(/>/g, '&gt;')
344
+ .replace(/"/g, '&quot;')
345
+ .replace(/'/g, '&apos;');
346
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Memory feedback system for confidence adjustments
3
+ */
4
+
5
+ import { generateId } from '../utils/id.js';
6
+ import * as logger from '../utils/logger.js';
7
+
8
+ /**
9
+ * Record feedback for a memory
10
+ * @param {Database} db - SQLite database instance
11
+ * @param {string} memoryId - ID of the memory
12
+ * @param {boolean} helpful - Whether the memory was helpful
13
+ * @param {string} [context] - Optional context for the feedback
14
+ * @returns {Object} Feedback result with updated scores
15
+ */
16
+ export function recordFeedback(db, memoryId, helpful, context = null) {
17
+ const id = generateId();
18
+ const now = Date.now();
19
+
20
+ // Insert feedback record
21
+ const insertStmt = db.prepare(`
22
+ INSERT INTO memory_feedback (id, memory_id, helpful, context, created_at)
23
+ VALUES (?, ?, ?, ?, ?)
24
+ `);
25
+
26
+ insertStmt.run(id, memoryId, helpful ? 1 : 0, context, now);
27
+
28
+ logger.debug('Feedback recorded', { feedbackId: id, memoryId, helpful });
29
+
30
+ // Recalculate feedback_score for the memory
31
+ const stats = db.prepare(`
32
+ SELECT
33
+ COUNT(*) as total,
34
+ SUM(CASE WHEN helpful = 1 THEN 1 ELSE 0 END) as helpful_count
35
+ FROM memory_feedback
36
+ WHERE memory_id = ?
37
+ `).get(memoryId);
38
+
39
+ // Calculate feedback_score: ranges from -1.0 (all unhelpful) to +1.0 (all helpful)
40
+ const feedbackScore = stats.total > 0
41
+ ? (2 * stats.helpful_count - stats.total) / stats.total
42
+ : 0;
43
+
44
+ // Update the memory's feedback_score
45
+ db.prepare(`UPDATE memories SET feedback_score = ? WHERE id = ?`)
46
+ .run(feedbackScore, memoryId);
47
+
48
+ logger.info('Memory feedback score updated', {
49
+ memoryId,
50
+ feedbackScore,
51
+ totalFeedback: stats.total,
52
+ helpfulCount: stats.helpful_count
53
+ });
54
+
55
+ // Apply automatic confidence adjustment if threshold met
56
+ const adjustmentResult = applyConfidenceAdjustment(db, memoryId, feedbackScore, stats.total);
57
+
58
+ return {
59
+ feedbackId: id,
60
+ memoryId,
61
+ feedbackScore,
62
+ feedbackCount: stats.total,
63
+ helpfulCount: stats.helpful_count,
64
+ unhelpfulCount: stats.total - stats.helpful_count,
65
+ confidenceAdjusted: adjustmentResult.adjusted,
66
+ newConfidence: adjustmentResult.confidence
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Apply automatic confidence adjustment based on feedback
72
+ * @param {Database} db - SQLite database instance
73
+ * @param {string} memoryId - ID of the memory
74
+ * @param {number} feedbackScore - Current feedback score (-1 to 1)
75
+ * @param {number} feedbackCount - Total number of feedback events
76
+ * @returns {Object} Adjustment result
77
+ */
78
+ function applyConfidenceAdjustment(db, memoryId, feedbackScore, feedbackCount) {
79
+ // Only adjust after minimum feedback threshold
80
+ const MIN_FEEDBACK_FOR_ADJUSTMENT = 5;
81
+
82
+ if (feedbackCount < MIN_FEEDBACK_FOR_ADJUSTMENT) {
83
+ const memory = db.prepare('SELECT confidence FROM memories WHERE id = ?').get(memoryId);
84
+ return { adjusted: false, confidence: memory?.confidence || 0.8 };
85
+ }
86
+
87
+ const memory = db.prepare('SELECT confidence FROM memories WHERE id = ?').get(memoryId);
88
+ if (!memory) {
89
+ return { adjusted: false, confidence: 0.8 };
90
+ }
91
+
92
+ let newConfidence = memory.confidence;
93
+
94
+ // Decrease confidence for consistently unhelpful memories
95
+ if (feedbackScore < -0.5) {
96
+ newConfidence = Math.max(0.1, memory.confidence - 0.1);
97
+ logger.info('Confidence decreased due to negative feedback', {
98
+ memoryId,
99
+ oldConfidence: memory.confidence,
100
+ newConfidence,
101
+ feedbackScore
102
+ });
103
+ }
104
+ // Increase confidence for consistently helpful memories
105
+ else if (feedbackScore > 0.5) {
106
+ newConfidence = Math.min(1.0, memory.confidence + 0.05);
107
+ logger.info('Confidence increased due to positive feedback', {
108
+ memoryId,
109
+ oldConfidence: memory.confidence,
110
+ newConfidence,
111
+ feedbackScore
112
+ });
113
+ }
114
+
115
+ if (newConfidence !== memory.confidence) {
116
+ db.prepare('UPDATE memories SET confidence = ? WHERE id = ?')
117
+ .run(newConfidence, memoryId);
118
+ return { adjusted: true, confidence: newConfidence };
119
+ }
120
+
121
+ return { adjusted: false, confidence: memory.confidence };
122
+ }
123
+
124
+ /**
125
+ * Get feedback history for a memory
126
+ * @param {Database} db - SQLite database instance
127
+ * @param {string} memoryId - ID of the memory
128
+ * @param {number} [limit=10] - Maximum feedback records to return
129
+ * @returns {Object[]} Array of feedback records
130
+ */
131
+ export function getFeedbackHistory(db, memoryId, limit = 10) {
132
+ const stmt = db.prepare(`
133
+ SELECT id, helpful, context, created_at
134
+ FROM memory_feedback
135
+ WHERE memory_id = ?
136
+ ORDER BY created_at DESC
137
+ LIMIT ?
138
+ `);
139
+
140
+ return stmt.all(memoryId, limit).map(row => ({
141
+ id: row.id,
142
+ helpful: row.helpful === 1,
143
+ context: row.context,
144
+ createdAt: row.created_at
145
+ }));
146
+ }
147
+
148
+ /**
149
+ * Get feedback statistics for a memory
150
+ * @param {Database} db - SQLite database instance
151
+ * @param {string} memoryId - ID of the memory
152
+ * @returns {Object} Feedback statistics
153
+ */
154
+ export function getFeedbackStats(db, memoryId) {
155
+ const stats = db.prepare(`
156
+ SELECT
157
+ COUNT(*) as total,
158
+ SUM(CASE WHEN helpful = 1 THEN 1 ELSE 0 END) as helpful_count,
159
+ MIN(created_at) as first_feedback,
160
+ MAX(created_at) as last_feedback
161
+ FROM memory_feedback
162
+ WHERE memory_id = ?
163
+ `).get(memoryId);
164
+
165
+ const memory = db.prepare('SELECT feedback_score, confidence FROM memories WHERE id = ?')
166
+ .get(memoryId);
167
+
168
+ return {
169
+ totalFeedback: stats.total || 0,
170
+ helpfulCount: stats.helpful_count || 0,
171
+ unhelpfulCount: (stats.total || 0) - (stats.helpful_count || 0),
172
+ feedbackScore: memory?.feedback_score || 0,
173
+ confidence: memory?.confidence || 0.8,
174
+ firstFeedbackAt: stats.first_feedback,
175
+ lastFeedbackAt: stats.last_feedback
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Get all memories with low feedback scores
181
+ * @param {Database} db - SQLite database instance
182
+ * @param {number} [threshold=-0.3] - Feedback score threshold
183
+ * @param {number} [minFeedback=3] - Minimum feedback count
184
+ * @returns {Object[]} Array of memories with low scores
185
+ */
186
+ export function getLowFeedbackMemories(db, threshold = -0.3, minFeedback = 3) {
187
+ const stmt = db.prepare(`
188
+ SELECT m.*,
189
+ (SELECT COUNT(*) FROM memory_feedback f WHERE f.memory_id = m.id) as feedback_count
190
+ FROM memories m
191
+ WHERE m.feedback_score <= ?
192
+ AND (SELECT COUNT(*) FROM memory_feedback f WHERE f.memory_id = m.id) >= ?
193
+ ORDER BY m.feedback_score ASC
194
+ `);
195
+
196
+ return stmt.all(threshold, minFeedback);
197
+ }