persyst-mcp 2.2.5 → 2.2.7

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.
@@ -9,7 +9,7 @@ import crypto from 'crypto';
9
9
  import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
10
10
  import { join } from 'path';
11
11
  import { homedir } from 'os';
12
- import db, { getLastAttestation, insertAttestation, getAttestationById } from './database.js';
12
+ import db, { stmts, getLastAttestation, insertAttestation, getAttestationById } from './database.js';
13
13
 
14
14
  const KEYS_DIR = join(homedir(), '.persyst', 'keys');
15
15
 
@@ -69,7 +69,7 @@ export function createAttestation(query, memories, agentId = null, sessionId = n
69
69
  return {
70
70
  id: m.id,
71
71
  content_hash: contentHash,
72
- score: parseFloat(scoreVal)
72
+ score: Math.round(parseFloat(scoreVal) * 10000)
73
73
  };
74
74
  });
75
75
 
@@ -135,6 +135,22 @@ export function verifyAttestationRecord(attestation) {
135
135
  };
136
136
 
137
137
  const dataToSign = JSON.stringify(doc);
138
+ const fullRecord = {
139
+ ...doc,
140
+ signature: attestation.signature
141
+ };
142
+ const computedHash = crypto.createHash('sha256').update(JSON.stringify(fullRecord)).digest('hex');
143
+
144
+ // Check hash first — if it matches, doc reconstruction is correct
145
+ const hashMatch = computedHash === attestation.hash;
146
+ if (!hashMatch) {
147
+ console.error('[persyst-attest] HASH MISMATCH for', attestation.attestation_id);
148
+ console.error('[persyst-attest] stored hash:', attestation.hash);
149
+ console.error('[persyst-attest] computed hash:', computedHash);
150
+ console.error('[persyst-attest] doc:', JSON.stringify(doc));
151
+ return { valid: false, error: 'Attestation hash mismatch' };
152
+ }
153
+
138
154
  const publicKey = getPublicKey();
139
155
 
140
156
  // Verify signature
@@ -150,20 +166,14 @@ export function verifyAttestationRecord(attestation) {
150
166
  );
151
167
 
152
168
  if (!isSignatureValid) {
169
+ console.error('[persyst-attest] SIG VERIFY FAIL for', attestation.attestation_id);
170
+ console.error('[persyst-attest] Hash matches but signature invalid');
171
+ console.error('[persyst-attest] dataToSign:', dataToSign);
172
+ console.error('[persyst-attest] signature:', attestation.signature);
173
+ console.error('[persyst-attest] public key:', publicKey);
153
174
  return { valid: false, error: 'Signature verification failed' };
154
175
  }
155
176
 
156
- // Verify hash integrity
157
- const fullRecord = {
158
- ...doc,
159
- signature: attestation.signature
160
- };
161
- const computedHash = crypto.createHash('sha256').update(JSON.stringify(fullRecord)).digest('hex');
162
-
163
- if (computedHash !== attestation.hash) {
164
- return { valid: false, error: 'Attestation hash mismatch' };
165
- }
166
-
167
177
  return { valid: true };
168
178
  } catch (err) {
169
179
  return { valid: false, error: err.message };
@@ -171,7 +181,10 @@ export function verifyAttestationRecord(attestation) {
171
181
  }
172
182
 
173
183
  /**
174
- * Verifies signature and chain integrity.
184
+ * Iteratively verifies signature and chain integrity.
185
+ * Walks backwards from the target attestation to the genesis link,
186
+ * confirming each previous_hash matches the predecessor's actual hash
187
+ * and that sequence order strictly increases.
175
188
  */
176
189
  export function verifyChainIntegrity(attestationId) {
177
190
  const att = getAttestationById(attestationId);
@@ -184,29 +197,37 @@ export function verifyChainIntegrity(attestationId) {
184
197
  return selfVerify;
185
198
  }
186
199
 
187
- // If there's a previous link, check it
188
- if (att.previous_hash) {
189
- const prevAtt = getAttestationByHash(att.previous_hash);
200
+ // Iterative chain walk no recursion, no stack overflow risk
201
+ const MAX_CHAIN_DEPTH = 10000;
202
+ let current = att;
203
+ let depth = 0;
204
+
205
+ while (current.previous_hash) {
206
+ if (depth >= MAX_CHAIN_DEPTH) {
207
+ return { valid: false, error: 'Broken chain: chain length exceeds maximum' };
208
+ }
209
+
210
+ const prevAtt = stmts.getAttestationByHash.get(current.previous_hash);
190
211
  if (!prevAtt) {
191
- return { valid: false, error: `Broken chain: Previous attestation with hash ${att.previous_hash} not found` };
212
+ return { valid: false, error: `Broken chain: Previous attestation with hash ${current.previous_hash} not found` };
192
213
  }
193
214
 
194
- if (prevAtt.id >= att.id) {
195
- return { valid: false, error: `Broken chain: Invalid sequence order` };
215
+ if (prevAtt.hash !== current.previous_hash) {
216
+ return { valid: false, error: 'Broken chain: previous_hash does not match predecessor hash' };
217
+ }
218
+
219
+ if (prevAtt.id >= current.id) {
220
+ return { valid: false, error: 'Broken chain: Invalid sequence order' };
196
221
  }
197
222
 
198
223
  const prevVerify = verifyAttestationRecord(prevAtt);
199
224
  if (!prevVerify.valid) {
200
225
  return { valid: false, error: `Broken chain: Previous link is invalid: ${prevVerify.error}` };
201
226
  }
202
- }
203
227
 
204
- return { valid: true, attestation: att };
205
- }
228
+ current = prevAtt;
229
+ depth++;
230
+ }
206
231
 
207
- /**
208
- * Helper to fetch attestation by hash since it's not exposed globally.
209
- */
210
- function getAttestationByHash(hash) {
211
- return db.prepare('SELECT * FROM attestations WHERE hash = ?').get(hash) || null;
232
+ return { valid: true, attestation: current };
212
233
  }
package/src/cache.js CHANGED
@@ -10,6 +10,8 @@
10
10
  * - Full invalidation on write operations
11
11
  */
12
12
 
13
+ import { logInfo } from './text-utils.js';
14
+
13
15
  /**
14
16
  * Simple LRU (Least Recently Used) cache with TTL support.
15
17
  */
@@ -97,7 +99,7 @@ export class LRUCache {
97
99
  const size = this.cache.size;
98
100
  this.cache.clear();
99
101
  if (size > 0) {
100
- console.error(`[persyst-cache] Invalidated ${size} cached entries`);
102
+ logInfo(`[persyst-cache] Invalidated ${size} cached entries`);
101
103
  }
102
104
  }
103
105
 
package/src/database.js CHANGED
@@ -17,6 +17,8 @@ import { join } from 'path';
17
17
  import { homedir } from 'os';
18
18
  import { mkdirSync } from 'fs';
19
19
 
20
+ import { logInfo } from './text-utils.js';
21
+
20
22
  // ============================================================
21
23
  // DATABASE LOCATION
22
24
  // Store in ~/.persyst/ per default to persist across sessions
@@ -41,7 +43,7 @@ db.pragma('cache_size = -64000'); // 64MB cache size
41
43
  // Load sqlite-vec BEFORE creating any vec0 tables
42
44
  sqliteVec.load(db);
43
45
 
44
- console.error(`[persyst] Database: ${DB_PATH}`);
46
+ logInfo(`[persyst] Database: ${DB_PATH}`);
45
47
 
46
48
  // ============================================================
47
49
  // CREATE TABLES & SCHEMA MIGRATIONS
@@ -63,27 +65,32 @@ db.exec(`
63
65
  `);
64
66
 
65
67
  // --- Migrations for bi-temporal validity on existing tables ---
66
- try {
68
+ function columnExists(table, name) {
69
+ const info = db.prepare(`PRAGMA table_info(${table})`).all();
70
+ return info.some(col => col.name === name);
71
+ }
72
+
73
+ if (!columnExists('memories', 'valid_from')) {
67
74
  db.exec('ALTER TABLE memories ADD COLUMN valid_from INTEGER DEFAULT (unixepoch())');
68
- } catch (e) { /* Column already exists */ }
75
+ }
69
76
 
70
- try {
77
+ if (!columnExists('memories', 'valid_until')) {
71
78
  db.exec('ALTER TABLE memories ADD COLUMN valid_until INTEGER DEFAULT NULL');
72
- } catch (e) { /* Column already exists */ }
79
+ }
73
80
 
74
- try {
81
+ if (!columnExists('memories', 'assertion_time')) {
75
82
  db.exec('ALTER TABLE memories ADD COLUMN assertion_time INTEGER DEFAULT (unixepoch())');
76
- } catch (e) { /* Column already exists */ }
83
+ }
77
84
 
78
85
  // --- Migration: add namespace column for per-agent isolation ---
79
- try {
86
+ if (!columnExists('memories', 'namespace')) {
80
87
  db.exec("ALTER TABLE memories ADD COLUMN namespace TEXT DEFAULT 'shared'");
81
- } catch (e) { /* Column already exists */ }
88
+ }
82
89
 
83
90
  // --- Migration: add parent_id column for history tracing ---
84
- try {
91
+ if (!columnExists('memories', 'parent_id')) {
85
92
  db.exec('ALTER TABLE memories ADD COLUMN parent_id INTEGER DEFAULT NULL');
86
- } catch (e) { /* Column already exists */ }
93
+ }
87
94
 
88
95
  // --- Index on namespace for fast filtered queries ---
89
96
  try {
@@ -224,7 +231,7 @@ db.exec(`
224
231
  )
225
232
  `);
226
233
 
227
- console.error('[persyst] Schema initialized ✓');
234
+ logInfo('[persyst] Schema initialized ✓');
228
235
 
229
236
  // ============================================================
230
237
  // PREPARED STATEMENTS
@@ -367,6 +374,11 @@ const stmts = {
367
374
  deleteEntity: db.prepare(
368
375
  'DELETE FROM entities WHERE id = ?'
369
376
  ),
377
+ deleteEdgesByEntity: db.prepare(
378
+ `DELETE FROM edges WHERE
379
+ (source_id = ? AND source_type = 'entity') OR
380
+ (target_id = ? AND target_type = 'entity')`
381
+ ),
370
382
 
371
383
  // -- Edges --
372
384
  insertEdge: db.prepare(
@@ -426,9 +438,171 @@ const stmts = {
426
438
  INSERT INTO watched_files (file_path, last_position)
427
439
  VALUES (?, ?)
428
440
  ON CONFLICT(file_path) DO UPDATE SET last_position = excluded.last_position, updated_at = unixepoch()
441
+ `),
442
+
443
+ // -- Internal lookups (pre-compiled for hot-loop use) --
444
+ getAttestationByHash: db.prepare(
445
+ 'SELECT * FROM attestations WHERE hash = ?'
446
+ ),
447
+ getMemoryParentId: db.prepare(
448
+ 'SELECT parent_id FROM memories WHERE id = ?'
449
+ ),
450
+ getMemoryChildren: db.prepare(
451
+ 'SELECT id FROM memories WHERE parent_id = ?'
452
+ ),
453
+ getMemoryContentById: db.prepare(
454
+ 'SELECT content FROM memories WHERE id = ?'
455
+ ),
456
+ getMemoryByIdRaw: db.prepare(
457
+ 'SELECT * FROM memories WHERE id = ? AND valid_until IS NULL'
458
+ ),
459
+ getMemoryLikeContent: db.prepare(
460
+ 'SELECT id FROM memories WHERE content LIKE ? AND valid_until IS NULL'
461
+ ),
462
+ getVecByRowId: db.prepare(
463
+ 'SELECT embedding FROM memories_vec WHERE rowid = ?'
464
+ ),
465
+ updateMemoryParentId: db.prepare(
466
+ 'UPDATE memories SET parent_id = ? WHERE id = ?'
467
+ ),
468
+ deleteProvenanceByMemoryId: db.prepare(
469
+ 'DELETE FROM provenance WHERE memory_id = ?'
470
+ ),
471
+ deleteContradictionsByMemoryId: db.prepare(
472
+ 'DELETE FROM contradictions WHERE old_memory_id = ? OR new_memory_id = ?'
473
+ ),
474
+ getReputationScore: db.prepare(
475
+ 'SELECT reputation_score FROM agent_stats WHERE agent_id = ?'
476
+ ),
477
+ updateProvenanceOwner: db.prepare(
478
+ "UPDATE provenance SET source_type = 'agent', source_id = ?, confidence = 1.0 WHERE memory_id = ?"
479
+ ),
480
+ archiveMemoryById: db.prepare(
481
+ 'UPDATE memories SET valid_until = unixepoch() WHERE id = ?'
482
+ ),
483
+ getEdgesBySourceAndType: db.prepare(`
484
+ SELECT * FROM edges
485
+ WHERE (source_id = ? AND source_type = ?)
486
+ OR (target_id = ? AND target_type = ?)
487
+ `),
488
+ getMemoriesByEntityEdges: db.prepare(`
489
+ SELECT * FROM edges
490
+ WHERE (source_id = ? AND source_type = 'entity' AND target_type = 'memory')
491
+ OR (target_id = ? AND target_type = 'entity' AND source_type = 'memory')
492
+ `),
493
+ consolidateVecSearch: db.prepare(`
494
+ SELECT rowid AS id, distance
495
+ FROM memories_vec
496
+ WHERE embedding MATCH ?
497
+ AND k = 30
498
+ `),
499
+ archiveAndInsertContradiction: db.prepare(
500
+ 'UPDATE memories SET valid_until = unixepoch() WHERE id = ?'
501
+ ),
502
+ archiveExpiredTransientMemories: db.prepare(`
503
+ UPDATE memories
504
+ SET valid_until = unixepoch()
505
+ WHERE valid_until IS NULL
506
+ AND (content LIKE 'Reminder:%' OR content LIKE 'Note:%')
507
+ AND (unixepoch() - created_at) > 1209600
429
508
  `)
430
509
  };
431
510
 
511
+ export { stmts };
512
+
513
+ // ============================================================
514
+ // SECRET DETECTION & REDACTION HELPERS
515
+ // ============================================================
516
+
517
+ /**
518
+ * Detects sensitive/credential patterns in a string and replaces values with [REDACTED].
519
+ * @param {string} content - The content to sanitize
520
+ * @returns {string} Sanitized content
521
+ */
522
+ export function redactSecrets(content) {
523
+ if (!content || typeof content !== 'string') return content;
524
+
525
+ let redacted = content;
526
+
527
+ // 1. Redact credentials in connection strings / URIs
528
+ // Matches scheme://user:pass@host and scheme://:pass@host
529
+ const connectionStringRegex = /\b([a-zA-Z0-9+.-]+:\/\/)([^/:\s]*):([^@/:\s]+)(@[^/\s]+)/gi;
530
+ redacted = redacted.replace(connectionStringRegex, (match, protocol, user, pass, host) => {
531
+ return protocol + user + ':[REDACTED]' + host;
532
+ });
533
+
534
+ // 2. Redact key-value pairs matching credentials (retaining key/operator, redacting value)
535
+ // Supports single-quoted, double-quoted, and unquoted values (non-whitespace).
536
+ 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;
537
+
538
+ redacted = redacted.replace(kvRegex, (match, key, op, sqVal, dqVal, uqVal) => {
539
+ const val = sqVal || dqVal || uqVal;
540
+ if (!val) return match;
541
+
542
+ // Strip trailing parenthesis if operator is '(' and value has trailing parenthesis
543
+ let cleanVal = val;
544
+ if (op === '(' && val.endsWith(')')) {
545
+ cleanVal = val.slice(0, -1);
546
+ }
547
+
548
+ const lastIdx = match.lastIndexOf(cleanVal);
549
+ if (lastIdx !== -1) {
550
+ return match.slice(0, lastIdx) + '[REDACTED]' + match.slice(lastIdx + cleanVal.length);
551
+ }
552
+ return match;
553
+ });
554
+
555
+ // 3. Redact standalone common API keys and tokens
556
+ const standalonePatterns = [
557
+ /\b(sk-[a-zA-Z0-9]{48})\b/g, // OpenAI
558
+ /\b(sk-proj-[a-zA-Z0-9-]{40,})\b/g, // OpenAI project
559
+ /\b(gh[pous]_[a-zA-Z0-9]{36,255})\b/g, // GitHub PAT/Fine-grained
560
+ /\b(xox[bapr]-[0-9]{12}-[a-zA-Z0-9]{24})\b/g, // Slack token
561
+ /\b(AIzaSy[A-Za-z0-9_-]{33})\b/g, // Google API key
562
+ /\b((?:sk|rk|pk)_(?:live|test)_[0-9a-zA-Z]{24,32})\b/g, // Stripe key
563
+ /\b(AKIA[0-9A-Z]{16,40})\b/gi, // AWS Access Key ID (case-insensitive)
564
+ /\b(ASCA[0-9A-Z]{16,40})\b/gi, // AWS ASCA Key
565
+ /\b(npm_[a-zA-Z0-9]{36,255})\b/g, // npm token
566
+ /\b(ey[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,})\b/g, // JWT token
567
+ /-----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
568
+ ];
569
+
570
+ for (const pattern of standalonePatterns) {
571
+ redacted = redacted.replace(pattern, '[REDACTED]');
572
+ }
573
+
574
+ // 4. Robust credential shape heuristic (independent of strict key-value punctuation)
575
+ const containsCredKeyword = /\b(password|passwd|pwd|passphrase|pass|secret|token|api|credential|auth|ssh|aws)\b/i.test(redacted);
576
+ if (containsCredKeyword) {
577
+ // Match password-like tokens: length 6 to 64, containing both letters and digits/symbols
578
+ const tokenRegex = /\b([a-zA-Z0-9_@#$%^&*+!~\-]{6,64})(?!\w)/g;
579
+
580
+ redacted = redacted.replace(tokenRegex, (match) => {
581
+ // Skip common query words and technical keywords
582
+ 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)) {
583
+ return match;
584
+ }
585
+
586
+ const hasLetter = /[a-zA-Z]/.test(match);
587
+ const hasDigitOrSpecialSymbol = /[0-9@#$%^&*+=!~]/.test(match);
588
+
589
+ if (hasLetter && hasDigitOrSpecialSymbol) {
590
+ // Require length >= 8 or containing special symbols/mixed case digits
591
+ const isStrongSecretCandidate = match.length >= 8 ||
592
+ (/[^a-zA-Z0-9_]/.test(match)) ||
593
+ (/[A-Z]/.test(match) && /[0-9]/.test(match));
594
+
595
+ if (isStrongSecretCandidate) {
596
+ return '[REDACTED]';
597
+ }
598
+ }
599
+ return match;
600
+ });
601
+ }
602
+
603
+ return redacted;
604
+ }
605
+
432
606
  // ============================================================
433
607
  // CRUD FUNCTIONS
434
608
  // Simple, one-purpose functions. No magic.
@@ -443,11 +617,12 @@ const stmts = {
443
617
  * @returns {number} The new memory's ID
444
618
  */
445
619
  export function insertMemory(content, importance = 1.0, provenanceInfo = null, namespace = 'shared', parentId = null) {
446
- if (content && content.length > 10000) {
620
+ const redactedContent = redactSecrets(content);
621
+ if (redactedContent && redactedContent.length > 10000) {
447
622
  throw new Error('Memory content exceeds maximum length of 10000 characters.');
448
623
  }
449
624
  const clampedImportance = Math.max(0.0, Math.min(1.0, Math.round(importance * 10000) / 10000));
450
- const result = stmts.insertMemory.run(content, clampedImportance, namespace || 'shared', parentId);
625
+ const result = stmts.insertMemory.run(redactedContent, clampedImportance, namespace || 'shared', parentId);
451
626
  const id = Number(result.lastInsertRowid);
452
627
 
453
628
  // Provenance Info handling
@@ -484,13 +659,12 @@ export function insertVector(id, embedding) {
484
659
  * @returns {object|null} The memory row, or null if not found
485
660
  */
486
661
  export function getMemory(id, namespace = null) {
487
- const memory = namespace
488
- ? stmts.getByIdNs.get(id, namespace)
489
- : stmts.getById.get(id);
662
+ const memory = (namespace === 'all' || namespace === null)
663
+ ? stmts.getById.get(id)
664
+ : stmts.getByIdNs.get(id, namespace);
490
665
  if (memory) {
491
666
  boostMemory(id);
492
- const prov = getProvenance(id);
493
- memory.provenance = prov;
667
+ memory.provenance = getProvenance(id);
494
668
  }
495
669
  return memory || null;
496
670
  }
@@ -514,10 +688,9 @@ export function getAnyMemoryById(id) {
514
688
  * @returns {object|null} The memory row, or null if not found
515
689
  */
516
690
  export function getMemoryById(id, namespace = null) {
517
- const ns = namespace || 'shared';
518
- const memory = ns === 'all'
691
+ const memory = (namespace === 'all' || namespace === null)
519
692
  ? stmts.getById.get(id)
520
- : stmts.getByIdNs.get(id, ns);
693
+ : stmts.getByIdNs.get(id, namespace);
521
694
  if (memory) {
522
695
  memory.provenance = getProvenance(id);
523
696
  }
@@ -530,7 +703,8 @@ export function getMemoryById(id, namespace = null) {
530
703
  * @returns {boolean} true if the memory existed and was updated
531
704
  */
532
705
  export function updateMemoryContent(id, content) {
533
- const result = stmts.updateContent.run(content, id);
706
+ const redactedContent = redactSecrets(content);
707
+ const result = stmts.updateContent.run(redactedContent, id);
534
708
  return result.changes > 0;
535
709
  }
536
710
 
@@ -550,8 +724,8 @@ export function deleteMemory(id) {
550
724
  stmts.deleteEdgesByMemory.run(id, id);
551
725
  deleteVec(id); // Remove vector first (no cascades on virtual tables)
552
726
  try {
553
- db.prepare('DELETE FROM provenance WHERE memory_id = ?').run(id);
554
- db.prepare('DELETE FROM contradictions WHERE old_memory_id = ? OR new_memory_id = ?').run(id, id);
727
+ stmts.deleteProvenanceByMemoryId.run(id);
728
+ stmts.deleteContradictionsByMemoryId.run(id, id);
555
729
  } catch (e) {
556
730
  console.error(`[persyst] Clean up provenance/contradictions error: ${e.message}`);
557
731
  }
@@ -698,6 +872,7 @@ export function getAllEntities(limit = 50) {
698
872
  * Delete an entity and its edges.
699
873
  */
700
874
  export function deleteEntity(id) {
875
+ stmts.deleteEdgesByEntity.run(id, id);
701
876
  stmts.deleteEntity.run(id);
702
877
  }
703
878
 
@@ -712,11 +887,7 @@ export function insertEdge(sourceId, targetId, relation, sourceType, targetType)
712
887
  * Get all memories linked to an entity.
713
888
  */
714
889
  export function getMemoriesByEntity(entityId) {
715
- const edges = db.prepare(`
716
- SELECT * FROM edges
717
- WHERE (source_id = ? AND source_type = 'entity' AND target_type = 'memory')
718
- OR (target_id = ? AND target_type = 'entity' AND source_type = 'memory')
719
- `).all(entityId, entityId);
890
+ const edges = stmts.getMemoriesByEntityEdges.all(entityId, entityId);
720
891
  const memoryIds = edges.map(e => e.source_type === 'memory' ? e.source_id : e.target_id);
721
892
  return memoryIds.map(id => stmts.getById.get(id)).filter(Boolean);
722
893
  }
@@ -779,7 +950,7 @@ export function getMemoryByContent(content, namespace = null) {
779
950
  const row = namespace
780
951
  ? stmts.findMemoryByContentNs.get(content, namespace)
781
952
  : stmts.findMemoryByContent.get(content);
782
- return row ? getMemoryById(row.id) : null;
953
+ return row ? getMemoryById(row.id, namespace) : null;
783
954
  }
784
955
 
785
956
  // ============================================================
@@ -797,7 +968,7 @@ export function logContradiction(oldMemoryId, newMemoryId, reason = '') {
797
968
  try {
798
969
  const parentId = Math.min(oldMemoryId, newMemoryId);
799
970
  const childId = Math.max(oldMemoryId, newMemoryId);
800
- db.prepare('UPDATE memories SET parent_id = ? WHERE id = ?').run(parentId, childId);
971
+ stmts.updateMemoryParentId.run(parentId, childId);
801
972
  } catch (e) {
802
973
  console.error(`[persyst] Failed to set parent_id on contradiction: ${e.message}`);
803
974
  }
@@ -910,13 +1081,13 @@ export function getMemoryHistoryChain(memoryId) {
910
1081
  versions.add(currentId);
911
1082
 
912
1083
  // 1. Find parent (ancestor) from memories table
913
- const row = db.prepare('SELECT parent_id FROM memories WHERE id = ?').get(currentId);
1084
+ const row = stmts.getMemoryParentId.get(currentId);
914
1085
  if (row && row.parent_id !== null) {
915
1086
  if (!versions.has(row.parent_id)) queue.push(row.parent_id);
916
1087
  }
917
1088
 
918
1089
  // 2. Find children (descendants) from memories table
919
- const children = db.prepare('SELECT id FROM memories WHERE parent_id = ?').all(currentId);
1090
+ const children = stmts.getMemoryChildren.all(currentId);
920
1091
  for (const child of children) {
921
1092
  if (!versions.has(child.id)) queue.push(child.id);
922
1093
  }
@@ -993,6 +1164,23 @@ export function upsertWatchPosition(filePath, position) {
993
1164
  // CLEANUP
994
1165
  // ============================================================
995
1166
 
1167
+ /**
1168
+ * Archive transient memories (reminders and notes) older than 14 days.
1169
+ * Returns the count of archived memories.
1170
+ */
1171
+ export function archiveExpiredMemories() {
1172
+ try {
1173
+ const info = stmts.archiveExpiredTransientMemories.run();
1174
+ if (info.changes > 0) {
1175
+ console.error(`[persyst] Archived ${info.changes} expired transient memories (Note/Reminder older than 14 days).`);
1176
+ }
1177
+ return info.changes;
1178
+ } catch (e) {
1179
+ console.error(`[persyst] Failed to archive expired memories: ${e.message}`);
1180
+ return 0;
1181
+ }
1182
+ }
1183
+
996
1184
  /**
997
1185
  * Close the database connection. Call on shutdown.
998
1186
  */
@@ -1001,4 +1189,9 @@ export function closeDatabase() {
1001
1189
  console.error('[persyst] Database closed');
1002
1190
  }
1003
1191
 
1192
+ // Run auto-expiry cleanup on database startup to prune transient bloat immediately
1193
+ try {
1194
+ archiveExpiredMemories();
1195
+ } catch (_) {}
1196
+
1004
1197
  export default db;
package/src/embeddings.js CHANGED
@@ -19,6 +19,8 @@ env.useWasmCache = false;
19
19
  // The embedding pipeline (lazy-loaded on first use)
20
20
  let extractor = null;
21
21
 
22
+ import { logInfo } from './text-utils.js';
23
+
22
24
  /**
23
25
  * Load the embedding model. Called automatically on first use.
24
26
  * First run downloads the model (~50MB). Subsequent runs use cache.
@@ -26,9 +28,9 @@ let extractor = null;
26
28
  async function loadModel() {
27
29
  if (extractor) return;
28
30
 
29
- console.error('[persyst] Loading embedding model (first run downloads ~50MB)...');
31
+ logInfo('[persyst] Loading embedding model (first run downloads ~50MB)...');
30
32
  extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
31
- console.error('[persyst] Embedding model loaded ✓');
33
+ logInfo('[persyst] Embedding model loaded ✓');
32
34
  }
33
35
 
34
36
  /**
package/src/events.js CHANGED
@@ -8,6 +8,8 @@
8
8
  * Events emitted:
9
9
  * memory_added { id, content, namespace, source }
10
10
  * memory_deleted { id }
11
+ * memory_updated { old_id, new_id, namespace }
12
+ * memory_retrieved { tool, query, count, agent_id, namespace, memory_ids, token_budget? }
11
13
  * memories_consolidated { consolidated_groups, details }
12
14
  */
13
15
 
@@ -425,15 +425,18 @@ export function extractHeuristic(text, options = {}) {
425
425
  return [];
426
426
  }
427
427
 
428
+ // Strip all markdown fenced code blocks to prevent extracting facts from example code/logs
429
+ const cleanSourceText = text.replace(/```[\s\S]*?```/g, '');
430
+
428
431
  // --- Step 1: Explicit saves (highest priority, no filter) ---
429
- const explicitFacts = extractExplicitSaves(text);
432
+ const explicitFacts = extractExplicitSaves(cleanSourceText);
430
433
 
431
434
  // --- Step 2: Implicit pattern matching (filtered, tech-required) ---
432
435
  const implicitFacts = [];
433
436
  const seen = new Set(explicitFacts.map(f => f.content.toLowerCase().replace(/\s+/g, ' ').trim()));
434
437
 
435
438
  // Process line-by-line to filter code/noise
436
- const lines = text.split('\n');
439
+ const lines = cleanSourceText.split('\n');
437
440
  const cleanLines = lines.filter(line => !isNoiseLine(line));
438
441
  const cleanText = cleanLines.join('\n');
439
442
 
package/src/sdk.js CHANGED
@@ -101,17 +101,18 @@ export class Persyst {
101
101
  * @private
102
102
  */
103
103
  async _trackLibrary({ content, importance, agent_id, session_id, shared }) {
104
- const { insertMemory, insertVector } = await import('./database.js');
104
+ const { insertMemory, insertVector, redactSecrets } = await import('./database.js');
105
105
  const { generateEmbedding } = await import('./embeddings.js');
106
106
 
107
107
  const namespace = shared ? 'shared' : agent_id;
108
- const id = insertMemory(content, importance, {
108
+ const redactedContent = redactSecrets ? redactSecrets(content) : content;
109
+ const id = insertMemory(redactedContent, importance, {
109
110
  source_type: 'api',
110
111
  source_id: agent_id,
111
112
  confidence: 1.0
112
113
  }, namespace);
113
114
 
114
- const embedding = await generateEmbedding(content);
115
+ const embedding = await generateEmbedding(redactedContent);
115
116
  insertVector(id, embedding);
116
117
  return { success: true, id };
117
118
  }