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