indelible-mcp 3.7.0 → 3.9.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Indelible MCP Server
2
2
 
3
- Blockchain-backed memory for Claude Code. Save your AI conversations permanently on BSV.
3
+ Blockchain-backed memory for Claude Code. Save your AI conversations permanently on BSV via a federated mesh of SPV bridges.
4
4
 
5
5
  ## Quick Start
6
6
 
@@ -71,6 +71,12 @@ indelible-mcp hook post-compact Post-compaction restore hook
71
71
  3. The signed transaction is broadcast via Indelible's SPV bridge
72
72
  4. Your private key **never** leaves your machine
73
73
 
74
+ ## Federation
75
+
76
+ Indelible uses a multi-seed architecture — your saves and loads automatically try multiple federation bridges. If one bridge is down, the next picks up. No single point of failure.
77
+
78
+ The federation mesh is powered by [Relay Federation](https://github.com/zcoolz/relay-federation).
79
+
74
80
  ## Security
75
81
 
76
82
  - **Zero-knowledge encryption** - your WIF-derived AES-256-GCM key encrypts all data before it touches the network
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "indelible-mcp",
3
- "version": "3.7.0",
3
+ "version": "3.9.0",
4
4
  "description": "Blockchain-backed memory and code storage for Claude Code. Save AI conversations and source code permanently on BSV.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -254,7 +254,7 @@ Commands:
254
254
 
255
255
  function printHelp() {
256
256
  console.log(`
257
- Indelible MCP — Blockchain memory for Claude Code (v3.7.0)
257
+ Indelible MCP — Blockchain memory for Claude Code (v3.9.0)
258
258
 
259
259
  Setup:
260
260
  indelible-mcp setup --wif=KEY --pin=PIN Import and encrypt your private key
@@ -466,7 +466,7 @@ function readStdin() {
466
466
 
467
467
  const SERVER_INFO = {
468
468
  name: 'indelible',
469
- version: '3.7.0',
469
+ version: '3.9.0',
470
470
  description: 'Blockchain-backed memory and code storage for Claude Code'
471
471
  }
472
472
 
@@ -4,6 +4,7 @@
4
4
  * Blockchain operations go through SPV bridge (see spv.js).
5
5
  */
6
6
 
7
+ import { Transaction } from '@bsv/sdk'
7
8
  import { loadConfig } from './config.js'
8
9
  import * as spv from './spv.js'
9
10
 
@@ -185,8 +186,17 @@ export async function getLatestSessions(address, wif, limit = 3) {
185
186
  for (const txInfo of history) {
186
187
  if (sessions.length >= limit) break
187
188
  try {
188
- const tx = await spv.getTx(txInfo.tx_hash)
189
- const encrypted = spv.extractEncryptedFromTx(tx)
189
+ const rawHex = await spv.getRawTx(txInfo.tx_hash)
190
+ const tx = Transaction.fromHex(rawHex)
191
+ let encrypted = null
192
+ for (const output of tx.outputs) {
193
+ if (output.satoshis === 0) {
194
+ const hex = output.lockingScript.toHex()
195
+ const str = Buffer.from(hex, 'hex').toString('utf8')
196
+ const match = str.match(/([A-Za-z0-9+/=]{12,}):([A-Za-z0-9+/=]{20,}):([A-Za-z0-9+/=]{20,})/)
197
+ if (match) { encrypted = match[0]; break }
198
+ }
199
+ }
190
200
  if (encrypted) {
191
201
  sessions.push({
192
202
  txId: txInfo.tx_hash,
package/src/lib/spv.js CHANGED
@@ -10,8 +10,12 @@
10
10
  import { Transaction, P2PKH, PrivateKey, SatoshisPerKilobyte, LockingScript, OP } from '@bsv/sdk'
11
11
  import { loadConfig, saveConfig } from './config.js'
12
12
 
13
- const GATEWAY_URL = 'http://155.138.238.167:8080'
14
- const GATEWAY_BRIDGE = { url: GATEWAY_URL, name: 'relay-gateway' }
13
+ const SEED_BRIDGES = [
14
+ { url: 'http://149.28.243.56:9333', name: 'bridge-delta' },
15
+ { url: 'http://144.202.48.217:9333', name: 'bridge-alpha' },
16
+ { url: 'http://45.63.77.31:9333', name: 'bridge-beta' },
17
+ { url: 'http://45.63.70.235:9333', name: 'bridge-gamma' },
18
+ ]
15
19
 
16
20
  // Old individual bridge IPs — used to detect configs that need migration
17
21
  const OLD_BRIDGE_IPS = [
@@ -67,7 +71,7 @@ async function getBridges() {
67
71
  return OLD_BRIDGE_IPS.some(ip => url.includes(ip))
68
72
  })
69
73
  if (hasOldIps) {
70
- bridges = [GATEWAY_BRIDGE]
74
+ bridges = [...SEED_BRIDGES]
71
75
  config.spv_bridges = bridges
72
76
  // Auto-generate relay key if missing
73
77
  if (!apiKey || !apiKey.startsWith('relay_sk_')) {
@@ -103,7 +107,7 @@ async function getBridges() {
103
107
  const result = await ensureRelayKey(config.address)
104
108
  if (result) {
105
109
  apiKey = result.key
106
- config.spv_bridges = [GATEWAY_BRIDGE]
110
+ config.spv_bridges = [...SEED_BRIDGES]
107
111
  config.spv_api_key = apiKey
108
112
  saveConfig(config)
109
113
  }
@@ -111,7 +115,7 @@ async function getBridges() {
111
115
  migrationDone = true
112
116
  }
113
117
 
114
- return [{ ...GATEWAY_BRIDGE, apiKey }]
118
+ return SEED_BRIDGES.map(b => ({ ...b, apiKey }))
115
119
  }
116
120
 
117
121
  async function spvFetch(path, options = {}) {
@@ -167,15 +171,8 @@ export async function checkHealth() {
167
171
  }
168
172
 
169
173
  export async function getUtxos(address) {
170
- try {
171
- const res = await spvFetch(`/api/address/${address}/unspent`)
172
- if (res.ok) return res.json()
173
- } catch { /* bridge unavailable */ }
174
-
175
- // WoC lookup for address-based indexing
176
- const wocRes = await fetch(`https://api.whatsonchain.com/v1/bsv/main/address/${address}/unspent`)
177
- if (wocRes.ok) return wocRes.json()
178
-
174
+ const res = await spvFetch(`/api/address/${address}/unspent`)
175
+ if (res.ok) return res.json()
179
176
  throw new Error(`Failed to get UTXOs for ${address}`)
180
177
  }
181
178
 
@@ -249,6 +246,24 @@ export async function broadcastTx(rawTxHex) {
249
246
  throw new Error(`Broadcast failed on all bridges: ${errors.join(', ')}`)
250
247
  }
251
248
 
249
+ /**
250
+ * Index session metadata on all federation bridges (best-effort).
251
+ * @param {object} sessionData — { txId, address, session_id, summary, message_count, save_type, timestamp, ... }
252
+ */
253
+ export async function indexSessionOnBridge(sessionData) {
254
+ const bridges = await getBridges()
255
+ for (const bridge of bridges) {
256
+ try {
257
+ await fetch(`${bridge.url}/api/sessions/index`, {
258
+ method: 'POST',
259
+ headers: { 'Content-Type': 'application/json' },
260
+ body: JSON.stringify(sessionData),
261
+ signal: AbortSignal.timeout(5000)
262
+ })
263
+ } catch {} // best-effort
264
+ }
265
+ }
266
+
252
267
  function pushDataChunk(data) {
253
268
  const len = data.length
254
269
  let op
@@ -11,6 +11,7 @@
11
11
  import { readFileSync, existsSync } from 'node:fs'
12
12
  import { join } from 'node:path'
13
13
  import { homedir } from 'node:os'
14
+ import { Transaction } from '@bsv/sdk'
14
15
  import { loadConfig, getWif } from '../lib/config.js'
15
16
  import { decrypt } from '../lib/crypto.js'
16
17
  import { getSessions } from '../lib/api-client.js'
@@ -152,28 +153,24 @@ function formatPreviousConversation(session) {
152
153
  }
153
154
 
154
155
  /**
155
- * Fetch encrypted data directly from a tx's OP_RETURN
156
- * Tries SPV bridge first, falls back to WhatsOnChain
156
+ * Fetch encrypted data directly from blockchain tx
157
+ * Uses getRawTx (federation bridge) no WoC dependency
157
158
  */
158
159
  async function fetchEncryptedFromChain(txId) {
159
160
  const cleanTxId = txId.trim()
160
-
161
- // Try SPV bridge
162
- try {
163
- const tx = await spv.getTx(cleanTxId)
164
- const encrypted = spv.extractEncryptedFromTx(tx)
165
- if (encrypted) return encrypted
166
- } catch { /* fall through */ }
167
-
168
- // Fallback: WhatsOnChain
169
161
  try {
170
- const res = await fetch(`https://api.whatsonchain.com/v1/bsv/main/tx/${cleanTxId}`)
171
- if (!res.ok) return null
172
- const tx = await res.json()
173
- return spv.extractEncryptedFromTx(tx)
174
- } catch {
175
- return null
176
- }
162
+ const rawHex = await spv.getRawTx(cleanTxId)
163
+ const tx = Transaction.fromHex(rawHex)
164
+ for (const output of tx.outputs) {
165
+ if (output.satoshis === 0) {
166
+ const hex = output.lockingScript.toHex()
167
+ const str = Buffer.from(hex, 'hex').toString('utf8')
168
+ const match = str.match(/([A-Za-z0-9+/=]{12,}):([A-Za-z0-9+/=]{20,}):([A-Za-z0-9+/=]{20,})/)
169
+ if (match) return match[0]
170
+ }
171
+ }
172
+ } catch { /* failed */ }
173
+ return null
177
174
  }
178
175
 
179
176
  /**
@@ -222,8 +219,7 @@ export async function loadContext(numSessions = 5) {
222
219
  for (const tx of recent) {
223
220
  if (sessions.length >= numSessions) break
224
221
  try {
225
- const txData = await spv.getTx(tx.tx_hash)
226
- const encrypted = spv.extractEncryptedFromTx(txData)
222
+ const encrypted = await fetchEncryptedFromChain(tx.tx_hash)
227
223
  if (encrypted) {
228
224
  sessions.push({ txId: tx.tx_hash, encrypted })
229
225
  }
@@ -7,10 +7,28 @@
7
7
  import { writeFileSync } from 'node:fs'
8
8
  import { dirname } from 'node:path'
9
9
  import { mkdirSync, existsSync } from 'node:fs'
10
+ import { Transaction } from '@bsv/sdk'
10
11
  import { getWif } from '../lib/config.js'
11
12
  import { decrypt, sha256 } from '../lib/crypto.js'
12
13
  import * as spv from '../lib/spv.js'
13
14
 
15
+ /**
16
+ * Parse OP_RETURN JSON from raw tx hex — no WoC/getTx dependency
17
+ */
18
+ function parseOpReturnJson(rawHex) {
19
+ const tx = Transaction.fromHex(rawHex)
20
+ for (const output of tx.outputs) {
21
+ if (output.satoshis === 0) {
22
+ const hex = output.lockingScript.toHex()
23
+ const str = Buffer.from(hex, 'hex').toString('utf8')
24
+ const jsonStart = str.indexOf('{')
25
+ if (jsonStart === -1) continue
26
+ try { return JSON.parse(str.slice(jsonStart)) } catch { continue }
27
+ }
28
+ }
29
+ return null
30
+ }
31
+
14
32
  export async function loadFile(txId, options = {}) {
15
33
  const wif = await getWif()
16
34
  if (!wif) {
@@ -18,31 +36,16 @@ export async function loadFile(txId, options = {}) {
18
36
  }
19
37
 
20
38
  try {
21
- const txData = await spv.getTx(txId)
22
- if (!txData) {
39
+ const rawHex = await spv.getRawTx(txId)
40
+ if (!rawHex) {
23
41
  return { success: false, error: `Transaction not found: ${txId}` }
24
42
  }
25
43
 
26
- // Extract OP_RETURN payload
27
- const opReturn = txData.vout?.find(out => out.value === 0)
28
- if (!opReturn) {
29
- return { success: false, error: 'No OP_RETURN output found' }
30
- }
31
-
32
- const hex = opReturn.scriptPubKey?.hex
33
- if (!hex) {
34
- return { success: false, error: 'Empty script' }
35
- }
36
-
37
- // Parse JSON from OP_RETURN
38
- const raw = Buffer.from(hex, 'hex').toString('utf8')
39
- const jsonStart = raw.indexOf('{')
40
- if (jsonStart === -1) {
44
+ const payload = parseOpReturnJson(rawHex)
45
+ if (!payload) {
41
46
  return { success: false, error: 'No JSON payload found in OP_RETURN' }
42
47
  }
43
48
 
44
- const payload = JSON.parse(raw.slice(jsonStart))
45
-
46
49
  if (payload.protocol !== 'indelible.file') {
47
50
  return { success: false, error: `Not an indelible.file tx (got: ${payload.protocol || 'unknown'})` }
48
51
  }
@@ -57,15 +60,11 @@ export async function loadFile(txId, options = {}) {
57
60
  if (!chunkTxId) {
58
61
  return { success: false, error: `Missing chunk_${i} reference` }
59
62
  }
60
- const chunkTx = await spv.getTx(chunkTxId)
61
- const chunkOp = chunkTx?.vout?.find(out => out.value === 0)
62
- const chunkHex = chunkOp?.scriptPubKey?.hex
63
- if (!chunkHex) {
63
+ const chunkRawHex = await spv.getRawTx(chunkTxId)
64
+ const chunkData = parseOpReturnJson(chunkRawHex)
65
+ if (!chunkData || !chunkData.data) {
64
66
  return { success: false, error: `Chunk ${i} has no data` }
65
67
  }
66
- const chunkRaw = Buffer.from(chunkHex, 'hex').toString('utf8')
67
- const chunkJsonStart = chunkRaw.indexOf('{')
68
- const chunkData = JSON.parse(chunkRaw.slice(chunkJsonStart))
69
68
  encryptedFull += chunkData.data
70
69
 
71
70
  if (i < payload._chunks) {
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
8
8
  import { join, dirname } from 'node:path'
9
+ import { Transaction } from '@bsv/sdk'
9
10
  import { getWif } from '../lib/config.js'
10
11
  import { decrypt, sha256 } from '../lib/crypto.js'
11
12
  import * as spv from '../lib/spv.js'
@@ -18,19 +19,25 @@ export async function loadProject(txId, options = {}) {
18
19
  }
19
20
 
20
21
  try {
21
- const txData = await spv.getTx(txId)
22
- if (!txData) {
22
+ const rawHex = await spv.getRawTx(txId)
23
+ if (!rawHex) {
23
24
  return { success: false, error: `Manifest transaction not found: ${txId}` }
24
25
  }
25
26
 
26
- // Extract OP_RETURN payload
27
- const opReturn = txData.vout?.find(out => out.value === 0)
28
- if (!opReturn) {
27
+ // Parse raw tx and extract OP_RETURN JSON payload
28
+ const txData = Transaction.fromHex(rawHex)
29
+ let raw = null
30
+ for (const output of txData.outputs) {
31
+ if (output.satoshis === 0) {
32
+ const hex = output.lockingScript.toHex()
33
+ raw = Buffer.from(hex, 'hex').toString('utf8')
34
+ break
35
+ }
36
+ }
37
+ if (!raw) {
29
38
  return { success: false, error: 'No OP_RETURN output found' }
30
39
  }
31
40
 
32
- const hex = opReturn.scriptPubKey?.hex
33
- const raw = Buffer.from(hex, 'hex').toString('utf8')
34
41
  const jsonStart = raw.indexOf('{')
35
42
  if (jsonStart === -1) {
36
43
  return { success: false, error: 'No JSON payload found in OP_RETURN' }
@@ -7,58 +7,45 @@
7
7
  * Used by post-compact hook to reload rules after context compaction.
8
8
  */
9
9
 
10
+ import { Transaction } from '@bsv/sdk'
10
11
  import { loadConfig, getWif } from '../lib/config.js'
11
12
  import { decrypt } from '../lib/crypto.js'
12
13
  import * as spv from '../lib/spv.js'
13
14
 
14
15
  /**
15
16
  * Fetch and extract encrypted style data from a blockchain tx.
16
- * Tries SPV bridge first, falls back to WhatsOnChain.
17
+ * Uses getRawTx (federation bridge) no WoC dependency.
17
18
  *
18
19
  * @param {string} txId - Transaction ID
19
20
  * @returns {Promise<string|null>} Encrypted data string or null
20
21
  */
21
22
  async function fetchStyleFromChain(txId) {
22
23
  const cleanTxId = txId.trim()
23
-
24
- // Try SPV bridge first
25
24
  try {
26
- const tx = await spv.getTx(cleanTxId)
27
- const encrypted = spv.extractEncryptedFromTx(tx)
28
- if (encrypted) return encrypted
29
- } catch {
30
- // SPV bridge failed, try WhatsOnChain
31
- }
25
+ const rawHex = await spv.getRawTx(cleanTxId)
26
+ const tx = Transaction.fromHex(rawHex)
27
+ for (const output of tx.outputs) {
28
+ if (output.satoshis === 0) {
29
+ const hex = output.lockingScript.toHex()
30
+ const str = Buffer.from(hex, 'hex').toString('utf8')
32
31
 
33
- // Fallback: WhatsOnChain parse OP_RETURN for JSON payload
34
- try {
35
- const res = await fetch(`https://api.whatsonchain.com/v1/bsv/main/tx/${cleanTxId}`)
36
- if (!res.ok) return null
37
- const tx = await res.json()
38
- const opReturn = tx.vout?.find(out => out.value === 0)
39
- if (!opReturn) return null
40
- const hex = opReturn.scriptPubKey?.hex
41
- if (!hex) return null
42
- const str = Buffer.from(hex, 'hex').toString('utf8')
32
+ // Try JSON payload first (style format has encrypted field inside JSON)
33
+ try {
34
+ const jsonStart = str.indexOf('{')
35
+ if (jsonStart !== -1) {
36
+ const json = JSON.parse(str.slice(jsonStart))
37
+ if (json.protocol === 'indelible.style' && json.encrypted) {
38
+ return json.encrypted
39
+ }
40
+ }
41
+ } catch { /* not JSON, try raw */ }
43
42
 
44
- // Try to parse as JSON payload (style format has encrypted field inside JSON)
45
- try {
46
- // Find the JSON object in the OP_RETURN data
47
- const jsonStart = str.indexOf('{')
48
- if (jsonStart === -1) return null
49
- const json = JSON.parse(str.slice(jsonStart))
50
- if (json.protocol === 'indelible.style' && json.encrypted) {
51
- return json.encrypted
43
+ // Raw encrypted format (iv:tag:ciphertext)
44
+ const match = str.match(/([A-Za-z0-9+/=]{12,}):([A-Za-z0-9+/=]{20,}):([A-Za-z0-9+/=]{20,})/)
45
+ if (match) return match[0]
52
46
  }
53
- } catch {
54
- // Not JSON format, try raw encrypted format (iv:tag:ciphertext)
55
- const match = str.match(/([A-Za-z0-9+/=]{12,}):([A-Za-z0-9+/=]{20,}):([A-Za-z0-9+/=]{20,})/)
56
- return match ? match[0] : null
57
47
  }
58
- } catch {
59
- return null
60
- }
61
-
48
+ } catch { /* failed */ }
62
49
  return null
63
50
  }
64
51
 
@@ -527,19 +527,19 @@ export async function saveSession(transcriptPath, summary) {
527
527
  // Encrypt locally — WIF never leaves
528
528
  const sessionJson = JSON.stringify(session)
529
529
  process.stderr.write(`[indelible] Saving session (${(sessionJson.length / 1024).toFixed(0)} KB, rich_mode: ${richMode})\n`)
530
+
531
+ // Cost warning — estimate fee from JSON size (base64 + tx overhead ≈ 1.4x)
532
+ const COST_WARNING_SATS = 333_333 // ~$0.05 at $15/BSV
533
+ const estimatedFee = Math.ceil(sessionJson.length * 1.4)
534
+ if (estimatedFee > COST_WARNING_SATS) {
535
+ process.stderr.write(`[indelible] WARNING: Estimated cost ~${estimatedFee} sats (~$${(estimatedFee / 1e8 * 15).toFixed(2)}). Rich mode is ${richMode ? 'ON' : 'OFF'}. Set "rich_saves": false in config to reduce cost.\n`)
536
+ }
530
537
  const encrypted = encrypt(sessionJson, wif)
531
538
 
532
539
  // Build the blockchain payload
533
540
  const payload = {
534
541
  protocol: 'indelible.claude-code',
535
- version: 2,
536
- address: config.address,
537
- session_id: sessionId,
538
- prev_session_id: prevSessionId,
539
- summary: session.summary,
540
- message_count: session.message_count,
541
- encrypted,
542
- timestamp: session.created_at
542
+ encrypted
543
543
  }
544
544
 
545
545
  // Build + sign transaction LOCALLY with @bsv/sdk
@@ -567,6 +567,19 @@ export async function saveSession(transcriptPath, summary) {
567
567
  }, config)
568
568
  } catch { /* indexing failure doesn't block save */ }
569
569
 
570
+ // Index session on federation bridges (LevelDB)
571
+ try {
572
+ await spv.indexSessionOnBridge({
573
+ txId: finalTxId, address: config.address,
574
+ session_id: sessionId,
575
+ prev_session_id: prevSessionId,
576
+ summary: session.summary,
577
+ message_count: session.message_count,
578
+ save_type: isDelta ? 'delta' : 'full',
579
+ timestamp: session.created_at
580
+ })
581
+ } catch { /* bridge indexing failure doesn't block save */ }
582
+
570
583
  // Update session history with txId now that we have it
571
584
  const structuredCtx = session.structured_context || {}
572
585
  updateSessionHistory(