@199-bio/engram 0.5.1 → 0.6.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.
@@ -12,7 +12,9 @@
12
12
  */
13
13
 
14
14
  import Anthropic from "@anthropic-ai/sdk";
15
- import { EngramDatabase, Memory, Digest } from "../storage/database.js";
15
+ import { EngramDatabase, Memory, Digest, Episode } from "../storage/database.js";
16
+ import { KnowledgeGraph } from "../graph/knowledge-graph.js";
17
+ import { HybridSearch } from "../retrieval/hybrid.js";
16
18
 
17
19
  const CONSOLIDATION_SYSTEM = `You are a high-quality memory consolidation system for a personal AI assistant. Your goal is to create comprehensive, nuanced digests that preserve the richness of human experience and relationships.
18
20
 
@@ -49,6 +51,37 @@ const CONSOLIDATION_SYSTEM = `You are a high-quality memory consolidation system
49
51
  - If memories span different time periods, note the evolution
50
52
  - Only flag true contradictions, not incomplete information or natural life changes`;
51
53
 
54
+ const EPISODE_EXTRACTION_SYSTEM = `You are extracting structured memories from a conversation. Your goal is to identify facts, preferences, events, and relationships worth remembering.
55
+
56
+ ## What to Extract
57
+ - Key facts about people, places, organizations
58
+ - User preferences and opinions
59
+ - Important events and their dates
60
+ - Relationships between entities
61
+ - Decisions made or plans discussed
62
+
63
+ ## What to Skip
64
+ - Small talk and pleasantries
65
+ - Repetitive information
66
+ - Context that's only relevant to the immediate task
67
+ - Technical details that don't reveal user preferences
68
+
69
+ ## Output Format (JSON)
70
+ {
71
+ "memories": [
72
+ {
73
+ "content": "The actual memory to store (clear, standalone statement)",
74
+ "importance": 0.5,
75
+ "emotional_weight": 0.5,
76
+ "event_time": "2024-12-01 or null if not mentioned",
77
+ "entities": [{"name": "Boris", "type": "person"}],
78
+ "relationships": [{"from": "Boris", "to": "Google", "type": "works_at"}]
79
+ }
80
+ ]
81
+ }
82
+
83
+ Extract 0-5 memories. Quality over quantity. If nothing worth remembering, return empty memories array.`;
84
+
52
85
  interface ConsolidationResult {
53
86
  digest: string;
54
87
  topic: string;
@@ -58,6 +91,19 @@ interface ConsolidationResult {
58
91
  }>;
59
92
  }
60
93
 
94
+ interface ExtractedMemory {
95
+ content: string;
96
+ importance: number;
97
+ emotional_weight: number;
98
+ event_time: string | null;
99
+ entities: Array<{ name: string; type: string }>;
100
+ relationships: Array<{ from: string; to: string; type: string }>;
101
+ }
102
+
103
+ interface EpisodeExtractionResult {
104
+ memories: ExtractedMemory[];
105
+ }
106
+
61
107
  interface ConsolidateOptions {
62
108
  batchSize?: number;
63
109
  minMemoriesForConsolidation?: number;
@@ -66,9 +112,17 @@ interface ConsolidateOptions {
66
112
  export class Consolidator {
67
113
  private client: Anthropic | null = null;
68
114
  private db: EngramDatabase;
69
-
70
- constructor(db: EngramDatabase) {
115
+ private graph: KnowledgeGraph | null = null;
116
+ private search: HybridSearch | null = null;
117
+
118
+ constructor(
119
+ db: EngramDatabase,
120
+ graph?: KnowledgeGraph,
121
+ search?: HybridSearch
122
+ ) {
71
123
  this.db = db;
124
+ this.graph = graph || null;
125
+ this.search = search || null;
72
126
 
73
127
  const apiKey = process.env.ANTHROPIC_API_KEY;
74
128
  if (apiKey) {
@@ -359,18 +413,204 @@ Create a rich, detailed profile. Do not summarize away important nuances. Respon
359
413
  getStatus(): {
360
414
  configured: boolean;
361
415
  unconsolidatedMemories: number;
416
+ unconsolidatedEpisodes: number;
362
417
  totalDigests: number;
363
418
  unresolvedContradictions: number;
364
419
  } {
365
- const unconsolidated = this.db.getUnconsolidatedMemories(undefined, 1000);
420
+ const unconsolidatedMem = this.db.getUnconsolidatedMemories(undefined, 1000);
421
+ const unconsolidatedEp = this.db.getUnconsolidatedEpisodes(1000);
366
422
  const digests = this.db.getDigests(undefined, 1000);
367
423
  const contradictions = this.db.getContradictions(false, 1000);
368
424
 
369
425
  return {
370
426
  configured: this.isConfigured(),
371
- unconsolidatedMemories: unconsolidated.length,
427
+ unconsolidatedMemories: unconsolidatedMem.length,
428
+ unconsolidatedEpisodes: unconsolidatedEp.length,
372
429
  totalDigests: digests.length,
373
430
  unresolvedContradictions: contradictions.length,
374
431
  };
375
432
  }
433
+
434
+ /**
435
+ * Process unconsolidated episodes into memories
436
+ * This is the "working memory → long-term memory" transfer
437
+ */
438
+ async consolidateEpisodes(options: {
439
+ minEpisodes?: number;
440
+ batchSize?: number;
441
+ } = {}): Promise<{
442
+ episodesProcessed: number;
443
+ memoriesCreated: number;
444
+ entitiesCreated: number;
445
+ }> {
446
+ if (!this.client) {
447
+ throw new Error("Consolidator not configured - set ANTHROPIC_API_KEY");
448
+ }
449
+
450
+ const { minEpisodes = 4, batchSize = 20 } = options;
451
+
452
+ // Get unconsolidated episodes
453
+ const episodes = this.db.getUnconsolidatedEpisodes(batchSize);
454
+
455
+ if (episodes.length < minEpisodes) {
456
+ return { episodesProcessed: 0, memoriesCreated: 0, entitiesCreated: 0 };
457
+ }
458
+
459
+ // Group by session for context
460
+ const sessionGroups = new Map<string, Episode[]>();
461
+ for (const ep of episodes) {
462
+ const existing = sessionGroups.get(ep.session_id) || [];
463
+ existing.push(ep);
464
+ sessionGroups.set(ep.session_id, existing);
465
+ }
466
+
467
+ let episodesProcessed = 0;
468
+ let memoriesCreated = 0;
469
+ let entitiesCreated = 0;
470
+
471
+ // Process each session
472
+ for (const [sessionId, sessionEpisodes] of sessionGroups) {
473
+ if (sessionEpisodes.length < 2) continue;
474
+
475
+ try {
476
+ const result = await this.extractMemoriesFromEpisodes(sessionEpisodes);
477
+
478
+ if (result && result.memories.length > 0) {
479
+ for (const mem of result.memories) {
480
+ // Create the memory
481
+ const memory = this.db.createMemory(
482
+ mem.content,
483
+ "episode_consolidation",
484
+ mem.importance,
485
+ {
486
+ eventTime: mem.event_time ? new Date(mem.event_time) : undefined,
487
+ emotionalWeight: mem.emotional_weight,
488
+ }
489
+ );
490
+ memoriesCreated++;
491
+
492
+ // Index for search
493
+ if (this.search) {
494
+ await this.search.indexMemory(memory);
495
+ }
496
+
497
+ // Create entities and relationships
498
+ if (this.graph) {
499
+ for (const ent of mem.entities || []) {
500
+ const entity = this.graph.getOrCreateEntity(
501
+ ent.name,
502
+ ent.type as "person" | "place" | "concept" | "event" | "organization"
503
+ );
504
+ this.db.addObservation(entity.id, mem.content, memory.id, 1.0);
505
+ entitiesCreated++;
506
+ }
507
+
508
+ for (const rel of mem.relationships || []) {
509
+ try {
510
+ const fromEntity = this.graph.getOrCreateEntity(rel.from, "person");
511
+ const toEntity = this.graph.getOrCreateEntity(rel.to, "person");
512
+ this.graph.relate(fromEntity.name, toEntity.name, rel.type);
513
+ } catch {
514
+ // Skip invalid relationships
515
+ }
516
+ }
517
+ }
518
+ }
519
+ }
520
+
521
+ // Mark episodes as consolidated
522
+ this.db.markEpisodesConsolidated(sessionEpisodes.map(e => e.id));
523
+ episodesProcessed += sessionEpisodes.length;
524
+
525
+ } catch (error) {
526
+ console.error("[Consolidator] Episode consolidation failed:", error);
527
+ }
528
+ }
529
+
530
+ return { episodesProcessed, memoriesCreated, entitiesCreated };
531
+ }
532
+
533
+ /**
534
+ * Extract memories from conversation episodes using Haiku (fast, cheap)
535
+ */
536
+ private async extractMemoriesFromEpisodes(
537
+ episodes: Episode[]
538
+ ): Promise<EpisodeExtractionResult | null> {
539
+ if (!this.client) return null;
540
+
541
+ // Format conversation
542
+ const conversationText = episodes
543
+ .sort((a, b) => a.turn_index - b.turn_index)
544
+ .map(ep => `${ep.role.toUpperCase()}: ${ep.content}`)
545
+ .join("\n\n");
546
+
547
+ const userPrompt = `Extract memorable facts from this conversation.
548
+
549
+ CONVERSATION:
550
+ ${conversationText}
551
+
552
+ Remember: Only extract information worth remembering long-term. Skip transient task details.
553
+ Respond with JSON only.`;
554
+
555
+ try {
556
+ // Use Haiku for speed/cost (no extended thinking needed)
557
+ const response = await this.client.messages.create({
558
+ model: "claude-haiku-4-5-20251201",
559
+ max_tokens: 4000,
560
+ messages: [
561
+ {
562
+ role: "user",
563
+ content: userPrompt,
564
+ },
565
+ ],
566
+ system: EPISODE_EXTRACTION_SYSTEM,
567
+ });
568
+
569
+ let text = "";
570
+ for (const block of response.content) {
571
+ if (block.type === "text") {
572
+ text = block.text;
573
+ break;
574
+ }
575
+ }
576
+
577
+ if (!text) return null;
578
+
579
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
580
+ if (!jsonMatch) return null;
581
+
582
+ return JSON.parse(jsonMatch[0]) as EpisodeExtractionResult;
583
+ } catch (error) {
584
+ console.error("[Consolidator] Episode extraction failed:", error);
585
+ return null;
586
+ }
587
+ }
588
+
589
+ /**
590
+ * Run full consolidation cycle (episodes → memories → digests)
591
+ * This is the "sleep cycle" that should run periodically
592
+ */
593
+ async runSleepCycle(): Promise<{
594
+ episodesProcessed: number;
595
+ memoriesCreated: number;
596
+ digestsCreated: number;
597
+ contradictionsFound: number;
598
+ }> {
599
+ console.error("[Consolidator] Starting sleep cycle...");
600
+
601
+ // Step 1: Process episodes into memories
602
+ const episodeResult = await this.consolidateEpisodes();
603
+ console.error(`[Consolidator] Episodes: ${episodeResult.episodesProcessed} → ${episodeResult.memoriesCreated} memories`);
604
+
605
+ // Step 2: Consolidate memories into digests
606
+ const memoryResult = await this.consolidate();
607
+ console.error(`[Consolidator] Memories: ${memoryResult.memoriesProcessed} → ${memoryResult.digestsCreated} digests`);
608
+
609
+ return {
610
+ episodesProcessed: episodeResult.episodesProcessed,
611
+ memoriesCreated: episodeResult.memoriesCreated,
612
+ digestsCreated: memoryResult.digestsCreated,
613
+ contradictionsFound: memoryResult.contradictionsFound,
614
+ };
615
+ }
376
616
  }
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import { KnowledgeGraph } from "./graph/knowledge-graph.js";
20
20
  import { createRetriever } from "./retrieval/colbert.js";
21
21
  import { HybridSearch } from "./retrieval/hybrid.js";
22
22
  import { EngramWebServer } from "./web/server.js";
23
+ import { Consolidator } from "./consolidation/consolidator.js";
23
24
 
24
25
  // ============ Configuration ============
25
26
 
@@ -34,6 +35,7 @@ const DB_FILE = path.join(DB_PATH, "engram.db");
34
35
  let db: EngramDatabase;
35
36
  let graph: KnowledgeGraph;
36
37
  let search: HybridSearch;
38
+ let consolidator: Consolidator;
37
39
  let webServer: EngramWebServer | null = null;
38
40
 
39
41
  async function initialize(): Promise<void> {
@@ -44,6 +46,7 @@ async function initialize(): Promise<void> {
44
46
 
45
47
  const retriever = await createRetriever(DB_PATH);
46
48
  search = new HybridSearch(db, graph, retriever);
49
+ consolidator = new Consolidator(db, graph, search);
47
50
 
48
51
  // Rebuild index with existing memories
49
52
  const stats = db.getStats();
@@ -53,6 +56,9 @@ async function initialize(): Promise<void> {
53
56
  }
54
57
 
55
58
  console.error(`[Engram] Ready. Stats: ${JSON.stringify(stats)}`);
59
+ if (consolidator.isConfigured()) {
60
+ console.error(`[Engram] Consolidation enabled (ANTHROPIC_API_KEY found)`);
61
+ }
56
62
  }
57
63
 
58
64
  // ============ MCP Server ============
@@ -60,7 +66,7 @@ async function initialize(): Promise<void> {
60
66
  const server = new Server(
61
67
  {
62
68
  name: "engram",
63
- version: "0.5.1",
69
+ version: "0.6.0",
64
70
  },
65
71
  {
66
72
  capabilities: {
@@ -90,6 +96,17 @@ const TOOLS = [
90
96
  maximum: 1,
91
97
  default: 0.5,
92
98
  },
99
+ emotional_weight: {
100
+ type: "number",
101
+ description: "0-1 emotional significance. Use 0.8+ for emotionally charged content, celebrations, losses. Affects memory retention.",
102
+ minimum: 0,
103
+ maximum: 1,
104
+ default: 0.5,
105
+ },
106
+ event_time: {
107
+ type: "string",
108
+ description: "When the event actually happened (ISO 8601), if different from now. E.g., 'Last week I went to Paris' → set event_time to that date.",
109
+ },
93
110
  entities: {
94
111
  type: "array",
95
112
  description: "Key entities mentioned (people, organizations, places). Only include clear, specific named entities.",
@@ -200,6 +217,28 @@ const TOOLS = [
200
217
  openWorldHint: true,
201
218
  },
202
219
  },
220
+ {
221
+ name: "consolidate",
222
+ description: "Run memory consolidation to compress episodes into memories and memories into digests. Like sleep for the memory system. Use periodically or when requested.",
223
+ inputSchema: {
224
+ type: "object" as const,
225
+ properties: {
226
+ mode: {
227
+ type: "string",
228
+ enum: ["full", "episodes_only", "memories_only"],
229
+ description: "What to consolidate: 'full' (default) runs everything, 'episodes_only' just processes conversation history, 'memories_only' creates digests",
230
+ default: "full",
231
+ },
232
+ },
233
+ },
234
+ annotations: {
235
+ title: "Consolidate Memories",
236
+ readOnlyHint: false,
237
+ destructiveHint: false,
238
+ idempotentHint: false,
239
+ openWorldHint: false,
240
+ },
241
+ },
203
242
  ];
204
243
 
205
244
  // List available tools
@@ -218,18 +257,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
218
257
  content,
219
258
  source = "conversation",
220
259
  importance = 0.5,
260
+ emotional_weight = 0.5,
261
+ event_time,
221
262
  entities: providedEntities = [],
222
263
  relationships: providedRelationships = [],
223
264
  } = args as {
224
265
  content: string;
225
266
  source?: string;
226
267
  importance?: number;
268
+ emotional_weight?: number;
269
+ event_time?: string;
227
270
  entities?: Array<{ name: string; type: "person" | "organization" | "place" }>;
228
271
  relationships?: Array<{ from: string; to: string; type: string }>;
229
272
  };
230
273
 
231
- // Create memory
232
- const memory = db.createMemory(content, source, importance);
274
+ // Create memory with new temporal and salience fields
275
+ const memory = db.createMemory(content, source, importance, {
276
+ eventTime: event_time ? new Date(event_time) : undefined,
277
+ emotionalWeight: emotional_weight,
278
+ });
233
279
 
234
280
  // Index for semantic search
235
281
  await search.indexMemory(memory);
@@ -290,6 +336,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
290
336
  source: r.memory.source,
291
337
  timestamp: r.memory.timestamp.toISOString(),
292
338
  relevance_score: r.score.toFixed(4),
339
+ retention: r.retention.toFixed(2), // How well-retained (0-1)
293
340
  matched_via: Object.entries(r.sources)
294
341
  .filter(([, v]) => v !== undefined)
295
342
  .map(([k]) => k)
@@ -365,6 +412,52 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
365
412
  };
366
413
  }
367
414
 
415
+ case "consolidate": {
416
+ const { mode = "full" } = args as { mode?: "full" | "episodes_only" | "memories_only" };
417
+
418
+ if (!consolidator.isConfigured()) {
419
+ return {
420
+ content: [
421
+ {
422
+ type: "text" as const,
423
+ text: JSON.stringify({
424
+ success: false,
425
+ error: "Consolidation requires ANTHROPIC_API_KEY environment variable",
426
+ }),
427
+ },
428
+ ],
429
+ isError: true,
430
+ };
431
+ }
432
+
433
+ let result;
434
+ switch (mode) {
435
+ case "episodes_only":
436
+ result = await consolidator.consolidateEpisodes();
437
+ break;
438
+ case "memories_only":
439
+ result = await consolidator.consolidate();
440
+ break;
441
+ case "full":
442
+ default:
443
+ result = await consolidator.runSleepCycle();
444
+ break;
445
+ }
446
+
447
+ return {
448
+ content: [
449
+ {
450
+ type: "text" as const,
451
+ text: JSON.stringify({
452
+ success: true,
453
+ mode,
454
+ ...result,
455
+ }, null, 2),
456
+ },
457
+ ],
458
+ };
459
+ }
460
+
368
461
  default:
369
462
  throw new Error(`Unknown tool: ${name}`);
370
463
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Hybrid Search with Reciprocal Rank Fusion (RRF)
3
3
  * Combines BM25 (keyword) and ColBERT (semantic) search
4
+ * Enhanced with temporal decay and salience scoring
4
5
  */
5
6
 
6
7
  import { EngramDatabase, Memory } from "../storage/database.js";
@@ -10,6 +11,7 @@ import { ColBERTRetriever, SimpleRetriever, SearchResult, Document } from "./col
10
11
  export interface HybridSearchResult {
11
12
  memory: Memory;
12
13
  score: number;
14
+ retention: number; // 0-1 how well-retained this memory is
13
15
  sources: {
14
16
  bm25?: number;
15
17
  semantic?: number;
@@ -17,6 +19,60 @@ export interface HybridSearchResult {
17
19
  };
18
20
  }
19
21
 
22
+ /**
23
+ * Calculate Ebbinghaus forgetting curve retention
24
+ * R = e^(-t/S) where t=time since last access, S=stability
25
+ *
26
+ * Higher stability = slower forgetting
27
+ * Recent access = higher retention
28
+ */
29
+ function calculateRetention(memory: Memory, now: Date): number {
30
+ // Use last_accessed if available, otherwise timestamp
31
+ const lastActive = memory.last_accessed || memory.timestamp;
32
+ const daysSinceAccess = (now.getTime() - lastActive.getTime()) / (1000 * 60 * 60 * 24);
33
+
34
+ // Stability is our memory strength (default 1.0, increases with recalls)
35
+ const stability = memory.stability || 1.0;
36
+
37
+ // Half-life in days = stability * 7 (so stability=1 means 7-day half-life)
38
+ const halfLife = stability * 7;
39
+
40
+ // Exponential decay: R = e^(-0.693 * t / halfLife)
41
+ const retention = Math.exp(-0.693 * daysSinceAccess / halfLife);
42
+
43
+ return Math.max(0, Math.min(1, retention));
44
+ }
45
+
46
+ /**
47
+ * Calculate salience score - how important/memorable is this?
48
+ * Combines emotional weight, importance, and access patterns
49
+ */
50
+ function calculateSalience(memory: Memory): number {
51
+ const importance = memory.importance || 0.5;
52
+ const emotionalWeight = memory.emotional_weight || 0.5;
53
+ const accessBonus = Math.min(1, Math.log(1 + (memory.access_count || 0)) / 5);
54
+
55
+ // Weighted combination
56
+ return (importance * 0.4) + (emotionalWeight * 0.4) + (accessBonus * 0.2);
57
+ }
58
+
59
+ /**
60
+ * Apply temporal and salience adjustments to search results
61
+ */
62
+ function adjustScore(memory: Memory, baseScore: number, now: Date): { adjusted: number; retention: number } {
63
+ const retention = calculateRetention(memory, now);
64
+ const salience = calculateSalience(memory);
65
+
66
+ // Final score = base * (0.5 + 0.3*retention + 0.2*salience)
67
+ // This means: 50% retrieval match, 30% recency/stability, 20% importance
68
+ const multiplier = 0.5 + (0.3 * retention) + (0.2 * salience);
69
+
70
+ return {
71
+ adjusted: baseScore * multiplier,
72
+ retention,
73
+ };
74
+ }
75
+
20
76
  export class HybridSearch {
21
77
  constructor(
22
78
  private db: EngramDatabase,
@@ -133,18 +189,21 @@ export class HybridSearch {
133
189
  // Sort by RRF score
134
190
  rrfScores.sort((a, b) => b.score - a.score);
135
191
 
136
- // Get top results with full memory data
137
- const results: HybridSearchResult[] = [];
192
+ // Get results with full memory data and apply temporal adjustments
193
+ const now = new Date();
194
+ const adjustedResults: Array<HybridSearchResult & { originalScore: number }> = [];
138
195
 
139
- for (const { id, score, sources } of rrfScores.slice(0, limit)) {
196
+ for (const { id, score, sources } of rrfScores) {
140
197
  const memory = this.db.getMemory(id);
141
198
  if (memory) {
142
- // Update access count
143
- this.db.touchMemory(id);
199
+ // Apply Ebbinghaus decay and salience scoring
200
+ const { adjusted, retention } = adjustScore(memory, score, now);
144
201
 
145
- results.push({
202
+ adjustedResults.push({
146
203
  memory,
147
- score,
204
+ score: adjusted,
205
+ retention,
206
+ originalScore: score,
148
207
  sources: {
149
208
  bm25: sources.bm25,
150
209
  semantic: sources.semantic,
@@ -154,6 +213,23 @@ export class HybridSearch {
154
213
  }
155
214
  }
156
215
 
216
+ // Re-sort by adjusted score (accounts for recency/stability)
217
+ adjustedResults.sort((a, b) => b.score - a.score);
218
+
219
+ // Take top results and update access counts
220
+ const results: HybridSearchResult[] = [];
221
+ for (const result of adjustedResults.slice(0, limit)) {
222
+ // Update access count (which also increases stability for future searches)
223
+ this.db.touchMemory(result.memory.id);
224
+
225
+ results.push({
226
+ memory: result.memory,
227
+ score: result.score,
228
+ retention: result.retention,
229
+ sources: result.sources,
230
+ });
231
+ }
232
+
157
233
  return results;
158
234
  }
159
235