@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.
- package/README.md +127 -168
- package/dist/consolidation/consolidator.d.ts.map +1 -1
- package/dist/index.js +89 -4
- package/dist/retrieval/hybrid.d.ts.map +1 -1
- package/dist/storage/database.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/consolidation/consolidator.ts +245 -5
- package/src/index.ts +96 -3
- package/src/retrieval/hybrid.ts +83 -7
- package/src/storage/database.ts +198 -8
package/src/storage/database.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|