@cogmem/engram 0.0.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.
@@ -0,0 +1,402 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { drizzle, type BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
3
+ import { migrate } from "drizzle-orm/bun-sqlite/migrator";
4
+ import { eq, and, gt, lt, or, desc, asc, count, sql } from "drizzle-orm";
5
+ import { memories, accessLog, associations, workingMemory, consolidationLog } from "./schema.ts";
6
+ import type { MemoryType, AccessType } from "../core/memory.ts";
7
+ import type {
8
+ Memory,
9
+ AccessLogEntry,
10
+ Association,
11
+ WorkingMemorySlot,
12
+ ConsolidationLog,
13
+ } from "./schema.ts";
14
+ import { generateId } from "../core/memory.ts";
15
+ import { resolveDbPath } from "../config/defaults.ts";
16
+ import { mkdirSync, existsSync } from "node:fs";
17
+ import { dirname, resolve } from "node:path";
18
+
19
+ import * as schema from "./schema.ts";
20
+
21
+ export class EngramStorage {
22
+ private sqlite: Database;
23
+ readonly db: BunSQLiteDatabase<typeof schema>;
24
+
25
+ private constructor(sqlite: Database, db: BunSQLiteDatabase<typeof schema>) {
26
+ this.sqlite = sqlite;
27
+ this.db = db;
28
+ this.initFTS();
29
+ }
30
+
31
+ private initFTS(): void {
32
+ this.sqlite.run(`
33
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(memory_id UNINDEXED, content)
34
+ `);
35
+ this.sqlite.run(`
36
+ CREATE TRIGGER IF NOT EXISTS memories_fts_insert AFTER INSERT ON memories BEGIN
37
+ INSERT INTO memories_fts(memory_id, content) VALUES (new.id, new.content);
38
+ END
39
+ `);
40
+ this.sqlite.run(`
41
+ CREATE TRIGGER IF NOT EXISTS memories_fts_delete AFTER DELETE ON memories BEGIN
42
+ DELETE FROM memories_fts WHERE memory_id = old.id;
43
+ END
44
+ `);
45
+
46
+ const ftsCount = this.sqlite.prepare("SELECT count(*) as c FROM memories_fts").get() as {
47
+ c: number;
48
+ };
49
+ const memCount = this.sqlite.prepare("SELECT count(*) as c FROM memories").get() as {
50
+ c: number;
51
+ };
52
+ if (ftsCount.c === 0 && memCount.c > 0) {
53
+ this.sqlite.run(
54
+ "INSERT INTO memories_fts(memory_id, content) SELECT id, content FROM memories",
55
+ );
56
+ }
57
+ }
58
+
59
+ private static readonly migrationsFolder = resolve(import.meta.dir, "../../drizzle");
60
+
61
+ static open(dbPath: string): EngramStorage {
62
+ const resolved = resolveDbPath(dbPath);
63
+ const dir = dirname(resolved);
64
+ if (!existsSync(dir)) {
65
+ mkdirSync(dir, { recursive: true });
66
+ }
67
+ const sqlite = new Database(resolved);
68
+ sqlite.run("PRAGMA journal_mode = WAL");
69
+ sqlite.run("PRAGMA foreign_keys = ON");
70
+ const db = drizzle({ client: sqlite, schema });
71
+ migrate(db, { migrationsFolder: EngramStorage.migrationsFolder });
72
+ return new EngramStorage(sqlite, db);
73
+ }
74
+
75
+ static inMemory(): EngramStorage {
76
+ const sqlite = new Database(":memory:");
77
+ sqlite.run("PRAGMA foreign_keys = ON");
78
+ const db = drizzle({ client: sqlite, schema });
79
+ migrate(db, { migrationsFolder: EngramStorage.migrationsFolder });
80
+ return new EngramStorage(sqlite, db);
81
+ }
82
+
83
+ close(): void {
84
+ this.sqlite.close();
85
+ }
86
+
87
+ // ─── Memories ──────────────────────────────────────────────
88
+
89
+ insertMemory(memory: Memory): void {
90
+ this.db
91
+ .insert(memories)
92
+ .values({
93
+ id: memory.id,
94
+ type: memory.type,
95
+ content: memory.content,
96
+ encodedAt: memory.encodedAt,
97
+ lastRecalledAt: memory.lastRecalledAt,
98
+ recallCount: memory.recallCount,
99
+ activation: memory.activation,
100
+ emotion: memory.emotion,
101
+ emotionWeight: memory.emotionWeight,
102
+ context: memory.context,
103
+ chunkId: memory.chunkId,
104
+ reconsolidationCount: memory.reconsolidationCount,
105
+ })
106
+ .run();
107
+ }
108
+
109
+ getMemory(id: string): Memory | null {
110
+ const row = this.db.select().from(memories).where(eq(memories.id, id)).get();
111
+ return row ?? null;
112
+ }
113
+
114
+ getAllMemories(type?: MemoryType): Memory[] {
115
+ if (type) {
116
+ return this.db
117
+ .select()
118
+ .from(memories)
119
+ .where(eq(memories.type, type))
120
+ .orderBy(desc(memories.encodedAt))
121
+ .all();
122
+ }
123
+ return this.db.select().from(memories).orderBy(desc(memories.encodedAt)).all();
124
+ }
125
+
126
+ updateMemory(memory: Memory): void {
127
+ this.db
128
+ .update(memories)
129
+ .set({
130
+ lastRecalledAt: memory.lastRecalledAt,
131
+ recallCount: memory.recallCount,
132
+ activation: memory.activation,
133
+ emotion: memory.emotion,
134
+ emotionWeight: memory.emotionWeight,
135
+ context: memory.context,
136
+ chunkId: memory.chunkId,
137
+ reconsolidationCount: memory.reconsolidationCount,
138
+ })
139
+ .where(eq(memories.id, memory.id))
140
+ .run();
141
+ }
142
+
143
+ deleteMemory(id: string): void {
144
+ this.db.delete(memories).where(eq(memories.id, id)).run();
145
+ }
146
+
147
+ getMemoriesAboveActivation(threshold: number): Memory[] {
148
+ return this.db
149
+ .select()
150
+ .from(memories)
151
+ .where(gt(memories.activation, threshold))
152
+ .orderBy(desc(memories.activation))
153
+ .all();
154
+ }
155
+
156
+ getMemoriesBelowActivation(threshold: number): Memory[] {
157
+ return this.db
158
+ .select()
159
+ .from(memories)
160
+ .where(and(lt(memories.activation, threshold), sql`${memories.type} != 'procedural'`))
161
+ .orderBy(asc(memories.activation))
162
+ .all();
163
+ }
164
+
165
+ searchMemories(query: string, limit = 20): Memory[] {
166
+ return this.db
167
+ .select()
168
+ .from(memories)
169
+ .where(sql`${memories.content} LIKE ${"%" + query + "%"}`)
170
+ .orderBy(desc(memories.activation))
171
+ .limit(limit)
172
+ .all();
173
+ }
174
+
175
+ getMemoryCount(type?: MemoryType): number {
176
+ if (type) {
177
+ const result = this.db
178
+ .select({ value: count() })
179
+ .from(memories)
180
+ .where(eq(memories.type, type))
181
+ .get();
182
+ return result?.value ?? 0;
183
+ }
184
+ const result = this.db.select({ value: count() }).from(memories).get();
185
+ return result?.value ?? 0;
186
+ }
187
+
188
+ getMemoriesByContext(context: string, type?: MemoryType, limit = 20): Memory[] {
189
+ const conditions = [sql`${memories.context} LIKE ${context + "%"}`];
190
+ if (type) conditions.push(eq(memories.type, type));
191
+ return this.db
192
+ .select()
193
+ .from(memories)
194
+ .where(and(...conditions))
195
+ .orderBy(desc(memories.activation))
196
+ .limit(limit)
197
+ .all();
198
+ }
199
+
200
+ getMemoryCountByContext(context: string, type?: MemoryType): number {
201
+ const conditions = [sql`${memories.context} LIKE ${context + "%"}`];
202
+ if (type) conditions.push(eq(memories.type, type));
203
+ const result = this.db
204
+ .select({ value: count() })
205
+ .from(memories)
206
+ .where(and(...conditions))
207
+ .get();
208
+ return result?.value ?? 0;
209
+ }
210
+
211
+ searchFTS(query: string, limit: number = 20): string[] {
212
+ const sanitized = query.replace(/[^a-zA-Z0-9\s]/g, "").trim();
213
+ if (!sanitized) return [];
214
+ const terms = sanitized
215
+ .split(/\s+/)
216
+ .map((t) => `"${t}"*`)
217
+ .join(" OR ");
218
+ const stmt = this.sqlite.prepare(
219
+ `SELECT memory_id FROM memories_fts WHERE memories_fts MATCH ? ORDER BY rank LIMIT ?`,
220
+ );
221
+ return stmt.all(terms, limit).map((row: any) => row.memory_id as string);
222
+ }
223
+
224
+ // ─── Access Log ────────────────────────────────────────────
225
+
226
+ logAccess(memoryId: string, accessType: AccessType, timestamp?: number): void {
227
+ this.db
228
+ .insert(accessLog)
229
+ .values({
230
+ id: generateId(),
231
+ memoryId,
232
+ accessedAt: timestamp ?? Date.now(),
233
+ accessType,
234
+ })
235
+ .run();
236
+ }
237
+
238
+ getAccessLog(memoryId: string): AccessLogEntry[] {
239
+ return this.db
240
+ .select()
241
+ .from(accessLog)
242
+ .where(eq(accessLog.memoryId, memoryId))
243
+ .orderBy(asc(accessLog.accessedAt))
244
+ .all();
245
+ }
246
+
247
+ getAccessTimestamps(memoryId: string): number[] {
248
+ return this.db
249
+ .select({ accessedAt: accessLog.accessedAt })
250
+ .from(accessLog)
251
+ .where(eq(accessLog.memoryId, memoryId))
252
+ .orderBy(asc(accessLog.accessedAt))
253
+ .all()
254
+ .map((r) => r.accessedAt);
255
+ }
256
+
257
+ // ─── Associations ──────────────────────────────────────────
258
+
259
+ insertAssociation(assoc: Association): void {
260
+ this.db
261
+ .insert(associations)
262
+ .values({
263
+ id: assoc.id,
264
+ sourceId: assoc.sourceId,
265
+ targetId: assoc.targetId,
266
+ strength: assoc.strength,
267
+ formedAt: assoc.formedAt,
268
+ type: assoc.type,
269
+ })
270
+ .onConflictDoUpdate({
271
+ target: [associations.sourceId, associations.targetId],
272
+ set: {
273
+ strength: assoc.strength,
274
+ type: assoc.type,
275
+ },
276
+ })
277
+ .run();
278
+ }
279
+
280
+ getAssociationsFrom(memoryId: string): Association[] {
281
+ return this.db
282
+ .select()
283
+ .from(associations)
284
+ .where(eq(associations.sourceId, memoryId))
285
+ .orderBy(desc(associations.strength))
286
+ .all();
287
+ }
288
+
289
+ getAssociationsTo(memoryId: string): Association[] {
290
+ return this.db
291
+ .select()
292
+ .from(associations)
293
+ .where(eq(associations.targetId, memoryId))
294
+ .orderBy(desc(associations.strength))
295
+ .all();
296
+ }
297
+
298
+ getAssociations(memoryId: string): Association[] {
299
+ return this.db
300
+ .select()
301
+ .from(associations)
302
+ .where(or(eq(associations.sourceId, memoryId), eq(associations.targetId, memoryId)))
303
+ .orderBy(desc(associations.strength))
304
+ .all();
305
+ }
306
+
307
+ getFanCount(memoryId: string): number {
308
+ const result = this.db
309
+ .select({ value: count() })
310
+ .from(associations)
311
+ .where(or(eq(associations.sourceId, memoryId), eq(associations.targetId, memoryId)))
312
+ .get();
313
+ return result?.value ?? 0;
314
+ }
315
+
316
+ getAssociationCount(): number {
317
+ const result = this.db.select({ value: count() }).from(associations).get();
318
+ return result?.value ?? 0;
319
+ }
320
+
321
+ updateAssociationStrength(id: string, strength: number): void {
322
+ this.db.update(associations).set({ strength }).where(eq(associations.id, id)).run();
323
+ }
324
+
325
+ deleteWeakAssociations(minStrength: number): number {
326
+ // Raw sqlite needed for .changes (drizzle sync delete returns void)
327
+ const stmt = this.sqlite.prepare("DELETE FROM associations WHERE strength < ?");
328
+ const result = stmt.run(minStrength);
329
+ return result.changes;
330
+ }
331
+
332
+ // ─── Working Memory ────────────────────────────────────────
333
+
334
+ getWorkingMemory(): WorkingMemorySlot[] {
335
+ return this.db.select().from(workingMemory).orderBy(asc(workingMemory.slot)).all();
336
+ }
337
+
338
+ pushWorkingMemory(slot: WorkingMemorySlot): void {
339
+ this.db
340
+ .insert(workingMemory)
341
+ .values({
342
+ slot: slot.slot,
343
+ memoryRef: slot.memoryRef,
344
+ content: slot.content,
345
+ pushedAt: slot.pushedAt,
346
+ })
347
+ .onConflictDoUpdate({
348
+ target: workingMemory.slot,
349
+ set: {
350
+ memoryRef: slot.memoryRef,
351
+ content: slot.content,
352
+ pushedAt: slot.pushedAt,
353
+ },
354
+ })
355
+ .run();
356
+ }
357
+
358
+ clearWorkingMemory(): void {
359
+ this.db.delete(workingMemory).run();
360
+ }
361
+
362
+ removeWorkingMemorySlot(slot: number): void {
363
+ this.db.delete(workingMemory).where(eq(workingMemory.slot, slot)).run();
364
+ }
365
+
366
+ getWorkingMemoryCount(): number {
367
+ const result = this.db.select({ value: count() }).from(workingMemory).get();
368
+ return result?.value ?? 0;
369
+ }
370
+
371
+ // ─── Consolidation Log ─────────────────────────────────────
372
+
373
+ logConsolidation(entry: ConsolidationLog): void {
374
+ this.db
375
+ .insert(consolidationLog)
376
+ .values({
377
+ id: entry.id,
378
+ ranAt: entry.ranAt,
379
+ memoriesStrengthened: entry.memoriesStrengthened,
380
+ memoriesPruned: entry.memoriesPruned,
381
+ factsExtracted: entry.factsExtracted,
382
+ associationsDiscovered: entry.associationsDiscovered,
383
+ })
384
+ .run();
385
+ }
386
+
387
+ getLastConsolidation(): ConsolidationLog | null {
388
+ const row = this.db
389
+ .select()
390
+ .from(consolidationLog)
391
+ .orderBy(desc(consolidationLog.ranAt))
392
+ .limit(1)
393
+ .get();
394
+ return row ?? null;
395
+ }
396
+
397
+ // ─── Bulk / Utility ────────────────────────────────────────
398
+
399
+ transaction<T>(fn: () => T): T {
400
+ return this.sqlite.transaction(fn)();
401
+ }
402
+ }