indelible-mcp 4.1.5 → 4.2.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 +1 -1
- package/src/index.js +24 -2
- package/src/lib/api-client.js +293 -293
- package/src/lib/config.js +80 -80
- package/src/lib/crypto.js +66 -66
- package/src/lib/spv.js +559 -437
- package/src/tools/x402_fetch.js +177 -0
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -31,6 +31,7 @@ import { updateVaultIndex } from './tools/update_vault_index.js'
|
|
|
31
31
|
import { diaryConnect } from './tools/diary_connect.js'
|
|
32
32
|
import { diaryChat } from './tools/diary_chat.js'
|
|
33
33
|
import { diarySave } from './tools/diary_save.js'
|
|
34
|
+
import { x402Fetch } from './tools/x402_fetch.js'
|
|
34
35
|
import * as spv from './lib/spv.js'
|
|
35
36
|
|
|
36
37
|
const CONTEXT_FILE = join(homedir(), '.indelible', 'indelible-context.jsonl')
|
|
@@ -269,7 +270,7 @@ Commands:
|
|
|
269
270
|
|
|
270
271
|
function printHelp() {
|
|
271
272
|
console.log(`
|
|
272
|
-
Indelible MCP — Blockchain memory for Claude Code (v4.
|
|
273
|
+
Indelible MCP — Blockchain memory for Claude Code (v4.2.0)
|
|
273
274
|
|
|
274
275
|
Setup:
|
|
275
276
|
indelible-mcp setup --wif=KEY --pin=PIN Import and encrypt your private key
|
|
@@ -297,6 +298,9 @@ Diary AI (Codex/ChatGPT companion):
|
|
|
297
298
|
indelible-mcp diary chat "message" Ask the AI companion
|
|
298
299
|
indelible-mcp diary save Save exchange to blockchain
|
|
299
300
|
|
|
301
|
+
Payments:
|
|
302
|
+
x402_fetch (MCP only) Fetch URL with automatic x402 payment
|
|
303
|
+
|
|
300
304
|
Hooks (auto-called by Claude Code):
|
|
301
305
|
indelible-mcp hook pre-compact Auto-save before compaction
|
|
302
306
|
indelible-mcp hook post-compact Auto-restore after compaction
|
|
@@ -481,7 +485,7 @@ function readStdin() {
|
|
|
481
485
|
|
|
482
486
|
const SERVER_INFO = {
|
|
483
487
|
name: 'indelible',
|
|
484
|
-
version: '4.
|
|
488
|
+
version: '4.2.0',
|
|
485
489
|
description: 'Blockchain-backed memory and code storage for Claude Code'
|
|
486
490
|
}
|
|
487
491
|
|
|
@@ -643,6 +647,21 @@ const TOOLS = [
|
|
|
643
647
|
},
|
|
644
648
|
required: ['messages']
|
|
645
649
|
}
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
name: 'x402_fetch',
|
|
653
|
+
description: 'Fetch a URL with automatic x402 payment. If the endpoint returns 402, the tool pays the required sats from your Indelible wallet, then re-requests with proof. Returns content and payment details.',
|
|
654
|
+
inputSchema: {
|
|
655
|
+
type: 'object',
|
|
656
|
+
properties: {
|
|
657
|
+
url: { type: 'string', description: 'URL to fetch (the x402-protected endpoint)' },
|
|
658
|
+
method: { type: 'string', description: 'HTTP method (default: GET)' },
|
|
659
|
+
headers: { type: 'object', description: 'Extra headers to include' },
|
|
660
|
+
body: { type: 'string', description: 'Request body (for POST requests)' },
|
|
661
|
+
maxSats: { type: 'number', description: 'Maximum sats to pay per request (default: 10000, safety cap)' }
|
|
662
|
+
},
|
|
663
|
+
required: ['url']
|
|
664
|
+
}
|
|
646
665
|
}
|
|
647
666
|
]
|
|
648
667
|
|
|
@@ -708,6 +727,9 @@ async function handleMcpRequest(request) {
|
|
|
708
727
|
case 'diary_save':
|
|
709
728
|
result = await diarySave({ messages: args?.messages, summary: args?.summary })
|
|
710
729
|
break
|
|
730
|
+
case 'x402_fetch':
|
|
731
|
+
result = await x402Fetch({ url: args?.url, method: args?.method, headers: args?.headers, body: args?.body, maxSats: args?.maxSats })
|
|
732
|
+
break
|
|
711
733
|
default:
|
|
712
734
|
throw new Error(`Unknown tool: ${name}`)
|
|
713
735
|
}
|
package/src/lib/api-client.js
CHANGED
|
@@ -1,293 +1,293 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* API client for Indelible CLI
|
|
3
|
-
* Handles registration, session indexing, and status checks.
|
|
4
|
-
* Blockchain operations go through SPV bridge (see spv.js).
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { Transaction } from '@bsv/sdk'
|
|
8
|
-
import { loadConfig } from './config.js'
|
|
9
|
-
import * as spv from './spv.js'
|
|
10
|
-
|
|
11
|
-
const DEFAULT_API_URL = 'https://indelible.one'
|
|
12
|
-
|
|
13
|
-
function getApiUrl() {
|
|
14
|
-
const config = loadConfig()
|
|
15
|
-
return config?.api_url || DEFAULT_API_URL
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function getHeaders(config) {
|
|
19
|
-
const headers = { 'Content-Type': 'application/json' }
|
|
20
|
-
if (config?.api_key) {
|
|
21
|
-
headers['Authorization'] = `Bearer ${config.api_key}`
|
|
22
|
-
}
|
|
23
|
-
return headers
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Check tier/usage with Indelible server before saving.
|
|
28
|
-
* Returns { ok, tier, usage, limit } or throws on rate limit.
|
|
29
|
-
*/
|
|
30
|
-
export async function checkTier(apiKey) {
|
|
31
|
-
if (!apiKey) return { ok: true, tier: 'unknown', usage: 0, limit: null } // no key = legacy, allow
|
|
32
|
-
const apiUrl = getApiUrl()
|
|
33
|
-
try {
|
|
34
|
-
const res = await fetch(`${apiUrl}/api/cli/status`, {
|
|
35
|
-
headers: { 'Authorization': `Bearer ${apiKey}` },
|
|
36
|
-
signal: AbortSignal.timeout(5000)
|
|
37
|
-
})
|
|
38
|
-
if (res.status === 429) {
|
|
39
|
-
const data = await res.json().catch(() => ({}))
|
|
40
|
-
throw new Error(`Monthly save limit reached (${data.limit || 50}). Upgrade at indelible.one/pricing`)
|
|
41
|
-
}
|
|
42
|
-
if (res.ok) {
|
|
43
|
-
const data = await res.json()
|
|
44
|
-
return { ok: true, tier: data.tier, usage: data.saves_this_month, limit: data.saves_limit }
|
|
45
|
-
}
|
|
46
|
-
} catch (err) {
|
|
47
|
-
if (err.message.includes('save limit')) throw err
|
|
48
|
-
// Server unreachable — allow save (offline-first)
|
|
49
|
-
}
|
|
50
|
-
return { ok: true, tier: 'unknown', usage: 0, limit: null }
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Register API key with server (one-time, for SPV bridge access + web app)
|
|
55
|
-
*/
|
|
56
|
-
export async function register(address, apiKey) {
|
|
57
|
-
const res = await fetch(`${getApiUrl()}/api/cli/register`, {
|
|
58
|
-
method: 'POST',
|
|
59
|
-
headers: { 'Content-Type': 'application/json' },
|
|
60
|
-
body: JSON.stringify({ address, api_key: apiKey }),
|
|
61
|
-
signal: AbortSignal.timeout(10000)
|
|
62
|
-
})
|
|
63
|
-
if (!res.ok) {
|
|
64
|
-
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
|
65
|
-
throw new Error(err.error || `Registration failed: ${res.status}`)
|
|
66
|
-
}
|
|
67
|
-
return res.json()
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Index a session on server for web app retrieval.
|
|
72
|
-
* Called after successful broadcast — best effort, doesn't block save.
|
|
73
|
-
*/
|
|
74
|
-
export async function indexSession(sessionData, config) {
|
|
75
|
-
const res = await fetch(`${getApiUrl()}/api/cli/index-session`, {
|
|
76
|
-
method: 'POST',
|
|
77
|
-
headers: getHeaders(config),
|
|
78
|
-
body: JSON.stringify(sessionData),
|
|
79
|
-
signal: AbortSignal.timeout(10000)
|
|
80
|
-
})
|
|
81
|
-
if (!res.ok) {
|
|
82
|
-
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
|
83
|
-
throw new Error(err.error || `Indexing failed: ${res.status}`)
|
|
84
|
-
}
|
|
85
|
-
return res.json()
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Fetch session index from server (faster than scanning blockchain)
|
|
90
|
-
*/
|
|
91
|
-
export async function getSessions(address, limit, config) {
|
|
92
|
-
const res = await fetch(`${getApiUrl()}/api/cli/sessions`, {
|
|
93
|
-
method: 'POST',
|
|
94
|
-
headers: getHeaders(config),
|
|
95
|
-
body: JSON.stringify({ address, limit }),
|
|
96
|
-
signal: AbortSignal.timeout(10000)
|
|
97
|
-
})
|
|
98
|
-
if (!res.ok) {
|
|
99
|
-
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
|
100
|
-
throw new Error(err.error || `Fetch sessions failed: ${res.status}`)
|
|
101
|
-
}
|
|
102
|
-
return res.json()
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Get account status
|
|
107
|
-
*/
|
|
108
|
-
export async function getStatus(config) {
|
|
109
|
-
const res = await fetch(`${getApiUrl()}/api/cli/status`, {
|
|
110
|
-
method: 'GET',
|
|
111
|
-
headers: getHeaders(config),
|
|
112
|
-
signal: AbortSignal.timeout(10000)
|
|
113
|
-
})
|
|
114
|
-
if (!res.ok) {
|
|
115
|
-
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
|
116
|
-
throw new Error(err.error || `Status check failed: ${res.status}`)
|
|
117
|
-
}
|
|
118
|
-
return res.json()
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Commit a session to the blockchain
|
|
123
|
-
* Checks tier limits first, then builds tx locally and broadcasts via SPV bridge.
|
|
124
|
-
* WIF never leaves the machine — no remote fallback.
|
|
125
|
-
* @param {Object} session - Session data to commit (must include address, encrypted)
|
|
126
|
-
* @param {string} wif - WIF for signing/encryption
|
|
127
|
-
* @returns {Promise<{success: boolean, txId?: string, error?: string}>}
|
|
128
|
-
*/
|
|
129
|
-
export async function commitSession(session, wif) {
|
|
130
|
-
const config = loadConfig()
|
|
131
|
-
const apiKey = config?.api_key || null
|
|
132
|
-
|
|
133
|
-
// Check tier/usage before broadcasting
|
|
134
|
-
await checkTier(apiKey)
|
|
135
|
-
|
|
136
|
-
// Build tx locally and broadcast via SPV bridge
|
|
137
|
-
try {
|
|
138
|
-
const utxos = await spv.getUtxos(session.address)
|
|
139
|
-
if (utxos && utxos.length > 0) {
|
|
140
|
-
const payload = {
|
|
141
|
-
protocol: 'indelible.claude-code',
|
|
142
|
-
encrypted: session.encrypted
|
|
143
|
-
}
|
|
144
|
-
const { txHex, txId, changeUtxos } = await spv.buildOpReturnTxWithChange(wif, utxos, JSON.stringify(payload))
|
|
145
|
-
await spv.broadcastTx(txHex)
|
|
146
|
-
|
|
147
|
-
// Index session for usage tracking
|
|
148
|
-
await indexSession({
|
|
149
|
-
txId,
|
|
150
|
-
address: session.address,
|
|
151
|
-
encrypted: session.encrypted,
|
|
152
|
-
session_id: session.session_id,
|
|
153
|
-
prev_session_id: session.prev_session_id,
|
|
154
|
-
summary: session.summary,
|
|
155
|
-
message_count: session.message_count,
|
|
156
|
-
save_type: session.type || 'full',
|
|
157
|
-
timestamp: new Date().toISOString()
|
|
158
|
-
}, config)
|
|
159
|
-
|
|
160
|
-
return { success: true, txId, changeUtxos }
|
|
161
|
-
}
|
|
162
|
-
} catch (err) {
|
|
163
|
-
if (err.message.includes('save limit')) throw err
|
|
164
|
-
throw new Error('Gateway unreachable, save failed. Check your SPV bridge connection.')
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
throw new Error('No UTXOs available. Fund your wallet or check gateway connection.')
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Get latest sessions for an address
|
|
172
|
-
* Scans address history via SPV bridge — no remote fallback
|
|
173
|
-
* @param {string} address - BSV address
|
|
174
|
-
* @param {string} wif - WIF for decryption (local only)
|
|
175
|
-
* @param {number} limit - Number of sessions to fetch
|
|
176
|
-
* @returns {Promise<{sessions: Array}>}
|
|
177
|
-
*/
|
|
178
|
-
export async function getLatestSessions(address, wif, limit = 3) {
|
|
179
|
-
try {
|
|
180
|
-
const history = await spv.getAddressHistory(address)
|
|
181
|
-
if (history && history.length > 0) {
|
|
182
|
-
// Sort by height descending (most recent first), unconfirmed (height 0 or -1) first
|
|
183
|
-
history.sort((a, b) => (b.height > 0 ? b.height : Infinity) - (a.height > 0 ? a.height : Infinity))
|
|
184
|
-
|
|
185
|
-
const sessions = []
|
|
186
|
-
for (const txInfo of history) {
|
|
187
|
-
if (sessions.length >= limit) break
|
|
188
|
-
try {
|
|
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
|
-
}
|
|
200
|
-
if (encrypted) {
|
|
201
|
-
sessions.push({
|
|
202
|
-
txId: txInfo.tx_hash,
|
|
203
|
-
encrypted,
|
|
204
|
-
timestamp: null,
|
|
205
|
-
summary: null,
|
|
206
|
-
message_count: null
|
|
207
|
-
})
|
|
208
|
-
}
|
|
209
|
-
} catch {
|
|
210
|
-
// Skip txs we can't fetch
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (sessions.length > 0) {
|
|
215
|
-
return { sessions }
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
} catch {
|
|
219
|
-
// SPV bridge failed — no fallback, WIF never leaves the machine
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
return { sessions: [] }
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Check if user has Pro or Developer tier (required for Diary AI tools)
|
|
227
|
-
* @param {string} address - User's BSV address
|
|
228
|
-
* @returns {Promise<{ok: boolean, plan: string, error?: string}>}
|
|
229
|
-
*/
|
|
230
|
-
export async function checkProTier(address) {
|
|
231
|
-
try {
|
|
232
|
-
const res = await fetch(`${getApiUrl()}/api/stripe/subscription-status-wif`, {
|
|
233
|
-
method: 'POST',
|
|
234
|
-
headers: { 'Content-Type': 'application/json' },
|
|
235
|
-
body: JSON.stringify({ address }),
|
|
236
|
-
signal: AbortSignal.timeout(5000)
|
|
237
|
-
})
|
|
238
|
-
if (res.ok) {
|
|
239
|
-
const data = await res.json()
|
|
240
|
-
if (data.plan === 'admin' || data.plan === 'pro' || data.plan === 'developer') {
|
|
241
|
-
return { ok: true, plan: data.plan }
|
|
242
|
-
}
|
|
243
|
-
return { ok: false, plan: data.plan || 'free', error: 'Pro tier required for Diary AI companion. Upgrade at indelible.one/pricing' }
|
|
244
|
-
}
|
|
245
|
-
} catch {
|
|
246
|
-
// Server unreachable — allow (offline-first)
|
|
247
|
-
return { ok: true, plan: 'unknown' }
|
|
248
|
-
}
|
|
249
|
-
return { ok: true, plan: 'unknown' }
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Auto-generate relay API key for SPV bridge access
|
|
254
|
-
* Logs in with address, then calls generate-key endpoint
|
|
255
|
-
* @param {string} address - BSV address
|
|
256
|
-
* @param {string} apiUrl - Optional API URL override
|
|
257
|
-
* @returns {Promise<{key: string, tier: string}|null>}
|
|
258
|
-
*/
|
|
259
|
-
export async function ensureRelayKey(address, apiUrl) {
|
|
260
|
-
if (!apiUrl) apiUrl = getApiUrl()
|
|
261
|
-
try {
|
|
262
|
-
const authRes = await fetch(`${apiUrl}/api/auth/wif`, {
|
|
263
|
-
method: 'POST',
|
|
264
|
-
headers: { 'Content-Type': 'application/json' },
|
|
265
|
-
body: JSON.stringify({ address }),
|
|
266
|
-
signal: AbortSignal.timeout(10000)
|
|
267
|
-
})
|
|
268
|
-
if (!authRes.ok) return null
|
|
269
|
-
const { token } = await authRes.json()
|
|
270
|
-
if (!token) return null
|
|
271
|
-
const keyRes = await fetch(`${apiUrl}/api/relay/generate-key`, {
|
|
272
|
-
method: 'POST',
|
|
273
|
-
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
|
274
|
-
body: JSON.stringify({}),
|
|
275
|
-
signal: AbortSignal.timeout(10000)
|
|
276
|
-
})
|
|
277
|
-
if (!keyRes.ok) return null
|
|
278
|
-
const keyData = await keyRes.json()
|
|
279
|
-
return { key: keyData.key, tier: keyData.tier }
|
|
280
|
-
} catch { return null }
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Check if server is reachable
|
|
285
|
-
*/
|
|
286
|
-
export async function checkConnection() {
|
|
287
|
-
try {
|
|
288
|
-
const res = await fetch(`${getApiUrl()}/api/health`, { signal: AbortSignal.timeout(5000) })
|
|
289
|
-
return res.ok
|
|
290
|
-
} catch {
|
|
291
|
-
return false
|
|
292
|
-
}
|
|
293
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* API client for Indelible CLI
|
|
3
|
+
* Handles registration, session indexing, and status checks.
|
|
4
|
+
* Blockchain operations go through SPV bridge (see spv.js).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Transaction } from '@bsv/sdk'
|
|
8
|
+
import { loadConfig } from './config.js'
|
|
9
|
+
import * as spv from './spv.js'
|
|
10
|
+
|
|
11
|
+
const DEFAULT_API_URL = 'https://indelible.one'
|
|
12
|
+
|
|
13
|
+
function getApiUrl() {
|
|
14
|
+
const config = loadConfig()
|
|
15
|
+
return config?.api_url || DEFAULT_API_URL
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getHeaders(config) {
|
|
19
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
20
|
+
if (config?.api_key) {
|
|
21
|
+
headers['Authorization'] = `Bearer ${config.api_key}`
|
|
22
|
+
}
|
|
23
|
+
return headers
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check tier/usage with Indelible server before saving.
|
|
28
|
+
* Returns { ok, tier, usage, limit } or throws on rate limit.
|
|
29
|
+
*/
|
|
30
|
+
export async function checkTier(apiKey) {
|
|
31
|
+
if (!apiKey) return { ok: true, tier: 'unknown', usage: 0, limit: null } // no key = legacy, allow
|
|
32
|
+
const apiUrl = getApiUrl()
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(`${apiUrl}/api/cli/status`, {
|
|
35
|
+
headers: { 'Authorization': `Bearer ${apiKey}` },
|
|
36
|
+
signal: AbortSignal.timeout(5000)
|
|
37
|
+
})
|
|
38
|
+
if (res.status === 429) {
|
|
39
|
+
const data = await res.json().catch(() => ({}))
|
|
40
|
+
throw new Error(`Monthly save limit reached (${data.limit || 50}). Upgrade at indelible.one/pricing`)
|
|
41
|
+
}
|
|
42
|
+
if (res.ok) {
|
|
43
|
+
const data = await res.json()
|
|
44
|
+
return { ok: true, tier: data.tier, usage: data.saves_this_month, limit: data.saves_limit }
|
|
45
|
+
}
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err.message.includes('save limit')) throw err
|
|
48
|
+
// Server unreachable — allow save (offline-first)
|
|
49
|
+
}
|
|
50
|
+
return { ok: true, tier: 'unknown', usage: 0, limit: null }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Register API key with server (one-time, for SPV bridge access + web app)
|
|
55
|
+
*/
|
|
56
|
+
export async function register(address, apiKey) {
|
|
57
|
+
const res = await fetch(`${getApiUrl()}/api/cli/register`, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'Content-Type': 'application/json' },
|
|
60
|
+
body: JSON.stringify({ address, api_key: apiKey }),
|
|
61
|
+
signal: AbortSignal.timeout(10000)
|
|
62
|
+
})
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
|
65
|
+
throw new Error(err.error || `Registration failed: ${res.status}`)
|
|
66
|
+
}
|
|
67
|
+
return res.json()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Index a session on server for web app retrieval.
|
|
72
|
+
* Called after successful broadcast — best effort, doesn't block save.
|
|
73
|
+
*/
|
|
74
|
+
export async function indexSession(sessionData, config) {
|
|
75
|
+
const res = await fetch(`${getApiUrl()}/api/cli/index-session`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: getHeaders(config),
|
|
78
|
+
body: JSON.stringify(sessionData),
|
|
79
|
+
signal: AbortSignal.timeout(10000)
|
|
80
|
+
})
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
|
83
|
+
throw new Error(err.error || `Indexing failed: ${res.status}`)
|
|
84
|
+
}
|
|
85
|
+
return res.json()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Fetch session index from server (faster than scanning blockchain)
|
|
90
|
+
*/
|
|
91
|
+
export async function getSessions(address, limit, config) {
|
|
92
|
+
const res = await fetch(`${getApiUrl()}/api/cli/sessions`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: getHeaders(config),
|
|
95
|
+
body: JSON.stringify({ address, limit }),
|
|
96
|
+
signal: AbortSignal.timeout(10000)
|
|
97
|
+
})
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
|
100
|
+
throw new Error(err.error || `Fetch sessions failed: ${res.status}`)
|
|
101
|
+
}
|
|
102
|
+
return res.json()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get account status
|
|
107
|
+
*/
|
|
108
|
+
export async function getStatus(config) {
|
|
109
|
+
const res = await fetch(`${getApiUrl()}/api/cli/status`, {
|
|
110
|
+
method: 'GET',
|
|
111
|
+
headers: getHeaders(config),
|
|
112
|
+
signal: AbortSignal.timeout(10000)
|
|
113
|
+
})
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
|
116
|
+
throw new Error(err.error || `Status check failed: ${res.status}`)
|
|
117
|
+
}
|
|
118
|
+
return res.json()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Commit a session to the blockchain
|
|
123
|
+
* Checks tier limits first, then builds tx locally and broadcasts via SPV bridge.
|
|
124
|
+
* WIF never leaves the machine — no remote fallback.
|
|
125
|
+
* @param {Object} session - Session data to commit (must include address, encrypted)
|
|
126
|
+
* @param {string} wif - WIF for signing/encryption
|
|
127
|
+
* @returns {Promise<{success: boolean, txId?: string, error?: string}>}
|
|
128
|
+
*/
|
|
129
|
+
export async function commitSession(session, wif) {
|
|
130
|
+
const config = loadConfig()
|
|
131
|
+
const apiKey = config?.api_key || null
|
|
132
|
+
|
|
133
|
+
// Check tier/usage before broadcasting
|
|
134
|
+
await checkTier(apiKey)
|
|
135
|
+
|
|
136
|
+
// Build tx locally and broadcast via SPV bridge
|
|
137
|
+
try {
|
|
138
|
+
const utxos = await spv.getUtxos(session.address)
|
|
139
|
+
if (utxos && utxos.length > 0) {
|
|
140
|
+
const payload = {
|
|
141
|
+
protocol: 'indelible.claude-code',
|
|
142
|
+
encrypted: session.encrypted
|
|
143
|
+
}
|
|
144
|
+
const { txHex, txId, changeUtxos } = await spv.buildOpReturnTxWithChange(wif, utxos, JSON.stringify(payload))
|
|
145
|
+
await spv.broadcastTx(txHex)
|
|
146
|
+
|
|
147
|
+
// Index session for usage tracking
|
|
148
|
+
await indexSession({
|
|
149
|
+
txId,
|
|
150
|
+
address: session.address,
|
|
151
|
+
encrypted: session.encrypted,
|
|
152
|
+
session_id: session.session_id,
|
|
153
|
+
prev_session_id: session.prev_session_id,
|
|
154
|
+
summary: session.summary,
|
|
155
|
+
message_count: session.message_count,
|
|
156
|
+
save_type: session.type || 'full',
|
|
157
|
+
timestamp: new Date().toISOString()
|
|
158
|
+
}, config)
|
|
159
|
+
|
|
160
|
+
return { success: true, txId, changeUtxos }
|
|
161
|
+
}
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (err.message.includes('save limit')) throw err
|
|
164
|
+
throw new Error('Gateway unreachable, save failed. Check your SPV bridge connection.')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
throw new Error('No UTXOs available. Fund your wallet or check gateway connection.')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Get latest sessions for an address
|
|
172
|
+
* Scans address history via SPV bridge — no remote fallback
|
|
173
|
+
* @param {string} address - BSV address
|
|
174
|
+
* @param {string} wif - WIF for decryption (local only)
|
|
175
|
+
* @param {number} limit - Number of sessions to fetch
|
|
176
|
+
* @returns {Promise<{sessions: Array}>}
|
|
177
|
+
*/
|
|
178
|
+
export async function getLatestSessions(address, wif, limit = 3) {
|
|
179
|
+
try {
|
|
180
|
+
const history = await spv.getAddressHistory(address)
|
|
181
|
+
if (history && history.length > 0) {
|
|
182
|
+
// Sort by height descending (most recent first), unconfirmed (height 0 or -1) first
|
|
183
|
+
history.sort((a, b) => (b.height > 0 ? b.height : Infinity) - (a.height > 0 ? a.height : Infinity))
|
|
184
|
+
|
|
185
|
+
const sessions = []
|
|
186
|
+
for (const txInfo of history) {
|
|
187
|
+
if (sessions.length >= limit) break
|
|
188
|
+
try {
|
|
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
|
+
}
|
|
200
|
+
if (encrypted) {
|
|
201
|
+
sessions.push({
|
|
202
|
+
txId: txInfo.tx_hash,
|
|
203
|
+
encrypted,
|
|
204
|
+
timestamp: null,
|
|
205
|
+
summary: null,
|
|
206
|
+
message_count: null
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
// Skip txs we can't fetch
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (sessions.length > 0) {
|
|
215
|
+
return { sessions }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
// SPV bridge failed — no fallback, WIF never leaves the machine
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { sessions: [] }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Check if user has Pro or Developer tier (required for Diary AI tools)
|
|
227
|
+
* @param {string} address - User's BSV address
|
|
228
|
+
* @returns {Promise<{ok: boolean, plan: string, error?: string}>}
|
|
229
|
+
*/
|
|
230
|
+
export async function checkProTier(address) {
|
|
231
|
+
try {
|
|
232
|
+
const res = await fetch(`${getApiUrl()}/api/stripe/subscription-status-wif`, {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers: { 'Content-Type': 'application/json' },
|
|
235
|
+
body: JSON.stringify({ address }),
|
|
236
|
+
signal: AbortSignal.timeout(5000)
|
|
237
|
+
})
|
|
238
|
+
if (res.ok) {
|
|
239
|
+
const data = await res.json()
|
|
240
|
+
if (data.plan === 'admin' || data.plan === 'pro' || data.plan === 'developer') {
|
|
241
|
+
return { ok: true, plan: data.plan }
|
|
242
|
+
}
|
|
243
|
+
return { ok: false, plan: data.plan || 'free', error: 'Pro tier required for Diary AI companion. Upgrade at indelible.one/pricing' }
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// Server unreachable — allow (offline-first)
|
|
247
|
+
return { ok: true, plan: 'unknown' }
|
|
248
|
+
}
|
|
249
|
+
return { ok: true, plan: 'unknown' }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Auto-generate relay API key for SPV bridge access
|
|
254
|
+
* Logs in with address, then calls generate-key endpoint
|
|
255
|
+
* @param {string} address - BSV address
|
|
256
|
+
* @param {string} apiUrl - Optional API URL override
|
|
257
|
+
* @returns {Promise<{key: string, tier: string}|null>}
|
|
258
|
+
*/
|
|
259
|
+
export async function ensureRelayKey(address, apiUrl) {
|
|
260
|
+
if (!apiUrl) apiUrl = getApiUrl()
|
|
261
|
+
try {
|
|
262
|
+
const authRes = await fetch(`${apiUrl}/api/auth/wif`, {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
headers: { 'Content-Type': 'application/json' },
|
|
265
|
+
body: JSON.stringify({ address }),
|
|
266
|
+
signal: AbortSignal.timeout(10000)
|
|
267
|
+
})
|
|
268
|
+
if (!authRes.ok) return null
|
|
269
|
+
const { token } = await authRes.json()
|
|
270
|
+
if (!token) return null
|
|
271
|
+
const keyRes = await fetch(`${apiUrl}/api/relay/generate-key`, {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
|
274
|
+
body: JSON.stringify({}),
|
|
275
|
+
signal: AbortSignal.timeout(10000)
|
|
276
|
+
})
|
|
277
|
+
if (!keyRes.ok) return null
|
|
278
|
+
const keyData = await keyRes.json()
|
|
279
|
+
return { key: keyData.key, tier: keyData.tier }
|
|
280
|
+
} catch { return null }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Check if server is reachable
|
|
285
|
+
*/
|
|
286
|
+
export async function checkConnection() {
|
|
287
|
+
try {
|
|
288
|
+
const res = await fetch(`${getApiUrl()}/api/health`, { signal: AbortSignal.timeout(5000) })
|
|
289
|
+
return res.ok
|
|
290
|
+
} catch {
|
|
291
|
+
return false
|
|
292
|
+
}
|
|
293
|
+
}
|