persyst-mcp 1.1.0 → 2.1.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/bin/aider.js ADDED
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * persyst-aider — Aider wrapper with automatic Persyst memory injection
5
+ *
6
+ * Usage:
7
+ * npx persyst-aider [aider-args...]
8
+ *
9
+ * Examples:
10
+ * npx persyst-aider --model anthropic/claude-sonnet-4
11
+ * npx persyst-aider --model openai/gpt-4o --auto-commits
12
+ *
13
+ * How it works:
14
+ * 1. On startup: connects to Persyst, ingests recent git commits
15
+ * 2. Before each prompt: queries Persyst for relevant memories, prepends context
16
+ * 3. On exit: ingests any new git commits created during the session
17
+ *
18
+ * Design decisions:
19
+ * - Only enriches prompts > 15 chars (skip "y", "ok", "/run", etc.)
20
+ * - Does NOT parse Aider's output (too fragile with ANSI codes, streaming, etc.)
21
+ * - Passes all args directly to Aider — fully transparent proxy
22
+ */
23
+
24
+ import { spawn } from 'child_process';
25
+ import { createInterface } from 'readline';
26
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
27
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
28
+ import { fileURLToPath } from 'url';
29
+ import { dirname, resolve } from 'path';
30
+
31
+ const __filename = fileURLToPath(import.meta.url);
32
+ const __dirname = dirname(__filename);
33
+
34
+ const MIN_PROMPT_LENGTH = 15;
35
+ const CWD = process.cwd();
36
+
37
+ // ============================================================
38
+ // MCP CLIENT
39
+ // ============================================================
40
+
41
+ let persystClient = null;
42
+
43
+ async function connectToPersyst() {
44
+ if (persystClient) return persystClient;
45
+
46
+ const persystPath = resolve(__dirname, '..', 'index.js');
47
+
48
+ const transport = new StdioClientTransport({
49
+ command: 'node',
50
+ args: [persystPath]
51
+ });
52
+
53
+ persystClient = new Client({
54
+ name: 'persyst-aider',
55
+ version: '1.0.0'
56
+ });
57
+
58
+ await persystClient.connect(transport);
59
+ return persystClient;
60
+ }
61
+
62
+ async function callTool(toolName, args) {
63
+ const client = await connectToPersyst();
64
+ const result = await client.callTool({ name: toolName, arguments: args });
65
+ if (result.content && result.content[0] && result.content[0].text) {
66
+ return JSON.parse(result.content[0].text);
67
+ }
68
+ return null;
69
+ }
70
+
71
+ async function closePersyst() {
72
+ if (persystClient) {
73
+ try { await persystClient.close(); } catch (_) {}
74
+ persystClient = null;
75
+ }
76
+ }
77
+
78
+ // ============================================================
79
+ // MEMORY FUNCTIONS
80
+ // ============================================================
81
+
82
+ /**
83
+ * Ingest recent git commits from the current directory.
84
+ */
85
+ async function ingestGitCommits() {
86
+ try {
87
+ const result = await callTool('ingest_git_commits', {
88
+ repo_path: CWD,
89
+ count: 15
90
+ });
91
+ if (result && result.added > 0) {
92
+ console.error(`[persyst] Ingested ${result.added} git commits into memory`);
93
+ }
94
+ } catch (_) {
95
+ // Not a git repo or Persyst unavailable — silent
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Search for memories relevant to the user's prompt.
101
+ * Returns a formatted context string or null.
102
+ */
103
+ async function getMemoryContext(prompt) {
104
+ try {
105
+ const result = await callTool('search_memories', {
106
+ query: prompt.slice(0, 200),
107
+ limit: 5,
108
+ agent_id: 'aider'
109
+ });
110
+
111
+ if (!result || !result.results || result.results.length === 0) {
112
+ return null;
113
+ }
114
+
115
+ const lines = ['[Persyst Memory — auto-retrieved context]'];
116
+ for (const mem of result.results) {
117
+ lines.push(`• ${mem.content}`);
118
+ }
119
+ lines.push('[End Memory]');
120
+ lines.push('');
121
+
122
+ return lines.join('\n');
123
+ } catch (_) {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ // ============================================================
129
+ // MAIN
130
+ // ============================================================
131
+
132
+ async function main() {
133
+ const aiderArgs = process.argv.slice(2);
134
+
135
+ // Check if Aider is available
136
+ console.error('[persyst] Starting Aider with Persyst memory...');
137
+
138
+ // Step 1: Connect to Persyst and ingest git history
139
+ try {
140
+ await connectToPersyst();
141
+ console.error('[persyst] Connected to memory server ✓');
142
+ await ingestGitCommits();
143
+ } catch (err) {
144
+ console.error(`[persyst] Warning: Could not connect to memory server: ${err.message}`);
145
+ console.error('[persyst] Aider will run without memory injection.');
146
+ }
147
+
148
+ // Step 2: Spawn Aider as a child process
149
+ const aider = spawn('aider', aiderArgs, {
150
+ stdio: ['pipe', 'inherit', 'inherit'],
151
+ shell: true,
152
+ cwd: CWD
153
+ });
154
+
155
+ // Step 3: Set up stdin interception
156
+ const rl = createInterface({
157
+ input: process.stdin,
158
+ terminal: false
159
+ });
160
+
161
+ rl.on('line', async (line) => {
162
+ const trimmed = line.trim();
163
+
164
+ // Only enrich prompts that are long enough to be real questions
165
+ if (trimmed.length >= MIN_PROMPT_LENGTH && persystClient) {
166
+ try {
167
+ const context = await getMemoryContext(trimmed);
168
+ if (context) {
169
+ // Prepend memory context to the prompt
170
+ aider.stdin.write(context);
171
+ console.error(`[persyst] Injected ${context.split('\n').length - 3} memories`);
172
+ }
173
+ } catch (_) {
174
+ // Memory injection failed — just pass through the original prompt
175
+ }
176
+ }
177
+
178
+ // Always forward the original line to Aider
179
+ aider.stdin.write(line + '\n');
180
+ });
181
+
182
+ // Handle user Ctrl+C
183
+ process.on('SIGINT', () => {
184
+ aider.kill('SIGINT');
185
+ });
186
+
187
+ // Step 4: On Aider exit, ingest any new commits and clean up
188
+ aider.on('close', async (code) => {
189
+ console.error('[persyst] Aider session ended. Indexing new commits...');
190
+ await ingestGitCommits();
191
+ await closePersyst();
192
+ process.exit(code || 0);
193
+ });
194
+
195
+ // Handle stdin close (user closed terminal)
196
+ rl.on('close', () => {
197
+ aider.stdin.end();
198
+ });
199
+ }
200
+
201
+ main().catch(err => {
202
+ console.error(`[persyst] Fatal error: ${err.message}`);
203
+ process.exit(1);
204
+ });
package/bin/setup.js ADDED
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * persyst-setup — One-command installer for Persyst Claude Code hooks
5
+ *
6
+ * Usage:
7
+ * npx persyst-mcp setup
8
+ *
9
+ * What it does:
10
+ * 1. Copies persyst-hook.js to ~/.persyst/hooks/
11
+ * 2. Creates or merges ~/.claude/settings.json with hook registrations
12
+ * 3. Prints success message with instructions
13
+ *
14
+ * Design:
15
+ * - Non-destructive: merges with existing settings, never overwrites
16
+ * - Cross-platform: works on Windows, macOS, and Linux
17
+ * - Idempotent: safe to run multiple times
18
+ */
19
+
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'fs';
21
+ import { join, resolve, dirname } from 'path';
22
+ import { homedir } from 'os';
23
+ import { fileURLToPath } from 'url';
24
+
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ const __dirname = dirname(__filename);
27
+
28
+ // ============================================================
29
+ // PATHS
30
+ // ============================================================
31
+
32
+ const HOME = homedir();
33
+ const PERSYST_DIR = join(HOME, '.persyst');
34
+ const PERSYST_HOOKS_DIR = join(PERSYST_DIR, 'hooks');
35
+ const HOOK_DEST = join(PERSYST_HOOKS_DIR, 'persyst-hook.js');
36
+
37
+ const CLAUDE_DIR = join(HOME, '.claude');
38
+ const CLAUDE_SETTINGS = join(CLAUDE_DIR, 'settings.json');
39
+
40
+ // Source hook file — shipped with the npm package
41
+ const HOOK_SOURCE = resolve(__dirname, '..', 'hooks', 'persyst-hook.js');
42
+
43
+ // ============================================================
44
+ // HOOK CONFIGURATION
45
+ // ============================================================
46
+
47
+ const HOOK_ENTRY = {
48
+ type: 'command',
49
+ command: `node "${HOOK_DEST}"`
50
+ };
51
+
52
+ const HOOK_CONFIG = {
53
+ SessionStart: [
54
+ {
55
+ matcher: '',
56
+ hooks: [{ ...HOOK_ENTRY }]
57
+ }
58
+ ],
59
+ UserPromptSubmit: [
60
+ {
61
+ matcher: '',
62
+ hooks: [{ ...HOOK_ENTRY, timeout: 10 }]
63
+ }
64
+ ]
65
+ };
66
+
67
+ // ============================================================
68
+ // HELPERS
69
+ // ============================================================
70
+
71
+ function ensureDir(dir) {
72
+ if (!existsSync(dir)) {
73
+ mkdirSync(dir, { recursive: true });
74
+ }
75
+ }
76
+
77
+ function readJsonFile(filePath) {
78
+ try {
79
+ const raw = readFileSync(filePath, 'utf8');
80
+ return JSON.parse(raw);
81
+ } catch {
82
+ return {};
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Merge Persyst hook entries into existing settings.
88
+ * Does NOT overwrite existing hooks — appends Persyst entries if not already present.
89
+ */
90
+ function mergeHookSettings(existing) {
91
+ const settings = { ...existing };
92
+ if (!settings.hooks) {
93
+ settings.hooks = {};
94
+ }
95
+
96
+ for (const [eventName, hookGroups] of Object.entries(HOOK_CONFIG)) {
97
+ if (!settings.hooks[eventName]) {
98
+ settings.hooks[eventName] = [];
99
+ }
100
+
101
+ // Check if a Persyst hook is already registered
102
+ const alreadyRegistered = settings.hooks[eventName].some(group =>
103
+ group.hooks && group.hooks.some(h =>
104
+ h.command && h.command.includes('persyst-hook')
105
+ )
106
+ );
107
+
108
+ if (!alreadyRegistered) {
109
+ settings.hooks[eventName].push(...hookGroups);
110
+ }
111
+ }
112
+
113
+ return settings;
114
+ }
115
+
116
+ // ============================================================
117
+ // MAIN
118
+ // ============================================================
119
+
120
+ function run() {
121
+ console.log('');
122
+ console.log(' 🧠 Persyst — Claude Code Hook Setup');
123
+ console.log(' ════════════════════════════════════');
124
+ console.log('');
125
+
126
+ // Step 1: Verify hook source exists
127
+ if (!existsSync(HOOK_SOURCE)) {
128
+ console.error(` ❌ Hook source not found at: ${HOOK_SOURCE}`);
129
+ console.error(' Make sure you are running this from the persyst-mcp package.');
130
+ process.exit(1);
131
+ }
132
+
133
+ // Step 2: Copy hook file to ~/.persyst/hooks/
134
+ console.log(' 📁 Installing hook script...');
135
+ ensureDir(PERSYST_HOOKS_DIR);
136
+ copyFileSync(HOOK_SOURCE, HOOK_DEST);
137
+ console.log(` ✅ Copied to ${HOOK_DEST}`);
138
+
139
+ // Step 3: Merge into ~/.claude/settings.json
140
+ console.log('');
141
+ console.log(' ⚙️ Configuring Claude Code...');
142
+ ensureDir(CLAUDE_DIR);
143
+
144
+ const existingSettings = readJsonFile(CLAUDE_SETTINGS);
145
+ const mergedSettings = mergeHookSettings(existingSettings);
146
+
147
+ writeFileSync(CLAUDE_SETTINGS, JSON.stringify(mergedSettings, null, 2) + '\n', 'utf8');
148
+ console.log(` ✅ Updated ${CLAUDE_SETTINGS}`);
149
+
150
+ // Step 4: Print success
151
+ console.log('');
152
+ console.log(' ════════════════════════════════════');
153
+ console.log(' ✅ Setup complete!');
154
+ console.log('');
155
+ console.log(' Persyst will now automatically:');
156
+ console.log(' • Load your stored memories when Claude Code starts');
157
+ console.log(' • Search for relevant context on every prompt');
158
+ console.log(' • Index your git commits into the memory database');
159
+ console.log('');
160
+ console.log(' ⚡ Restart Claude Code to activate the hooks.');
161
+ console.log('');
162
+ console.log(' Memory database: ~/.persyst/persyst.db');
163
+ console.log(' Hook script: ~/.persyst/hooks/persyst-hook.js');
164
+ console.log(' Claude settings: ~/.claude/settings.json');
165
+ console.log('');
166
+ }
167
+
168
+ run();
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * persyst-hook.js — Claude Code Hook for Persyst Memory
5
+ *
6
+ * Automatically injects relevant memories into Claude Code's context
7
+ * on SessionStart and UserPromptSubmit events.
8
+ *
9
+ * How it works:
10
+ * 1. Claude Code sends a JSON payload on stdin with hook_event_name, session_id, cwd, etc.
11
+ * 2. This script connects to the Persyst MCP server via StdioClientTransport.
12
+ * 3. It calls get_optimized_context or search_memories to retrieve relevant memories.
13
+ * 4. It returns a JSON response on stdout with additionalContext for Claude Code to inject.
14
+ *
15
+ * Installation:
16
+ * npx persyst-mcp setup
17
+ *
18
+ * Manual registration in ~/.claude/settings.json:
19
+ * { "hooks": { "SessionStart": [...], "UserPromptSubmit": [...] } }
20
+ */
21
+
22
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
23
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
24
+ import { fileURLToPath } from 'url';
25
+ import { dirname, resolve } from 'path';
26
+
27
+ const __filename = fileURLToPath(import.meta.url);
28
+ const __dirname = dirname(__filename);
29
+
30
+ // Minimum prompt length to trigger memory search (skip "y", "ok", "/run", etc.)
31
+ const MIN_PROMPT_LENGTH = 15;
32
+
33
+ // Maximum time to wait for Persyst MCP connection (ms)
34
+ const CONNECTION_TIMEOUT = 8000;
35
+
36
+ /**
37
+ * Read the full JSON payload from stdin.
38
+ * Claude Code sends the hook context as a single JSON object.
39
+ */
40
+ function readStdin() {
41
+ return new Promise((resolve, reject) => {
42
+ let data = '';
43
+ process.stdin.setEncoding('utf8');
44
+ process.stdin.on('data', chunk => { data += chunk; });
45
+ process.stdin.on('end', () => {
46
+ try {
47
+ resolve(JSON.parse(data));
48
+ } catch (e) {
49
+ reject(new Error(`Failed to parse stdin JSON: ${e.message}`));
50
+ }
51
+ });
52
+ process.stdin.on('error', reject);
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Connect to the Persyst MCP server as a client.
58
+ * Uses StdioClientTransport to spawn and communicate with the server.
59
+ */
60
+ async function connectToPersyst() {
61
+ // Resolve the path to Persyst's index.js relative to this hook file
62
+ const persystPath = resolve(__dirname, '..', 'index.js');
63
+
64
+ const transport = new StdioClientTransport({
65
+ command: 'node',
66
+ args: [persystPath]
67
+ });
68
+
69
+ const client = new Client({
70
+ name: 'persyst-hook',
71
+ version: '1.0.0'
72
+ });
73
+
74
+ // Connect with a timeout
75
+ await Promise.race([
76
+ client.connect(transport),
77
+ new Promise((_, reject) =>
78
+ setTimeout(() => reject(new Error('Persyst connection timeout')), CONNECTION_TIMEOUT)
79
+ )
80
+ ]);
81
+
82
+ return client;
83
+ }
84
+
85
+ /**
86
+ * Call a Persyst MCP tool and parse the JSON result.
87
+ */
88
+ async function callTool(client, toolName, args) {
89
+ const result = await client.callTool({ name: toolName, arguments: args });
90
+ if (result.content && result.content[0] && result.content[0].text) {
91
+ return JSON.parse(result.content[0].text);
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Handle SessionStart: load project-wide context and ingest git history.
98
+ */
99
+ async function handleSessionStart(client, input) {
100
+ const cwd = input.cwd || process.cwd();
101
+ const repoName = cwd.replace(/\\/g, '/').split('/').pop();
102
+
103
+ // 1. Get project-wide memory context
104
+ const contextResult = await callTool(client, 'get_optimized_context', {
105
+ query: `Project ${repoName} conventions, architecture, user preferences, coding rules`,
106
+ max_tokens: 2000,
107
+ agent_id: 'claude-code',
108
+ session_id: input.session_id || undefined
109
+ });
110
+
111
+ // 2. Ingest recent git commits (best-effort, don't fail if not a git repo)
112
+ try {
113
+ await callTool(client, 'ingest_git_commits', {
114
+ repo_path: cwd,
115
+ count: 15
116
+ });
117
+ } catch (_) {
118
+ // Not a git repo or git not available — that's fine
119
+ }
120
+
121
+ // 3. Build the additional context string
122
+ let additionalContext = '';
123
+ if (contextResult && contextResult.context) {
124
+ additionalContext = contextResult.context;
125
+ }
126
+
127
+ // 4. Get memory count for status line
128
+ let memoryCount = 0;
129
+ try {
130
+ const recentResult = await callTool(client, 'get_recent_memories', { limit: 1 });
131
+ if (recentResult && recentResult.count !== undefined) {
132
+ // The count from get_recent is just the returned count, not total
133
+ // Use a search to estimate total active memories
134
+ const importantResult = await callTool(client, 'get_important_memories', { limit: 100 });
135
+ memoryCount = importantResult?.count || 0;
136
+ }
137
+ } catch (_) {
138
+ // Non-critical
139
+ }
140
+
141
+ if (additionalContext) {
142
+ additionalContext = `[Persyst Memory: ${memoryCount} memories loaded for project "${repoName}"]\n${additionalContext}`;
143
+ }
144
+
145
+ return {
146
+ hookSpecificOutput: {
147
+ hookEventName: 'SessionStart',
148
+ additionalContext: additionalContext || undefined
149
+ }
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Handle UserPromptSubmit: search for memories relevant to the user's prompt.
155
+ */
156
+ async function handleUserPromptSubmit(client, input) {
157
+ const prompt = input.prompt || '';
158
+
159
+ // Skip trivial prompts (commands, confirmations, short inputs)
160
+ if (prompt.trim().length < MIN_PROMPT_LENGTH) {
161
+ return {};
162
+ }
163
+
164
+ // Use search_memories for speed on per-prompt lookups (faster than get_optimized_context)
165
+ const searchResult = await callTool(client, 'search_memories', {
166
+ query: prompt.slice(0, 200), // Truncate very long prompts for search efficiency
167
+ limit: 5,
168
+ agent_id: 'claude-code',
169
+ session_id: input.session_id || undefined
170
+ });
171
+
172
+ if (!searchResult || !searchResult.results || searchResult.results.length === 0) {
173
+ return {};
174
+ }
175
+
176
+ // Format memories as context
177
+ let contextLines = ['=== PERSYST MEMORY (auto-retrieved) ==='];
178
+ for (const mem of searchResult.results) {
179
+ contextLines.push(`• [Memory #${mem.id}] ${mem.content}`);
180
+ }
181
+ contextLines.push('=== END MEMORY ===');
182
+
183
+ return {
184
+ hookSpecificOutput: {
185
+ hookEventName: 'UserPromptSubmit',
186
+ additionalContext: contextLines.join('\n')
187
+ }
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Main entry point.
193
+ */
194
+ async function main() {
195
+ let client = null;
196
+
197
+ try {
198
+ const input = await readStdin();
199
+ const eventName = input.hook_event_name;
200
+
201
+ // Only handle events we care about
202
+ if (eventName !== 'SessionStart' && eventName !== 'UserPromptSubmit') {
203
+ console.log(JSON.stringify({}));
204
+ return;
205
+ }
206
+
207
+ // Connect to Persyst
208
+ client = await connectToPersyst();
209
+
210
+ let response;
211
+ if (eventName === 'SessionStart') {
212
+ response = await handleSessionStart(client, input);
213
+ } else if (eventName === 'UserPromptSubmit') {
214
+ response = await handleUserPromptSubmit(client, input);
215
+ } else {
216
+ response = {};
217
+ }
218
+
219
+ console.log(JSON.stringify(response));
220
+
221
+ } catch (err) {
222
+ // Hooks must NEVER break Claude Code — always fail silently
223
+ console.error(`[persyst-hook] Error: ${err.message}`);
224
+ console.log(JSON.stringify({}));
225
+ } finally {
226
+ // Clean up MCP connection
227
+ if (client) {
228
+ try { await client.close(); } catch (_) {}
229
+ }
230
+ process.exit(0);
231
+ }
232
+ }
233
+
234
+ main();
package/index.js CHANGED
@@ -7,14 +7,23 @@
7
7
  * Starts the MCP server on stdio transport.
8
8
  *
9
9
  * Usage:
10
- * node index.js (direct)
11
- * npx persyst-mcp (via npm)
10
+ * node index.js (direct — starts MCP server)
11
+ * npx persyst-mcp (via npm — starts MCP server)
12
+ * npx persyst-mcp setup (install Claude Code hooks)
12
13
  * persyst-mcp (if installed globally)
13
14
  */
14
15
 
15
- import { startServer } from './src/server.js';
16
+ // Handle subcommands before starting the server
17
+ const subcommand = process.argv[2];
16
18
 
17
- await startServer().catch(err => {
18
- console.error('❌ Persyst failed to start:', err.message);
19
- process.exit(1);
20
- });
19
+ if (subcommand === 'setup') {
20
+ // Delegate to the setup CLI
21
+ await import('./bin/setup.js');
22
+ } else {
23
+ // Default: start the MCP server
24
+ const { startServer } = await import('./src/server.js');
25
+ await startServer().catch(err => {
26
+ console.error('❌ Persyst failed to start:', err.message);
27
+ process.exit(1);
28
+ });
29
+ }
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "persyst-mcp",
3
- "version": "1.1.0",
3
+ "version": "2.1.0",
4
4
  "description": "Local-first MCP memory server with hybrid keyword + semantic search for coding agents",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "bin": {
8
- "persyst-mcp": "index.js"
8
+ "persyst-mcp": "index.js",
9
+ "persyst-setup": "bin/setup.js",
10
+ "persyst-aider": "bin/aider.js"
9
11
  },
10
12
  "engines": {
11
13
  "node": ">=18.0.0"
@@ -13,6 +15,8 @@
13
15
  "files": [
14
16
  "index.js",
15
17
  "src/",
18
+ "bin/",
19
+ "hooks/",
16
20
  "README.md",
17
21
  "LICENSE"
18
22
  ],
@@ -180,7 +180,6 @@ export function verifyChainIntegrity(attestationId) {
180
180
 
181
181
  // If there's a previous link, check it
182
182
  if (att.previous_hash) {
183
- const db = getLastAttestation(); // Wait, we can run raw query on DB or use prepared stmt
184
183
  const prevAtt = getAttestationByHash(att.previous_hash);
185
184
  if (!prevAtt) {
186
185
  return { valid: false, error: `Broken chain: Previous attestation with hash ${att.previous_hash} not found` };