indelible-mcp 3.5.0 → 3.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "indelible-mcp",
3
- "version": "3.5.0",
3
+ "version": "3.7.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",
@@ -29,7 +29,7 @@
29
29
  "license": "MIT",
30
30
  "repository": {
31
31
  "type": "git",
32
- "url": "https://github.com/indelibleai/indelible-mcp"
32
+ "url": "git+https://github.com/indelibleai/indelible-mcp.git"
33
33
  },
34
34
  "homepage": "https://indelible.one",
35
35
  "dependencies": {
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.5.0)
257
+ Indelible MCP — Blockchain memory for Claude Code (v3.7.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.5.0',
469
+ version: '3.7.0',
470
470
  description: 'Blockchain-backed memory and code storage for Claude Code'
471
471
  }
472
472
 
package/src/lib/spv.js CHANGED
@@ -363,7 +363,7 @@ export async function buildOpReturnTxWithChange(wif, utxos, dataStr) {
363
363
  })
364
364
  }
365
365
 
366
- return { txHex, txId, changeUtxos }
366
+ return { txHex, txId, changeUtxos, fee, txSize }
367
367
  }
368
368
 
369
369
  export function extractEncryptedFromTx(tx) {
@@ -46,11 +46,14 @@ export function findCurrentTranscript() {
46
46
  return newest
47
47
  }
48
48
 
49
+ const MAX_BLOCK_BYTES = 65536 // 64KB per tool_result block
50
+
49
51
  /**
50
- * Parse JSONL transcript file — extracts user/assistant text messages only.
51
- * Filters out tool_use, tool_result, system entries, IDE selection context.
52
+ * Parse JSONL transcript file.
53
+ * richMode=false (default): extracts user/assistant text messages only (current behavior).
54
+ * richMode=true: preserves tool_use, tool_result, and thinking blocks as array content.
52
55
  */
53
- export function parseTranscript(filePath) {
56
+ export function parseTranscript(filePath, richMode = false) {
54
57
  const content = readFileSync(filePath, 'utf-8')
55
58
  const lines = content.trim().split('\n')
56
59
  const messages = []
@@ -62,46 +65,110 @@ export function parseTranscript(filePath) {
62
65
 
63
66
  if (entry.type === 'user') {
64
67
  const contentArr = entry.message?.content
65
- if (Array.isArray(contentArr) && contentArr.some(c => c.type === 'tool_result')) continue
66
-
67
- let text = ''
68
- if (Array.isArray(contentArr)) {
69
- text = contentArr
70
- .filter(c => c.type === 'text')
71
- .map(c => c.text)
72
- .join('\n')
73
- .replace(/<ide_selection>[\s\S]*?<\/ide_selection>\s*/g, '')
74
- .trim()
75
- } else if (typeof entry.message?.content === 'string') {
76
- text = entry.message.content
77
- }
78
68
 
79
- if (text) {
80
- messages.push({
81
- role: 'user',
82
- content: text,
83
- timestamp: entry.timestamp || new Date().toISOString()
84
- })
69
+ if (!richMode) {
70
+ // Text-only mode (current behavior)
71
+ if (Array.isArray(contentArr) && contentArr.some(c => c.type === 'tool_result')) continue
72
+ let text = ''
73
+ if (Array.isArray(contentArr)) {
74
+ text = contentArr
75
+ .filter(c => c.type === 'text')
76
+ .map(c => c.text)
77
+ .join('\n')
78
+ .replace(/<ide_selection>[\s\S]*?<\/ide_selection>\s*/g, '')
79
+ .trim()
80
+ } else if (typeof entry.message?.content === 'string') {
81
+ text = entry.message.content
82
+ }
83
+ if (text) {
84
+ messages.push({
85
+ role: 'user',
86
+ content: text,
87
+ timestamp: entry.timestamp || new Date().toISOString()
88
+ })
89
+ }
90
+ } else {
91
+ // Rich mode — keep text + tool_result blocks
92
+ const blocks = []
93
+ if (Array.isArray(contentArr)) {
94
+ for (const block of contentArr) {
95
+ if (block.type === 'text') {
96
+ const cleaned = (block.text || '').replace(/<ide_selection>[\s\S]*?<\/ide_selection>\s*/g, '').trim()
97
+ if (cleaned) blocks.push({ type: 'text', text: cleaned })
98
+ } else if (block.type === 'tool_result') {
99
+ const resultText = typeof block.content === 'string' ? block.content : JSON.stringify(block.content || '')
100
+ const truncated = resultText.length > MAX_BLOCK_BYTES
101
+ blocks.push({
102
+ type: 'tool_result',
103
+ tool_use_id: block.tool_use_id,
104
+ content: truncated ? resultText.slice(0, MAX_BLOCK_BYTES - 100) + `\n...[truncated, original ${resultText.length} bytes]` : resultText,
105
+ is_error: block.is_error || false,
106
+ ...(truncated ? { truncated: true } : {})
107
+ })
108
+ }
109
+ }
110
+ } else if (typeof entry.message?.content === 'string') {
111
+ blocks.push({ type: 'text', text: entry.message.content })
112
+ }
113
+ if (blocks.length > 0) {
114
+ const textOnly = blocks.filter(b => b.type === 'text').map(b => b.text).join('\n')
115
+ messages.push({
116
+ role: 'user',
117
+ content: blocks,
118
+ content_text: textOnly,
119
+ timestamp: entry.timestamp || new Date().toISOString()
120
+ })
121
+ }
85
122
  }
86
123
  } else if (entry.type === 'assistant') {
87
124
  const contentArr = entry.message?.content
88
- let text = ''
89
- if (Array.isArray(contentArr)) {
90
- text = contentArr
91
- .filter(c => c.type === 'text')
92
- .map(c => c.text)
93
- .join('\n')
94
- .trim()
95
- } else if (typeof entry.message?.content === 'string') {
96
- text = entry.message.content
97
- }
98
125
 
99
- if (text) {
100
- messages.push({
101
- role: 'assistant',
102
- content: text,
103
- timestamp: entry.timestamp || new Date().toISOString()
104
- })
126
+ if (!richMode) {
127
+ // Text-only mode (current behavior)
128
+ let text = ''
129
+ if (Array.isArray(contentArr)) {
130
+ text = contentArr
131
+ .filter(c => c.type === 'text')
132
+ .map(c => c.text)
133
+ .join('\n')
134
+ .trim()
135
+ } else if (typeof entry.message?.content === 'string') {
136
+ text = entry.message.content
137
+ }
138
+ if (text) {
139
+ messages.push({
140
+ role: 'assistant',
141
+ content: text,
142
+ timestamp: entry.timestamp || new Date().toISOString()
143
+ })
144
+ }
145
+ } else {
146
+ // Rich mode — keep text + tool_use + thinking blocks
147
+ const blocks = []
148
+ if (Array.isArray(contentArr)) {
149
+ for (const block of contentArr) {
150
+ if (block.type === 'text') {
151
+ if (block.text?.trim()) blocks.push({ type: 'text', text: block.text.trim() })
152
+ } else if (block.type === 'tool_use') {
153
+ blocks.push({ type: 'tool_use', name: block.name, input: block.input })
154
+ } else if (block.type === 'thinking') {
155
+ // Strip signature (large base64, wastes space)
156
+ const { signature, ...rest } = block
157
+ blocks.push({ type: 'thinking', thinking: rest.thinking || '' })
158
+ }
159
+ }
160
+ } else if (typeof entry.message?.content === 'string') {
161
+ blocks.push({ type: 'text', text: entry.message.content })
162
+ }
163
+ if (blocks.length > 0) {
164
+ const textOnly = blocks.filter(b => b.type === 'text').map(b => b.text).join('\n')
165
+ messages.push({
166
+ role: 'assistant',
167
+ content: blocks,
168
+ content_text: textOnly,
169
+ timestamp: entry.timestamp || new Date().toISOString()
170
+ })
171
+ }
105
172
  }
106
173
  }
107
174
  } catch { /* skip malformed lines */ }
@@ -42,9 +42,34 @@ function mergeDeltaSessions(sessions) {
42
42
  return merged.reverse()
43
43
  }
44
44
 
45
+ /**
46
+ * Convert rich content (block arrays) to readable text.
47
+ * Handles both string content (pass-through) and array content (render blocks).
48
+ */
49
+ function renderRichContent(msg) {
50
+ // Use content_text shim if available (fastest path)
51
+ if (msg.content_text) return msg.content_text
52
+ if (typeof msg.content === 'string') return msg.content
53
+ if (!Array.isArray(msg.content)) return ''
54
+ return msg.content.map(block => {
55
+ if (block.type === 'text') return block.text
56
+ if (block.type === 'tool_use') {
57
+ const desc = block.input?.description || ''
58
+ const summary = block.input?.command || block.input?.pattern || block.input?.file_path || JSON.stringify(block.input).slice(0, 100)
59
+ return desc ? `[${block.name}] ${summary} — ${desc}` : `[${block.name}] ${summary}`
60
+ }
61
+ if (block.type === 'thinking') return `[Thinking] ${(block.thinking || '').slice(0, 200)}`
62
+ if (block.type === 'tool_result') {
63
+ const text = typeof block.content === 'string' ? block.content : JSON.stringify(block.content)
64
+ return `[Result] ${text.slice(0, 200)}`
65
+ }
66
+ return ''
67
+ }).filter(Boolean).join('\n')
68
+ }
69
+
45
70
  function summarizeMessage(msg) {
46
71
  const role = msg.role === 'user' ? 'User' : 'Assistant'
47
- const text = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
72
+ const text = renderRichContent(msg)
48
73
  const oneLine = text.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim()
49
74
  const truncated = oneLine.length > 150 ? oneLine.slice(0, 150) + '...' : oneLine
50
75
  return `- **${role}:** ${truncated}`
@@ -95,7 +120,7 @@ function formatCurrentConversation(session) {
95
120
  lines.push(`### Recent messages (last ${recentMessages.length}):`)
96
121
  for (const msg of recentMessages) {
97
122
  const role = msg.role === 'user' ? 'User' : 'Assistant'
98
- const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
123
+ const content = renderRichContent(msg)
99
124
  const truncated = content.length > 2000 ? content.slice(0, 2000) + '\n...(truncated)' : content
100
125
  lines.push(`**${role}:**`)
101
126
  lines.push(truncated)
@@ -69,9 +69,10 @@ export async function saveFile(filePath, options = {}) {
69
69
  timestamp
70
70
  }
71
71
 
72
- const { txHex, txId, changeUtxos } = await spv.buildOpReturnTxWithChange(
72
+ const { txHex, txId, changeUtxos, fee, txSize } = await spv.buildOpReturnTxWithChange(
73
73
  wif, utxos, JSON.stringify(payload)
74
74
  )
75
+ process.stderr.write(`[indelible] save_file: ${fee} sats (${txSize} bytes)\n`)
75
76
  const result = await spv.broadcastTx(txHex)
76
77
  cacheTx(txId, payload)
77
78
  masterTxId = result.txid || txId
@@ -92,9 +93,10 @@ export async function saveFile(filePath, options = {}) {
92
93
  data: chunks[i]
93
94
  })
94
95
 
95
- const { txHex, txId, changeUtxos } = await spv.buildOpReturnTxWithChange(
96
+ const { txHex, txId, changeUtxos, fee, txSize } = await spv.buildOpReturnTxWithChange(
96
97
  wif, utxos, chunkPayload
97
98
  )
99
+ process.stderr.write(`[indelible] save_file chunk ${i + 1}/${chunks.length}: ${fee} sats (${txSize} bytes)\n`)
98
100
  const result = await spv.broadcastTx(txHex)
99
101
  cacheTx(txId, JSON.parse(chunkPayload))
100
102
  chunkTxIds.push(result.txid || txId)
@@ -133,9 +135,10 @@ export async function saveFile(filePath, options = {}) {
133
135
  await new Promise(r => setTimeout(r, 500))
134
136
  utxos = await spv.getUtxos(config.address)
135
137
  }
136
- const { txHex, txId, changeUtxos: masterChangeUtxos } = await spv.buildOpReturnTxWithChange(
138
+ const { txHex, txId, changeUtxos: masterChangeUtxos, fee: masterFee, txSize: masterTxSize } = await spv.buildOpReturnTxWithChange(
137
139
  wif, utxos, JSON.stringify(masterPayload)
138
140
  )
141
+ process.stderr.write(`[indelible] save_file master index: ${masterFee} sats (${masterTxSize} bytes)\n`)
139
142
  const result = await spv.broadcastTx(txHex)
140
143
  cacheTx(txId, masterPayload)
141
144
  masterTxId = result.txid || txId
@@ -141,9 +141,10 @@ export async function saveProject(dirPath, options = {}) {
141
141
  return { success: false, error: 'No UTXOs available. Fund your wallet first.' }
142
142
  }
143
143
 
144
- const { txHex, txId } = await spv.buildOpReturnTxWithChange(
144
+ const { txHex, txId, fee, txSize } = await spv.buildOpReturnTxWithChange(
145
145
  wif, utxos, JSON.stringify(payload), 'INDELIBLE_PROJECT_BUNDLE'
146
146
  )
147
+ process.stderr.write(`[indelible] save_project "${projectName}": ${fee} sats (${txSize} bytes)\n`)
147
148
  await spv.broadcastTx(txHex)
148
149
  cacheTx(txId, payload)
149
150
 
@@ -21,7 +21,7 @@ import { loadConfig, saveConfig, getWif } from '../lib/config.js'
21
21
  import { encrypt, sha256 } from '../lib/crypto.js'
22
22
  import { findCurrentTranscript, parseTranscript } from '../lib/transcript.js'
23
23
  import * as spv from '../lib/spv.js'
24
- import { indexSession } from '../lib/api-client.js'
24
+ import { indexSession, checkProTier } from '../lib/api-client.js'
25
25
  import { saveFile } from './save_file.js'
26
26
 
27
27
  const CONTEXT_FILE = join(homedir(), '.indelible', 'indelible-context.jsonl')
@@ -40,9 +40,11 @@ function createSummary(messages, userSummary) {
40
40
  if (userSummary) return userSummary
41
41
  const firstUser = messages.find(m => m.role === 'user')
42
42
  if (firstUser) {
43
- const preview = typeof firstUser.content === 'string'
44
- ? firstUser.content.slice(0, 200)
45
- : JSON.stringify(firstUser.content).slice(0, 200)
43
+ // Use content_text (rich mode fallback) or extract text from content
44
+ const text = firstUser.content_text
45
+ || (typeof firstUser.content === 'string' ? firstUser.content : null)
46
+ || (Array.isArray(firstUser.content) ? (firstUser.content.find(b => b.type === 'text')?.text || '') : '')
47
+ const preview = text.slice(0, 200)
46
48
  return preview + (preview.length >= 200 ? '...' : '')
47
49
  }
48
50
  return 'Claude Code session'
@@ -312,11 +314,15 @@ function ensureJsonlBackup(path, messages) {
312
314
  try {
313
315
  const dir = dirname(path)
314
316
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
315
- const lines = messages.map(m => JSON.stringify({
316
- type: m.role === 'user' ? 'user' : 'assistant',
317
- message: { content: [{ type: 'text', text: m.content }] },
318
- timestamp: m.timestamp
319
- }))
317
+ const lines = messages.map(m => {
318
+ // If content is already an array (rich mode), pass through. Otherwise wrap as text block.
319
+ const content = Array.isArray(m.content) ? m.content : [{ type: 'text', text: m.content }]
320
+ return JSON.stringify({
321
+ type: m.role === 'user' ? 'user' : 'assistant',
322
+ message: { content },
323
+ timestamp: m.timestamp
324
+ })
325
+ })
320
326
  writeFileSync(path, lines.join('\n') + '\n')
321
327
  } catch { /* don't block */ }
322
328
  }
@@ -422,12 +428,46 @@ export async function saveSession(transcriptPath, summary) {
422
428
  return { success: false, error: `Transcript not found: ${transcriptPath}` }
423
429
  }
424
430
 
431
+ // Check tier for rich mode
432
+ let richMode = false
433
+ const richSetting = config.rich_saves
434
+ if (richSetting === false || richSetting === 'disabled') {
435
+ richMode = false
436
+ } else {
437
+ try {
438
+ const tierResult = await checkProTier(config.address)
439
+ richMode = tierResult.ok === true
440
+ } catch { /* offline or error — stay text-only */ }
441
+ }
442
+
443
+ // First-time rich mode warning
444
+ if (richMode && !config.rich_saves_acknowledged) {
445
+ process.stderr.write(
446
+ `[indelible] Rich mode active — saves capture tool calls, results, and thinking blocks (~6-8x larger). ` +
447
+ `To disable: set "rich_saves": false in ~/.indelible/config.json\n`
448
+ )
449
+ config.rich_saves_acknowledged = true
450
+ saveConfig({ ...config })
451
+ }
452
+
425
453
  // Parse transcript
426
- const allMessages = parseTranscript(actualPath)
454
+ const allMessages = parseTranscript(actualPath, richMode)
427
455
  if (allMessages.length === 0) {
428
456
  return { success: false, error: 'No messages found in transcript' }
429
457
  }
430
458
 
459
+ // Session size cap — if rich mode produces >8MB, fall back to text-only
460
+ const MAX_SESSION_BYTES = 8 * 1024 * 1024
461
+ if (richMode) {
462
+ const estimatedSize = JSON.stringify(allMessages).length
463
+ if (estimatedSize > MAX_SESSION_BYTES) {
464
+ process.stderr.write(`[indelible] Rich session too large (${(estimatedSize / 1024 / 1024).toFixed(1)} MB) — falling back to text-only\n`)
465
+ richMode = false
466
+ allMessages.length = 0
467
+ allMessages.push(...parseTranscript(actualPath, false))
468
+ }
469
+ }
470
+
431
471
  // Append recent plan files (modified in last 24h) — plans are context worth saving
432
472
  const plans = getRecentPlans()
433
473
  if (plans.length > 0) {
@@ -460,6 +500,8 @@ export async function saveSession(transcriptPath, summary) {
460
500
  const session = {
461
501
  id: sessionId,
462
502
  type: isDelta ? 'delta' : 'full',
503
+ schema_version: richMode ? 2 : 1,
504
+ rich_mode: richMode,
463
505
  prev_session_id: prevSessionId,
464
506
  summary: createSummary(allMessages, summary),
465
507
  message_count: allMessages.length,
@@ -483,7 +525,9 @@ export async function saveSession(transcriptPath, summary) {
483
525
  if (transcriptPath) ensureJsonlBackup(transcriptPath, allMessages)
484
526
 
485
527
  // Encrypt locally — WIF never leaves
486
- const encrypted = encrypt(JSON.stringify(session), wif)
528
+ const sessionJson = JSON.stringify(session)
529
+ process.stderr.write(`[indelible] Saving session (${(sessionJson.length / 1024).toFixed(0)} KB, rich_mode: ${richMode})\n`)
530
+ const encrypted = encrypt(sessionJson, wif)
487
531
 
488
532
  // Build the blockchain payload
489
533
  const payload = {
@@ -501,7 +545,8 @@ export async function saveSession(transcriptPath, summary) {
501
545
  // Build + sign transaction LOCALLY with @bsv/sdk
502
546
  try {
503
547
  const utxos = await spv.getUtxos(config.address)
504
- const { txHex, txId, changeUtxos } = await spv.buildOpReturnTxWithChange(wif, utxos, JSON.stringify(payload))
548
+ const { txHex, txId, changeUtxos, fee, txSize } = await spv.buildOpReturnTxWithChange(wif, utxos, JSON.stringify(payload))
549
+ process.stderr.write(`[indelible] save_session: ${fee} sats (${txSize} bytes)\n`)
505
550
 
506
551
  // Broadcast via SPV bridge (gated by API key)
507
552
  const broadcastResult = await spv.broadcastTx(txHex)
@@ -626,9 +671,13 @@ export async function saveSession(transcriptPath, summary) {
626
671
  : ` (full: ${allMessages.length} messages)`
627
672
  const memInfo = memoryTxId ? ' Memory files saved.' : ''
628
673
 
674
+ const costInfo = fee ? ` Cost: ${fee} sats.` : ''
675
+
629
676
  return {
630
677
  success: true,
631
678
  txId: finalTxId,
679
+ fee,
680
+ txSize,
632
681
  sessionId,
633
682
  prevSessionId,
634
683
  messageCount: allMessages.length,
@@ -637,7 +686,7 @@ export async function saveSession(transcriptPath, summary) {
637
686
  summary: session.summary,
638
687
  memoryTxId,
639
688
  historyTxId,
640
- message: `Session saved!${deltaInfo} committed to blockchain.${prevSessionId ? ' Linked to previous session.' : ' (First session)'}${memInfo}`
689
+ message: `Session saved!${deltaInfo} committed to blockchain.${costInfo}${prevSessionId ? ' Linked to previous session.' : ' (First session)'}${memInfo}`
641
690
  }
642
691
  } catch (error) {
643
692
  return { success: false, error: `Failed to commit: ${error.message}` }