@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.
- package/README.md +205 -46
- 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 -9
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,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(
|
|
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
|
-
|
|
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
|
|