persyst-mcp 2.2.4 → 2.2.6

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
@@ -34,6 +34,9 @@ const db = new Database(DB_PATH);
34
34
  db.pragma('journal_mode = WAL'); // Better performance for concurrent reads
35
35
  db.pragma('foreign_keys = ON'); // Enforce referential integrity
36
36
  db.pragma('mmap_size = 268435456'); // 256MB memory-mapped I/O for faster reads
37
+ db.pragma('synchronous = NORMAL'); // Performance boost for WAL mode
38
+ db.pragma('temp_store = MEMORY'); // Keep temp tables in memory
39
+ db.pragma('cache_size = -64000'); // 64MB cache size
37
40
 
38
41
  // Load sqlite-vec BEFORE creating any vec0 tables
39
42
  sqliteVec.load(db);
@@ -60,27 +63,32 @@ db.exec(`
60
63
  `);
61
64
 
62
65
  // --- Migrations for bi-temporal validity on existing tables ---
63
- try {
66
+ function columnExists(table, name) {
67
+ const info = db.prepare(`PRAGMA table_info(${table})`).all();
68
+ return info.some(col => col.name === name);
69
+ }
70
+
71
+ if (!columnExists('memories', 'valid_from')) {
64
72
  db.exec('ALTER TABLE memories ADD COLUMN valid_from INTEGER DEFAULT (unixepoch())');
65
- } catch (e) { /* Column already exists */ }
73
+ }
66
74
 
67
- try {
75
+ if (!columnExists('memories', 'valid_until')) {
68
76
  db.exec('ALTER TABLE memories ADD COLUMN valid_until INTEGER DEFAULT NULL');
69
- } catch (e) { /* Column already exists */ }
77
+ }
70
78
 
71
- try {
79
+ if (!columnExists('memories', 'assertion_time')) {
72
80
  db.exec('ALTER TABLE memories ADD COLUMN assertion_time INTEGER DEFAULT (unixepoch())');
73
- } catch (e) { /* Column already exists */ }
81
+ }
74
82
 
75
83
  // --- Migration: add namespace column for per-agent isolation ---
76
- try {
84
+ if (!columnExists('memories', 'namespace')) {
77
85
  db.exec("ALTER TABLE memories ADD COLUMN namespace TEXT DEFAULT 'shared'");
78
- } catch (e) { /* Column already exists */ }
86
+ }
79
87
 
80
88
  // --- Migration: add parent_id column for history tracing ---
81
- try {
89
+ if (!columnExists('memories', 'parent_id')) {
82
90
  db.exec('ALTER TABLE memories ADD COLUMN parent_id INTEGER DEFAULT NULL');
83
- } catch (e) { /* Column already exists */ }
91
+ }
84
92
 
85
93
  // --- Index on namespace for fast filtered queries ---
86
94
  try {
@@ -330,7 +338,7 @@ const stmts = {
330
338
  decay: db.prepare(`
331
339
  UPDATE memories
332
340
  SET importance_score = ROUND(MAX(importance_score * 0.95, 0.0), 4)
333
- WHERE (? - last_accessed) > 604800
341
+ WHERE (unixepoch() - last_accessed) > 604800
334
342
  `),
335
343
 
336
344
  // -- Search --
@@ -364,6 +372,11 @@ const stmts = {
364
372
  deleteEntity: db.prepare(
365
373
  'DELETE FROM entities WHERE id = ?'
366
374
  ),
375
+ deleteEdgesByEntity: db.prepare(
376
+ `DELETE FROM edges WHERE
377
+ (source_id = ? AND source_type = 'entity') OR
378
+ (target_id = ? AND target_type = 'entity')`
379
+ ),
367
380
 
368
381
  // -- Edges --
369
382
  insertEdge: db.prepare(
@@ -423,9 +436,171 @@ const stmts = {
423
436
  INSERT INTO watched_files (file_path, last_position)
424
437
  VALUES (?, ?)
425
438
  ON CONFLICT(file_path) DO UPDATE SET last_position = excluded.last_position, updated_at = unixepoch()
439
+ `),
440
+
441
+ // -- Internal lookups (pre-compiled for hot-loop use) --
442
+ getAttestationByHash: db.prepare(
443
+ 'SELECT * FROM attestations WHERE hash = ?'
444
+ ),
445
+ getMemoryParentId: db.prepare(
446
+ 'SELECT parent_id FROM memories WHERE id = ?'
447
+ ),
448
+ getMemoryChildren: db.prepare(
449
+ 'SELECT id FROM memories WHERE parent_id = ?'
450
+ ),
451
+ getMemoryContentById: db.prepare(
452
+ 'SELECT content FROM memories WHERE id = ?'
453
+ ),
454
+ getMemoryByIdRaw: db.prepare(
455
+ 'SELECT * FROM memories WHERE id = ? AND valid_until IS NULL'
456
+ ),
457
+ getMemoryLikeContent: db.prepare(
458
+ 'SELECT id FROM memories WHERE content LIKE ? AND valid_until IS NULL'
459
+ ),
460
+ getVecByRowId: db.prepare(
461
+ 'SELECT embedding FROM memories_vec WHERE rowid = ?'
462
+ ),
463
+ updateMemoryParentId: db.prepare(
464
+ 'UPDATE memories SET parent_id = ? WHERE id = ?'
465
+ ),
466
+ deleteProvenanceByMemoryId: db.prepare(
467
+ 'DELETE FROM provenance WHERE memory_id = ?'
468
+ ),
469
+ deleteContradictionsByMemoryId: db.prepare(
470
+ 'DELETE FROM contradictions WHERE old_memory_id = ? OR new_memory_id = ?'
471
+ ),
472
+ getReputationScore: db.prepare(
473
+ 'SELECT reputation_score FROM agent_stats WHERE agent_id = ?'
474
+ ),
475
+ updateProvenanceOwner: db.prepare(
476
+ "UPDATE provenance SET source_type = 'agent', source_id = ?, confidence = 1.0 WHERE memory_id = ?"
477
+ ),
478
+ archiveMemoryById: db.prepare(
479
+ 'UPDATE memories SET valid_until = unixepoch() WHERE id = ?'
480
+ ),
481
+ getEdgesBySourceAndType: db.prepare(`
482
+ SELECT * FROM edges
483
+ WHERE (source_id = ? AND source_type = ?)
484
+ OR (target_id = ? AND target_type = ?)
485
+ `),
486
+ getMemoriesByEntityEdges: db.prepare(`
487
+ SELECT * FROM edges
488
+ WHERE (source_id = ? AND source_type = 'entity' AND target_type = 'memory')
489
+ OR (target_id = ? AND target_type = 'entity' AND source_type = 'memory')
490
+ `),
491
+ consolidateVecSearch: db.prepare(`
492
+ SELECT rowid AS id, distance
493
+ FROM memories_vec
494
+ WHERE embedding MATCH ?
495
+ AND k = 30
496
+ `),
497
+ archiveAndInsertContradiction: db.prepare(
498
+ 'UPDATE memories SET valid_until = unixepoch() WHERE id = ?'
499
+ ),
500
+ archiveExpiredTransientMemories: db.prepare(`
501
+ UPDATE memories
502
+ SET valid_until = unixepoch()
503
+ WHERE valid_until IS NULL
504
+ AND (content LIKE 'Reminder:%' OR content LIKE 'Note:%')
505
+ AND (unixepoch() - created_at) > 1209600
426
506
  `)
427
507
  };
428
508
 
509
+ export { stmts };
510
+
511
+ // ============================================================
512
+ // SECRET DETECTION & REDACTION HELPERS
513
+ // ============================================================
514
+
515
+ /**
516
+ * Detects sensitive/credential patterns in a string and replaces values with [REDACTED].
517
+ * @param {string} content - The content to sanitize
518
+ * @returns {string} Sanitized content
519
+ */
520
+ export function redactSecrets(content) {
521
+ if (!content || typeof content !== 'string') return content;
522
+
523
+ let redacted = content;
524
+
525
+ // 1. Redact credentials in connection strings / URIs
526
+ // Matches scheme://user:pass@host and scheme://:pass@host
527
+ const connectionStringRegex = /\b([a-zA-Z0-9+.-]+:\/\/)([^/:\s]*):([^@/:\s]+)(@[^/\s]+)/gi;
528
+ redacted = redacted.replace(connectionStringRegex, (match, protocol, user, pass, host) => {
529
+ return protocol + user + ':[REDACTED]' + host;
530
+ });
531
+
532
+ // 2. Redact key-value pairs matching credentials (retaining key/operator, redacting value)
533
+ // Supports single-quoted, double-quoted, and unquoted values (non-whitespace).
534
+ const kvRegex = /['"]?\b(api[_-]?key|secret[_-]?key|secret|password|passwd|pwd|passphrase|auth[_-]?token|access[_-]?token|client[_-]?secret|private[_-]?key|auth|access|client|aws|gcp|google|stripe|github|openai|vercel|heroku|slack|ssh[_-]?(?:key|password|passphrase|pass)?|credential|aws_secret|secret_access_key|aws_access_key|ssh_passphrase|ssh_password|ssh_key_pass)\b['"]?\s*(?:key|token|secret|password|pwd|passwd|value|string|id)?(?:\b|(?<=['"]))\s*([:=]|is|of|to|set\s+to|\(|\buses\b)\s*(?:'([^']{6,2048})'|"([^"]{6,2048})"|([^\s]+(?:\n(?![a-zA-Z0-9_-]+\s*[:=])(?=[^\s]+(?:\n|$))[^\s]+)*))/gi;
535
+
536
+ redacted = redacted.replace(kvRegex, (match, key, op, sqVal, dqVal, uqVal) => {
537
+ const val = sqVal || dqVal || uqVal;
538
+ if (!val) return match;
539
+
540
+ // Strip trailing parenthesis if operator is '(' and value has trailing parenthesis
541
+ let cleanVal = val;
542
+ if (op === '(' && val.endsWith(')')) {
543
+ cleanVal = val.slice(0, -1);
544
+ }
545
+
546
+ const lastIdx = match.lastIndexOf(cleanVal);
547
+ if (lastIdx !== -1) {
548
+ return match.slice(0, lastIdx) + '[REDACTED]' + match.slice(lastIdx + cleanVal.length);
549
+ }
550
+ return match;
551
+ });
552
+
553
+ // 3. Redact standalone common API keys and tokens
554
+ const standalonePatterns = [
555
+ /\b(sk-[a-zA-Z0-9]{48})\b/g, // OpenAI
556
+ /\b(sk-proj-[a-zA-Z0-9-]{40,})\b/g, // OpenAI project
557
+ /\b(gh[pous]_[a-zA-Z0-9]{36,255})\b/g, // GitHub PAT/Fine-grained
558
+ /\b(xox[bapr]-[0-9]{12}-[a-zA-Z0-9]{24})\b/g, // Slack token
559
+ /\b(AIzaSy[A-Za-z0-9_-]{33})\b/g, // Google API key
560
+ /\b((?:sk|rk|pk)_(?:live|test)_[0-9a-zA-Z]{24,32})\b/g, // Stripe key
561
+ /\b(AKIA[0-9A-Z]{16,40})\b/gi, // AWS Access Key ID (case-insensitive)
562
+ /\b(ASCA[0-9A-Z]{16,40})\b/gi, // AWS ASCA Key
563
+ /\b(npm_[a-zA-Z0-9]{36,255})\b/g, // npm token
564
+ /\b(ey[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,})\b/g, // JWT token
565
+ /-----BEGIN[A-Z0-9\s_-]+PRIVATE\s+KEY[A-Z0-9\s_-]*-----\s*[\s\S]*?-----END[A-Z0-9\s_-]+PRIVATE\s+KEY[A-Z0-9\s_-]*-----/gi, // PEM private key
566
+ ];
567
+
568
+ for (const pattern of standalonePatterns) {
569
+ redacted = redacted.replace(pattern, '[REDACTED]');
570
+ }
571
+
572
+ // 4. Robust credential shape heuristic (independent of strict key-value punctuation)
573
+ const containsCredKeyword = /\b(password|passwd|pwd|passphrase|pass|secret|token|api|credential|auth|ssh|aws)\b/i.test(redacted);
574
+ if (containsCredKeyword) {
575
+ // Match password-like tokens: length 6 to 64, containing both letters and digits/symbols
576
+ const tokenRegex = /\b([a-zA-Z0-9_@#$%^&*+!~\-]{6,64})(?!\w)/g;
577
+
578
+ redacted = redacted.replace(tokenRegex, (match) => {
579
+ // Skip common query words and technical keywords
580
+ if (/^(password|passwd|pwd|passphrase|pass|secret|token|api|credential|auth|ssh|uses|with|key|from|here|what|have|this|that|your|same|then|want|more|base64|base64-like|base64-encoded|sha256|sha1|md5|aes256|aes128|utf8|utf-8|url|uri|ipv4|ipv6|http|https|sha-256|sha-1)$/i.test(match)) {
581
+ return match;
582
+ }
583
+
584
+ const hasLetter = /[a-zA-Z]/.test(match);
585
+ const hasDigitOrSpecialSymbol = /[0-9@#$%^&*+=!~]/.test(match);
586
+
587
+ if (hasLetter && hasDigitOrSpecialSymbol) {
588
+ // Require length >= 8 or containing special symbols/mixed case digits
589
+ const isStrongSecretCandidate = match.length >= 8 ||
590
+ (/[^a-zA-Z0-9_]/.test(match)) ||
591
+ (/[A-Z]/.test(match) && /[0-9]/.test(match));
592
+
593
+ if (isStrongSecretCandidate) {
594
+ return '[REDACTED]';
595
+ }
596
+ }
597
+ return match;
598
+ });
599
+ }
600
+
601
+ return redacted;
602
+ }
603
+
429
604
  // ============================================================
430
605
  // CRUD FUNCTIONS
431
606
  // Simple, one-purpose functions. No magic.
@@ -440,11 +615,12 @@ const stmts = {
440
615
  * @returns {number} The new memory's ID
441
616
  */
442
617
  export function insertMemory(content, importance = 1.0, provenanceInfo = null, namespace = 'shared', parentId = null) {
443
- if (content && content.length > 10000) {
618
+ const redactedContent = redactSecrets(content);
619
+ if (redactedContent && redactedContent.length > 10000) {
444
620
  throw new Error('Memory content exceeds maximum length of 10000 characters.');
445
621
  }
446
622
  const clampedImportance = Math.max(0.0, Math.min(1.0, Math.round(importance * 10000) / 10000));
447
- const result = stmts.insertMemory.run(content, clampedImportance, namespace || 'shared', parentId);
623
+ const result = stmts.insertMemory.run(redactedContent, clampedImportance, namespace || 'shared', parentId);
448
624
  const id = Number(result.lastInsertRowid);
449
625
 
450
626
  // Provenance Info handling
@@ -481,13 +657,12 @@ export function insertVector(id, embedding) {
481
657
  * @returns {object|null} The memory row, or null if not found
482
658
  */
483
659
  export function getMemory(id, namespace = null) {
484
- const memory = namespace
485
- ? stmts.getByIdNs.get(id, namespace)
486
- : stmts.getById.get(id);
660
+ const memory = (namespace === 'all' || namespace === null)
661
+ ? stmts.getById.get(id)
662
+ : stmts.getByIdNs.get(id, namespace);
487
663
  if (memory) {
488
664
  boostMemory(id);
489
- const prov = getProvenance(id);
490
- memory.provenance = prov;
665
+ memory.provenance = getProvenance(id);
491
666
  }
492
667
  return memory || null;
493
668
  }
@@ -511,10 +686,9 @@ export function getAnyMemoryById(id) {
511
686
  * @returns {object|null} The memory row, or null if not found
512
687
  */
513
688
  export function getMemoryById(id, namespace = null) {
514
- const ns = namespace || 'shared';
515
- const memory = ns === 'all'
689
+ const memory = (namespace === 'all' || namespace === null)
516
690
  ? stmts.getById.get(id)
517
- : stmts.getByIdNs.get(id, ns);
691
+ : stmts.getByIdNs.get(id, namespace);
518
692
  if (memory) {
519
693
  memory.provenance = getProvenance(id);
520
694
  }
@@ -527,7 +701,8 @@ export function getMemoryById(id, namespace = null) {
527
701
  * @returns {boolean} true if the memory existed and was updated
528
702
  */
529
703
  export function updateMemoryContent(id, content) {
530
- const result = stmts.updateContent.run(content, id);
704
+ const redactedContent = redactSecrets(content);
705
+ const result = stmts.updateContent.run(redactedContent, id);
531
706
  return result.changes > 0;
532
707
  }
533
708
 
@@ -547,8 +722,8 @@ export function deleteMemory(id) {
547
722
  stmts.deleteEdgesByMemory.run(id, id);
548
723
  deleteVec(id); // Remove vector first (no cascades on virtual tables)
549
724
  try {
550
- db.prepare('DELETE FROM provenance WHERE memory_id = ?').run(id);
551
- db.prepare('DELETE FROM contradictions WHERE old_memory_id = ? OR new_memory_id = ?').run(id, id);
725
+ stmts.deleteProvenanceByMemoryId.run(id);
726
+ stmts.deleteContradictionsByMemoryId.run(id, id);
552
727
  } catch (e) {
553
728
  console.error(`[persyst] Clean up provenance/contradictions error: ${e.message}`);
554
729
  }
@@ -615,8 +790,7 @@ export function boostMemory(id) {
615
790
  * Called automatically every hour by the server.
616
791
  */
617
792
  export function applyTemporalDecay() {
618
- const now = Math.floor(Date.now() / 1000);
619
- const result = stmts.decay.run(now);
793
+ const result = stmts.decay.run();
620
794
  if (result.changes > 0) {
621
795
  console.error(`[persyst] Decay applied to ${result.changes} memories`);
622
796
  }
@@ -696,6 +870,7 @@ export function getAllEntities(limit = 50) {
696
870
  * Delete an entity and its edges.
697
871
  */
698
872
  export function deleteEntity(id) {
873
+ stmts.deleteEdgesByEntity.run(id, id);
699
874
  stmts.deleteEntity.run(id);
700
875
  }
701
876
 
@@ -710,11 +885,7 @@ export function insertEdge(sourceId, targetId, relation, sourceType, targetType)
710
885
  * Get all memories linked to an entity.
711
886
  */
712
887
  export function getMemoriesByEntity(entityId) {
713
- const edges = db.prepare(`
714
- SELECT * FROM edges
715
- WHERE (source_id = ? AND source_type = 'entity' AND target_type = 'memory')
716
- OR (target_id = ? AND target_type = 'entity' AND source_type = 'memory')
717
- `).all(entityId, entityId);
888
+ const edges = stmts.getMemoriesByEntityEdges.all(entityId, entityId);
718
889
  const memoryIds = edges.map(e => e.source_type === 'memory' ? e.source_id : e.target_id);
719
890
  return memoryIds.map(id => stmts.getById.get(id)).filter(Boolean);
720
891
  }
@@ -749,7 +920,7 @@ export function memoryExistsByHashPrefix(pattern) {
749
920
  * @returns {number}
750
921
  */
751
922
  export function getActiveMemoryCount(namespace = null) {
752
- if (namespace) {
923
+ if (namespace && namespace !== 'all') {
753
924
  return stmts.getActiveMemoryCountNs.get(namespace).count;
754
925
  }
755
926
  return stmts.getActiveMemoryCount.get().count;
@@ -777,7 +948,7 @@ export function getMemoryByContent(content, namespace = null) {
777
948
  const row = namespace
778
949
  ? stmts.findMemoryByContentNs.get(content, namespace)
779
950
  : stmts.findMemoryByContent.get(content);
780
- return row ? getMemoryById(row.id) : null;
951
+ return row ? getMemoryById(row.id, namespace) : null;
781
952
  }
782
953
 
783
954
  // ============================================================
@@ -795,7 +966,7 @@ export function logContradiction(oldMemoryId, newMemoryId, reason = '') {
795
966
  try {
796
967
  const parentId = Math.min(oldMemoryId, newMemoryId);
797
968
  const childId = Math.max(oldMemoryId, newMemoryId);
798
- db.prepare('UPDATE memories SET parent_id = ? WHERE id = ?').run(parentId, childId);
969
+ stmts.updateMemoryParentId.run(parentId, childId);
799
970
  } catch (e) {
800
971
  console.error(`[persyst] Failed to set parent_id on contradiction: ${e.message}`);
801
972
  }
@@ -908,13 +1079,13 @@ export function getMemoryHistoryChain(memoryId) {
908
1079
  versions.add(currentId);
909
1080
 
910
1081
  // 1. Find parent (ancestor) from memories table
911
- const row = db.prepare('SELECT parent_id FROM memories WHERE id = ?').get(currentId);
1082
+ const row = stmts.getMemoryParentId.get(currentId);
912
1083
  if (row && row.parent_id !== null) {
913
1084
  if (!versions.has(row.parent_id)) queue.push(row.parent_id);
914
1085
  }
915
1086
 
916
1087
  // 2. Find children (descendants) from memories table
917
- const children = db.prepare('SELECT id FROM memories WHERE parent_id = ?').all(currentId);
1088
+ const children = stmts.getMemoryChildren.all(currentId);
918
1089
  for (const child of children) {
919
1090
  if (!versions.has(child.id)) queue.push(child.id);
920
1091
  }
@@ -991,6 +1162,23 @@ export function upsertWatchPosition(filePath, position) {
991
1162
  // CLEANUP
992
1163
  // ============================================================
993
1164
 
1165
+ /**
1166
+ * Archive transient memories (reminders and notes) older than 14 days.
1167
+ * Returns the count of archived memories.
1168
+ */
1169
+ export function archiveExpiredMemories() {
1170
+ try {
1171
+ const info = stmts.archiveExpiredTransientMemories.run();
1172
+ if (info.changes > 0) {
1173
+ console.error(`[persyst] Archived ${info.changes} expired transient memories (Note/Reminder older than 14 days).`);
1174
+ }
1175
+ return info.changes;
1176
+ } catch (e) {
1177
+ console.error(`[persyst] Failed to archive expired memories: ${e.message}`);
1178
+ return 0;
1179
+ }
1180
+ }
1181
+
994
1182
  /**
995
1183
  * Close the database connection. Call on shutdown.
996
1184
  */
@@ -999,4 +1187,9 @@ export function closeDatabase() {
999
1187
  console.error('[persyst] Database closed');
1000
1188
  }
1001
1189
 
1190
+ // Run auto-expiry cleanup on database startup to prune transient bloat immediately
1191
+ try {
1192
+ archiveExpiredMemories();
1193
+ } catch (_) {}
1194
+
1002
1195
  export default db;
package/src/events.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * events.js — Persyst In-Process Memory Event Bus
3
+ *
4
+ * A shared EventEmitter used by the HTTP gateway (SSE broadcasting),
5
+ * the log watcher, and the MCP tool handlers to signal memory changes
6
+ * in real-time without tight coupling.
7
+ *
8
+ * Events emitted:
9
+ * memory_added { id, content, namespace, source }
10
+ * memory_deleted { id }
11
+ * memories_consolidated { consolidated_groups, details }
12
+ */
13
+
14
+ import { EventEmitter } from 'events';
15
+
16
+ export const memoryEventBus = new EventEmitter();
17
+
18
+ // Support large swarms with many simultaneous SSE subscribers
19
+ memoryEventBus.setMaxListeners(500);