persyst-mcp 1.0.1 → 2.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.
package/src/database.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * - Opens SQLite connection at ~/.persyst/persyst.db
6
6
  * - Loads the sqlite-vec extension for vector search
7
7
  * - Creates all tables (memories, FTS5 index, vector index)
8
+ * - Runs schema migrations for production-grade bi-temporal model
8
9
  * - Exports simple CRUD functions for other modules to use
9
10
  *
10
11
  * IMPORTANT: better-sqlite3 is SYNCHRONOUS. No async/await here.
@@ -18,7 +19,7 @@ import { mkdirSync } from 'fs';
18
19
 
19
20
  // ============================================================
20
21
  // DATABASE LOCATION
21
- // Store in ~/.persyst/ so data persists across sessions
22
+ // Store in ~/.persyst/ per default to persist across sessions
22
23
  // ============================================================
23
24
 
24
25
  const DB_DIR = join(homedir(), '.persyst');
@@ -32,6 +33,7 @@ const DB_PATH = process.env.NODE_ENV === 'test' ? ':memory:' : join(DB_DIR, 'per
32
33
  const db = new Database(DB_PATH);
33
34
  db.pragma('journal_mode = WAL'); // Better performance for concurrent reads
34
35
  db.pragma('foreign_keys = ON'); // Enforce referential integrity
36
+ db.pragma('mmap_size = 268435456'); // 256MB memory-mapped I/O for faster reads
35
37
 
36
38
  // Load sqlite-vec BEFORE creating any vec0 tables
37
39
  sqliteVec.load(db);
@@ -39,7 +41,7 @@ sqliteVec.load(db);
39
41
  console.error(`[persyst] Database: ${DB_PATH}`);
40
42
 
41
43
  // ============================================================
42
- // CREATE TABLES
44
+ // CREATE TABLES & SCHEMA MIGRATIONS
43
45
  // ============================================================
44
46
 
45
47
  // --- Main memories table ---
@@ -50,7 +52,79 @@ db.exec(`
50
52
  importance_score REAL DEFAULT 1.0,
51
53
  created_at INTEGER DEFAULT (unixepoch()),
52
54
  last_accessed INTEGER DEFAULT (unixepoch()),
53
- access_count INTEGER DEFAULT 0
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
+ // --- Contradictions table ---
76
+ db.exec(`
77
+ CREATE TABLE IF NOT EXISTS contradictions (
78
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
79
+ old_memory_id INTEGER NOT NULL,
80
+ new_memory_id INTEGER NOT NULL,
81
+ resolved_at INTEGER DEFAULT (unixepoch()),
82
+ resolution_reason TEXT
83
+ )
84
+ `);
85
+
86
+ // --- Provenance table ---
87
+ db.exec(`
88
+ CREATE TABLE IF NOT EXISTS provenance (
89
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
90
+ memory_id INTEGER NOT NULL,
91
+ source_type TEXT NOT NULL, -- agent | git | manual | api
92
+ source_id TEXT, -- agent name or git hash
93
+ created_at INTEGER DEFAULT (unixepoch()),
94
+ confidence REAL NOT NULL
95
+ )
96
+ `);
97
+
98
+ // --- Agent Stats table ---
99
+ db.exec(`
100
+ CREATE TABLE IF NOT EXISTS agent_stats (
101
+ agent_id TEXT PRIMARY KEY,
102
+ memories_created INTEGER DEFAULT 0,
103
+ memories_confirmed INTEGER DEFAULT 0,
104
+ memories_contradicted INTEGER DEFAULT 0,
105
+ reputation_score REAL DEFAULT 1.0,
106
+ last_active INTEGER DEFAULT (unixepoch())
107
+ )
108
+ `);
109
+
110
+ // --- Migration: add domain column to agent_stats ---
111
+ try {
112
+ db.exec('ALTER TABLE agent_stats ADD COLUMN domain TEXT DEFAULT "general"');
113
+ } catch (e) { /* Column already exists */ }
114
+
115
+ // --- Attestations table ---
116
+ db.exec(`
117
+ CREATE TABLE IF NOT EXISTS attestations (
118
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
119
+ attestation_id TEXT NOT NULL UNIQUE,
120
+ query TEXT NOT NULL,
121
+ timestamp TEXT NOT NULL,
122
+ memories_retrieved TEXT NOT NULL,
123
+ agent_id TEXT,
124
+ session_id TEXT,
125
+ signature TEXT NOT NULL,
126
+ previous_hash TEXT,
127
+ hash TEXT NOT NULL
54
128
  )
55
129
  `);
56
130
 
@@ -64,9 +138,6 @@ db.exec(`
64
138
  `);
65
139
 
66
140
  // --- FTS5 auto-sync triggers ---
67
- // These keep the FTS index in sync when memories are added/updated/deleted.
68
- // Using try/catch because "CREATE TRIGGER IF NOT EXISTS" isn't supported.
69
-
70
141
  try {
71
142
  db.exec(`
72
143
  CREATE TRIGGER memories_fts_insert AFTER INSERT ON memories
@@ -106,7 +177,6 @@ db.exec(`
106
177
  `);
107
178
 
108
179
  // --- Knowledge Graph: entities + edges ---
109
- // Entities are the "nouns" — people, files, tech, concepts
110
180
  db.exec(`
111
181
  CREATE TABLE IF NOT EXISTS entities (
112
182
  id INTEGER PRIMARY KEY,
@@ -116,7 +186,6 @@ db.exec(`
116
186
  )
117
187
  `);
118
188
 
119
- // Edges connect entities to memories (or entities to entities)
120
189
  db.exec(`
121
190
  CREATE TABLE IF NOT EXISTS edges (
122
191
  id INTEGER PRIMARY KEY,
@@ -144,22 +213,71 @@ const stmts = {
144
213
  insertVec: db.prepare(
145
214
  'INSERT INTO memories_vec (rowid, embedding) VALUES (?, ?)'
146
215
  ),
216
+ insertProvenance: db.prepare(
217
+ 'INSERT INTO provenance (memory_id, source_type, source_id, confidence) VALUES (?, ?, ?, ?)'
218
+ ),
219
+ insertContradiction: db.prepare(
220
+ 'INSERT INTO contradictions (old_memory_id, new_memory_id, resolution_reason) VALUES (?, ?, ?)'
221
+ ),
222
+ upsertAgent: db.prepare(`
223
+ INSERT INTO agent_stats (agent_id) VALUES (?)
224
+ ON CONFLICT(agent_id) DO UPDATE SET last_active = unixepoch()
225
+ `),
226
+ incrementCreated: db.prepare(
227
+ 'UPDATE agent_stats SET memories_created = memories_created + 1 WHERE agent_id = ?'
228
+ ),
229
+ incrementConfirmed: db.prepare(
230
+ 'UPDATE agent_stats SET memories_confirmed = memories_confirmed + 1 WHERE agent_id = ?'
231
+ ),
232
+ incrementContradicted: db.prepare(
233
+ 'UPDATE agent_stats SET memories_contradicted = memories_contradicted + 1 WHERE agent_id = ?'
234
+ ),
235
+ recalculateReputation: db.prepare(
236
+ 'UPDATE agent_stats SET reputation_score = (memories_confirmed + 1.0) / (memories_contradicted + 1.0) WHERE agent_id = ?'
237
+ ),
238
+ insertAttestation: db.prepare(`
239
+ INSERT INTO attestations (
240
+ attestation_id, query, timestamp, memories_retrieved,
241
+ agent_id, session_id, signature, previous_hash, hash
242
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
243
+ `),
147
244
 
148
245
  // -- Read --
149
246
  getById: db.prepare(
247
+ 'SELECT * FROM memories WHERE id = ? AND valid_until IS NULL'
248
+ ),
249
+ getAnyById: db.prepare(
150
250
  'SELECT * FROM memories WHERE id = ?'
151
251
  ),
152
252
  getRecent: db.prepare(
153
- 'SELECT * FROM memories ORDER BY created_at DESC LIMIT ?'
253
+ 'SELECT * FROM memories WHERE valid_until IS NULL ORDER BY created_at DESC LIMIT ?'
154
254
  ),
155
255
  getImportant: db.prepare(
156
- 'SELECT * FROM memories ORDER BY importance_score DESC LIMIT ?'
256
+ 'SELECT * FROM memories WHERE valid_until IS NULL ORDER BY importance_score DESC LIMIT ?'
257
+ ),
258
+ getProvenance: db.prepare(
259
+ 'SELECT * FROM provenance WHERE memory_id = ?'
260
+ ),
261
+ getAllAgentStats: db.prepare(
262
+ 'SELECT * FROM agent_stats ORDER BY reputation_score DESC'
263
+ ),
264
+ getAttestation: db.prepare(
265
+ 'SELECT * FROM attestations WHERE attestation_id = ?'
266
+ ),
267
+ getLastAttestation: db.prepare(
268
+ 'SELECT * FROM attestations ORDER BY id DESC LIMIT 1'
269
+ ),
270
+ getAttestationsByDate: db.prepare(
271
+ 'SELECT * FROM attestations WHERE timestamp >= ? AND timestamp <= ? ORDER BY id ASC'
157
272
  ),
158
273
 
159
274
  // -- Update --
160
275
  updateContent: db.prepare(
161
276
  'UPDATE memories SET content = ? WHERE id = ?'
162
277
  ),
278
+ archiveMemory: db.prepare(
279
+ 'UPDATE memories SET valid_until = unixepoch() WHERE id = ?'
280
+ ),
163
281
 
164
282
  // -- Delete --
165
283
  deleteMemory: db.prepare(
@@ -233,7 +351,25 @@ const stmts = {
233
351
 
234
352
  // -- Dedup --
235
353
  findMemoryByContent: db.prepare(
236
- 'SELECT id FROM memories WHERE content LIKE ? LIMIT 1'
354
+ 'SELECT id FROM memories WHERE content = ? AND valid_until IS NULL LIMIT 1'
355
+ ),
356
+
357
+ // -- Hash-prefix lookup for git dedup (Bug 1 fix) --
358
+ findMemoryByHashPrefix: db.prepare(
359
+ 'SELECT id FROM memories WHERE content LIKE ? AND valid_until IS NULL LIMIT 1'
360
+ ),
361
+
362
+ // -- Active memory count --
363
+ getActiveMemoryCount: db.prepare(
364
+ 'SELECT COUNT(*) as count FROM memories WHERE valid_until IS NULL'
365
+ ),
366
+
367
+ // -- Memory History Chain (Feature 6: prepared statements) --
368
+ getContradictionAncestors: db.prepare(
369
+ 'SELECT old_memory_id FROM contradictions WHERE new_memory_id = ?'
370
+ ),
371
+ getContradictionDescendants: db.prepare(
372
+ 'SELECT new_memory_id FROM contradictions WHERE old_memory_id = ?'
237
373
  )
238
374
  };
239
375
 
@@ -243,13 +379,26 @@ const stmts = {
243
379
  // ============================================================
244
380
 
245
381
  /**
246
- * Insert a new memory into the memories table.
247
- * FTS5 index is auto-updated via trigger.
382
+ * Insert a new memory into the memories table and log its provenance.
248
383
  * @returns {number} The new memory's ID
249
384
  */
250
- export function insertMemory(content, importance = 1.0) {
385
+ export function insertMemory(content, importance = 1.0, provenanceInfo = null) {
251
386
  const result = stmts.insertMemory.run(content, importance);
252
- return Number(result.lastInsertRowid);
387
+ const id = Number(result.lastInsertRowid);
388
+
389
+ // Provenance Info handling
390
+ const source_type = provenanceInfo?.source_type || 'manual';
391
+ const source_id = provenanceInfo?.source_id || null;
392
+ const confidence = provenanceInfo?.confidence !== undefined ? provenanceInfo.confidence : 1.0;
393
+
394
+ stmts.insertProvenance.run(id, source_type, source_id, confidence);
395
+
396
+ // Agent Stats handling
397
+ if (source_type === 'agent' && source_id) {
398
+ incrementAgentStat(source_id, 'created');
399
+ }
400
+
401
+ return id;
253
402
  }
254
403
 
255
404
  /**
@@ -258,7 +407,6 @@ export function insertMemory(content, importance = 1.0) {
258
407
  * @param {Float32Array} embedding - 384-dim embedding vector
259
408
  */
260
409
  export function insertVector(id, embedding) {
261
- // better-sqlite3 needs Buffer, sqlite-vec needs BigInt for rowid
262
410
  stmts.insertVec.run(BigInt(id), Buffer.from(embedding.buffer));
263
411
  }
264
412
 
@@ -268,7 +416,24 @@ export function insertVector(id, embedding) {
268
416
  */
269
417
  export function getMemory(id) {
270
418
  const memory = stmts.getById.get(id);
271
- if (memory) boostMemory(id);
419
+ if (memory) {
420
+ boostMemory(id);
421
+ // Fetch and link provenance info
422
+ const prov = getProvenance(id);
423
+ memory.provenance = prov;
424
+ }
425
+ return memory || null;
426
+ }
427
+
428
+ /**
429
+ * Get a memory by ID WITHOUT boosting or checking bi-temporal validity.
430
+ * @returns {object|null} The memory row, or null if not found
431
+ */
432
+ export function getAnyMemoryById(id) {
433
+ const memory = stmts.getAnyById.get(id);
434
+ if (memory) {
435
+ memory.provenance = getProvenance(id);
436
+ }
272
437
  return memory || null;
273
438
  }
274
439
 
@@ -277,7 +442,11 @@ export function getMemory(id) {
277
442
  * @returns {object|null} The memory row, or null if not found
278
443
  */
279
444
  export function getMemoryById(id) {
280
- return stmts.getById.get(id) || null;
445
+ const memory = stmts.getById.get(id);
446
+ if (memory) {
447
+ memory.provenance = getProvenance(id);
448
+ }
449
+ return memory || null;
281
450
  }
282
451
 
283
452
  /**
@@ -298,11 +467,12 @@ export function deleteVec(id) {
298
467
  }
299
468
 
300
469
  /**
301
- * Delete a memory and its vector embedding.
470
+ * Delete a memory, its vector embedding, and all associated graph edges.
302
471
  * FTS5 index auto-updates via trigger.
303
472
  * @returns {boolean} true if the memory existed and was deleted
304
473
  */
305
474
  export function deleteMemory(id) {
475
+ stmts.deleteEdgesByMemory.run(id, id);
306
476
  deleteVec(id); // Remove vector first (no cascades on virtual tables)
307
477
  const result = stmts.deleteMemory.run(id);
308
478
  return result.changes > 0;
@@ -312,14 +482,22 @@ export function deleteMemory(id) {
312
482
  * Get the N most recently created memories.
313
483
  */
314
484
  export function getRecentMemories(limit = 10) {
315
- return stmts.getRecent.all(limit);
485
+ const rows = stmts.getRecent.all(limit);
486
+ rows.forEach(r => {
487
+ r.provenance = getProvenance(r.id);
488
+ });
489
+ return rows;
316
490
  }
317
491
 
318
492
  /**
319
493
  * Get the N most important memories (by importance_score).
320
494
  */
321
495
  export function getImportantMemories(limit = 10) {
322
- return stmts.getImportant.all(limit);
496
+ const rows = stmts.getImportant.all(limit);
497
+ rows.forEach(r => {
498
+ r.provenance = getProvenance(r.id);
499
+ });
500
+ return rows;
323
501
  }
324
502
 
325
503
  // ============================================================
@@ -379,7 +557,7 @@ export function searchVector(embedding, limit = 10) {
379
557
  // ============================================================
380
558
 
381
559
  /**
382
- * Create a named entity (person, tech, file, concept, etc.).
560
+ * Create a named entity (person, tech, project, concept, file).
383
561
  * Silently skips if entity with that name already exists.
384
562
  * @returns {number|null} The entity ID, or null if already existed
385
563
  */
@@ -439,23 +617,181 @@ export function getMemoriesByEntity(entityId) {
439
617
  }
440
618
 
441
619
  /**
442
- * Check if a memory with similar content already exists.
443
- * Used for deduplication during git ingestion.
444
- * @param {string} pattern - SQL LIKE pattern to match
620
+ * Check if a memory with exact content already exists.
621
+ * Used for deduplication.
622
+ * @param {string} content - Exact content to match
445
623
  * @returns {boolean}
446
624
  */
447
- export function memoryExists(pattern) {
448
- return stmts.findMemoryByContent.get(pattern) !== undefined;
625
+ export function memoryExists(content) {
626
+ return stmts.findMemoryByContent.get(content) !== undefined;
449
627
  }
450
628
 
451
629
  /**
452
- * Delete a memory and clean up its edges.
630
+ * Check if a memory exists by hash prefix pattern (LIKE query).
631
+ * Used for git commit deduplication where we match `[hashPrefix]%`.
632
+ * @param {string} pattern - SQL LIKE pattern to match (e.g. '[abc1234]%')
633
+ * @returns {boolean}
453
634
  */
454
- export function deleteMemoryFull(id) {
455
- stmts.deleteEdgesByMemory.run(id, id);
456
- deleteVec(id);
457
- const result = stmts.deleteMemory.run(id);
458
- return result.changes > 0;
635
+ export function memoryExistsByHashPrefix(pattern) {
636
+ return stmts.findMemoryByHashPrefix.get(pattern) !== undefined;
637
+ }
638
+
639
+ /**
640
+ * Get count of active (non-archived) memories.
641
+ * @returns {number}
642
+ */
643
+ export function getActiveMemoryCount() {
644
+ return stmts.getActiveMemoryCount.get().count;
645
+ }
646
+
647
+ // ============================================================
648
+ // DEDUPLICATION BY EXACT CONTENT
649
+ // ============================================================
650
+
651
+ /**
652
+ * Find memory by exact content.
653
+ * @param {string} content
654
+ * @returns {object|null} The memory row, or null if not found
655
+ */
656
+ export function getMemoryByContent(content) {
657
+ const row = stmts.findMemoryByContent.get(content);
658
+ return row ? getMemoryById(row.id) : null;
659
+ }
660
+
661
+ // ============================================================
662
+ // TEMPORAL CONTRADICTIONS & AGENT STATS & ATTESTATIONS CRUD
663
+ // ============================================================
664
+
665
+ /**
666
+ * Archive a memory and log the contradiction.
667
+ */
668
+ export function logContradiction(oldMemoryId, newMemoryId, reason = '') {
669
+ stmts.archiveMemory.run(oldMemoryId);
670
+ stmts.insertContradiction.run(oldMemoryId, newMemoryId, reason);
671
+
672
+ // Track that the agent's memory was contradicted
673
+ const oldProvenance = getProvenance(oldMemoryId);
674
+ if (oldProvenance && oldProvenance.source_type === 'agent' && oldProvenance.source_id) {
675
+ incrementAgentStat(oldProvenance.source_id, 'contradicted');
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Get provenance for a memory.
681
+ */
682
+ export function getProvenance(memoryId) {
683
+ return stmts.getProvenance.get(memoryId) || null;
684
+ }
685
+
686
+ /**
687
+ * Update agent reputation counters.
688
+ */
689
+ export function incrementAgentStat(agentId, action) {
690
+ stmts.upsertAgent.run(agentId);
691
+ if (action === 'created') {
692
+ stmts.incrementCreated.run(agentId);
693
+ } else if (action === 'confirmed') {
694
+ stmts.incrementConfirmed.run(agentId);
695
+ } else if (action === 'contradicted') {
696
+ stmts.incrementContradicted.run(agentId);
697
+ }
698
+ stmts.recalculateReputation.run(agentId);
699
+ }
700
+
701
+ /**
702
+ * Get all agent stats.
703
+ */
704
+ export function getAllAgentStats() {
705
+ return stmts.getAllAgentStats.all();
706
+ }
707
+
708
+ /**
709
+ * Upsert agent signature / record attestation in database.
710
+ */
711
+ export function insertAttestation(att) {
712
+ stmts.insertAttestation.run(
713
+ att.attestation_id,
714
+ att.query,
715
+ att.timestamp,
716
+ JSON.stringify(att.memories_retrieved),
717
+ att.agent_id || null,
718
+ att.session_id || null,
719
+ att.signature,
720
+ att.previous_hash || null,
721
+ att.hash
722
+ );
723
+ }
724
+
725
+ /**
726
+ * Retrieve a specific attestation by ID.
727
+ */
728
+ export function getAttestationById(attestationId) {
729
+ return stmts.getAttestation.get(attestationId) || null;
730
+ }
731
+
732
+ /**
733
+ * Retrieve the last attestation logged for chaining.
734
+ */
735
+ export function getLastAttestation() {
736
+ return stmts.getLastAttestation.get() || null;
737
+ }
738
+
739
+ /**
740
+ * Retrieve attestations within a timestamp range.
741
+ */
742
+ export function getAttestationsByDateRange(startDate, endDate) {
743
+ return stmts.getAttestationsByDate.all(startDate, endDate);
744
+ }
745
+
746
+ /**
747
+ * Traverses contradictions to get historical versions of a memory.
748
+ */
749
+ export function getMemoryHistoryChain(memoryId) {
750
+ const versions = new Set();
751
+ const queue = [memoryId];
752
+
753
+ while (queue.length > 0) {
754
+ const currentId = queue.shift();
755
+ if (versions.has(currentId)) continue;
756
+ versions.add(currentId);
757
+
758
+ // Find ancestors (replaced by current) — using prepared statement
759
+ const ancestors = stmts.getContradictionAncestors.all(currentId);
760
+ ancestors.forEach(a => {
761
+ if (!versions.has(a.old_memory_id)) queue.push(a.old_memory_id);
762
+ });
763
+
764
+ // Find descendants (replaces current) — using prepared statement
765
+ const descendants = stmts.getContradictionDescendants.all(currentId);
766
+ descendants.forEach(d => {
767
+ if (!versions.has(d.new_memory_id)) queue.push(d.new_memory_id);
768
+ });
769
+ }
770
+
771
+ const ids = Array.from(versions);
772
+ if (ids.length === 0) return [];
773
+
774
+ const placeholders = ids.map(() => '?').join(',');
775
+ const rows = db.prepare(`
776
+ SELECT m.*, p.source_type, p.source_id, p.confidence
777
+ FROM memories m
778
+ LEFT JOIN provenance p ON m.id = p.memory_id
779
+ WHERE m.id IN (${placeholders})
780
+ ORDER BY m.created_at ASC
781
+ `).all(...ids);
782
+
783
+ return rows;
784
+ }
785
+
786
+ /**
787
+ * Search all memories FTS (including archived memories).
788
+ */
789
+ export function searchAllMemoriesFts(queryText, limit = 10) {
790
+ try {
791
+ return stmts.searchFts.all(queryText, limit);
792
+ } catch (e) {
793
+ return [];
794
+ }
459
795
  }
460
796
 
461
797
  // ============================================================