persyst-mcp 2.1.3 → 2.2.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/src/database.js CHANGED
@@ -1,877 +1,973 @@
1
- /**
2
- * database.js — SQLite Database Setup & CRUD Operations
3
- *
4
- * This file handles everything database-related:
5
- * - Opens SQLite connection at ~/.persyst/persyst.db
6
- * - Loads the sqlite-vec extension for vector search
7
- * - Creates all tables (memories, FTS5 index, vector index)
8
- * - Runs schema migrations for production-grade bi-temporal model
9
- * - Exports simple CRUD functions for other modules to use
10
- *
11
- * IMPORTANT: better-sqlite3 is SYNCHRONOUS. No async/await here.
12
- */
13
-
14
- import Database from 'better-sqlite3';
15
- import * as sqliteVec from 'sqlite-vec';
16
- import { join } from 'path';
17
- import { homedir } from 'os';
18
- import { mkdirSync } from 'fs';
19
-
20
- // ============================================================
21
- // DATABASE LOCATION
22
- // Store in ~/.persyst/ per default to persist across sessions
23
- // ============================================================
24
-
25
- const DB_DIR = join(homedir(), '.persyst');
26
- mkdirSync(DB_DIR, { recursive: true });
27
- const DB_PATH = process.env.NODE_ENV === 'test' ? ':memory:' : join(DB_DIR, 'persyst.db');
28
-
29
- // ============================================================
30
- // INITIALIZE CONNECTION
31
- // ============================================================
32
-
33
- const db = new Database(DB_PATH);
34
- db.pragma('journal_mode = WAL'); // Better performance for concurrent reads
35
- db.pragma('foreign_keys = ON'); // Enforce referential integrity
36
- db.pragma('mmap_size = 268435456'); // 256MB memory-mapped I/O for faster reads
37
-
38
- // Load sqlite-vec BEFORE creating any vec0 tables
39
- sqliteVec.load(db);
40
-
41
- console.error(`[persyst] Database: ${DB_PATH}`);
42
-
43
- // ============================================================
44
- // CREATE TABLES & SCHEMA MIGRATIONS
45
- // ============================================================
46
-
47
- // --- Main memories table ---
48
- db.exec(`
49
- CREATE TABLE IF NOT EXISTS memories (
50
- id INTEGER PRIMARY KEY,
51
- content TEXT NOT NULL,
52
- importance_score REAL DEFAULT 1.0,
53
- created_at INTEGER DEFAULT (unixepoch()),
54
- last_accessed INTEGER DEFAULT (unixepoch()),
55
- access_count INTEGER DEFAULT 0,
56
- valid_from INTEGER DEFAULT (unixepoch()),
57
- valid_until INTEGER DEFAULT NULL,
58
- assertion_time INTEGER DEFAULT (unixepoch())
59
- )
60
- `);
61
-
62
- // --- Migrations for bi-temporal validity on existing tables ---
63
- try {
64
- db.exec('ALTER TABLE memories ADD COLUMN valid_from INTEGER DEFAULT (unixepoch())');
65
- } catch (e) { /* Column already exists */ }
66
-
67
- try {
68
- db.exec('ALTER TABLE memories ADD COLUMN valid_until INTEGER DEFAULT NULL');
69
- } catch (e) { /* Column already exists */ }
70
-
71
- try {
72
- db.exec('ALTER TABLE memories ADD COLUMN assertion_time INTEGER DEFAULT (unixepoch())');
73
- } catch (e) { /* Column already exists */ }
74
-
75
- // --- Migration: add namespace column for per-agent isolation ---
76
- try {
77
- db.exec("ALTER TABLE memories ADD COLUMN namespace TEXT DEFAULT 'shared'");
78
- } catch (e) { /* Column already exists */ }
79
-
80
- // --- Index on namespace for fast filtered queries ---
81
- try {
82
- db.exec('CREATE INDEX IF NOT EXISTS idx_memories_namespace ON memories (namespace)');
83
- } catch (e) { /* Index already exists */ }
84
-
85
- // --- Contradictions table ---
86
- db.exec(`
87
- CREATE TABLE IF NOT EXISTS contradictions (
88
- id INTEGER PRIMARY KEY AUTOINCREMENT,
89
- old_memory_id INTEGER NOT NULL,
90
- new_memory_id INTEGER NOT NULL,
91
- resolved_at INTEGER DEFAULT (unixepoch()),
92
- resolution_reason TEXT
93
- )
94
- `);
95
-
96
- // --- Provenance table ---
97
- db.exec(`
98
- CREATE TABLE IF NOT EXISTS provenance (
99
- id INTEGER PRIMARY KEY AUTOINCREMENT,
100
- memory_id INTEGER NOT NULL,
101
- source_type TEXT NOT NULL, -- agent | git | manual | api
102
- source_id TEXT, -- agent name or git hash
103
- created_at INTEGER DEFAULT (unixepoch()),
104
- confidence REAL NOT NULL
105
- )
106
- `);
107
-
108
- // --- Agent Stats table ---
109
- db.exec(`
110
- CREATE TABLE IF NOT EXISTS agent_stats (
111
- agent_id TEXT PRIMARY KEY,
112
- memories_created INTEGER DEFAULT 0,
113
- memories_confirmed INTEGER DEFAULT 0,
114
- memories_contradicted INTEGER DEFAULT 0,
115
- reputation_score REAL DEFAULT 1.0,
116
- last_active INTEGER DEFAULT (unixepoch())
117
- )
118
- `);
119
-
120
- // --- Migration: add domain column to agent_stats ---
121
- try {
122
- db.exec('ALTER TABLE agent_stats ADD COLUMN domain TEXT DEFAULT "general"');
123
- } catch (e) { /* Column already exists */ }
124
-
125
- // --- Attestations table ---
126
- db.exec(`
127
- CREATE TABLE IF NOT EXISTS attestations (
128
- id INTEGER PRIMARY KEY AUTOINCREMENT,
129
- attestation_id TEXT NOT NULL UNIQUE,
130
- query TEXT NOT NULL,
131
- timestamp TEXT NOT NULL,
132
- memories_retrieved TEXT NOT NULL,
133
- agent_id TEXT,
134
- session_id TEXT,
135
- signature TEXT NOT NULL,
136
- previous_hash TEXT,
137
- hash TEXT NOT NULL
138
- )
139
- `);
140
-
141
- // --- FTS5 full-text search index (keyword search with BM25) ---
142
- db.exec(`
143
- CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
144
- content,
145
- content='memories',
146
- content_rowid='id'
147
- )
148
- `);
149
-
150
- // --- FTS5 auto-sync triggers ---
151
- try {
152
- db.exec(`
153
- CREATE TRIGGER memories_fts_insert AFTER INSERT ON memories
154
- BEGIN
155
- INSERT INTO memories_fts(rowid, content) VALUES (new.id, new.content);
156
- END
157
- `);
158
- } catch (e) { /* trigger already exists */ }
159
-
160
- try {
161
- db.exec(`
162
- CREATE TRIGGER memories_fts_delete AFTER DELETE ON memories
163
- BEGIN
164
- INSERT INTO memories_fts(memories_fts, rowid, content)
165
- VALUES ('delete', old.id, old.content);
166
- END
167
- `);
168
- } catch (e) { /* trigger already exists */ }
169
-
170
- try {
171
- db.exec(`
172
- CREATE TRIGGER memories_fts_update AFTER UPDATE OF content ON memories
173
- BEGIN
174
- INSERT INTO memories_fts(memories_fts, rowid, content)
175
- VALUES ('delete', old.id, old.content);
176
- INSERT INTO memories_fts(rowid, content)
177
- VALUES (new.id, new.content);
178
- END
179
- `);
180
- } catch (e) { /* trigger already exists */ }
181
-
182
- // --- Vector table for semantic search (384-dim embeddings) ---
183
- db.exec(`
184
- CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0(
185
- embedding float[384]
186
- )
187
- `);
188
-
189
- // --- Knowledge Graph: entities + edges ---
190
- db.exec(`
191
- CREATE TABLE IF NOT EXISTS entities (
192
- id INTEGER PRIMARY KEY,
193
- name TEXT NOT NULL UNIQUE,
194
- type TEXT NOT NULL,
195
- created_at INTEGER DEFAULT (unixepoch())
196
- )
197
- `);
198
-
199
- db.exec(`
200
- CREATE TABLE IF NOT EXISTS edges (
201
- id INTEGER PRIMARY KEY,
202
- source_id INTEGER NOT NULL,
203
- target_id INTEGER NOT NULL,
204
- relation TEXT NOT NULL,
205
- source_type TEXT NOT NULL,
206
- target_type TEXT NOT NULL,
207
- created_at INTEGER DEFAULT (unixepoch())
208
- )
209
- `);
210
-
211
- console.error('[persyst] Schema initialized ✓');
212
-
213
- // ============================================================
214
- // PREPARED STATEMENTS
215
- // Pre-compile SQL for performance. better-sqlite3 is synchronous.
216
- // ============================================================
217
-
218
- const stmts = {
219
- // -- Insert --
220
- insertMemory: db.prepare(
221
- 'INSERT INTO memories (content, importance_score, namespace) VALUES (?, ?, ?)'
222
- ),
223
- insertVec: db.prepare(
224
- 'INSERT INTO memories_vec (rowid, embedding) VALUES (?, ?)'
225
- ),
226
- insertProvenance: db.prepare(
227
- 'INSERT INTO provenance (memory_id, source_type, source_id, confidence) VALUES (?, ?, ?, ?)'
228
- ),
229
- insertContradiction: db.prepare(
230
- 'INSERT INTO contradictions (old_memory_id, new_memory_id, resolution_reason) VALUES (?, ?, ?)'
231
- ),
232
- upsertAgent: db.prepare(`
233
- INSERT INTO agent_stats (agent_id) VALUES (?)
234
- ON CONFLICT(agent_id) DO UPDATE SET last_active = unixepoch()
235
- `),
236
- incrementCreated: db.prepare(
237
- 'UPDATE agent_stats SET memories_created = memories_created + 1 WHERE agent_id = ?'
238
- ),
239
- incrementConfirmed: db.prepare(
240
- 'UPDATE agent_stats SET memories_confirmed = memories_confirmed + 1 WHERE agent_id = ?'
241
- ),
242
- incrementContradicted: db.prepare(
243
- 'UPDATE agent_stats SET memories_contradicted = memories_contradicted + 1 WHERE agent_id = ?'
244
- ),
245
- recalculateReputation: db.prepare(
246
- 'UPDATE agent_stats SET reputation_score = (memories_confirmed + 1.0) / (memories_contradicted + 1.0) WHERE agent_id = ?'
247
- ),
248
- insertAttestation: db.prepare(`
249
- INSERT INTO attestations (
250
- attestation_id, query, timestamp, memories_retrieved,
251
- agent_id, session_id, signature, previous_hash, hash
252
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
253
- `),
254
-
255
- // -- Read --
256
- getById: db.prepare(
257
- 'SELECT * FROM memories WHERE id = ? AND valid_until IS NULL'
258
- ),
259
- getByIdNs: db.prepare(
260
- "SELECT * FROM memories WHERE id = ? AND (namespace = ? OR namespace = 'shared') AND valid_until IS NULL"
261
- ),
262
- getAnyById: db.prepare(
263
- 'SELECT * FROM memories WHERE id = ?'
264
- ),
265
- getRecent: db.prepare(
266
- 'SELECT * FROM memories WHERE valid_until IS NULL ORDER BY created_at DESC LIMIT ?'
267
- ),
268
- getRecentNs: db.prepare(
269
- "SELECT * FROM memories WHERE (namespace = ? OR namespace = 'shared') AND valid_until IS NULL ORDER BY created_at DESC LIMIT ?"
270
- ),
271
- getImportant: db.prepare(
272
- 'SELECT * FROM memories WHERE valid_until IS NULL ORDER BY importance_score DESC LIMIT ?'
273
- ),
274
- getImportantNs: db.prepare(
275
- "SELECT * FROM memories WHERE (namespace = ? OR namespace = 'shared') AND valid_until IS NULL ORDER BY importance_score DESC LIMIT ?"
276
- ),
277
- getProvenance: db.prepare(
278
- 'SELECT * FROM provenance WHERE memory_id = ?'
279
- ),
280
- getAllAgentStats: db.prepare(
281
- 'SELECT * FROM agent_stats ORDER BY reputation_score DESC'
282
- ),
283
- getAttestation: db.prepare(
284
- 'SELECT * FROM attestations WHERE attestation_id = ?'
285
- ),
286
- getLastAttestation: db.prepare(
287
- 'SELECT * FROM attestations ORDER BY id DESC LIMIT 1'
288
- ),
289
- getAttestationsByDate: db.prepare(
290
- 'SELECT * FROM attestations WHERE timestamp >= ? AND timestamp <= ? ORDER BY id ASC'
291
- ),
292
-
293
- // -- Update --
294
- updateContent: db.prepare(
295
- 'UPDATE memories SET content = ? WHERE id = ?'
296
- ),
297
- archiveMemory: db.prepare(
298
- 'UPDATE memories SET valid_until = unixepoch() WHERE id = ?'
299
- ),
300
-
301
- // -- Delete --
302
- deleteMemory: db.prepare(
303
- 'DELETE FROM memories WHERE id = ?'
304
- ),
305
- deleteVec: db.prepare(
306
- 'DELETE FROM memories_vec WHERE rowid = ?'
307
- ),
308
-
309
- // -- Memory Lifecycle --
310
- boost: db.prepare(`
311
- UPDATE memories
312
- SET access_count = access_count + 1,
313
- importance_score = MIN(importance_score + 0.1, 2.0),
314
- last_accessed = unixepoch()
315
- WHERE id = ?
316
- `),
317
- decay: db.prepare(`
318
- UPDATE memories
319
- SET importance_score = importance_score * 0.95
320
- WHERE (? - last_accessed) > 604800
321
- `),
322
-
323
- // -- Search --
324
- searchFts: db.prepare(`
325
- SELECT rowid AS id, rank
326
- FROM memories_fts
327
- WHERE memories_fts MATCH ?
328
- ORDER BY rank
329
- LIMIT ?
330
- `),
331
- searchVec: db.prepare(`
332
- SELECT rowid, distance
333
- FROM memories_vec
334
- WHERE embedding MATCH ?
335
- AND k = ?
336
- `),
337
-
338
- // -- Entity CRUD --
339
- insertEntity: db.prepare(
340
- 'INSERT OR IGNORE INTO entities (name, type) VALUES (?, ?)'
341
- ),
342
- getEntityByName: db.prepare(
343
- 'SELECT * FROM entities WHERE name = ?'
344
- ),
345
- getEntityById: db.prepare(
346
- 'SELECT * FROM entities WHERE id = ?'
347
- ),
348
- getAllEntities: db.prepare(
349
- 'SELECT * FROM entities ORDER BY created_at DESC LIMIT ?'
350
- ),
351
- deleteEntity: db.prepare(
352
- 'DELETE FROM entities WHERE id = ?'
353
- ),
354
-
355
- // -- Edges --
356
- insertEdge: db.prepare(
357
- 'INSERT INTO edges (source_id, target_id, relation, source_type, target_type) VALUES (?, ?, ?, ?, ?)'
358
- ),
359
- getEdgesBySource: db.prepare(
360
- 'SELECT * FROM edges WHERE source_id = ? AND source_type = ?'
361
- ),
362
- getEdgesByTarget: db.prepare(
363
- 'SELECT * FROM edges WHERE target_id = ? AND target_type = ?'
364
- ),
365
- deleteEdgesByMemory: db.prepare(
366
- `DELETE FROM edges WHERE
367
- (source_id = ? AND source_type = 'memory') OR
368
- (target_id = ? AND target_type = 'memory')`
369
- ),
370
-
371
- // -- Dedup --
372
- findMemoryByContent: db.prepare(
373
- 'SELECT id FROM memories WHERE content = ? AND valid_until IS NULL LIMIT 1'
374
- ),
375
- findMemoryByContentNs: db.prepare(
376
- "SELECT id FROM memories WHERE content = ? AND (namespace = ? OR namespace = 'shared') AND valid_until IS NULL LIMIT 1"
377
- ),
378
-
379
- // -- Hash-prefix lookup for git dedup (Bug 1 fix) --
380
- findMemoryByHashPrefix: db.prepare(
381
- 'SELECT id FROM memories WHERE content LIKE ? AND valid_until IS NULL LIMIT 1'
382
- ),
383
-
384
- // -- Active memory count --
385
- getActiveMemoryCount: db.prepare(
386
- 'SELECT COUNT(*) as count FROM memories WHERE valid_until IS NULL'
387
- ),
388
- getActiveMemoryCountNs: db.prepare(
389
- "SELECT COUNT(*) as count FROM memories WHERE (namespace = ? OR namespace = 'shared') AND valid_until IS NULL"
390
- ),
391
-
392
- // -- Namespace stats --
393
- getNamespaceStats: db.prepare(
394
- 'SELECT namespace, COUNT(*) as count FROM memories WHERE valid_until IS NULL GROUP BY namespace ORDER BY count DESC'
395
- ),
396
-
397
- // -- Memory History Chain (Feature 6: prepared statements) --
398
- getContradictionAncestors: db.prepare(
399
- 'SELECT old_memory_id FROM contradictions WHERE new_memory_id = ?'
400
- ),
401
- getContradictionDescendants: db.prepare(
402
- 'SELECT new_memory_id FROM contradictions WHERE old_memory_id = ?'
403
- )
404
- };
405
-
406
- // ============================================================
407
- // CRUD FUNCTIONS
408
- // Simple, one-purpose functions. No magic.
409
- // ============================================================
410
-
411
- /**
412
- * Insert a new memory into the memories table and log its provenance.
413
- * @param {string} content - Memory content
414
- * @param {number} importance - Importance score (0-1)
415
- * @param {Object} provenanceInfo - Provenance metadata
416
- * @param {string} namespace - Namespace for agent isolation (default: 'shared')
417
- * @returns {number} The new memory's ID
418
- */
419
- export function insertMemory(content, importance = 1.0, provenanceInfo = null, namespace = 'shared') {
420
- const result = stmts.insertMemory.run(content, importance, namespace || 'shared');
421
- const id = Number(result.lastInsertRowid);
422
-
423
- // Provenance Info handling
424
- const source_type = provenanceInfo?.source_type || 'manual';
425
- const source_id = provenanceInfo?.source_id || null;
426
- const confidence = provenanceInfo?.confidence !== undefined ? provenanceInfo.confidence : 1.0;
427
-
428
- stmts.insertProvenance.run(id, source_type, source_id, confidence);
429
-
430
- // Agent Stats handling
431
- if (source_type === 'agent' && source_id) {
432
- incrementAgentStat(source_id, 'created');
433
- }
434
-
435
- return id;
436
- }
437
-
438
- /**
439
- * Store an embedding vector for a memory.
440
- * @param {number} id - Memory ID (used as rowid in vec table)
441
- * @param {Float32Array} embedding - 384-dim embedding vector
442
- */
443
- export function insertVector(id, embedding) {
444
- stmts.insertVec.run(BigInt(id), Buffer.from(embedding.buffer));
445
- }
446
-
447
- /**
448
- * Get a memory by ID. Boosts its importance on access.
449
- * @param {number} id - Memory ID
450
- * @param {string|null} namespace - Namespace filter (null = no filter)
451
- * @returns {object|null} The memory row, or null if not found
452
- */
453
- export function getMemory(id, namespace = null) {
454
- const memory = namespace
455
- ? stmts.getByIdNs.get(id, namespace)
456
- : stmts.getById.get(id);
457
- if (memory) {
458
- boostMemory(id);
459
- const prov = getProvenance(id);
460
- memory.provenance = prov;
461
- }
462
- return memory || null;
463
- }
464
-
465
- /**
466
- * Get a memory by ID WITHOUT boosting or checking bi-temporal validity.
467
- * @returns {object|null} The memory row, or null if not found
468
- */
469
- export function getAnyMemoryById(id) {
470
- const memory = stmts.getAnyById.get(id);
471
- if (memory) {
472
- memory.provenance = getProvenance(id);
473
- }
474
- return memory || null;
475
- }
476
-
477
- /**
478
- * Get a memory by ID WITHOUT boosting. Used internally for search results.
479
- * @param {number} id - Memory ID
480
- * @param {string|null} namespace - Namespace filter (null = no filter)
481
- * @returns {object|null} The memory row, or null if not found
482
- */
483
- export function getMemoryById(id, namespace = null) {
484
- const memory = namespace
485
- ? stmts.getByIdNs.get(id, namespace)
486
- : stmts.getById.get(id);
487
- if (memory) {
488
- memory.provenance = getProvenance(id);
489
- }
490
- return memory || null;
491
- }
492
-
493
- /**
494
- * Update a memory's content. FTS5 index auto-updates via trigger.
495
- * Caller must also update the vector embedding separately.
496
- * @returns {boolean} true if the memory existed and was updated
497
- */
498
- export function updateMemoryContent(id, content) {
499
- const result = stmts.updateContent.run(content, id);
500
- return result.changes > 0;
501
- }
502
-
503
- /**
504
- * Delete a vector embedding by memory ID.
505
- */
506
- export function deleteVec(id) {
507
- try { stmts.deleteVec.run(BigInt(id)); } catch (e) { /* may not exist */ }
508
- }
509
-
510
- /**
511
- * Delete a memory, its vector embedding, and all associated graph edges.
512
- * FTS5 index auto-updates via trigger.
513
- * @returns {boolean} true if the memory existed and was deleted
514
- */
515
- export function deleteMemory(id) {
516
- stmts.deleteEdgesByMemory.run(id, id);
517
- deleteVec(id); // Remove vector first (no cascades on virtual tables)
518
- const result = stmts.deleteMemory.run(id);
519
- return result.changes > 0;
520
- }
521
-
522
- /**
523
- * Get the N most recently created memories.
524
- * @param {number} limit - Max results
525
- * @param {string|null} namespace - Namespace filter (null = all)
526
- */
527
- export function getRecentMemories(limit = 10, namespace = null) {
528
- const rows = namespace
529
- ? stmts.getRecentNs.all(namespace, limit)
530
- : stmts.getRecent.all(limit);
531
- rows.forEach(r => {
532
- r.provenance = getProvenance(r.id);
533
- });
534
- return rows;
535
- }
536
-
537
- /**
538
- * Get the N most important memories (by importance_score).
539
- * @param {number} limit - Max results
540
- * @param {string|null} namespace - Namespace filter (null = all)
541
- */
542
- export function getImportantMemories(limit = 10, namespace = null) {
543
- const rows = namespace
544
- ? stmts.getImportantNs.all(namespace, limit)
545
- : stmts.getImportant.all(limit);
546
- rows.forEach(r => {
547
- r.provenance = getProvenance(r.id);
548
- });
549
- return rows;
550
- }
551
-
552
- // ============================================================
553
- // MEMORY LIFECYCLE
554
- // ============================================================
555
-
556
- /**
557
- * Boost a memory's importance when it's accessed.
558
- * Increments access_count, adds 0.1 to importance (max 2.0),
559
- * and updates last_accessed timestamp.
560
- */
561
- export function boostMemory(id) {
562
- stmts.boost.run(id);
563
- }
564
-
565
- /**
566
- * Apply temporal decay to old memories.
567
- * Reduces importance by 5% for memories not accessed in 7+ days.
568
- * Called automatically every hour by the server.
569
- */
570
- export function applyTemporalDecay() {
571
- const now = Math.floor(Date.now() / 1000);
572
- const result = stmts.decay.run(now);
573
- if (result.changes > 0) {
574
- console.error(`[persyst] Decay applied to ${result.changes} memories`);
575
- }
576
- }
577
-
578
- // ============================================================
579
- // SEARCH HELPERS (used by search.js)
580
- // ============================================================
581
-
582
- /**
583
- * Keyword search using FTS5 with BM25 ranking.
584
- * @returns {Array<{id: number, rank: number}>}
585
- */
586
- export function searchKeyword(query, limit = 10) {
587
- try {
588
- return stmts.searchFts.all(query, limit);
589
- } catch (e) {
590
- // FTS5 can throw on special characters in query
591
- return [];
592
- }
593
- }
594
-
595
- /**
596
- * Vector similarity search using sqlite-vec KNN.
597
- * @param {Float32Array} embedding - Query vector (384-dim)
598
- * @returns {Array<{rowid: number, distance: number}>}
599
- */
600
- export function searchVector(embedding, limit = 10) {
601
- return stmts.searchVec.all(Buffer.from(embedding.buffer), limit);
602
- }
603
-
604
- // ============================================================
605
- // ENTITY FUNCTIONS (Knowledge Graph)
606
- // ============================================================
607
-
608
- /**
609
- * Create a named entity (person, tech, project, concept, file).
610
- * Silently skips if entity with that name already exists.
611
- * @returns {number|null} The entity ID, or null if already existed
612
- */
613
- export function insertEntity(name, type) {
614
- const result = stmts.insertEntity.run(name, type);
615
- if (result.changes === 0) {
616
- // Already exists — return existing ID
617
- const existing = stmts.getEntityByName.get(name);
618
- return existing ? existing.id : null;
619
- }
620
- return Number(result.lastInsertRowid);
621
- }
622
-
623
- /**
624
- * Get an entity by its name.
625
- */
626
- export function getEntityByName(name) {
627
- return stmts.getEntityByName.get(name) || null;
628
- }
629
-
630
- /**
631
- * Get an entity by its ID.
632
- */
633
- export function getEntityById(id) {
634
- return stmts.getEntityById.get(id) || null;
635
- }
636
-
637
- /**
638
- * Get all entities, most recent first.
639
- */
640
- export function getAllEntities(limit = 50) {
641
- return stmts.getAllEntities.all(limit);
642
- }
643
-
644
- /**
645
- * Delete an entity and its edges.
646
- */
647
- export function deleteEntity(id) {
648
- stmts.deleteEntity.run(id);
649
- }
650
-
651
- /**
652
- * Create an edge connecting two nodes (entity↔entity or entity↔memory).
653
- */
654
- export function insertEdge(sourceId, targetId, relation, sourceType, targetType) {
655
- stmts.insertEdge.run(sourceId, targetId, relation, sourceType, targetType);
656
- }
657
-
658
- /**
659
- * Get all memories linked to an entity.
660
- */
661
- export function getMemoriesByEntity(entityId) {
662
- // Find edges where this entity is the source pointing to memories
663
- const edges = stmts.getEdgesBySource.all(entityId, 'entity');
664
- const memoryEdges = edges.filter(e => e.target_type === 'memory');
665
- return memoryEdges.map(e => stmts.getById.get(e.target_id)).filter(Boolean);
666
- }
667
-
668
- /**
669
- * Check if a memory with exact content already exists.
670
- * Used for deduplication.
671
- * @param {string} content - Exact content to match
672
- * @param {string|null} namespace - Namespace filter (null = global dedup)
673
- * @returns {boolean}
674
- */
675
- export function memoryExists(content, namespace = null) {
676
- if (namespace) {
677
- return stmts.findMemoryByContentNs.get(content, namespace) !== undefined;
678
- }
679
- return stmts.findMemoryByContent.get(content) !== undefined;
680
- }
681
-
682
- /**
683
- * Check if a memory exists by hash prefix pattern (LIKE query).
684
- * Used for git commit deduplication where we match `[hashPrefix]%`.
685
- * @param {string} pattern - SQL LIKE pattern to match (e.g. '[abc1234]%')
686
- * @returns {boolean}
687
- */
688
- export function memoryExistsByHashPrefix(pattern) {
689
- return stmts.findMemoryByHashPrefix.get(pattern) !== undefined;
690
- }
691
-
692
- /**
693
- * Get count of active (non-archived) memories.
694
- * @param {string|null} namespace - Namespace filter (null = all)
695
- * @returns {number}
696
- */
697
- export function getActiveMemoryCount(namespace = null) {
698
- if (namespace) {
699
- return stmts.getActiveMemoryCountNs.get(namespace).count;
700
- }
701
- return stmts.getActiveMemoryCount.get().count;
702
- }
703
-
704
- /**
705
- * Get namespace breakdown stats.
706
- * @returns {Array<{namespace: string, count: number}>}
707
- */
708
- export function getNamespaceStats() {
709
- return stmts.getNamespaceStats.all();
710
- }
711
-
712
- // ============================================================
713
- // DEDUPLICATION BY EXACT CONTENT
714
- // ============================================================
715
-
716
- /**
717
- * Find memory by exact content.
718
- * @param {string} content
719
- * @param {string|null} namespace - Namespace filter (null = global)
720
- * @returns {object|null} The memory row, or null if not found
721
- */
722
- export function getMemoryByContent(content, namespace = null) {
723
- const row = namespace
724
- ? stmts.findMemoryByContentNs.get(content, namespace)
725
- : stmts.findMemoryByContent.get(content);
726
- return row ? getMemoryById(row.id) : null;
727
- }
728
-
729
- // ============================================================
730
- // TEMPORAL CONTRADICTIONS & AGENT STATS & ATTESTATIONS CRUD
731
- // ============================================================
732
-
733
- /**
734
- * Archive a memory and log the contradiction.
735
- */
736
- export function logContradiction(oldMemoryId, newMemoryId, reason = '') {
737
- stmts.archiveMemory.run(oldMemoryId);
738
- stmts.insertContradiction.run(oldMemoryId, newMemoryId, reason);
739
-
740
- // Track that the agent's memory was contradicted
741
- const oldProvenance = getProvenance(oldMemoryId);
742
- if (oldProvenance && oldProvenance.source_type === 'agent' && oldProvenance.source_id) {
743
- incrementAgentStat(oldProvenance.source_id, 'contradicted');
744
- }
745
- }
746
-
747
- /**
748
- * Get provenance for a memory.
749
- */
750
- export function getProvenance(memoryId) {
751
- return stmts.getProvenance.get(memoryId) || null;
752
- }
753
-
754
- /**
755
- * Update agent reputation counters.
756
- */
757
- export function incrementAgentStat(agentId, action) {
758
- stmts.upsertAgent.run(agentId);
759
- if (action === 'created') {
760
- stmts.incrementCreated.run(agentId);
761
- } else if (action === 'confirmed') {
762
- stmts.incrementConfirmed.run(agentId);
763
- } else if (action === 'contradicted') {
764
- stmts.incrementContradicted.run(agentId);
765
- }
766
- stmts.recalculateReputation.run(agentId);
767
- }
768
-
769
- /**
770
- * Get all agent stats.
771
- */
772
- export function getAllAgentStats() {
773
- return stmts.getAllAgentStats.all();
774
- }
775
-
776
- /**
777
- * Upsert agent signature / record attestation in database.
778
- */
779
- export function insertAttestation(att) {
780
- stmts.insertAttestation.run(
781
- att.attestation_id,
782
- att.query,
783
- att.timestamp,
784
- JSON.stringify(att.memories_retrieved),
785
- att.agent_id || null,
786
- att.session_id || null,
787
- att.signature,
788
- att.previous_hash || null,
789
- att.hash
790
- );
791
- }
792
-
793
- /**
794
- * Retrieve a specific attestation by ID.
795
- */
796
- export function getAttestationById(attestationId) {
797
- return stmts.getAttestation.get(attestationId) || null;
798
- }
799
-
800
- /**
801
- * Retrieve the last attestation logged for chaining.
802
- */
803
- export function getLastAttestation() {
804
- return stmts.getLastAttestation.get() || null;
805
- }
806
-
807
- /**
808
- * Retrieve attestations within a timestamp range.
809
- */
810
- export function getAttestationsByDateRange(startDate, endDate) {
811
- return stmts.getAttestationsByDate.all(startDate, endDate);
812
- }
813
-
814
- /**
815
- * Traverses contradictions to get historical versions of a memory.
816
- */
817
- export function getMemoryHistoryChain(memoryId) {
818
- const versions = new Set();
819
- const queue = [memoryId];
820
-
821
- while (queue.length > 0) {
822
- const currentId = queue.shift();
823
- if (versions.has(currentId)) continue;
824
- versions.add(currentId);
825
-
826
- // Find ancestors (replaced by current) — using prepared statement
827
- const ancestors = stmts.getContradictionAncestors.all(currentId);
828
- ancestors.forEach(a => {
829
- if (!versions.has(a.old_memory_id)) queue.push(a.old_memory_id);
830
- });
831
-
832
- // Find descendants (replaces current) — using prepared statement
833
- const descendants = stmts.getContradictionDescendants.all(currentId);
834
- descendants.forEach(d => {
835
- if (!versions.has(d.new_memory_id)) queue.push(d.new_memory_id);
836
- });
837
- }
838
-
839
- const ids = Array.from(versions);
840
- if (ids.length === 0) return [];
841
-
842
- const placeholders = ids.map(() => '?').join(',');
843
- const rows = db.prepare(`
844
- SELECT m.*, p.source_type, p.source_id, p.confidence
845
- FROM memories m
846
- LEFT JOIN provenance p ON m.id = p.memory_id
847
- WHERE m.id IN (${placeholders})
848
- ORDER BY m.created_at ASC
849
- `).all(...ids);
850
-
851
- return rows;
852
- }
853
-
854
- /**
855
- * Search all memories FTS (including archived memories).
856
- */
857
- export function searchAllMemoriesFts(queryText, limit = 10) {
858
- try {
859
- return stmts.searchFts.all(queryText, limit);
860
- } catch (e) {
861
- return [];
862
- }
863
- }
864
-
865
- // ============================================================
866
- // CLEANUP
867
- // ============================================================
868
-
869
- /**
870
- * Close the database connection. Call on shutdown.
871
- */
872
- export function closeDatabase() {
873
- db.close();
874
- console.error('[persyst] Database closed');
875
- }
876
-
877
- export default db;
1
+ /**
2
+ * database.js — SQLite Database Setup & CRUD Operations
3
+ *
4
+ * This file handles everything database-related:
5
+ * - Opens SQLite connection at ~/.persyst/persyst.db
6
+ * - Loads the sqlite-vec extension for vector search
7
+ * - Creates all tables (memories, FTS5 index, vector index)
8
+ * - Runs schema migrations for production-grade bi-temporal model
9
+ * - Exports simple CRUD functions for other modules to use
10
+ *
11
+ * IMPORTANT: better-sqlite3 is SYNCHRONOUS. No async/await here.
12
+ */
13
+
14
+ import Database from 'better-sqlite3';
15
+ import * as sqliteVec from 'sqlite-vec';
16
+ import { join } from 'path';
17
+ import { homedir } from 'os';
18
+ import { mkdirSync } from 'fs';
19
+
20
+ // ============================================================
21
+ // DATABASE LOCATION
22
+ // Store in ~/.persyst/ per default to persist across sessions
23
+ // ============================================================
24
+
25
+ const DB_DIR = join(homedir(), '.persyst');
26
+ mkdirSync(DB_DIR, { recursive: true });
27
+ const DB_PATH = process.env.NODE_ENV === 'test' ? ':memory:' : join(DB_DIR, 'persyst.db');
28
+
29
+ // ============================================================
30
+ // INITIALIZE CONNECTION
31
+ // ============================================================
32
+
33
+ const db = new Database(DB_PATH);
34
+ db.pragma('journal_mode = WAL'); // Better performance for concurrent reads
35
+ db.pragma('foreign_keys = ON'); // Enforce referential integrity
36
+ db.pragma('mmap_size = 268435456'); // 256MB memory-mapped I/O for faster reads
37
+
38
+ // Load sqlite-vec BEFORE creating any vec0 tables
39
+ sqliteVec.load(db);
40
+
41
+ console.error(`[persyst] Database: ${DB_PATH}`);
42
+
43
+ // ============================================================
44
+ // CREATE TABLES & SCHEMA MIGRATIONS
45
+ // ============================================================
46
+
47
+ // --- Main memories table ---
48
+ db.exec(`
49
+ CREATE TABLE IF NOT EXISTS memories (
50
+ id INTEGER PRIMARY KEY,
51
+ content TEXT NOT NULL,
52
+ importance_score REAL DEFAULT 1.0,
53
+ created_at INTEGER DEFAULT (unixepoch()),
54
+ last_accessed INTEGER DEFAULT (unixepoch()),
55
+ access_count INTEGER DEFAULT 0,
56
+ valid_from INTEGER DEFAULT (unixepoch()),
57
+ valid_until INTEGER DEFAULT NULL,
58
+ assertion_time INTEGER DEFAULT (unixepoch())
59
+ )
60
+ `);
61
+
62
+ // --- Migrations for bi-temporal validity on existing tables ---
63
+ try {
64
+ db.exec('ALTER TABLE memories ADD COLUMN valid_from INTEGER DEFAULT (unixepoch())');
65
+ } catch (e) { /* Column already exists */ }
66
+
67
+ try {
68
+ db.exec('ALTER TABLE memories ADD COLUMN valid_until INTEGER DEFAULT NULL');
69
+ } catch (e) { /* Column already exists */ }
70
+
71
+ try {
72
+ db.exec('ALTER TABLE memories ADD COLUMN assertion_time INTEGER DEFAULT (unixepoch())');
73
+ } catch (e) { /* Column already exists */ }
74
+
75
+ // --- Migration: add namespace column for per-agent isolation ---
76
+ try {
77
+ db.exec("ALTER TABLE memories ADD COLUMN namespace TEXT DEFAULT 'shared'");
78
+ } catch (e) { /* Column already exists */ }
79
+
80
+ // --- Migration: add parent_id column for history tracing ---
81
+ try {
82
+ db.exec('ALTER TABLE memories ADD COLUMN parent_id INTEGER DEFAULT NULL');
83
+ } catch (e) { /* Column already exists */ }
84
+
85
+ // --- Index on namespace for fast filtered queries ---
86
+ try {
87
+ db.exec('CREATE INDEX IF NOT EXISTS idx_memories_namespace ON memories (namespace)');
88
+ } catch (e) { /* Index already exists */ }
89
+
90
+ // --- Contradictions table ---
91
+ db.exec(`
92
+ CREATE TABLE IF NOT EXISTS contradictions (
93
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
94
+ old_memory_id INTEGER NOT NULL,
95
+ new_memory_id INTEGER NOT NULL,
96
+ resolved_at INTEGER DEFAULT (unixepoch()),
97
+ resolution_reason TEXT
98
+ )
99
+ `);
100
+
101
+ // --- Provenance table ---
102
+ db.exec(`
103
+ CREATE TABLE IF NOT EXISTS provenance (
104
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
105
+ memory_id INTEGER NOT NULL,
106
+ source_type TEXT NOT NULL, -- agent | git | manual | api
107
+ source_id TEXT, -- agent name or git hash
108
+ created_at INTEGER DEFAULT (unixepoch()),
109
+ confidence REAL NOT NULL
110
+ )
111
+ `);
112
+
113
+ // --- Agent Stats table ---
114
+ db.exec(`
115
+ CREATE TABLE IF NOT EXISTS agent_stats (
116
+ agent_id TEXT PRIMARY KEY,
117
+ memories_created INTEGER DEFAULT 0,
118
+ memories_confirmed INTEGER DEFAULT 0,
119
+ memories_contradicted INTEGER DEFAULT 0,
120
+ reputation_score REAL DEFAULT 1.0,
121
+ last_active INTEGER DEFAULT (unixepoch())
122
+ )
123
+ `);
124
+
125
+ // --- Migration: add domain column to agent_stats ---
126
+ try {
127
+ db.exec('ALTER TABLE agent_stats ADD COLUMN domain TEXT DEFAULT "general"');
128
+ } catch (e) { /* Column already exists */ }
129
+
130
+ // --- Attestations table ---
131
+ db.exec(`
132
+ CREATE TABLE IF NOT EXISTS attestations (
133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
134
+ attestation_id TEXT NOT NULL UNIQUE,
135
+ query TEXT NOT NULL,
136
+ timestamp TEXT NOT NULL,
137
+ memories_retrieved TEXT NOT NULL,
138
+ agent_id TEXT,
139
+ session_id TEXT,
140
+ signature TEXT NOT NULL,
141
+ previous_hash TEXT,
142
+ hash TEXT NOT NULL
143
+ )
144
+ `);
145
+
146
+ // --- FTS5 full-text search index (keyword search with BM25) ---
147
+ db.exec(`
148
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
149
+ content,
150
+ content='memories',
151
+ content_rowid='id'
152
+ )
153
+ `);
154
+
155
+ // --- FTS5 auto-sync triggers ---
156
+ try {
157
+ db.exec(`
158
+ CREATE TRIGGER memories_fts_insert AFTER INSERT ON memories
159
+ BEGIN
160
+ INSERT INTO memories_fts(rowid, content) VALUES (new.id, new.content);
161
+ END
162
+ `);
163
+ } catch (e) { /* trigger already exists */ }
164
+
165
+ try {
166
+ db.exec(`
167
+ CREATE TRIGGER memories_fts_delete AFTER DELETE ON memories
168
+ BEGIN
169
+ INSERT INTO memories_fts(memories_fts, rowid, content)
170
+ VALUES ('delete', old.id, old.content);
171
+ END
172
+ `);
173
+ } catch (e) { /* trigger already exists */ }
174
+
175
+ try {
176
+ db.exec(`
177
+ CREATE TRIGGER memories_fts_update AFTER UPDATE OF content ON memories
178
+ BEGIN
179
+ INSERT INTO memories_fts(memories_fts, rowid, content)
180
+ VALUES ('delete', old.id, old.content);
181
+ INSERT INTO memories_fts(rowid, content)
182
+ VALUES (new.id, new.content);
183
+ END
184
+ `);
185
+ } catch (e) { /* trigger already exists */ }
186
+
187
+ // --- Vector table for semantic search (384-dim embeddings) ---
188
+ db.exec(`
189
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0(
190
+ embedding float[384]
191
+ )
192
+ `);
193
+
194
+ // --- Knowledge Graph: entities + edges ---
195
+ db.exec(`
196
+ CREATE TABLE IF NOT EXISTS entities (
197
+ id INTEGER PRIMARY KEY,
198
+ name TEXT NOT NULL UNIQUE,
199
+ type TEXT NOT NULL,
200
+ created_at INTEGER DEFAULT (unixepoch())
201
+ )
202
+ `);
203
+
204
+ db.exec(`
205
+ CREATE TABLE IF NOT EXISTS edges (
206
+ id INTEGER PRIMARY KEY,
207
+ source_id INTEGER NOT NULL,
208
+ target_id INTEGER NOT NULL,
209
+ relation TEXT NOT NULL,
210
+ source_type TEXT NOT NULL,
211
+ target_type TEXT NOT NULL,
212
+ created_at INTEGER DEFAULT (unixepoch())
213
+ )
214
+ `);
215
+
216
+ db.exec(`
217
+ CREATE TABLE IF NOT EXISTS watched_files (
218
+ file_path TEXT PRIMARY KEY,
219
+ last_position INTEGER NOT NULL,
220
+ updated_at INTEGER DEFAULT (unixepoch())
221
+ )
222
+ `);
223
+
224
+ console.error('[persyst] Schema initialized ✓');
225
+
226
+ // ============================================================
227
+ // PREPARED STATEMENTS
228
+ // Pre-compile SQL for performance. better-sqlite3 is synchronous.
229
+ // ============================================================
230
+
231
+ const stmts = {
232
+ // -- Insert --
233
+ insertMemory: db.prepare(
234
+ 'INSERT INTO memories (content, importance_score, namespace, parent_id) VALUES (?, ?, ?, ?)'
235
+ ),
236
+ insertVec: db.prepare(
237
+ 'INSERT INTO memories_vec (rowid, embedding) VALUES (?, ?)'
238
+ ),
239
+ insertProvenance: db.prepare(
240
+ 'INSERT INTO provenance (memory_id, source_type, source_id, confidence) VALUES (?, ?, ?, ?)'
241
+ ),
242
+ insertContradiction: db.prepare(
243
+ 'INSERT INTO contradictions (old_memory_id, new_memory_id, resolution_reason) VALUES (?, ?, ?)'
244
+ ),
245
+ upsertAgent: db.prepare(`
246
+ INSERT INTO agent_stats (agent_id) VALUES (?)
247
+ ON CONFLICT(agent_id) DO UPDATE SET last_active = unixepoch()
248
+ `),
249
+ incrementCreated: db.prepare(
250
+ 'UPDATE agent_stats SET memories_created = memories_created + 1 WHERE agent_id = ?'
251
+ ),
252
+ incrementConfirmed: db.prepare(
253
+ 'UPDATE agent_stats SET memories_confirmed = memories_confirmed + 1 WHERE agent_id = ?'
254
+ ),
255
+ incrementContradicted: db.prepare(
256
+ 'UPDATE agent_stats SET memories_contradicted = memories_contradicted + 1 WHERE agent_id = ?'
257
+ ),
258
+ recalculateReputation: db.prepare(
259
+ 'UPDATE agent_stats SET reputation_score = (memories_confirmed + 1.0) / (memories_contradicted + 1.0) WHERE agent_id = ?'
260
+ ),
261
+ insertAttestation: db.prepare(`
262
+ INSERT INTO attestations (
263
+ attestation_id, query, timestamp, memories_retrieved,
264
+ agent_id, session_id, signature, previous_hash, hash
265
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
266
+ `),
267
+
268
+ // -- Read --
269
+ getById: db.prepare(
270
+ 'SELECT * FROM memories WHERE id = ? AND valid_until IS NULL'
271
+ ),
272
+ getByIdNs: db.prepare(
273
+ "SELECT * FROM memories WHERE id = ? AND (namespace = ? OR namespace = 'shared') AND valid_until IS NULL"
274
+ ),
275
+ getAnyById: db.prepare(
276
+ 'SELECT * FROM memories WHERE id = ?'
277
+ ),
278
+ getRecent: db.prepare(
279
+ 'SELECT * FROM memories WHERE valid_until IS NULL ORDER BY created_at DESC LIMIT ?'
280
+ ),
281
+ getRecentNs: db.prepare(
282
+ "SELECT * FROM memories WHERE (namespace = ? OR namespace = 'shared') AND valid_until IS NULL ORDER BY created_at DESC LIMIT ?"
283
+ ),
284
+ getImportant: db.prepare(
285
+ 'SELECT * FROM memories WHERE valid_until IS NULL ORDER BY importance_score DESC LIMIT ?'
286
+ ),
287
+ getImportantNs: db.prepare(
288
+ "SELECT * FROM memories WHERE (namespace = ? OR namespace = 'shared') AND valid_until IS NULL ORDER BY importance_score DESC LIMIT ?"
289
+ ),
290
+ getProvenance: db.prepare(
291
+ 'SELECT * FROM provenance WHERE memory_id = ?'
292
+ ),
293
+ getAllAgentStats: db.prepare(
294
+ 'SELECT * FROM agent_stats ORDER BY reputation_score DESC'
295
+ ),
296
+ getAttestation: db.prepare(
297
+ 'SELECT * FROM attestations WHERE attestation_id = ?'
298
+ ),
299
+ getLastAttestation: db.prepare(
300
+ 'SELECT * FROM attestations ORDER BY id DESC LIMIT 1'
301
+ ),
302
+ getAttestationsByDate: db.prepare(
303
+ 'SELECT * FROM attestations WHERE timestamp >= ? AND timestamp <= ? ORDER BY id ASC'
304
+ ),
305
+
306
+ // -- Update --
307
+ updateContent: db.prepare(
308
+ 'UPDATE memories SET content = ? WHERE id = ?'
309
+ ),
310
+ archiveMemory: db.prepare(
311
+ 'UPDATE memories SET valid_until = unixepoch() WHERE id = ?'
312
+ ),
313
+
314
+ // -- Delete --
315
+ deleteMemory: db.prepare(
316
+ 'DELETE FROM memories WHERE id = ?'
317
+ ),
318
+ deleteVec: db.prepare(
319
+ 'DELETE FROM memories_vec WHERE rowid = ?'
320
+ ),
321
+
322
+ // -- Memory Lifecycle --
323
+ boost: db.prepare(`
324
+ UPDATE memories
325
+ SET access_count = access_count + 1,
326
+ importance_score = MIN(importance_score + 0.1, 2.0),
327
+ last_accessed = unixepoch()
328
+ WHERE id = ?
329
+ `),
330
+ decay: db.prepare(`
331
+ UPDATE memories
332
+ SET importance_score = importance_score * 0.95
333
+ WHERE (? - last_accessed) > 604800
334
+ `),
335
+
336
+ // -- Search --
337
+ searchFts: db.prepare(`
338
+ SELECT rowid AS id, rank
339
+ FROM memories_fts
340
+ WHERE memories_fts MATCH ?
341
+ ORDER BY rank
342
+ LIMIT ?
343
+ `),
344
+ searchVec: db.prepare(`
345
+ SELECT rowid, distance
346
+ FROM memories_vec
347
+ WHERE embedding MATCH ?
348
+ AND k = ?
349
+ `),
350
+
351
+ // -- Entity CRUD --
352
+ insertEntity: db.prepare(
353
+ 'INSERT OR IGNORE INTO entities (name, type) VALUES (?, ?)'
354
+ ),
355
+ getEntityByName: db.prepare(
356
+ 'SELECT * FROM entities WHERE name = ?'
357
+ ),
358
+ getEntityById: db.prepare(
359
+ 'SELECT * FROM entities WHERE id = ?'
360
+ ),
361
+ getAllEntities: db.prepare(
362
+ 'SELECT * FROM entities ORDER BY created_at DESC LIMIT ?'
363
+ ),
364
+ deleteEntity: db.prepare(
365
+ 'DELETE FROM entities WHERE id = ?'
366
+ ),
367
+
368
+ // -- Edges --
369
+ insertEdge: db.prepare(
370
+ 'INSERT INTO edges (source_id, target_id, relation, source_type, target_type) VALUES (?, ?, ?, ?, ?)'
371
+ ),
372
+ getEdgesBySource: db.prepare(
373
+ 'SELECT * FROM edges WHERE source_id = ? AND source_type = ?'
374
+ ),
375
+ getEdgesByTarget: db.prepare(
376
+ 'SELECT * FROM edges WHERE target_id = ? AND target_type = ?'
377
+ ),
378
+ deleteEdgesByMemory: db.prepare(
379
+ `DELETE FROM edges WHERE
380
+ (source_id = ? AND source_type = 'memory') OR
381
+ (target_id = ? AND target_type = 'memory')`
382
+ ),
383
+
384
+ // -- Dedup --
385
+ findMemoryByContent: db.prepare(
386
+ 'SELECT id FROM memories WHERE content = ? AND valid_until IS NULL LIMIT 1'
387
+ ),
388
+ findMemoryByContentNs: db.prepare(
389
+ "SELECT id FROM memories WHERE content = ? AND (namespace = ? OR namespace = 'shared') AND valid_until IS NULL LIMIT 1"
390
+ ),
391
+
392
+ // -- Hash-prefix lookup for git dedup (Bug 1 fix) --
393
+ findMemoryByHashPrefix: db.prepare(
394
+ 'SELECT id FROM memories WHERE content LIKE ? AND valid_until IS NULL LIMIT 1'
395
+ ),
396
+
397
+ // -- Active memory count --
398
+ getActiveMemoryCount: db.prepare(
399
+ 'SELECT COUNT(*) as count FROM memories WHERE valid_until IS NULL'
400
+ ),
401
+ getActiveMemoryCountNs: db.prepare(
402
+ "SELECT COUNT(*) as count FROM memories WHERE (namespace = ? OR namespace = 'shared') AND valid_until IS NULL"
403
+ ),
404
+
405
+ // -- Namespace stats --
406
+ getNamespaceStats: db.prepare(
407
+ 'SELECT namespace, COUNT(*) as count FROM memories WHERE valid_until IS NULL GROUP BY namespace ORDER BY count DESC'
408
+ ),
409
+
410
+ // -- Memory History Chain (Feature 6: prepared statements) --
411
+ getContradictionAncestors: db.prepare(
412
+ 'SELECT old_memory_id FROM contradictions WHERE new_memory_id = ?'
413
+ ),
414
+ getContradictionDescendants: db.prepare(
415
+ 'SELECT new_memory_id FROM contradictions WHERE old_memory_id = ?'
416
+ ),
417
+
418
+ // -- Watcher Offsets --
419
+ getWatchPosition: db.prepare(
420
+ 'SELECT last_position FROM watched_files WHERE file_path = ?'
421
+ ),
422
+ upsertWatchPosition: db.prepare(`
423
+ INSERT INTO watched_files (file_path, last_position)
424
+ VALUES (?, ?)
425
+ ON CONFLICT(file_path) DO UPDATE SET last_position = excluded.last_position, updated_at = unixepoch()
426
+ `)
427
+ };
428
+
429
+ // ============================================================
430
+ // CRUD FUNCTIONS
431
+ // Simple, one-purpose functions. No magic.
432
+ // ============================================================
433
+
434
+ /**
435
+ * Insert a new memory into the memories table and log its provenance.
436
+ * @param {string} content - Memory content
437
+ * @param {number} importance - Importance score (0-1)
438
+ * @param {Object} provenanceInfo - Provenance metadata
439
+ * @param {string} namespace - Namespace for agent isolation (default: 'shared')
440
+ * @returns {number} The new memory's ID
441
+ */
442
+ export function insertMemory(content, importance = 1.0, provenanceInfo = null, namespace = 'shared', parentId = null) {
443
+ if (content && content.length > 10000) {
444
+ throw new Error('Memory content exceeds maximum length of 10000 characters.');
445
+ }
446
+ const result = stmts.insertMemory.run(content, importance, namespace || 'shared', parentId);
447
+ const id = Number(result.lastInsertRowid);
448
+
449
+ // Provenance Info handling
450
+ const source_type = provenanceInfo?.source_type || 'manual';
451
+ let source_id = provenanceInfo?.source_id || null;
452
+ if (source_type === 'agent' && source_id) {
453
+ source_id = source_id.toLowerCase();
454
+ }
455
+ const confidence = provenanceInfo?.confidence !== undefined ? provenanceInfo.confidence : 1.0;
456
+
457
+ stmts.insertProvenance.run(id, source_type, source_id, confidence);
458
+
459
+ // Agent Stats handling
460
+ if (source_type === 'agent' && source_id) {
461
+ incrementAgentStat(source_id, 'created');
462
+ }
463
+
464
+ return id;
465
+ }
466
+
467
+ /**
468
+ * Store an embedding vector for a memory.
469
+ * @param {number} id - Memory ID (used as rowid in vec table)
470
+ * @param {Float32Array} embedding - 384-dim embedding vector
471
+ */
472
+ export function insertVector(id, embedding) {
473
+ stmts.insertVec.run(BigInt(id), Buffer.from(embedding.buffer));
474
+ }
475
+
476
+ /**
477
+ * Get a memory by ID. Boosts its importance on access.
478
+ * @param {number} id - Memory ID
479
+ * @param {string|null} namespace - Namespace filter (null = no filter)
480
+ * @returns {object|null} The memory row, or null if not found
481
+ */
482
+ export function getMemory(id, namespace = null) {
483
+ const memory = namespace
484
+ ? stmts.getByIdNs.get(id, namespace)
485
+ : stmts.getById.get(id);
486
+ if (memory) {
487
+ boostMemory(id);
488
+ const prov = getProvenance(id);
489
+ memory.provenance = prov;
490
+ }
491
+ return memory || null;
492
+ }
493
+
494
+ /**
495
+ * Get a memory by ID WITHOUT boosting or checking bi-temporal validity.
496
+ * @returns {object|null} The memory row, or null if not found
497
+ */
498
+ export function getAnyMemoryById(id) {
499
+ const memory = stmts.getAnyById.get(id);
500
+ if (memory) {
501
+ memory.provenance = getProvenance(id);
502
+ }
503
+ return memory || null;
504
+ }
505
+
506
+ /**
507
+ * Get a memory by ID WITHOUT boosting. Used internally for search results.
508
+ * @param {number} id - Memory ID
509
+ * @param {string|null} namespace - Namespace filter (null = no filter)
510
+ * @returns {object|null} The memory row, or null if not found
511
+ */
512
+ export function getMemoryById(id, namespace = null) {
513
+ const memory = namespace
514
+ ? stmts.getByIdNs.get(id, namespace)
515
+ : stmts.getById.get(id);
516
+ if (memory) {
517
+ memory.provenance = getProvenance(id);
518
+ }
519
+ return memory || null;
520
+ }
521
+
522
+ /**
523
+ * Update a memory's content. FTS5 index auto-updates via trigger.
524
+ * Caller must also update the vector embedding separately.
525
+ * @returns {boolean} true if the memory existed and was updated
526
+ */
527
+ export function updateMemoryContent(id, content) {
528
+ const result = stmts.updateContent.run(content, id);
529
+ return result.changes > 0;
530
+ }
531
+
532
+ /**
533
+ * Delete a vector embedding by memory ID.
534
+ */
535
+ export function deleteVec(id) {
536
+ try { stmts.deleteVec.run(BigInt(id)); } catch (e) { /* may not exist */ }
537
+ }
538
+
539
+ /**
540
+ * Delete a memory, its vector embedding, and all associated graph edges.
541
+ * FTS5 index auto-updates via trigger.
542
+ * @returns {boolean} true if the memory existed and was deleted
543
+ */
544
+ export function deleteMemory(id) {
545
+ stmts.deleteEdgesByMemory.run(id, id);
546
+ deleteVec(id); // Remove vector first (no cascades on virtual tables)
547
+ const result = stmts.deleteMemory.run(id);
548
+ return result.changes > 0;
549
+ }
550
+
551
+ /**
552
+ * Get the N most recently created memories.
553
+ * @param {number} limit - Max results
554
+ * @param {string|null} namespace - Namespace filter (null = all)
555
+ */
556
+ export function getRecentMemories(limit = 10, namespace = null) {
557
+ const rows = namespace
558
+ ? stmts.getRecentNs.all(namespace, limit)
559
+ : stmts.getRecent.all(limit);
560
+ rows.forEach(r => {
561
+ r.provenance = getProvenance(r.id);
562
+ });
563
+ return rows;
564
+ }
565
+
566
+ /**
567
+ * Get the N most important memories (by importance_score).
568
+ * @param {number} limit - Max results
569
+ * @param {string|null} namespace - Namespace filter (null = all)
570
+ */
571
+ export function getImportantMemories(limit = 10, namespace = null) {
572
+ const rows = namespace
573
+ ? stmts.getImportantNs.all(namespace, limit)
574
+ : stmts.getImportant.all(limit);
575
+ rows.forEach(r => {
576
+ r.provenance = getProvenance(r.id);
577
+ });
578
+ return rows;
579
+ }
580
+
581
+ // ============================================================
582
+ // MEMORY LIFECYCLE
583
+ // ============================================================
584
+
585
+ /**
586
+ * Boost a memory's importance when it's accessed.
587
+ * Increments access_count, adds 0.1 to importance (max 2.0),
588
+ * and updates last_accessed timestamp.
589
+ */
590
+ export function boostMemory(id) {
591
+ stmts.boost.run(id);
592
+ }
593
+
594
+ /**
595
+ * Apply temporal decay to old memories.
596
+ * Reduces importance by 5% for memories not accessed in 7+ days.
597
+ * Called automatically every hour by the server.
598
+ */
599
+ export function applyTemporalDecay() {
600
+ const now = Math.floor(Date.now() / 1000);
601
+ const result = stmts.decay.run(now);
602
+ if (result.changes > 0) {
603
+ console.error(`[persyst] Decay applied to ${result.changes} memories`);
604
+ }
605
+ }
606
+
607
+ // ============================================================
608
+ // SEARCH HELPERS (used by search.js)
609
+ // ============================================================
610
+
611
+ /**
612
+ * Keyword search using FTS5 with BM25 ranking.
613
+ * @returns {Array<{id: number, rank: number}>}
614
+ */
615
+ export function searchKeyword(query, limit = 10) {
616
+ try {
617
+ return stmts.searchFts.all(query, limit);
618
+ } catch (e) {
619
+ // FTS5 can throw on special characters in query
620
+ return [];
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Vector similarity search using sqlite-vec KNN.
626
+ * @param {Float32Array} embedding - Query vector (384-dim)
627
+ * @returns {Array<{rowid: number, distance: number}>}
628
+ */
629
+ export function searchVector(embedding, limit = 10) {
630
+ return stmts.searchVec.all(Buffer.from(embedding.buffer), limit);
631
+ }
632
+
633
+ // ============================================================
634
+ // ENTITY FUNCTIONS (Knowledge Graph)
635
+ // ============================================================
636
+
637
+ /**
638
+ * Create a named entity (person, tech, project, concept, file).
639
+ * Silently skips if entity with that name already exists.
640
+ * @returns {number|null} The entity ID, or null if already existed
641
+ */
642
+ export function insertEntity(name, type) {
643
+ const result = stmts.insertEntity.run(name, type);
644
+ if (result.changes === 0) {
645
+ // Already exists return existing ID
646
+ const existing = stmts.getEntityByName.get(name);
647
+ return existing ? existing.id : null;
648
+ }
649
+ return Number(result.lastInsertRowid);
650
+ }
651
+
652
+ /**
653
+ * Get an entity by its name.
654
+ */
655
+ export function getEntityByName(name) {
656
+ return stmts.getEntityByName.get(name) || null;
657
+ }
658
+
659
+ /**
660
+ * Get an entity by its ID.
661
+ */
662
+ export function getEntityById(id) {
663
+ return stmts.getEntityById.get(id) || null;
664
+ }
665
+
666
+ /**
667
+ * Get all entities, most recent first.
668
+ */
669
+ export function getAllEntities(limit = 50) {
670
+ return stmts.getAllEntities.all(limit);
671
+ }
672
+
673
+ /**
674
+ * Delete an entity and its edges.
675
+ */
676
+ export function deleteEntity(id) {
677
+ stmts.deleteEntity.run(id);
678
+ }
679
+
680
+ /**
681
+ * Create an edge connecting two nodes (entity↔entity or entity↔memory).
682
+ */
683
+ export function insertEdge(sourceId, targetId, relation, sourceType, targetType) {
684
+ stmts.insertEdge.run(sourceId, targetId, relation, sourceType, targetType);
685
+ }
686
+
687
+ /**
688
+ * Get all memories linked to an entity.
689
+ */
690
+ export function getMemoriesByEntity(entityId) {
691
+ const edges = db.prepare(`
692
+ SELECT * FROM edges
693
+ WHERE (source_id = ? AND source_type = 'entity' AND target_type = 'memory')
694
+ OR (target_id = ? AND target_type = 'entity' AND source_type = 'memory')
695
+ `).all(entityId, entityId);
696
+ const memoryIds = edges.map(e => e.source_type === 'memory' ? e.source_id : e.target_id);
697
+ return memoryIds.map(id => stmts.getById.get(id)).filter(Boolean);
698
+ }
699
+
700
+ /**
701
+ * Check if a memory with exact content already exists.
702
+ * Used for deduplication.
703
+ * @param {string} content - Exact content to match
704
+ * @param {string|null} namespace - Namespace filter (null = global dedup)
705
+ * @returns {boolean}
706
+ */
707
+ export function memoryExists(content, namespace = null) {
708
+ if (namespace) {
709
+ return stmts.findMemoryByContentNs.get(content, namespace) !== undefined;
710
+ }
711
+ return stmts.findMemoryByContent.get(content) !== undefined;
712
+ }
713
+
714
+ /**
715
+ * Check if a memory exists by hash prefix pattern (LIKE query).
716
+ * Used for git commit deduplication where we match `[hashPrefix]%`.
717
+ * @param {string} pattern - SQL LIKE pattern to match (e.g. '[abc1234]%')
718
+ * @returns {boolean}
719
+ */
720
+ export function memoryExistsByHashPrefix(pattern) {
721
+ return stmts.findMemoryByHashPrefix.get(pattern) !== undefined;
722
+ }
723
+
724
+ /**
725
+ * Get count of active (non-archived) memories.
726
+ * @param {string|null} namespace - Namespace filter (null = all)
727
+ * @returns {number}
728
+ */
729
+ export function getActiveMemoryCount(namespace = null) {
730
+ if (namespace) {
731
+ return stmts.getActiveMemoryCountNs.get(namespace).count;
732
+ }
733
+ return stmts.getActiveMemoryCount.get().count;
734
+ }
735
+
736
+ /**
737
+ * Get namespace breakdown stats.
738
+ * @returns {Array<{namespace: string, count: number}>}
739
+ */
740
+ export function getNamespaceStats() {
741
+ return stmts.getNamespaceStats.all();
742
+ }
743
+
744
+ // ============================================================
745
+ // DEDUPLICATION BY EXACT CONTENT
746
+ // ============================================================
747
+
748
+ /**
749
+ * Find memory by exact content.
750
+ * @param {string} content
751
+ * @param {string|null} namespace - Namespace filter (null = global)
752
+ * @returns {object|null} The memory row, or null if not found
753
+ */
754
+ export function getMemoryByContent(content, namespace = null) {
755
+ const row = namespace
756
+ ? stmts.findMemoryByContentNs.get(content, namespace)
757
+ : stmts.findMemoryByContent.get(content);
758
+ return row ? getMemoryById(row.id) : null;
759
+ }
760
+
761
+ // ============================================================
762
+ // TEMPORAL CONTRADICTIONS & AGENT STATS & ATTESTATIONS CRUD
763
+ // ============================================================
764
+
765
+ /**
766
+ * Archive a memory and log the contradiction.
767
+ */
768
+ export function logContradiction(oldMemoryId, newMemoryId, reason = '') {
769
+ stmts.archiveMemory.run(oldMemoryId);
770
+ stmts.insertContradiction.run(oldMemoryId, newMemoryId, reason);
771
+
772
+ // Set parent_id to link memories for bidirectional history tracing
773
+ try {
774
+ db.prepare('UPDATE memories SET parent_id = ? WHERE id = ?').run(oldMemoryId, newMemoryId);
775
+ } catch (e) {
776
+ console.error(`[persyst] Failed to set parent_id on contradiction: ${e.message}`);
777
+ }
778
+
779
+ // Retrieve provenance of both versions for game-theoretic reputation calculation
780
+ const oldProvenance = getProvenance(oldMemoryId);
781
+ const newProvenance = getProvenance(newMemoryId);
782
+
783
+ if (oldProvenance && oldProvenance.source_type === 'agent' && oldProvenance.source_id) {
784
+ const isSelfCorrection = (newProvenance && newProvenance.source_id &&
785
+ newProvenance.source_id.toLowerCase() === oldProvenance.source_id.toLowerCase()) ||
786
+ reason.includes('update_memory');
787
+ if (!isSelfCorrection) {
788
+ // Different agent/manual source contradicts the old memory
789
+ incrementAgentStat(oldProvenance.source_id, 'contradicted');
790
+
791
+ // Boost reputation of the confirmer/contradictor if it's an agent
792
+ if (newProvenance && newProvenance.source_type === 'agent' && newProvenance.source_id !== oldProvenance.source_id) {
793
+ incrementAgentStat(newProvenance.source_id, 'confirmed');
794
+ }
795
+ }
796
+ }
797
+ }
798
+
799
+ /**
800
+ * Get provenance for a memory.
801
+ */
802
+ export function getProvenance(memoryId) {
803
+ const prov = stmts.getProvenance.get(memoryId) || null;
804
+ if (prov && prov.source_type === 'agent' && prov.source_id) {
805
+ prov.source_id = prov.source_id.toLowerCase();
806
+ }
807
+ return prov;
808
+ }
809
+
810
+ /**
811
+ * Update agent reputation counters.
812
+ */
813
+ export function incrementAgentStat(agentId, action) {
814
+ const normalizedAgentId = agentId.toLowerCase();
815
+ stmts.upsertAgent.run(normalizedAgentId);
816
+ if (action === 'created') {
817
+ stmts.incrementCreated.run(normalizedAgentId);
818
+ } else if (action === 'confirmed') {
819
+ stmts.incrementConfirmed.run(normalizedAgentId);
820
+ } else if (action === 'contradicted') {
821
+ stmts.incrementContradicted.run(normalizedAgentId);
822
+ }
823
+ stmts.recalculateReputation.run(normalizedAgentId);
824
+ }
825
+
826
+ /**
827
+ * Get all agent stats.
828
+ */
829
+ export function getAllAgentStats() {
830
+ return stmts.getAllAgentStats.all();
831
+ }
832
+
833
+ /**
834
+ * Upsert agent signature / record attestation in database.
835
+ */
836
+ export function insertAttestation(att) {
837
+ stmts.insertAttestation.run(
838
+ att.attestation_id,
839
+ att.query,
840
+ att.timestamp,
841
+ JSON.stringify(att.memories_retrieved),
842
+ att.agent_id || null,
843
+ att.session_id || null,
844
+ att.signature,
845
+ att.previous_hash || null,
846
+ att.hash
847
+ );
848
+ }
849
+
850
+ /**
851
+ * Retrieve a specific attestation by ID.
852
+ */
853
+ export function getAttestationById(attestationId) {
854
+ return stmts.getAttestation.get(attestationId) || null;
855
+ }
856
+
857
+ /**
858
+ * Retrieve the last attestation logged for chaining.
859
+ */
860
+ export function getLastAttestation() {
861
+ return stmts.getLastAttestation.get() || null;
862
+ }
863
+
864
+ /**
865
+ * Retrieve attestations within a timestamp range.
866
+ */
867
+ export function getAttestationsByDateRange(startDate, endDate) {
868
+ return stmts.getAttestationsByDate.all(startDate, endDate);
869
+ }
870
+
871
+ /**
872
+ * Traverses contradictions to get historical versions of a memory.
873
+ */
874
+ export function getMemoryHistoryChain(memoryId) {
875
+ const versions = new Set();
876
+ const queue = [memoryId];
877
+
878
+ while (queue.length > 0) {
879
+ const currentId = queue.shift();
880
+ if (versions.has(currentId)) continue;
881
+ versions.add(currentId);
882
+
883
+ // 1. Find parent (ancestor) from memories table
884
+ const row = db.prepare('SELECT parent_id FROM memories WHERE id = ?').get(currentId);
885
+ if (row && row.parent_id !== null) {
886
+ if (!versions.has(row.parent_id)) queue.push(row.parent_id);
887
+ }
888
+
889
+ // 2. Find children (descendants) from memories table
890
+ const children = db.prepare('SELECT id FROM memories WHERE parent_id = ?').all(currentId);
891
+ for (const child of children) {
892
+ if (!versions.has(child.id)) queue.push(child.id);
893
+ }
894
+
895
+ // 3. Fallback: Find ancestors (replaced by current) from contradictions table
896
+ const ancestors = stmts.getContradictionAncestors.all(currentId);
897
+ ancestors.forEach(a => {
898
+ if (!versions.has(a.old_memory_id)) queue.push(a.old_memory_id);
899
+ });
900
+
901
+ // 4. Fallback: Find descendants (replaces current) from contradictions table
902
+ const descendants = stmts.getContradictionDescendants.all(currentId);
903
+ descendants.forEach(d => {
904
+ if (!versions.has(d.new_memory_id)) queue.push(d.new_memory_id);
905
+ });
906
+ }
907
+
908
+ const ids = Array.from(versions);
909
+ if (ids.length === 0) return [];
910
+
911
+ const placeholders = ids.map(() => '?').join(',');
912
+ const rows = db.prepare(`
913
+ SELECT m.*, p.source_type, p.source_id, p.confidence
914
+ FROM memories m
915
+ LEFT JOIN provenance p ON m.id = p.memory_id
916
+ WHERE m.id IN (${placeholders})
917
+ ORDER BY m.created_at ASC
918
+ `).all(...ids);
919
+
920
+ const uniqueRows = [];
921
+ const seenIds = new Set();
922
+ for (const row of rows) {
923
+ if (row && !seenIds.has(row.id)) {
924
+ seenIds.add(row.id);
925
+ if (row.source_type === 'agent' && row.source_id) {
926
+ row.source_id = row.source_id.toLowerCase();
927
+ }
928
+ uniqueRows.push(row);
929
+ }
930
+ }
931
+
932
+ return uniqueRows;
933
+ }
934
+
935
+ /**
936
+ * Search all memories FTS (including archived memories).
937
+ */
938
+ export function searchAllMemoriesFts(queryText, limit = 10) {
939
+ try {
940
+ return stmts.searchFts.all(queryText, limit);
941
+ } catch (e) {
942
+ return [];
943
+ }
944
+ }
945
+
946
+ /**
947
+ * Retrieve the last read position of a watched file.
948
+ */
949
+ export function getWatchPosition(filePath) {
950
+ const row = stmts.getWatchPosition.get(filePath);
951
+ return row ? row.last_position : 0;
952
+ }
953
+
954
+ /**
955
+ * Upsert the last read position of a watched file.
956
+ */
957
+ export function upsertWatchPosition(filePath, position) {
958
+ stmts.upsertWatchPosition.run(filePath, position);
959
+ }
960
+
961
+ // ============================================================
962
+ // CLEANUP
963
+ // ============================================================
964
+
965
+ /**
966
+ * Close the database connection. Call on shutdown.
967
+ */
968
+ export function closeDatabase() {
969
+ db.close();
970
+ console.error('[persyst] Database closed');
971
+ }
972
+
973
+ export default db;