@199-bio/engram 0.5.1 → 0.6.1

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,10 +12,27 @@ export interface Memory {
12
12
  id: string;
13
13
  content: string;
14
14
  source: string;
15
- timestamp: Date;
15
+ timestamp: Date; // ingestion_time: when we learned this
16
+ event_time: Date | null; // when the event actually happened (bi-temporal)
16
17
  importance: number;
17
18
  access_count: number;
18
19
  last_accessed: Date | null;
20
+ stability: number; // Ebbinghaus stability score (increases with recalls)
21
+ emotional_weight: number; // Salience: emotional significance 0-1
22
+ }
23
+
24
+ /**
25
+ * Episode: Raw conversation turns (hippocampal buffer)
26
+ * These are the sensory/working memory before consolidation into semantic memory
27
+ */
28
+ export interface Episode {
29
+ id: string;
30
+ session_id: string; // Groups conversation turns
31
+ turn_index: number; // Order within session
32
+ role: "user" | "assistant";
33
+ content: string;
34
+ timestamp: Date;
35
+ consolidated: boolean; // Has this been processed into memories?
19
36
  }
20
37
 
21
38
  export interface Entity {
@@ -94,7 +111,8 @@ export class EngramDatabase {
94
111
  }
95
112
 
96
113
  private initialize(): void {
97
- // Memories table
114
+ // Memories table (Semantic Memory - neocortex analog)
115
+ // Create table with base columns first (for new databases)
98
116
  this.db.exec(`
99
117
  CREATE TABLE IF NOT EXISTS memories (
100
118
  id TEXT PRIMARY KEY,
@@ -105,9 +123,33 @@ export class EngramDatabase {
105
123
  access_count INTEGER DEFAULT 0,
106
124
  last_accessed DATETIME
107
125
  );
126
+ `);
127
+
128
+ // Episodes table (Episodic Memory - hippocampal buffer)
129
+ this.db.exec(`
130
+ CREATE TABLE IF NOT EXISTS episodes (
131
+ id TEXT PRIMARY KEY,
132
+ session_id TEXT NOT NULL,
133
+ turn_index INTEGER DEFAULT 0,
134
+ role TEXT NOT NULL CHECK(role IN ('user', 'assistant')),
135
+ content TEXT NOT NULL,
136
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
137
+ consolidated INTEGER DEFAULT 0
138
+ );
139
+ `);
140
+
141
+ // IMPORTANT: Migrate schema BEFORE creating indexes on new columns
142
+ // This adds event_time, stability, emotional_weight to existing databases
143
+ this.migrateSchema();
108
144
 
145
+ // Now create all indexes (after migration ensures columns exist)
146
+ this.db.exec(`
109
147
  CREATE INDEX IF NOT EXISTS idx_memories_timestamp ON memories(timestamp);
110
148
  CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance);
149
+ CREATE INDEX IF NOT EXISTS idx_memories_event_time ON memories(event_time);
150
+ CREATE INDEX IF NOT EXISTS idx_episodes_session ON episodes(session_id);
151
+ CREATE INDEX IF NOT EXISTS idx_episodes_consolidated ON episodes(consolidated);
152
+ CREATE INDEX IF NOT EXISTS idx_episodes_timestamp ON episodes(timestamp);
111
153
  `);
112
154
 
113
155
  // FTS5 for BM25 search
@@ -228,19 +270,50 @@ export class EngramDatabase {
228
270
  `);
229
271
  }
230
272
 
273
+ /**
274
+ * Add new columns to existing tables for seamless upgrades
275
+ */
276
+ private migrateSchema(): void {
277
+ // Check and add new memory columns
278
+ const memoryInfo = this.db.pragma("table_info(memories)") as Array<{ name: string }>;
279
+ const memoryColumns = new Set(memoryInfo.map(c => c.name));
280
+
281
+ if (!memoryColumns.has("event_time")) {
282
+ this.db.exec("ALTER TABLE memories ADD COLUMN event_time DATETIME");
283
+ }
284
+ if (!memoryColumns.has("stability")) {
285
+ this.db.exec("ALTER TABLE memories ADD COLUMN stability REAL DEFAULT 1.0");
286
+ }
287
+ if (!memoryColumns.has("emotional_weight")) {
288
+ this.db.exec("ALTER TABLE memories ADD COLUMN emotional_weight REAL DEFAULT 0.5");
289
+ }
290
+ }
291
+
231
292
  // ============ Memory Operations ============
232
293
 
233
294
  createMemory(
234
295
  content: string,
235
296
  source: string = "conversation",
236
- importance: number = 0.5
297
+ importance: number = 0.5,
298
+ options: {
299
+ eventTime?: Date;
300
+ emotionalWeight?: number;
301
+ } = {}
237
302
  ): Memory {
238
303
  const id = randomUUID();
239
304
  const stmt = this.db.prepare(`
240
- INSERT INTO memories (id, content, source, importance)
241
- VALUES (?, ?, ?, ?)
305
+ INSERT INTO memories (id, content, source, importance, event_time, emotional_weight, stability)
306
+ VALUES (?, ?, ?, ?, ?, ?, ?)
242
307
  `);
243
- stmt.run(id, content, source, importance);
308
+ stmt.run(
309
+ id,
310
+ content,
311
+ source,
312
+ importance,
313
+ options.eventTime?.toISOString() || null,
314
+ options.emotionalWeight ?? 0.5,
315
+ 1.0 // Initial stability
316
+ );
244
317
  return this.getMemory(id)!;
245
318
  }
246
319
 
@@ -276,8 +349,19 @@ export class EngramDatabase {
276
349
  return result.changes > 0;
277
350
  }
278
351
 
352
+ /**
353
+ * Record a memory access - increases access_count and stability
354
+ * Each recall strengthens the memory (Ebbinghaus spacing effect)
355
+ */
279
356
  touchMemory(id: string): void {
280
- this.stmt(`UPDATE memories SET access_count = access_count + 1, last_accessed = CURRENT_TIMESTAMP WHERE id = ?`).run(id);
357
+ // Stability increases with each access: S_new = S_old * 1.2 (capped at 10)
358
+ this.stmt(`
359
+ UPDATE memories
360
+ SET access_count = access_count + 1,
361
+ last_accessed = CURRENT_TIMESTAMP,
362
+ stability = MIN(stability * 1.2, 10.0)
363
+ WHERE id = ?
364
+ `).run(id);
281
365
  }
282
366
 
283
367
  getAllMemories(limit: number = 1000): Memory[] {
@@ -285,6 +369,103 @@ export class EngramDatabase {
285
369
  return rows.map((row) => this.rowToMemory(row));
286
370
  }
287
371
 
372
+ // ============ Episode Operations (Raw Conversations) ============
373
+
374
+ /**
375
+ * Store a conversation turn for later consolidation
376
+ */
377
+ createEpisode(
378
+ sessionId: string,
379
+ role: "user" | "assistant",
380
+ content: string,
381
+ turnIndex?: number
382
+ ): Episode {
383
+ const id = randomUUID();
384
+
385
+ // Auto-calculate turn index if not provided
386
+ const actualTurnIndex = turnIndex ?? this.getNextTurnIndex(sessionId);
387
+
388
+ const stmt = this.db.prepare(`
389
+ INSERT INTO episodes (id, session_id, turn_index, role, content)
390
+ VALUES (?, ?, ?, ?, ?)
391
+ `);
392
+ stmt.run(id, sessionId, actualTurnIndex, role, content);
393
+ return this.getEpisode(id)!;
394
+ }
395
+
396
+ getEpisode(id: string): Episode | null {
397
+ const row = this.stmt("SELECT * FROM episodes WHERE id = ?").get(id) as Record<string, unknown> | undefined;
398
+ return row ? this.rowToEpisode(row) : null;
399
+ }
400
+
401
+ private getNextTurnIndex(sessionId: string): number {
402
+ const row = this.stmt(
403
+ "SELECT MAX(turn_index) as max_turn FROM episodes WHERE session_id = ?"
404
+ ).get(sessionId) as { max_turn: number | null } | undefined;
405
+ return (row?.max_turn ?? -1) + 1;
406
+ }
407
+
408
+ /**
409
+ * Get all episodes in a session
410
+ */
411
+ getSessionEpisodes(sessionId: string): Episode[] {
412
+ const rows = this.stmt(
413
+ "SELECT * FROM episodes WHERE session_id = ? ORDER BY turn_index"
414
+ ).all(sessionId) as Record<string, unknown>[];
415
+ return rows.map((row) => this.rowToEpisode(row));
416
+ }
417
+
418
+ /**
419
+ * Get unconsolidated episodes for processing
420
+ */
421
+ getUnconsolidatedEpisodes(limit: number = 100): Episode[] {
422
+ const rows = this.stmt(
423
+ "SELECT * FROM episodes WHERE consolidated = 0 ORDER BY timestamp ASC LIMIT ?"
424
+ ).all(limit) as Record<string, unknown>[];
425
+ return rows.map((row) => this.rowToEpisode(row));
426
+ }
427
+
428
+ /**
429
+ * Mark episodes as consolidated
430
+ */
431
+ markEpisodesConsolidated(episodeIds: string[]): void {
432
+ const stmt = this.db.prepare("UPDATE episodes SET consolidated = 1 WHERE id = ?");
433
+ for (const id of episodeIds) {
434
+ stmt.run(id);
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Get recent sessions for context
440
+ */
441
+ getRecentSessions(limit: number = 10): Array<{ session_id: string; episode_count: number; last_activity: Date }> {
442
+ const rows = this.stmt(`
443
+ SELECT session_id, COUNT(*) as episode_count, MAX(timestamp) as last_activity
444
+ FROM episodes
445
+ GROUP BY session_id
446
+ ORDER BY last_activity DESC
447
+ LIMIT ?
448
+ `).all(limit) as Array<{ session_id: string; episode_count: number; last_activity: string }>;
449
+
450
+ return rows.map(r => ({
451
+ session_id: r.session_id,
452
+ episode_count: r.episode_count,
453
+ last_activity: new Date(r.last_activity),
454
+ }));
455
+ }
456
+
457
+ private rowToEpisode(row: Record<string, unknown>): Episode {
458
+ return {
459
+ id: row.id as string,
460
+ session_id: row.session_id as string,
461
+ turn_index: row.turn_index as number,
462
+ role: row.role as "user" | "assistant",
463
+ content: row.content as string,
464
+ timestamp: new Date(row.timestamp as string),
465
+ consolidated: Boolean(row.consolidated),
466
+ };
467
+ }
468
+
288
469
  // ============ BM25 Search ============
289
470
 
290
471
  searchBM25(query: string, limit: number = 20): Array<Memory & { score: number }> {
@@ -860,6 +1041,8 @@ export class EngramDatabase {
860
1041
  observations: number;
861
1042
  digests: number;
862
1043
  contradictions: number;
1044
+ episodes: number;
1045
+ unconsolidated_episodes: number;
863
1046
  } {
864
1047
  // Single query for all stats - much faster than separate queries
865
1048
  const row = this.stmt(`
@@ -869,7 +1052,9 @@ export class EngramDatabase {
869
1052
  (SELECT COUNT(*) FROM relations) as relations,
870
1053
  (SELECT COUNT(*) FROM observations) as observations,
871
1054
  (SELECT COUNT(*) FROM digests) as digests,
872
- (SELECT COUNT(*) FROM contradictions WHERE resolved = 0) as contradictions
1055
+ (SELECT COUNT(*) FROM contradictions WHERE resolved = 0) as contradictions,
1056
+ (SELECT COUNT(*) FROM episodes) as episodes,
1057
+ (SELECT COUNT(*) FROM episodes WHERE consolidated = 0) as unconsolidated_episodes
873
1058
  `).get() as {
874
1059
  memories: number;
875
1060
  entities: number;
@@ -877,6 +1062,8 @@ export class EngramDatabase {
877
1062
  observations: number;
878
1063
  digests: number;
879
1064
  contradictions: number;
1065
+ episodes: number;
1066
+ unconsolidated_episodes: number;
880
1067
  };
881
1068
 
882
1069
  return row;
@@ -906,9 +1093,12 @@ export class EngramDatabase {
906
1093
  content: row.content as string,
907
1094
  source: row.source as string,
908
1095
  timestamp: new Date(row.timestamp as string),
1096
+ event_time: row.event_time ? new Date(row.event_time as string) : null,
909
1097
  importance: row.importance as number,
910
1098
  access_count: row.access_count as number,
911
1099
  last_accessed: row.last_accessed ? new Date(row.last_accessed as string) : null,
1100
+ stability: (row.stability as number) ?? 1.0,
1101
+ emotional_weight: (row.emotional_weight as number) ?? 0.5,
912
1102
  };
913
1103
  }
914
1104