@paytaca/opencode-plugin 0.2.2 → 0.3.1
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 +30 -1
- package/dist/bundled/mcp.d.ts +1 -1
- package/dist/bundled/mcp.d.ts.map +1 -1
- package/dist/bundled/mcp.js +251 -23
- package/dist/bundled/mcp.js.map +1 -1
- package/dist/bundled/proxy.d.ts +1 -1
- package/dist/bundled/proxy.d.ts.map +1 -1
- package/dist/bundled/proxy.js +393 -43
- package/dist/bundled/proxy.js.map +1 -1
- package/dist/bundled/wrapper.d.ts +1 -1
- package/dist/bundled/wrapper.d.ts.map +1 -1
- package/dist/bundled/wrapper.js +123 -3
- package/dist/bundled/wrapper.js.map +1 -1
- package/dist/credits.d.ts +9 -0
- package/dist/credits.d.ts.map +1 -0
- package/dist/credits.js +120 -0
- package/dist/credits.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -1
- package/dist/index.js.map +1 -1
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +6 -4
- package/dist/proxy.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,11 +51,13 @@ The assistant answers using live data via these tools:
|
|
|
51
51
|
|
|
52
52
|
| Tool | Description |
|
|
53
53
|
|---|---|
|
|
54
|
+
| `get_help` | Q&A guide on the wallet, funding, plans, paying with BCH or LIFT, and more |
|
|
54
55
|
| `get_credits` | Remaining time credits per active model session |
|
|
55
56
|
| `get_balance` | BCH balance of the Paytaca wallet |
|
|
56
57
|
| `get_models` | Available models (id, display name, tier) |
|
|
57
58
|
| `get_plans` | Plan pricing grouped by tier (minutes, USD, BCH) |
|
|
58
|
-
| `buy_plan` | Buy time credits for any model + plan duration (spends BCH) |
|
|
59
|
+
| `buy_plan` | Buy time credits for any model + plan duration (spends BCH or LIFT) |
|
|
60
|
+
| `auto_refill` | Arm/disarm automatic plan refills so long sessions never stall |
|
|
59
61
|
| `get_transactions` | Recent wallet transactions (filter by direction, page) |
|
|
60
62
|
| `get_receiving_address` | Receiving address, optionally as a BIP21 URI with amount |
|
|
61
63
|
| `get_tokens` | CashToken holdings, or details for one token category |
|
|
@@ -69,6 +71,33 @@ same x402 payment flow as the interactive proxy prompt. Because this spends
|
|
|
69
71
|
real funds, opencode always asks for approval before the tool runs (the plugin
|
|
70
72
|
sets the `paytaca_buy_plan` permission to `ask`).
|
|
71
73
|
|
|
74
|
+
Plans can be paid with **BCH** (default) or with **LIFT tokens** — say "pay with
|
|
75
|
+
LIFT" or "pay with my LIFT tokens" and the tool sells your LIFT balance via
|
|
76
|
+
Cauldron to fund the plan. LIFT payments get a discount (rate set server-side;
|
|
77
|
+
the assistant can quote the current percent via `get_help` or `get_plans`).
|
|
78
|
+
You can't buy a plan while the model still has active credits — spending would
|
|
79
|
+
charge nothing and time doesn't stack, so use up or wait out remaining credits
|
|
80
|
+
before buying again.
|
|
81
|
+
|
|
82
|
+
### Auto-refilling a plan
|
|
83
|
+
|
|
84
|
+
For uninterrupted long sessions ("keep buying 15-minute plans until the task is
|
|
85
|
+
done, max 2 hours"), the assistant can arm `auto_refill`. When the model's
|
|
86
|
+
credits run out mid-task, the plugin then silently buys another plan of the
|
|
87
|
+
chosen size and keeps working — no interactive prompt — until either the task
|
|
88
|
+
finishes, `auto_refill` is disarmed, or the total auto-bought minutes reach your
|
|
89
|
+
cap. Each refill is a real wallet payment (BCH, or LIFT when requested), so
|
|
90
|
+
opencode prompts for approval when the tool is armed.
|
|
91
|
+
|
|
92
|
+
### Free concierge answers
|
|
93
|
+
|
|
94
|
+
Even with no paid capacity left, balance and "how it works" questions are
|
|
95
|
+
answered for free: the backend runs a lightweight concierge model and, when your
|
|
96
|
+
wallet can't pay, answers questions about your BCH/LIFT balance, models, pricing,
|
|
97
|
+
and how to top up — instead of a bare `402`. Genuine purchases still require
|
|
98
|
+
payment and are never stuck behind the concierge. Accounts with paid capacity
|
|
99
|
+
get the concierge too, but the real model answers as usual.
|
|
100
|
+
|
|
72
101
|
### Sending funds
|
|
73
102
|
|
|
74
103
|
You can ask the assistant to send BCH or CashTokens ("send 0.01 BCH to
|
package/dist/bundled/mcp.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const MCP_SERVER_CONTENT = "#!/usr/bin/env node\n/**\n * Paytaca MCP Server\n *\n * Registers Paytaca tools with opencode so the assistant can work with real\n * data instead of guessing. Two groups:\n * - Paytaca AI account (backend): credits, models, plan pricing\n * - Paytaca wallet (paytaca CLI): balance, transactions, receiving address,\n * token holdings, and sending funds\n *\n * The send tool moves real funds \u2014 opencode is configured (via the plugin's\n * config hook) to require explicit user approval before it runs.\n *\n * Loaded automatically via the plugin's config hook (cfg.mcp['paytaca']).\n * Uses only Node.js built-in modules.\n */\n\nconst http = require('http');\nconst https = require('https');\nconst { spawn } = require('child_process');\nconst fs = require('fs');\nconst path = require('path');\nconst os = require('os');\n\nconst CONFIG_DIR = process.env.PAYTACA_CONFIG_DIR || path.join(os.homedir(), '.opencode-paytaca');\nconst PAYTACA_CMD = process.env.PAYTACA_CMD || 'paytaca';\nconst DEFAULT_BACKEND = process.env.PAYTACA_BACKEND_URL || 'https://api.paytaca.ai';\n\n// LIFT is the Paytaca token users can pay AI plans with (sold via Cauldron).\n// The same token id is used by the payment wrapper's payWithLift().\nconst LIFT_TOKEN_ID = '5932b2fd4915d6a75d3ec53282cd49118149a2176ee67ed68b1111ff0786f7fc';\n\nconst PROTOCOL_VERSION = '2025-06-18';\n\n// Logging setup\nconst LOG_FILE = path.join(CONFIG_DIR, 'mcp.log');\nconst logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });\nfunction log(message) {\n const timestamp = new Date().toISOString();\n logStream.write(timestamp + ' [MCP] ' + message + '\\n');\n}\n\n// Load fresh config on every call so walletHash/backendUrl never go stale\nfunction loadConfig() {\n try {\n return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, 'config.json'), 'utf8'));\n } catch (e) {\n return {};\n }\n}\n\nlet BACKEND_URL = DEFAULT_BACKEND;\nlet WALLET_HASH = '';\nfunction refreshConfig() {\n const cfg = loadConfig();\n BACKEND_URL = process.env.PAYTACA_BACKEND_URL || cfg.backendUrl || DEFAULT_BACKEND;\n WALLET_HASH = cfg.walletHash || '';\n}\n\n// Fetch a JSON payload over HTTP(S)\nfunction getJson(url, headers) {\n return new Promise((resolve, reject) => {\n let u;\n try {\n u = new URL(url);\n } catch (e) {\n return reject(new Error('Invalid URL: ' + url));\n }\n const requester = u.protocol === 'https:' ? https : http;\n const req = requester.get({\n hostname: u.hostname,\n port: u.port || (u.protocol === 'https:' ? 443 : 80),\n path: u.pathname + u.search,\n headers: headers || {},\n }, (res) => {\n let data = '';\n res.on('data', (chunk) => { data += chunk; });\n res.on('end', () => {\n if (res.statusCode >= 400) {\n reject(new Error('HTTP ' + res.statusCode + ': ' + data.substring(0, 200)));\n return;\n }\n try {\n resolve(JSON.parse(data));\n } catch (e) {\n reject(new Error('Invalid JSON response'));\n }\n });\n });\n req.on('error', reject);\n req.setTimeout(15000, () => {\n req.destroy();\n reject(new Error('Request timed out'));\n });\n });\n}\n\n// Run a shell command with a timeout\nfunction runCommand(cmd, args, timeoutMs) {\n return new Promise((resolve, reject) => {\n const child = spawn(cmd, args, { shell: false });\n let stdout = '';\n let stderr = '';\n let settled = false;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try { child.kill(); } catch (e) {}\n reject(new Error('Command timed out'));\n }, timeoutMs || 15000);\n child.stdout.on('data', (d) => { stdout += d.toString(); });\n child.stderr.on('data', (d) => { stderr += d.toString(); });\n child.on('close', (code) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n if (code === 0) resolve(stdout.trim());\n else reject(new Error(stderr.trim() || 'Command exited with code ' + code));\n });\n child.on('error', (err) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n reject(err);\n });\n });\n}\n\n// Format seconds as MM:SS or HH:MM:SS\nfunction formatDuration(totalSeconds) {\n const hours = Math.floor(totalSeconds / 3600);\n const minutes = Math.floor((totalSeconds % 3600) / 60);\n const secs = totalSeconds % 60;\n if (hours > 0) {\n return hours + ':' + String(minutes).padStart(2, '0') + ':' + String(secs).padStart(2, '0');\n }\n return minutes + ':' + String(secs).padStart(2, '0');\n}\n\n// Remaining time credits per model session\nasync function getCredits() {\n const data = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n const sessions = Array.isArray(data.sessions) ? data.sessions : [];\n const active = sessions.filter((s) => s.time_remaining_seconds > 0 && s.model_active);\n const inactive = sessions.filter((s) => s.time_remaining_seconds > 0 && !s.model_active);\n const parts = [];\n if (active.length > 0) {\n parts.push('Active time credits:');\n for (const s of active) {\n const total = formatDuration(s.time_credits_seconds);\n const remaining = formatDuration(s.time_remaining_seconds);\n const used = formatDuration(s.time_used_seconds);\n parts.push('- ' + (s.display_name || s.ai_model) + ': ' + remaining + ' remaining of ' + total + ' (' + used + ' used)');\n }\n }\n if (inactive.length > 0) {\n parts.push('');\n parts.push('Inactive models (credits but session not active):');\n for (const s of inactive) {\n parts.push('- ' + (s.display_name || s.ai_model) + ': ' + formatDuration(s.time_remaining_seconds) + ' remaining');\n }\n }\n if (parts.length === 0) {\n return 'No active time credits.';\n }\n return parts.join('\\n');\n}\n\n// Wallet BCH balance via the paytaca CLI\nasync function getBalance() {\n const out = await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n const match = out.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);\n if (match) {\n return 'Wallet balance: ' + match[1] + ' BCH.';\n }\n return 'Could not parse balance. Raw output:\\n' + out.split('\\n').slice(0, 5).join('\\n');\n}\n\n// List all available models\nasync function getModels() {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n const models = Array.isArray(data.models) ? data.models : [];\n const lines = ['Available models:'];\n for (const m of models) {\n let line = m.id || 'unknown';\n if (m.display_name && m.display_name !== m.id) line += ' (' + m.display_name + ')';\n if (m.tier) line += ' [' + m.tier + ']';\n lines.push('- ' + line);\n }\n return lines.join('\\n');\n}\n\n// Plan pricing as a markdown table per model, optionally filtered to one model\nasync function getPlans(filterModel) {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n let models = Array.isArray(data.models) ? data.models : [];\n if (filterModel) {\n const f = String(filterModel).toLowerCase();\n models = models.filter((m) => {\n const id = String(m.id || '').toLowerCase();\n const name = String(m.display_name || '').toLowerCase();\n return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;\n });\n }\n if (models.length === 0) {\n return 'No models available.';\n }\n const lines = [];\n for (const m of models) {\n const name = m.display_name || m.id;\n const tier = m.tier ? String(m.tier).charAt(0).toUpperCase() + String(m.tier).slice(1) : '';\n const tierInName = tier && name.toLowerCase().indexOf(tier.toLowerCase()) !== -1;\n if (lines.length > 0) lines.push('');\n lines.push('**' + name + (tier && !tierInName ? ' (' + tier + ')' : '') + '**');\n lines.push('');\n const tiers = Array.isArray(m.price_tiers) ? m.price_tiers : [];\n if (tiers.length === 0) {\n lines.push('No pricing configured.');\n continue;\n }\n lines.push('| Duration | USD | BCH | Sats |');\n lines.push('|---|---|---|---|');\n const sorted = tiers.slice().sort((a, b) => (a.minutes || 0) - (b.minutes || 0));\n for (const t of sorted) {\n const sats = typeof t.price_sats === 'number' ? t.price_sats : 0;\n const bch = (sats / 100000000).toFixed(8);\n const usd = typeof t.price_usd === 'number' ? '$' + t.price_usd.toFixed(2) : '?.??';\n const satsStr = Number(sats).toLocaleString('en-US');\n lines.push('| ' + (t.minutes || 0) + ' min | ' + usd + ' | ' + bch + ' | ' + satsStr + ' |');\n }\n }\n return lines.join('\\n');\n}\n\n// Resolve a model (by id or display name) and its price tier by minutes.\n// Uses the same /v1/config data as get_plans.\nasync function resolvePlan(modelFilter, minutes) {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n const models = Array.isArray(data.models) ? data.models : [];\n const f = String(modelFilter || '').toLowerCase();\n const matches = models.filter((m) => {\n const id = String(m.id || '').toLowerCase();\n const name = String(m.display_name || '').toLowerCase();\n return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;\n });\n if (matches.length === 0) {\n const ids = models.map((m) => m.id).join(', ');\n throw new Error('Model not found: ' + modelFilter + '. Available models: ' + ids);\n }\n const model = matches[0];\n const tiers = Array.isArray(model.price_tiers) ? model.price_tiers : [];\n const want = Number(minutes);\n const tier = tiers.find((t) => Number(t.minutes) === want);\n if (!tier) {\n const avail = tiers.map((t) => t.minutes).join(', ');\n throw new Error('No ' + minutes + '-minute plan for ' + (model.display_name || model.id) + '. Available minutes: ' + avail);\n }\n return { model, tier };\n}\n\n// Wallet balance in sats (mirrors the proxy's pre-payment check)\nasync function getBalanceSats() {\n const out = await runCommand(PAYTACA_CMD, ['wallet', 'info'], 20000);\n const match = out.match(/Balance:s*([d.]+)s*BCH/i);\n if (!match) return null;\n return Math.floor(parseFloat(match[1]) * 100000000);\n}\n\n// LIFT token balance in base units (2 decimals), null when the CLI output\n// cannot be parsed.\nasync function getLiftBalanceUnits() {\n const out = await runCommand(PAYTACA_CMD, ['token', 'info', LIFT_TOKEN_ID], 20000);\n const match = out.match(/Balance:s*([d.]+)s*LIFT/i);\n if (!match) return null;\n return BigInt(Math.round(parseFloat(match[1]) * 100));\n}\n\n// Purchase time credits for a specific model and plan duration. Reuses the\n// same payment wrapper the proxy runs on a 402 (x402 payment + retry with the\n// PAYMENT-SIGNATURE header), but works for ANY model/tier the user picks \u2014\n// not just the model active in the current session. Spends real BCH.\nasync function buyPlan(args) {\n const modelFilter = String(args.model || '').trim();\n const minutes = Number(args.minutes);\n const paymentMethod = args.payment_method === 'lift' ? 'lift' : 'bch';\n if (!modelFilter) {\n throw new Error('Missing model. Pass the model id or display name (e.g. deepseek/deepseek-v4-flash).');\n }\n if (isNaN(minutes) || minutes <= 0) {\n throw new Error('Missing or invalid minutes. Pass the plan duration, e.g. 30 for the 30-minute plan.');\n }\n\n const { model, tier } = await resolvePlan(modelFilter, minutes);\n const priceSats = Number(tier.price_sats) || 0;\n\n // Fail fast on insufficient balance instead of sending a txn that cannot\n // fund the plan (mirrors the proxy's 402 flow). Only applies to BCH \u2014 the\n // LIFT path funds the plan by selling tokens, so no BCH balance is required.\n if (paymentMethod === 'bch') {\n const balanceSats = await getBalanceSats();\n if (balanceSats !== null && balanceSats < priceSats) {\n const addr = await getReceivingAddress({});\n const shortfall = (priceSats - balanceSats) / 100000000;\n throw new Error('Insufficient balance: ' + (balanceSats / 100000000).toFixed(8) + ' BCH available but the ' + minutes + '-minute plan costs ' + (priceSats / 100000000).toFixed(8) + ' BCH. Top up at least ' + shortfall.toFixed(8) + ' BCH to: ' + addr);\n }\n } else {\n // LIFT path: fail fast if the wallet holds no LIFT, instead of prompting\n // for approval and then failing inside the wrapper.\n const liftBalanceUnits = await getLiftBalanceUnits();\n if (liftBalanceUnits !== null && liftBalanceUnits <= 0n) {\n throw new Error('No LIFT tokens in the wallet. Add LIFT to pay this plan with tokens, or call again with payment_method \"bch\".');\n }\n }\n\n const wrapper = path.join(CONFIG_DIR, 'paytaca-pay-wrapper.mjs');\n if (!fs.existsSync(wrapper)) {\n throw new Error('Payment wrapper not found at ' + wrapper + '. Restart opencode so the plugin writes it.');\n }\n\n // Minimal chat request for the target model; the payment flow only needs a\n // valid request that triggers the x402 PaymentRequired for that model.\n const body = JSON.stringify({\n model: model.id,\n messages: [{ role: 'user', content: 'Purchase plan' }],\n stream: false,\n });\n\n const url = BACKEND_URL + '/v1/chat/completions?wallet_hash=' + encodeURIComponent(WALLET_HASH || '');\n const extraHeaders = {\n 'X-Model-Id': model.id,\n 'X-Duration-Minutes': String(tier.minutes),\n };\n\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paytaca-buy-plan-'));\n const bodyFile = path.join(tmpDir, 'body.json');\n const configFile = path.join(tmpDir, 'config.json');\n try {\n fs.writeFileSync(bodyFile, body, 'utf8');\n const config = {\n url: url,\n method: 'POST',\n headers: Object.assign({ 'Content-Type': 'application/json' }, extraHeaders),\n bodyFile: bodyFile,\n confirmed: true,\n };\n if (paymentMethod === 'lift') {\n config.paymentMethod = 'lift';\n }\n fs.writeFileSync(configFile, JSON.stringify(config), 'utf8');\n } catch (e) {\n try { fs.rmdirSync(tmpDir); } catch (e2) {}\n throw new Error('Failed to write payment files: ' + e.message);\n }\n\n log('Buy plan requested: ' + model.id + ' ' + tier.minutes + ' min (' + paymentMethod + ')');\n let stdout;\n try {\n stdout = await runCommand('node', [wrapper, configFile], 250000);\n } finally {\n try { fs.unlinkSync(bodyFile); } catch (e2) {}\n try { fs.unlinkSync(configFile); } catch (e2) {}\n try { fs.rmdirSync(tmpDir); } catch (e2) {}\n }\n\n let result;\n try {\n result = JSON.parse(stdout);\n } catch (e) {\n throw new Error('Could not parse payment result: ' + stdout.substring(0, 200));\n }\n\n if (result.timeout) {\n return 'Payment was processed but the response timed out. Check your credits with get_credits.';\n }\n if (!result.success) {\n throw new Error(result.error || 'Payment failed (status ' + result.status + ').');\n }\n\n const lines = ['Plan purchased for ' + (model.display_name || model.id) + ': ' + tier.minutes + ' minutes' + (paymentMethod === 'lift' ? ' (paid with LIFT).' : '.')];\n if (result.payment && result.payment.txid) {\n lines.push('Transaction: ' + result.payment.txid);\n }\n try {\n const status = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n const sessions = Array.isArray(status.sessions) ? status.sessions : [];\n const found = sessions.find((s) => s.ai_model === model.id);\n if (found && found.time_remaining_seconds > 0) {\n lines.push('Credits: ' + formatDuration(found.time_remaining_seconds) + ' remaining.');\n }\n } catch (e) {}\n return lines.join('\\n');\n}\n\n// Recent wallet transactions via the paytaca CLI\nasync function getTransactions(args) {\n const cmdArgs = ['history'];\n if (args.type === 'incoming' || args.type === 'outgoing') {\n cmdArgs.push('--type', args.type);\n }\n const page = parseInt(args.page, 10);\n if (!isNaN(page) && page > 0) {\n cmdArgs.push('--page', String(page));\n }\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out || 'No transactions found.';\n}\n\n// Receiving address via the paytaca CLI (QR art suppressed)\nasync function getReceivingAddress(args) {\n const cmdArgs = ['receive', '--no-qr'];\n const amount = parseFloat(args.amount);\n if (!isNaN(amount) && amount > 0) {\n cmdArgs.push('--amount', String(amount));\n }\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out.trim() || 'Could not get receiving address.';\n}\n\n// CashToken holdings via the paytaca CLI (all tokens, or one category)\nasync function getTokens(args) {\n const category = args.category ? String(args.category).trim() : '';\n const cmdArgs = category ? ['token', 'info', category] : ['token', 'list'];\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out.trim() || (category ? 'Token not found: ' + category : 'No tokens found.');\n}\n\n// Send BCH or CashTokens. This spends real funds \u2014 opencode requires manual\n// user approval for this tool (permission 'paytaca_send' set to 'ask' by the\n// plugin's config hook), so it must never be called without the user asking\n// for the send.\nasync function sendFunds(args) {\n const address = String(args.address || '').trim();\n const amount = String(args.amount || '').trim();\n const unit = args.unit === 'sats' ? 'sats' : 'bch';\n const tokenCategory = args.token_category ? String(args.token_category).trim() : '';\n if (!address) {\n throw new Error('Missing recipient address.');\n }\n if (!amount || isNaN(Number(amount)) || Number(amount) <= 0) {\n throw new Error('Missing or invalid amount.');\n }\n const cmdArgs = tokenCategory\n ? ['token', 'send', address, amount, '--token', tokenCategory]\n : ['send', address, amount];\n if (!tokenCategory && unit === 'sats') {\n cmdArgs.push('--unit', 'sats');\n }\n log('Send requested: ' + cmdArgs.join(' '));\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 90000);\n log('Send completed');\n return 'Transaction sent.\\n\\n' + out;\n}\n\n// Tool schemas (concise descriptions so MCP tool context stays small)\nconst TOOLS = [\n {\n name: 'get_credits',\n description: 'Get remaining Paytaca AI time credits (active model sessions, time left). Use when the user asks about credits, remaining time, session status, or how much usage they have left.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_balance',\n description: 'Get the BCH balance of the user\\'s Paytaca wallet. Use when the user asks about wallet balance or funds.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_models',\n description: 'List the AI models available on Paytaca AI (id, display name, tier). Use when the user asks which models are available.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_plans',\n description: 'Get Paytaca AI plan pricing: time tiers in minutes with USD and BCH prices, grouped by tier. Optionally pass a model id/name to filter one model. Use when the user asks about plans, pricing, costs, or how much a model costs.',\n inputSchema: {\n type: 'object',\n properties: {\n model: { type: 'string', description: 'Optional model id or display name to filter pricing to a single model.' },\n },\n },\n },\n {\n name: 'buy_plan',\n description: 'Purchase Paytaca AI time credits for a specific model and plan duration. SPENDS FUNDS FROM THE WALLET (BCH, or LIFT tokens sold via Cauldron when payment_method=lift) \u2014 only call when the user explicitly asks to buy, purchase, or pay for a plan; opencode prompts the user for approval. Show pricing with get_plans first, then call with the model and minutes the user picked. Works for any model, even one not active in the current session. IMPORTANT \u2014 LIFT phrasing: when the user says \"pay with LIFT\", \"pay with LIFT tokens\", \"use LIFT\", or mentions paying a plan with their LIFT token balance, set payment_method to \"lift\". LIFT is the Paytaca token users hold to pay for AI plans; \"pay with LIFT\" is NOT asking to buy a plan called LIFT. Default to \"bch\" unless the user explicitly mentions LIFT/tokens.',\n inputSchema: {\n type: 'object',\n required: ['model', 'minutes'],\n properties: {\n model: { type: 'string', description: 'Model id or display name, e.g. deepseek/deepseek-v4-flash or DeepSeek V4 Flash.' },\n minutes: { type: 'number', description: 'Plan duration in minutes, e.g. 30 for the 30-minute tier.' },\n payment_method: { type: 'string', enum: ['bch', 'lift'], description: 'Payment method. bch (default) pays from the BCH balance. Set to lift when the user says \"pay with LIFT\" or \"pay with LIFT tokens\" \u2014 this sells the wallet LIFT token balance via Cauldron to fund the plan. Use lift when the wallet lacks BCH but holds LIFT, or when the user explicitly asks to pay with LIFT.' },\n },\n },\n },\n {\n name: 'get_transactions',\n description: 'Get recent Paytaca wallet transactions (sent/received BCH). Use when the user asks about transaction history or latest transactions.',\n inputSchema: {\n type: 'object',\n properties: {\n type: { type: 'string', enum: ['incoming', 'outgoing'], description: 'Optional direction filter.' },\n page: { type: 'number', description: 'Optional 1-based page number for older history.' },\n },\n },\n },\n {\n name: 'get_receiving_address',\n description: 'Get a Paytaca wallet receiving address for depositing BCH, optionally as a BIP21 URI with an amount. Use when the user wants to fund the wallet or needs their address.',\n inputSchema: {\n type: 'object',\n properties: {\n amount: { type: 'number', description: 'Optional BCH amount to embed in a BIP21 payment URI.' },\n },\n },\n },\n {\n name: 'get_tokens',\n description: 'List CashToken holdings of the Paytaca wallet, or get details (name, symbol, balance, NFTs) for one token category. Use when the user asks about tokens or NFTs.',\n inputSchema: {\n type: 'object',\n properties: {\n category: { type: 'string', description: 'Optional token category id for details of a single token.' },\n },\n },\n },\n {\n name: 'send',\n description: 'Send BCH or CashTokens from the Paytaca wallet to an address. SPENDS REAL FUNDS \u2014 only call when the user explicitly asks to send; opencode will prompt the user for approval and that prompt must never be bypassed. Token amounts are in base units; recipients of tokens should use token-aware (z-prefix) addresses.',\n inputSchema: {\n type: 'object',\n required: ['address', 'amount'],\n properties: {\n address: { type: 'string', description: 'Recipient CashAddr (e.g. bitcoincash:qp...).' },\n amount: { type: 'string', description: 'Amount to send.' },\n unit: { type: 'string', enum: ['bch', 'sats'], description: 'Amount unit, default bch. Ignored for token sends.' },\n token_category: { type: 'string', description: 'Token category id to send CashTokens instead of BCH.' },\n },\n },\n },\n];\n\n// JSON-RPC over stdio (newline-delimited)\nlet buffer = '';\nfunction handleMessage(msg) {\n if (msg.method === 'initialize') {\n const requestedVersion = msg.params && msg.params.protocolVersion;\n send(msg.id, {\n protocolVersion: requestedVersion || PROTOCOL_VERSION,\n capabilities: { tools: {} },\n serverInfo: { name: 'paytaca', version: '1.2.0' },\n });\n return;\n }\n if (msg.method === 'notifications/initialized') {\n return;\n }\n if (msg.method === 'ping') {\n send(msg.id, {});\n return;\n }\n if (msg.method === 'tools/list') {\n send(msg.id, { tools: TOOLS });\n return;\n }\n if (msg.method === 'tools/call') {\n const name = msg.params && msg.params.name;\n const args = (msg.params && msg.params.arguments) || {};\n refreshConfig();\n (async () => {\n let text;\n try {\n switch (name) {\n case 'get_credits': text = await getCredits(); break;\n case 'get_balance': text = await getBalance(); break;\n case 'get_models': text = await getModels(); break;\n case 'get_plans': text = await getPlans(args.model); break;\n case 'buy_plan': text = await buyPlan(args); break;\n case 'get_transactions': text = await getTransactions(args); break;\n case 'get_receiving_address': text = await getReceivingAddress(args); break;\n case 'get_tokens': text = await getTokens(args); break;\n case 'send': text = await sendFunds(args); break;\n default: throw new Error('Unknown tool: ' + name);\n }\n send(msg.id, { content: [{ type: 'text', text: text }] });\n } catch (e) {\n log('Tool ' + name + ' failed: ' + e.message);\n send(msg.id, { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true });\n }\n })();\n return;\n }\n // Unknown request \u2014 respond with an empty result so the client never hangs\n if (typeof msg.id !== 'undefined' && msg.id !== null) {\n send(msg.id, {});\n }\n}\n\nfunction send(id, result) {\n const payload = { jsonrpc: '2.0', id, result };\n process.stdout.write(JSON.stringify(payload) + '\\n');\n}\n\nprocess.stdin.on('data', (chunk) => {\n buffer += chunk.toString();\n let idx;\n while ((idx = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.substring(0, idx).trim();\n buffer = buffer.substring(idx + 1);\n if (!line) continue;\n let msg;\n try {\n msg = JSON.parse(line);\n } catch (e) {\n continue;\n }\n try {\n handleMessage(msg);\n } catch (e) {\n log('handleMessage error: ' + e.message);\n }\n }\n});\n\nrefreshConfig();\nlog('MCP server started (backend=' + BACKEND_URL + ')');\n";
|
|
1
|
+
export declare const MCP_SERVER_CONTENT = "#!/usr/bin/env node\n/**\n * Paytaca MCP Server\n *\n * Registers Paytaca tools with opencode so the assistant can work with real\n * data instead of guessing. Two groups:\n * - Paytaca AI account (backend): credits, models, plan pricing\n * - Paytaca wallet (paytaca CLI): balance, transactions, receiving address,\n * token holdings, and sending funds\n *\n * The send tool moves real funds \u2014 opencode is configured (via the plugin's\n * config hook) to require explicit user approval before it runs.\n *\n * Loaded automatically via the plugin's config hook (cfg.mcp['paytaca']).\n * Uses only Node.js built-in modules.\n */\n\nconst http = require('http');\nconst https = require('https');\nconst { spawn } = require('child_process');\nconst fs = require('fs');\nconst path = require('path');\nconst os = require('os');\n\nconst CONFIG_DIR = process.env.PAYTACA_CONFIG_DIR || path.join(os.homedir(), '.opencode-paytaca');\nconst PAYTACA_CMD = process.env.PAYTACA_CMD || 'paytaca';\nconst DEFAULT_BACKEND = process.env.PAYTACA_BACKEND_URL || 'https://api.paytaca.ai';\n\n// LIFT is the Paytaca token users can pay AI plans with (sold via Cauldron).\n// The same token id is used by the payment wrapper's payWithLift().\nconst LIFT_TOKEN_ID = '5932b2fd4915d6a75d3ec53282cd49118149a2176ee67ed68b1111ff0786f7fc';\n\nconst PROTOCOL_VERSION = '2025-06-18';\n\n// Logging setup\nconst LOG_FILE = path.join(CONFIG_DIR, 'mcp.log');\nconst logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });\nfunction log(message) {\n const timestamp = new Date().toISOString();\n logStream.write(timestamp + ' [MCP] ' + message + '\\n');\n}\n\n// Load fresh config on every call so walletHash/backendUrl never go stale\nfunction loadConfig() {\n try {\n return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, 'config.json'), 'utf8'));\n } catch (e) {\n return {};\n }\n}\n\nlet BACKEND_URL = DEFAULT_BACKEND;\nlet WALLET_HASH = '';\nfunction refreshConfig() {\n const cfg = loadConfig();\n BACKEND_URL = process.env.PAYTACA_BACKEND_URL || cfg.backendUrl || DEFAULT_BACKEND;\n WALLET_HASH = cfg.walletHash || '';\n}\n\n// Fetch a JSON payload over HTTP(S)\nfunction getJson(url, headers) {\n return new Promise((resolve, reject) => {\n let u;\n try {\n u = new URL(url);\n } catch (e) {\n return reject(new Error('Invalid URL: ' + url));\n }\n const requester = u.protocol === 'https:' ? https : http;\n const req = requester.get({\n hostname: u.hostname,\n port: u.port || (u.protocol === 'https:' ? 443 : 80),\n path: u.pathname + u.search,\n headers: headers || {},\n }, (res) => {\n let data = '';\n res.on('data', (chunk) => { data += chunk; });\n res.on('end', () => {\n if (res.statusCode >= 400) {\n reject(new Error('HTTP ' + res.statusCode + ': ' + data.substring(0, 200)));\n return;\n }\n try {\n resolve(JSON.parse(data));\n } catch (e) {\n reject(new Error('Invalid JSON response'));\n }\n });\n });\n req.on('error', reject);\n req.setTimeout(15000, () => {\n req.destroy();\n reject(new Error('Request timed out'));\n });\n });\n}\n\n// Run a shell command with a timeout\nfunction runCommand(cmd, args, timeoutMs) {\n return new Promise((resolve, reject) => {\n const child = spawn(cmd, args, { shell: process.platform === 'win32' });\n let stdout = '';\n let stderr = '';\n let settled = false;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try { child.kill(); } catch (e) {}\n reject(new Error('Command timed out'));\n }, timeoutMs || 15000);\n child.stdout.on('data', (d) => { stdout += d.toString(); });\n child.stderr.on('data', (d) => { stderr += d.toString(); });\n child.on('close', (code) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n if (code === 0) resolve(stdout.trim());\n else {\n // The pay wrapper writes JSON errors to stdout, not stderr. Fall back\n // to stdout so the real error surfaces instead of \"Command exited\n // with code 1\".\n const errText = stderr.trim();\n let message = errText || stdout.trim();\n if (!message) {\n message = 'Command exited with code ' + code;\n } else {\n // The pay wrapper writes { success:false, error } JSON to stdout.\n if (!errText && message) {\n try {\n const parsed = JSON.parse(message);\n message = (parsed && parsed.error) || message;\n } catch (e) {}\n } else if (errText && stdout.trim()) {\n message = errText + '\\n' + stdout.trim();\n }\n }\n reject(new Error(message));\n }\n });\n child.on('error', (err) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n reject(err);\n });\n });\n}\n\n// Format seconds as MM:SS or HH:MM:SS\nfunction formatDuration(totalSeconds) {\n const hours = Math.floor(totalSeconds / 3600);\n const minutes = Math.floor((totalSeconds % 3600) / 60);\n const secs = totalSeconds % 60;\n if (hours > 0) {\n return hours + ':' + String(minutes).padStart(2, '0') + ':' + String(secs).padStart(2, '0');\n }\n return minutes + ':' + String(secs).padStart(2, '0');\n}\n\n// Q&A guide about the wallet, buying plans, Paytaca AI, and Bitcoin Cash.\n// The assistant fetches this when the user asks \"how do I...\", \"what is...\",\n// or anything about funding, paying, or how the service works.\nconst FAQ = [\n ['What is Paytaca AI?', 'Paytaca AI is a service that gives you affordable, convenient access to AI inference through your Paytaca wallet. Instead of per-token metering or a monthly subscription, you buy short time-based plans in advance \u2014 15, 30, or 60 minutes of a model \u2014 and use them up as you chat.'],\n ['What makes Paytaca AI different from other AI services?', 'We keep AI access affordable and simple by billing in pre-purchased blocks of time rather than per token. Pick a model, buy a 15/30/60-minute plan with your wallet, and the time is yours to use. No surprises per message, no recurring billing.'],\n ['Did this plugin create a wallet for me?', 'Yes. Installing the Paytaca AI opencode plugin automatically created a Paytaca wallet on this device (a Bitcoin Cash wallet). Any BCH or LIFT tokens you send to its address are available to this plugin to spend.'],\n ['How do I fund my wallet?', 'Get your wallet\\'s receiving address (ask \"what\\'s my address?\" or call get_receiving_address), then send BCH or LIFT tokens to it from any Bitcoin Cash wallet or exchange. Funding must happen BEFORE you can buy a plan. Check your balance anytime with get_balance.'],\n ['How do I buy a plan?', 'Three steps: (1) make sure your wallet has funds \u2014 get_receiving_address to deposit, get_balance to confirm; (2) see pricing with get_plans; (3) ask to buy, e.g. \"Buy a DeepSeek V4 Flash plan for 30 minutes.\" opencode will ask you to approve the payment.'],\n ['Can I pay with LIFT tokens?', 'Yes. Plans can be paid in BCH or LIFT tokens. To pay with tokens, add \"pay with LIFT\" to your buy request, e.g. \"Buy a 15-minute GLM plan and pay with LIFT.\" Paying with LIFT applies a discount \u2014 see the current rate below (the backend sets it, so it can change anytime).'],\n ['Why can\\'t I buy again while I still have credits?', 'Plans are time blocks, not balances that stack. While a model still has active time, buying another plan for it would charge nothing extra. Use up or wait out the remaining credits, then buy again. Check remaining time with get_credits.'],\n ['Can the plugin keep buying plans automatically during a long task?', 'Yes. Tell the assistant something like \"auto-refill the DeepSeek plan at 15 minutes, max 2 hours\" or \"keep buying 15-minute plans until I stop it\". It arms auto-refill; from then on, whenever credits run out mid-task it silently buys another plan and continues without interrupting you. It stops when the cap is reached, the wallet runs short, or you tell it to stop. Each refill is a real wallet payment (opencode asks you to approve the auto-refill tool once when arming).'],\n ['What is Bitcoin Cash (BCH)?', 'Bitcoin Cash is peer-to-peer electronic cash. Transactions are confirmed in seconds to minutes with extremely low fees (fractions of a cent), which makes it practical for small, everyday payments like buying a plan. It is the currency Paytaca AI payments run on.'],\n ['What are CashTokens and LIFT?', 'CashTokens are fungible and non-fungible tokens issued on the Bitcoin Cash blockchain. LIFT is a Paytaca CashToken you can use to pay for AI plans, and it can be bought/sold for BCH on the Cauldron DEX.'],\n ['What is Paytaca?', 'Paytaca is a Bitcoin Cash wallet and payments ecosystem, and Paytaca AI is its AI inference service. The Paytaca wallet app holds your BCH and CashTokens, and Paytaca AI lets you spend them on AI usage.'],\n].map(([q, a]) => '- **' + q + '**\\n ' + a).join('\\n');\n\n// Appended to Paytaca AI info tool outputs (plans, credits, models): asks the\n// user what they want to do next and steers them toward buying a plan, with\n// concrete example prompts they can reuse to place the order.\nasync function nextSteps() {\n const percent = await getLiftDiscountPercent();\n const liftLine = percent > 0\n ? '- Pay with LIFT tokens instead of BCH: just add \"pay with LIFT\" to your request, e.g. \"Buy a 30-minute GLM plan and pay with LIFT.\" \u2014 you get **' + percent + '% off**.'\n : '- Pay with LIFT tokens instead of BCH: just add \"pay with LIFT\" to your request, e.g. \"Buy a 30-minute GLM plan and pay with LIFT.\"';\n return [\n '',\n 'What would you like to do next?',\n '- Buy a plan: pick a model and duration from the list above, then say something like: \"Buy a DeepSeek V4 Flash plan for 15 minutes.\"',\n liftLine,\n '- Check your credits: \"How much time do I have left?\"',\n '- See prices for another model: \"Show me the plans for <model>.\"',\n ].join('\\n');\n}\n\n// Remaining time credits per model session\nasync function getCredits() {\n const data = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n const sessions = Array.isArray(data.sessions) ? data.sessions : [];\n const active = sessions.filter((s) => s.time_remaining_seconds > 0 && s.model_active);\n const inactive = sessions.filter((s) => s.time_remaining_seconds > 0 && !s.model_active);\n const parts = [];\n if (active.length > 0) {\n parts.push('Active time credits:');\n for (const s of active) {\n const total = formatDuration(s.time_credits_seconds);\n const remaining = formatDuration(s.time_remaining_seconds);\n const used = formatDuration(s.time_used_seconds);\n parts.push('- ' + (s.display_name || s.ai_model) + ': ' + remaining + ' remaining of ' + total + ' (' + used + ' used)');\n }\n }\n if (inactive.length > 0) {\n parts.push('');\n parts.push('Inactive models (credits but session not active):');\n for (const s of inactive) {\n parts.push('- ' + (s.display_name || s.ai_model) + ': ' + formatDuration(s.time_remaining_seconds) + ' remaining');\n }\n }\n if (parts.length === 0) {\n return 'No active time credits.' + await nextSteps();\n }\n return parts.join('\\n') + await nextSteps();\n}\n\n// Q&A guide about the wallet, buying plans, Paytaca AI, and Bitcoin Cash.\nasync function getHelp() {\n let liftLine = '';\n const percent = await getLiftDiscountPercent();\n if (percent > 0) {\n liftLine = '\\n\\n\uD83D\uDCA1 **Current LIFT discount: ' + percent + '%** off plans paid with LIFT tokens.';\n }\n return 'Paytaca AI and your wallet \u2014 frequently asked questions:\\n\\n' + FAQ + liftLine + await nextSteps();\n}\n\n// Wallet BCH balance via the paytaca CLI\nasync function getBalance() {\n const out = await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n const match = out.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);\n if (match) {\n return 'Wallet balance: ' + match[1] + ' BCH.';\n }\n return 'Could not parse balance. Raw output:\\n' + out.split('\\n').slice(0, 5).join('\\n');\n}\n\n// List all available models\nasync function getModels() {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n const models = Array.isArray(data.models) ? data.models : [];\n const lines = ['Available models:'];\n for (const m of models) {\n let line = m.id || 'unknown';\n if (m.display_name && m.display_name !== m.id) line += ' (' + m.display_name + ')';\n if (m.tier) line += ' [' + m.tier + ']';\n lines.push('- ' + line);\n }\n return lines.join('\\n') + await nextSteps();\n}\n\n// Plan pricing as a compact matrix: models as rows, durations as columns,\n// with USD and BCH price in each cell. Durations are always 15/30/60 min.\nasync function getPlans(filterModel) {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n let models = Array.isArray(data.models) ? data.models : [];\n if (filterModel) {\n const f = String(filterModel).toLowerCase();\n models = models.filter((m) => {\n const id = String(m.id || '').toLowerCase();\n const name = String(m.display_name || '').toLowerCase();\n return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;\n });\n }\n if (models.length === 0) {\n return 'No models available.' + await nextSteps();\n }\n const durationColumns = [15, 30, 60];\n const lines = ['| Model | 15 min | 30 min | 60 min |', '|---|---|---|---|'];\n for (const m of models) {\n const name = m.display_name || m.id;\n const tier = m.tier ? String(m.tier).charAt(0).toUpperCase() + String(m.tier).slice(1) : '';\n const tierInName = tier && name.toLowerCase().indexOf(tier.toLowerCase()) !== -1;\n const label = name + (tier && !tierInName ? ' (' + tier + ')' : '');\n const tiers = Array.isArray(m.price_tiers) ? m.price_tiers : [];\n const byMinutes = {};\n for (const t of tiers) {\n byMinutes[Number(t.minutes)] = t;\n }\n const cells = durationColumns.map((mins) => {\n const t = byMinutes[mins];\n if (!t) return '-';\n const sats = typeof t.price_sats === 'number' ? t.price_sats : 0;\n const bch = (sats / 100000000).toFixed(8);\n const usd = typeof t.price_usd === 'number' ? '$' + t.price_usd.toFixed(2) : '?.??';\n return usd + ' / ' + bch + ' BCH';\n });\n lines.push('| ' + label + ' | ' + cells.join(' | ') + ' |');\n }\n return lines.join('\\n') + await nextSteps();\n}\n\n// Resolve a model (by id or display name) and its price tier by minutes.\n// Uses the same /v1/config data as get_plans.\nasync function resolvePlan(modelFilter, minutes) {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n const models = Array.isArray(data.models) ? data.models : [];\n const f = String(modelFilter || '').toLowerCase();\n const matches = models.filter((m) => {\n const id = String(m.id || '').toLowerCase();\n const name = String(m.display_name || '').toLowerCase();\n return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;\n });\n if (matches.length === 0) {\n const ids = models.map((m) => m.id).join(', ');\n throw new Error('Model not found: ' + modelFilter + '. Available models: ' + ids);\n }\n const model = matches[0];\n const tiers = Array.isArray(model.price_tiers) ? model.price_tiers : [];\n const want = Number(minutes);\n const tier = tiers.find((t) => Number(t.minutes) === want);\n if (!tier) {\n const avail = tiers.map((t) => t.minutes).join(', ');\n throw new Error('No ' + minutes + '-minute plan for ' + (model.display_name || model.id) + '. Available minutes: ' + avail);\n }\n return { model, tier };\n}\n\n// Wallet balance in sats (mirrors the proxy's pre-payment check)\nasync function getBalanceSats() {\n const out = await runCommand(PAYTACA_CMD, ['wallet', 'info'], 20000);\n const match = out.match(/Balance:s*([d.]+)s*BCH/i);\n if (!match) return null;\n return Math.floor(parseFloat(match[1]) * 100000000);\n}\n\n// LIFT token balance in base units (2 decimals), null when the CLI output\n// cannot be parsed.\nasync function getLiftBalanceUnits() {\n const out = await runCommand(PAYTACA_CMD, ['token', 'info', LIFT_TOKEN_ID], 20000);\n const match = out.match(/Balance:s*([d.]+)s*LIFT/i);\n if (!match) return null;\n return BigInt(Math.round(parseFloat(match[1]) * 100));\n}\n\n// LIFT payment discount percent advertised by the backend (/v1/config), cached\n// for ~5 minutes. Returns 0 when unset/unavailable so callers can fall back to\n// no-discount messaging.\nlet liftDiscountCache = { at: 0, percent: 0 };\nasync function getLiftDiscountPercent() {\n const now = Date.now();\n if (liftDiscountCache.at && now - liftDiscountCache.at < 300000) {\n return liftDiscountCache.percent;\n }\n let percent = 0;\n try {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n percent = Number(data.lift_payment_discount_percent) || 0;\n } catch (e) {\n log('Failed to fetch LIFT discount config: ' + e.message);\n }\n liftDiscountCache = { at: now, percent };\n return percent;\n}\n\n// Format a satoshi amount as a BCH string with up to 8 decimals.\nfunction formatBch(sats) {\n return (Number(sats) / 100000000).toFixed(8);\n}\n\n// Purchase time credits for a specific model and plan duration. Reuses the\n// same payment wrapper the proxy runs on a 402 (x402 payment + retry with the\n// PAYMENT-SIGNATURE header), but works for ANY model/tier the user picks \u2014\n// not just the model active in the current session. Spends real BCH.\nasync function buyPlan(args) {\n const modelFilter = String(args.model || '').trim();\n const minutes = Number(args.minutes);\n const paymentMethod = args.payment_method === 'lift' ? 'lift' : 'bch';\n if (!modelFilter) {\n throw new Error('Missing model. Pass the model id or display name (e.g. deepseek/deepseek-v4-flash).');\n }\n if (isNaN(minutes) || minutes <= 0) {\n throw new Error('Missing or invalid minutes. Pass the plan duration, e.g. 30 for the 30-minute plan.');\n }\n\n const { model, tier } = await resolvePlan(modelFilter, minutes);\n const priceSats = Number(tier.price_sats) || 0;\n\n // Explicit restriction: a plan cannot be purchased while the model still has\n // active credits. The backend serves requests without a 402 once credits are\n // active, so buying again would silently charge nothing and the user would\n // think they stacked time. Block the purchase and tell them to use up the\n // remaining credits (or wait for them to expire) before buying again.\n let status;\n try {\n status = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n } catch (e) {\n throw new Error('Could not check existing credits before purchase: ' + e.message + '. Try again, or check credits with get_credits.');\n }\n const sessions = Array.isArray(status.sessions) ? status.sessions : [];\n const existing = sessions.find((s) => s.ai_model === model.id && s.time_remaining_seconds > 0);\n if (existing) {\n const remaining = formatDuration(existing.time_remaining_seconds);\n throw new Error((model.display_name || model.id) + ' still has ' + remaining + ' of active credits. A new plan cannot be purchased until the remaining credits are used up or expire. Check credits with get_credits and buy again once they run out.');\n }\n\n // Fail fast on insufficient balance instead of sending a txn that cannot\n // fund the plan (mirrors the proxy's 402 flow). Only applies to BCH \u2014 the\n // LIFT path funds the plan by selling tokens, so no BCH balance is required.\n if (paymentMethod === 'bch') {\n const balanceSats = await getBalanceSats();\n if (balanceSats !== null && balanceSats < priceSats) {\n const addr = await getReceivingAddress({});\n const shortfall = (priceSats - balanceSats) / 100000000;\n throw new Error('Insufficient balance: ' + (balanceSats / 100000000).toFixed(8) + ' BCH available but the ' + minutes + '-minute plan costs ' + (priceSats / 100000000).toFixed(8) + ' BCH. Top up at least ' + shortfall.toFixed(8) + ' BCH to: ' + addr);\n }\n } else {\n // LIFT path: fail fast if the wallet holds no LIFT, instead of prompting\n // for approval and then failing inside the wrapper.\n const liftBalanceUnits = await getLiftBalanceUnits();\n if (liftBalanceUnits !== null && liftBalanceUnits <= 0n) {\n throw new Error('No LIFT tokens in the wallet. Add LIFT to pay this plan with tokens, or call again with payment_method \"bch\".');\n }\n }\n\n const wrapper = path.join(CONFIG_DIR, 'paytaca-pay-wrapper.mjs');\n if (!fs.existsSync(wrapper)) {\n throw new Error('Payment wrapper not found at ' + wrapper + '. Restart opencode so the plugin writes it.');\n }\n\n // Minimal chat request for the target model; the payment flow only needs a\n // valid request that triggers the x402 PaymentRequired for that model.\n const body = JSON.stringify({\n model: model.id,\n messages: [{ role: 'user', content: 'Purchase plan' }],\n stream: false,\n });\n\n const url = BACKEND_URL + '/v1/chat/completions?wallet_hash=' + encodeURIComponent(WALLET_HASH || '');\n const extraHeaders = {\n 'X-Model-Id': model.id,\n 'X-Duration-Minutes': String(tier.minutes),\n };\n if (paymentMethod === 'lift') {\n extraHeaders['X-Payment-Method'] = 'lift';\n }\n\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paytaca-buy-plan-'));\n const bodyFile = path.join(tmpDir, 'body.json');\n const configFile = path.join(tmpDir, 'config.json');\n try {\n fs.writeFileSync(bodyFile, body, 'utf8');\n const config = {\n url: url,\n method: 'POST',\n headers: Object.assign({ 'Content-Type': 'application/json' }, extraHeaders),\n bodyFile: bodyFile,\n confirmed: true,\n };\n if (paymentMethod === 'lift') {\n config.paymentMethod = 'lift';\n }\n fs.writeFileSync(configFile, JSON.stringify(config), 'utf8');\n } catch (e) {\n try { fs.rmdirSync(tmpDir); } catch (e2) {}\n throw new Error('Failed to write payment files: ' + e.message);\n }\n\n log('Buy plan requested: ' + model.id + ' ' + tier.minutes + ' min (' + paymentMethod + ')');\n let stdout;\n try {\n stdout = await runCommand('node', [wrapper, configFile], 250000);\n } finally {\n try { fs.unlinkSync(bodyFile); } catch (e2) {}\n try { fs.unlinkSync(configFile); } catch (e2) {}\n try { fs.rmdirSync(tmpDir); } catch (e2) {}\n }\n\n let result;\n try {\n result = JSON.parse(stdout);\n } catch (e) {\n throw new Error('Could not parse payment result: ' + stdout.substring(0, 200));\n }\n\n if (result.timeout) {\n return 'Payment was processed but the response timed out. Check your credits with get_credits.';\n }\n if (!result.success) {\n throw new Error(result.error || 'Payment failed (status ' + result.status + ').');\n }\n\n const paid = result.payment && result.payment.required;\n if (!paid) {\n // No payment was required \u2014 the model already had active credits, so the\n // server served the request for free. Report this instead of claiming a\n // purchase so the user knows nothing was charged.\n return 'No purchase made: ' + (model.display_name || model.id) + ' already has active credits, so the request was served without payment. Check your credits with get_credits.';\n }\n\n const lines = ['Plan purchased for ' + (model.display_name || model.id) + ': ' + tier.minutes + ' minutes' + (paymentMethod === 'lift' ? ' (paid with LIFT).' : '.')];\n if (paymentMethod === 'lift') {\n const discountPercent = await getLiftDiscountPercent();\n if (discountPercent > 0) {\n const discountSats = Math.round(priceSats * (discountPercent / 100));\n lines.push('**LIFT discount applied: ' + discountPercent + '% \u2014 you saved ' + formatBch(discountSats) + ' BCH.**');\n } else {\n lines.push('Paid with LIFT tokens.');\n }\n }\n if (result.payment && result.payment.txid) {\n lines.push('Transaction: ' + result.payment.txid);\n }\n try {\n const status = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n const sessions = Array.isArray(status.sessions) ? status.sessions : [];\n const found = sessions.find((s) => s.ai_model === model.id);\n if (found && found.time_remaining_seconds > 0) {\n lines.push('Credits: ' + formatDuration(found.time_remaining_seconds) + ' remaining.');\n }\n } catch (e) {}\n return lines.join('\\n');\n}\n\n// Arm/disarm automatic plan refills for the current session. When armed, the\n// proxy (which sits in front of the AI backend) silently buys another plan of\n// the configured size every time the model's credits run out mid-request and\n// retries, instead of pausing for the interactive tier-selection prompt. It\n// stops automatically once the cumulative max_minutes budget is reached. State\n// lives in ~/.opencode-paytaca/auto-refill.json, shared with the proxy.\nconst AUTO_REFILL_FILE = path.join(CONFIG_DIR, 'auto-refill.json');\n\nasync function setAutoRefill(args) {\n const enabled = args.enabled === true;\n const stateFile = AUTO_REFILL_FILE;\n\n const readState = () => {\n try {\n return JSON.parse(fs.readFileSync(stateFile, 'utf8'));\n } catch (e) {\n return null;\n }\n };\n const prev = readState();\n\n if (!enabled) {\n const spent = (prev && prev.spentMinutes) ? Number(prev.spentMinutes) : 0;\n fs.writeFileSync(stateFile, JSON.stringify(Object.assign({}, prev || {}, { enabled: false }), null, 2), 'utf8');\n if (prev && prev.enabled) {\n return 'Auto-refill is now OFF. It purchased ' + spent + ' minutes before being stopped.';\n }\n return 'Auto-refill is already off. Nothing changed.';\n }\n\n const modelFilter = String(args.model || '').trim();\n if (!modelFilter) {\n throw new Error('Missing model. Pass the model id or display name to auto-refill, e.g. deepseek/deepseek-v4-flash.');\n }\n const minutes = Number(args.minutes);\n if (isNaN(minutes) || minutes <= 0) {\n throw new Error('Missing or invalid minutes. Pass the plan size to buy on each refill, e.g. 15.');\n }\n const maxMinutes = Number(args.max_minutes);\n if (isNaN(maxMinutes) || maxMinutes < minutes) {\n throw new Error('Missing or invalid max_minutes. It caps total auto-bought minutes and must be at least the plan size (' + minutes + '). E.g. 120 for 2 hours.');\n }\n const paymentMethod = args.payment_method === 'lift' ? 'lift' : 'bch';\n\n const { model, tier } = await resolvePlan(modelFilter, minutes);\n const priceSats = Number(tier.price_sats) || 0;\n const perRefillBch = (priceSats / 100000000).toFixed(8);\n const refillCount = Math.max(1, Math.ceil(maxMinutes / minutes));\n const maxCostBch = (priceSats * refillCount / 100000000).toFixed(8);\n\n const state = {\n enabled: true,\n minutes: minutes,\n maxMinutes: maxMinutes,\n spentMinutes: 0,\n model: model.id,\n paymentMethod: paymentMethod,\n startedAt: new Date().toISOString(),\n lastRefillAt: null,\n };\n try {\n fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');\n } catch (e) {\n throw new Error('Failed to save auto-refill state: ' + e.message);\n }\n\n const paidWith = paymentMethod === 'lift' ? 'LIFT tokens' : 'BCH';\n const lines = [\n 'Auto-refill is now ARMED for ' + (model.display_name || model.id) + '.',\n '',\n '- Refill: when credits run out, the plugin buys another ' + minutes + '-minute plan (' + perRefillBch + ' BCH, paid with ' + paidWith + ') automatically and keeps working.',\n '- Cap: up to ' + maxMinutes + ' total minutes (' + refillCount + ' refills, max ' + maxCostBch + ' BCH). Auto-refill turns itself off once the cap is reached or when you stop it (auto_refill with enabled=false).',\n '- It also stops automatically if the wallet runs out of funds or if no refill happens for 24 hours.',\n '',\n 'Continue the task \u2014 the next time credits run out, the plugin resumes by itself.',\n ];\n return lines.join('\\n');\n}\n\n// Recent wallet transactions via the paytaca CLI\nasync function getTransactions(args) {\n const cmdArgs = ['history'];\n if (args.type === 'incoming' || args.type === 'outgoing') {\n cmdArgs.push('--type', args.type);\n }\n const page = parseInt(args.page, 10);\n if (!isNaN(page) && page > 0) {\n cmdArgs.push('--page', String(page));\n }\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out || 'No transactions found.';\n}\n\n// Receiving address via the paytaca CLI (QR art suppressed)\nasync function getReceivingAddress(args) {\n const cmdArgs = ['receive', '--no-qr'];\n const amount = parseFloat(args.amount);\n if (!isNaN(amount) && amount > 0) {\n cmdArgs.push('--amount', String(amount));\n }\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out.trim() || 'Could not get receiving address.';\n}\n\n// CashToken holdings via the paytaca CLI (all tokens, or one category)\nasync function getTokens(args) {\n const category = args.category ? String(args.category).trim() : '';\n const cmdArgs = category ? ['token', 'info', category] : ['token', 'list'];\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out.trim() || (category ? 'Token not found: ' + category : 'No tokens found.');\n}\n\n// Send BCH or CashTokens. This spends real funds \u2014 opencode requires manual\n// user approval for this tool (permission 'paytaca_send' set to 'ask' by the\n// plugin's config hook), so it must never be called without the user asking\n// for the send.\nasync function sendFunds(args) {\n const address = String(args.address || '').trim();\n const amount = String(args.amount || '').trim();\n const unit = args.unit === 'sats' ? 'sats' : 'bch';\n const tokenCategory = args.token_category ? String(args.token_category).trim() : '';\n if (!address) {\n throw new Error('Missing recipient address.');\n }\n if (!amount || isNaN(Number(amount)) || Number(amount) <= 0) {\n throw new Error('Missing or invalid amount.');\n }\n const cmdArgs = tokenCategory\n ? ['token', 'send', address, amount, '--token', tokenCategory]\n : ['send', address, amount];\n if (!tokenCategory && unit === 'sats') {\n cmdArgs.push('--unit', 'sats');\n }\n log('Send requested: ' + cmdArgs.join(' '));\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 90000);\n log('Send completed');\n return 'Transaction sent.\\n\\n' + out;\n}\n\n// Tool schemas (concise descriptions so MCP tool context stays small)\nconst TOOLS = [\n {\n name: 'get_help',\n description: 'Get a Q&A guide about the Paytaca wallet, funding it, buying plans, paying with BCH or LIFT, what makes Paytaca AI unique, Bitcoin Cash, CashTokens/LIFT, and Paytaca itself. Use when the user asks how the wallet or buying works, how to fund or top up, what Paytaca AI is, what makes it different, or anything about Bitcoin Cash, CashTokens, or LIFT.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_credits',\n description: 'Get remaining Paytaca AI time credits (active model sessions, time left). Use when the user asks about credits, remaining time, session status, or how much usage they have left.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_balance',\n description: 'Get the BCH balance of the user\\'s Paytaca wallet. Use when the user asks about wallet balance or funds.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_models',\n description: 'List the AI models available on Paytaca AI (id, display name, tier). Use when the user asks which models are available.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_plans',\n description: 'Get Paytaca AI plan pricing: time tiers in minutes with USD and BCH prices, grouped by tier. Optionally pass a model id/name to filter one model. Use when the user asks about plans, pricing, costs, or how much a model costs.',\n inputSchema: {\n type: 'object',\n properties: {\n model: { type: 'string', description: 'Optional model id or display name to filter pricing to a single model.' },\n },\n },\n },\n {\n name: 'buy_plan',\n description: 'Purchase Paytaca AI time credits for a specific model and plan duration. SPENDS FUNDS FROM THE WALLET (BCH, or LIFT tokens sold via Cauldron when payment_method=lift) \u2014 only call when the user explicitly asks to buy, purchase, or pay for a plan; opencode prompts the user for approval. IMPORTANT RESTRICTION: a plan CANNOT be purchased while the model still has active credits \u2014 the backend serves requests without payment once credits are active, so buying again charges nothing and does not stack time. Before calling, check the model\\'s credits with get_credits; if the model still has time remaining, do NOT buy \u2014 tell the user to use up or wait out the remaining credits first. Show pricing with get_plans first, then call with the model and minutes the user picked. Works for any model, even one not active in the current session. IMPORTANT \u2014 LIFT phrasing: when the user says \"pay with LIFT\", \"pay with LIFT tokens\", \"use LIFT\", or mentions paying a plan with their LIFT token balance, set payment_method to \"lift\". LIFT is the Paytaca token users hold to pay for AI plans; \"pay with LIFT\" is NOT asking to buy a plan called LIFT. Default to \"bch\" unless the user explicitly mentions LIFT/tokens. Paying with LIFT gets a discount (rate set server-side; see get_help or get_plans for the current percent).',\n inputSchema: {\n type: 'object',\n required: ['model', 'minutes'],\n properties: {\n model: { type: 'string', description: 'Model id or display name, e.g. deepseek/deepseek-v4-flash or DeepSeek V4 Flash.' },\n minutes: { type: 'number', description: 'Plan duration in minutes, e.g. 30 for the 30-minute tier.' },\n payment_method: { type: 'string', enum: ['bch', 'lift'], description: 'Payment method. bch (default) pays from the BCH balance. Set to lift when the user says \"pay with LIFT\" or \"pay with LIFT tokens\" \u2014 this sells the wallet LIFT token balance via Cauldron to fund the plan. Use lift when the wallet lacks BCH but holds LIFT, or when the user explicitly asks to pay with LIFT.' },\n },\n },\n },\n {\n name: 'auto_refill',\n description: 'Arm (or disarm) automatic plan refills. When armed, if the model\\'s credits run out mid-task, the plugin silently buys another plan of the chosen size and continues working without the interactive buy prompt, up to a cap. Use when the user wants an uninterrupted long session, e.g. \"keep buying 15-minute plans until the task is done, max 2 hours\" or \"auto-refill the plan when credits run out\". Call with enabled=true plus model, minutes (plan size per refill) and max_minutes (cap on total auto-bought minutes; e.g. 120 for 2 hours). Each refill is a real wallet payment. Call with enabled=false to stop early. After arming, tell the user to keep working.',\n inputSchema: {\n type: 'object',\n required: ['enabled'],\n properties: {\n enabled: { type: 'boolean', description: 'Arm (true) or disarm (false) automatic refills.' },\n model: { type: 'string', description: 'Model id or display name to auto-refill (required when enabling), e.g. deepseek/deepseek-v4-flash.' },\n minutes: { type: 'number', description: 'Plan size in minutes bought per refill (required when enabling), e.g. 15.' },\n max_minutes: { type: 'number', description: 'Maximum total minutes to auto-buy across all refills (required when enabling), e.g. 120 for a 2-hour cap.' },\n payment_method: { type: 'string', enum: ['bch', 'lift'], description: 'Payment method for each refill. Default bch. Set to lift when the user wants to pay with LIFT tokens (discount applies).' },\n },\n },\n },\n {\n name: 'get_transactions',\n description: 'Get recent Paytaca wallet transactions (sent/received BCH). Use when the user asks about transaction history or latest transactions.',\n inputSchema: {\n type: 'object',\n properties: {\n type: { type: 'string', enum: ['incoming', 'outgoing'], description: 'Optional direction filter.' },\n page: { type: 'number', description: 'Optional 1-based page number for older history.' },\n },\n },\n },\n {\n name: 'get_receiving_address',\n description: 'Get a Paytaca wallet receiving address for depositing BCH, optionally as a BIP21 URI with an amount. Use when the user wants to fund the wallet or needs their address.',\n inputSchema: {\n type: 'object',\n properties: {\n amount: { type: 'number', description: 'Optional BCH amount to embed in a BIP21 payment URI.' },\n },\n },\n },\n {\n name: 'get_tokens',\n description: 'List CashToken holdings of the Paytaca wallet, or get details (name, symbol, balance, NFTs) for one token category. Use when the user asks about tokens or NFTs.',\n inputSchema: {\n type: 'object',\n properties: {\n category: { type: 'string', description: 'Optional token category id for details of a single token.' },\n },\n },\n },\n {\n name: 'send',\n description: 'Send BCH or CashTokens from the Paytaca wallet to an address. SPENDS REAL FUNDS \u2014 only call when the user explicitly asks to send; opencode will prompt the user for approval and that prompt must never be bypassed. Token amounts are in base units; recipients of tokens should use token-aware (z-prefix) addresses.',\n inputSchema: {\n type: 'object',\n required: ['address', 'amount'],\n properties: {\n address: { type: 'string', description: 'Recipient CashAddr (e.g. bitcoincash:qp...).' },\n amount: { type: 'string', description: 'Amount to send.' },\n unit: { type: 'string', enum: ['bch', 'sats'], description: 'Amount unit, default bch. Ignored for token sends.' },\n token_category: { type: 'string', description: 'Token category id to send CashTokens instead of BCH.' },\n },\n },\n },\n];\n\n// JSON-RPC over stdio (newline-delimited)\nlet buffer = '';\nfunction handleMessage(msg) {\n if (msg.method === 'initialize') {\n const requestedVersion = msg.params && msg.params.protocolVersion;\n send(msg.id, {\n protocolVersion: requestedVersion || PROTOCOL_VERSION,\n capabilities: { tools: {} },\n serverInfo: { name: 'paytaca', version: '1.2.0' },\n });\n return;\n }\n if (msg.method === 'notifications/initialized') {\n return;\n }\n if (msg.method === 'ping') {\n send(msg.id, {});\n return;\n }\n if (msg.method === 'tools/list') {\n send(msg.id, { tools: TOOLS });\n return;\n }\n if (msg.method === 'tools/call') {\n const name = msg.params && msg.params.name;\n const args = (msg.params && msg.params.arguments) || {};\n refreshConfig();\n (async () => {\n let text;\n try {\n switch (name) {\n case 'get_help': text = await getHelp(); break;\n case 'get_credits': text = await getCredits(); break;\n case 'get_balance': text = await getBalance(); break;\n case 'get_models': text = await getModels(); break;\n case 'get_plans': text = await getPlans(args.model); break;\n case 'buy_plan': text = await buyPlan(args); break;\n case 'auto_refill': text = await setAutoRefill(args); break;\n case 'get_transactions': text = await getTransactions(args); break;\n case 'get_receiving_address': text = await getReceivingAddress(args); break;\n case 'get_tokens': text = await getTokens(args); break;\n case 'send': text = await sendFunds(args); break;\n default: throw new Error('Unknown tool: ' + name);\n }\n send(msg.id, { content: [{ type: 'text', text: text }] });\n } catch (e) {\n log('Tool ' + name + ' failed: ' + e.message);\n send(msg.id, { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true });\n }\n })();\n return;\n }\n // Unknown request \u2014 respond with an empty result so the client never hangs\n if (typeof msg.id !== 'undefined' && msg.id !== null) {\n send(msg.id, {});\n }\n}\n\nfunction send(id, result) {\n const payload = { jsonrpc: '2.0', id, result };\n process.stdout.write(JSON.stringify(payload) + '\\n');\n}\n\nprocess.stdin.on('data', (chunk) => {\n buffer += chunk.toString();\n let idx;\n while ((idx = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.substring(0, idx).trim();\n buffer = buffer.substring(idx + 1);\n if (!line) continue;\n let msg;\n try {\n msg = JSON.parse(line);\n } catch (e) {\n continue;\n }\n try {\n handleMessage(msg);\n } catch (e) {\n log('handleMessage error: ' + e.message);\n }\n }\n});\n\nrefreshConfig();\nlog('MCP server started (backend=' + BACKEND_URL + ')');\n";
|
|
2
2
|
//# sourceMappingURL=mcp.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mcp.d.ts","sourceRoot":"","sources":["../../src/bundled/mcp.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,kBAAkB,
|
|
1
|
+
{"version":3,"file":"mcp.d.ts","sourceRoot":"","sources":["../../src/bundled/mcp.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,kBAAkB,w8wCAo1B9B,CAAC"}
|
package/dist/bundled/mcp.js
CHANGED
|
@@ -103,7 +103,7 @@ function getJson(url, headers) {
|
|
|
103
103
|
// Run a shell command with a timeout
|
|
104
104
|
function runCommand(cmd, args, timeoutMs) {
|
|
105
105
|
return new Promise((resolve, reject) => {
|
|
106
|
-
const child = spawn(cmd, args, { shell:
|
|
106
|
+
const child = spawn(cmd, args, { shell: process.platform === 'win32' });
|
|
107
107
|
let stdout = '';
|
|
108
108
|
let stderr = '';
|
|
109
109
|
let settled = false;
|
|
@@ -120,7 +120,27 @@ function runCommand(cmd, args, timeoutMs) {
|
|
|
120
120
|
settled = true;
|
|
121
121
|
clearTimeout(timer);
|
|
122
122
|
if (code === 0) resolve(stdout.trim());
|
|
123
|
-
else
|
|
123
|
+
else {
|
|
124
|
+
// The pay wrapper writes JSON errors to stdout, not stderr. Fall back
|
|
125
|
+
// to stdout so the real error surfaces instead of "Command exited
|
|
126
|
+
// with code 1".
|
|
127
|
+
const errText = stderr.trim();
|
|
128
|
+
let message = errText || stdout.trim();
|
|
129
|
+
if (!message) {
|
|
130
|
+
message = 'Command exited with code ' + code;
|
|
131
|
+
} else {
|
|
132
|
+
// The pay wrapper writes { success:false, error } JSON to stdout.
|
|
133
|
+
if (!errText && message) {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(message);
|
|
136
|
+
message = (parsed && parsed.error) || message;
|
|
137
|
+
} catch (e) {}
|
|
138
|
+
} else if (errText && stdout.trim()) {
|
|
139
|
+
message = errText + '\\n' + stdout.trim();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
reject(new Error(message));
|
|
143
|
+
}
|
|
124
144
|
});
|
|
125
145
|
child.on('error', (err) => {
|
|
126
146
|
if (settled) return;
|
|
@@ -142,6 +162,41 @@ function formatDuration(totalSeconds) {
|
|
|
142
162
|
return minutes + ':' + String(secs).padStart(2, '0');
|
|
143
163
|
}
|
|
144
164
|
|
|
165
|
+
// Q&A guide about the wallet, buying plans, Paytaca AI, and Bitcoin Cash.
|
|
166
|
+
// The assistant fetches this when the user asks "how do I...", "what is...",
|
|
167
|
+
// or anything about funding, paying, or how the service works.
|
|
168
|
+
const FAQ = [
|
|
169
|
+
['What is Paytaca AI?', 'Paytaca AI is a service that gives you affordable, convenient access to AI inference through your Paytaca wallet. Instead of per-token metering or a monthly subscription, you buy short time-based plans in advance — 15, 30, or 60 minutes of a model — and use them up as you chat.'],
|
|
170
|
+
['What makes Paytaca AI different from other AI services?', 'We keep AI access affordable and simple by billing in pre-purchased blocks of time rather than per token. Pick a model, buy a 15/30/60-minute plan with your wallet, and the time is yours to use. No surprises per message, no recurring billing.'],
|
|
171
|
+
['Did this plugin create a wallet for me?', 'Yes. Installing the Paytaca AI opencode plugin automatically created a Paytaca wallet on this device (a Bitcoin Cash wallet). Any BCH or LIFT tokens you send to its address are available to this plugin to spend.'],
|
|
172
|
+
['How do I fund my wallet?', 'Get your wallet\\'s receiving address (ask "what\\'s my address?" or call get_receiving_address), then send BCH or LIFT tokens to it from any Bitcoin Cash wallet or exchange. Funding must happen BEFORE you can buy a plan. Check your balance anytime with get_balance.'],
|
|
173
|
+
['How do I buy a plan?', 'Three steps: (1) make sure your wallet has funds — get_receiving_address to deposit, get_balance to confirm; (2) see pricing with get_plans; (3) ask to buy, e.g. "Buy a DeepSeek V4 Flash plan for 30 minutes." opencode will ask you to approve the payment.'],
|
|
174
|
+
['Can I pay with LIFT tokens?', 'Yes. Plans can be paid in BCH or LIFT tokens. To pay with tokens, add "pay with LIFT" to your buy request, e.g. "Buy a 15-minute GLM plan and pay with LIFT." Paying with LIFT applies a discount — see the current rate below (the backend sets it, so it can change anytime).'],
|
|
175
|
+
['Why can\\'t I buy again while I still have credits?', 'Plans are time blocks, not balances that stack. While a model still has active time, buying another plan for it would charge nothing extra. Use up or wait out the remaining credits, then buy again. Check remaining time with get_credits.'],
|
|
176
|
+
['Can the plugin keep buying plans automatically during a long task?', 'Yes. Tell the assistant something like "auto-refill the DeepSeek plan at 15 minutes, max 2 hours" or "keep buying 15-minute plans until I stop it". It arms auto-refill; from then on, whenever credits run out mid-task it silently buys another plan and continues without interrupting you. It stops when the cap is reached, the wallet runs short, or you tell it to stop. Each refill is a real wallet payment (opencode asks you to approve the auto-refill tool once when arming).'],
|
|
177
|
+
['What is Bitcoin Cash (BCH)?', 'Bitcoin Cash is peer-to-peer electronic cash. Transactions are confirmed in seconds to minutes with extremely low fees (fractions of a cent), which makes it practical for small, everyday payments like buying a plan. It is the currency Paytaca AI payments run on.'],
|
|
178
|
+
['What are CashTokens and LIFT?', 'CashTokens are fungible and non-fungible tokens issued on the Bitcoin Cash blockchain. LIFT is a Paytaca CashToken you can use to pay for AI plans, and it can be bought/sold for BCH on the Cauldron DEX.'],
|
|
179
|
+
['What is Paytaca?', 'Paytaca is a Bitcoin Cash wallet and payments ecosystem, and Paytaca AI is its AI inference service. The Paytaca wallet app holds your BCH and CashTokens, and Paytaca AI lets you spend them on AI usage.'],
|
|
180
|
+
].map(([q, a]) => '- **' + q + '**\\n ' + a).join('\\n');
|
|
181
|
+
|
|
182
|
+
// Appended to Paytaca AI info tool outputs (plans, credits, models): asks the
|
|
183
|
+
// user what they want to do next and steers them toward buying a plan, with
|
|
184
|
+
// concrete example prompts they can reuse to place the order.
|
|
185
|
+
async function nextSteps() {
|
|
186
|
+
const percent = await getLiftDiscountPercent();
|
|
187
|
+
const liftLine = percent > 0
|
|
188
|
+
? '- Pay with LIFT tokens instead of BCH: just add "pay with LIFT" to your request, e.g. "Buy a 30-minute GLM plan and pay with LIFT." — you get **' + percent + '% off**.'
|
|
189
|
+
: '- Pay with LIFT tokens instead of BCH: just add "pay with LIFT" to your request, e.g. "Buy a 30-minute GLM plan and pay with LIFT."';
|
|
190
|
+
return [
|
|
191
|
+
'',
|
|
192
|
+
'What would you like to do next?',
|
|
193
|
+
'- Buy a plan: pick a model and duration from the list above, then say something like: "Buy a DeepSeek V4 Flash plan for 15 minutes."',
|
|
194
|
+
liftLine,
|
|
195
|
+
'- Check your credits: "How much time do I have left?"',
|
|
196
|
+
'- See prices for another model: "Show me the plans for <model>."',
|
|
197
|
+
].join('\\n');
|
|
198
|
+
}
|
|
199
|
+
|
|
145
200
|
// Remaining time credits per model session
|
|
146
201
|
async function getCredits() {
|
|
147
202
|
const data = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });
|
|
@@ -166,9 +221,19 @@ async function getCredits() {
|
|
|
166
221
|
}
|
|
167
222
|
}
|
|
168
223
|
if (parts.length === 0) {
|
|
169
|
-
return 'No active time credits.';
|
|
224
|
+
return 'No active time credits.' + await nextSteps();
|
|
170
225
|
}
|
|
171
|
-
return parts.join('\\n');
|
|
226
|
+
return parts.join('\\n') + await nextSteps();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Q&A guide about the wallet, buying plans, Paytaca AI, and Bitcoin Cash.
|
|
230
|
+
async function getHelp() {
|
|
231
|
+
let liftLine = '';
|
|
232
|
+
const percent = await getLiftDiscountPercent();
|
|
233
|
+
if (percent > 0) {
|
|
234
|
+
liftLine = '\\n\\n💡 **Current LIFT discount: ' + percent + '%** off plans paid with LIFT tokens.';
|
|
235
|
+
}
|
|
236
|
+
return 'Paytaca AI and your wallet — frequently asked questions:\\n\\n' + FAQ + liftLine + await nextSteps();
|
|
172
237
|
}
|
|
173
238
|
|
|
174
239
|
// Wallet BCH balance via the paytaca CLI
|
|
@@ -192,10 +257,11 @@ async function getModels() {
|
|
|
192
257
|
if (m.tier) line += ' [' + m.tier + ']';
|
|
193
258
|
lines.push('- ' + line);
|
|
194
259
|
}
|
|
195
|
-
return lines.join('\\n');
|
|
260
|
+
return lines.join('\\n') + await nextSteps();
|
|
196
261
|
}
|
|
197
262
|
|
|
198
|
-
// Plan pricing as a
|
|
263
|
+
// Plan pricing as a compact matrix: models as rows, durations as columns,
|
|
264
|
+
// with USD and BCH price in each cell. Durations are always 15/30/60 min.
|
|
199
265
|
async function getPlans(filterModel) {
|
|
200
266
|
const data = await getJson(BACKEND_URL + '/v1/config', {});
|
|
201
267
|
let models = Array.isArray(data.models) ? data.models : [];
|
|
@@ -208,33 +274,31 @@ async function getPlans(filterModel) {
|
|
|
208
274
|
});
|
|
209
275
|
}
|
|
210
276
|
if (models.length === 0) {
|
|
211
|
-
return 'No models available.';
|
|
277
|
+
return 'No models available.' + await nextSteps();
|
|
212
278
|
}
|
|
213
|
-
const
|
|
279
|
+
const durationColumns = [15, 30, 60];
|
|
280
|
+
const lines = ['| Model | 15 min | 30 min | 60 min |', '|---|---|---|---|'];
|
|
214
281
|
for (const m of models) {
|
|
215
282
|
const name = m.display_name || m.id;
|
|
216
283
|
const tier = m.tier ? String(m.tier).charAt(0).toUpperCase() + String(m.tier).slice(1) : '';
|
|
217
284
|
const tierInName = tier && name.toLowerCase().indexOf(tier.toLowerCase()) !== -1;
|
|
218
|
-
|
|
219
|
-
lines.push('**' + name + (tier && !tierInName ? ' (' + tier + ')' : '') + '**');
|
|
220
|
-
lines.push('');
|
|
285
|
+
const label = name + (tier && !tierInName ? ' (' + tier + ')' : '');
|
|
221
286
|
const tiers = Array.isArray(m.price_tiers) ? m.price_tiers : [];
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
287
|
+
const byMinutes = {};
|
|
288
|
+
for (const t of tiers) {
|
|
289
|
+
byMinutes[Number(t.minutes)] = t;
|
|
225
290
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
for (const t of sorted) {
|
|
291
|
+
const cells = durationColumns.map((mins) => {
|
|
292
|
+
const t = byMinutes[mins];
|
|
293
|
+
if (!t) return '-';
|
|
230
294
|
const sats = typeof t.price_sats === 'number' ? t.price_sats : 0;
|
|
231
295
|
const bch = (sats / 100000000).toFixed(8);
|
|
232
296
|
const usd = typeof t.price_usd === 'number' ? '$' + t.price_usd.toFixed(2) : '?.??';
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
297
|
+
return usd + ' / ' + bch + ' BCH';
|
|
298
|
+
});
|
|
299
|
+
lines.push('| ' + label + ' | ' + cells.join(' | ') + ' |');
|
|
236
300
|
}
|
|
237
|
-
return lines.join('\\n');
|
|
301
|
+
return lines.join('\\n') + await nextSteps();
|
|
238
302
|
}
|
|
239
303
|
|
|
240
304
|
// Resolve a model (by id or display name) and its price tier by minutes.
|
|
@@ -280,6 +344,31 @@ async function getLiftBalanceUnits() {
|
|
|
280
344
|
return BigInt(Math.round(parseFloat(match[1]) * 100));
|
|
281
345
|
}
|
|
282
346
|
|
|
347
|
+
// LIFT payment discount percent advertised by the backend (/v1/config), cached
|
|
348
|
+
// for ~5 minutes. Returns 0 when unset/unavailable so callers can fall back to
|
|
349
|
+
// no-discount messaging.
|
|
350
|
+
let liftDiscountCache = { at: 0, percent: 0 };
|
|
351
|
+
async function getLiftDiscountPercent() {
|
|
352
|
+
const now = Date.now();
|
|
353
|
+
if (liftDiscountCache.at && now - liftDiscountCache.at < 300000) {
|
|
354
|
+
return liftDiscountCache.percent;
|
|
355
|
+
}
|
|
356
|
+
let percent = 0;
|
|
357
|
+
try {
|
|
358
|
+
const data = await getJson(BACKEND_URL + '/v1/config', {});
|
|
359
|
+
percent = Number(data.lift_payment_discount_percent) || 0;
|
|
360
|
+
} catch (e) {
|
|
361
|
+
log('Failed to fetch LIFT discount config: ' + e.message);
|
|
362
|
+
}
|
|
363
|
+
liftDiscountCache = { at: now, percent };
|
|
364
|
+
return percent;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Format a satoshi amount as a BCH string with up to 8 decimals.
|
|
368
|
+
function formatBch(sats) {
|
|
369
|
+
return (Number(sats) / 100000000).toFixed(8);
|
|
370
|
+
}
|
|
371
|
+
|
|
283
372
|
// Purchase time credits for a specific model and plan duration. Reuses the
|
|
284
373
|
// same payment wrapper the proxy runs on a 402 (x402 payment + retry with the
|
|
285
374
|
// PAYMENT-SIGNATURE header), but works for ANY model/tier the user picks —
|
|
@@ -298,6 +387,24 @@ async function buyPlan(args) {
|
|
|
298
387
|
const { model, tier } = await resolvePlan(modelFilter, minutes);
|
|
299
388
|
const priceSats = Number(tier.price_sats) || 0;
|
|
300
389
|
|
|
390
|
+
// Explicit restriction: a plan cannot be purchased while the model still has
|
|
391
|
+
// active credits. The backend serves requests without a 402 once credits are
|
|
392
|
+
// active, so buying again would silently charge nothing and the user would
|
|
393
|
+
// think they stacked time. Block the purchase and tell them to use up the
|
|
394
|
+
// remaining credits (or wait for them to expire) before buying again.
|
|
395
|
+
let status;
|
|
396
|
+
try {
|
|
397
|
+
status = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });
|
|
398
|
+
} catch (e) {
|
|
399
|
+
throw new Error('Could not check existing credits before purchase: ' + e.message + '. Try again, or check credits with get_credits.');
|
|
400
|
+
}
|
|
401
|
+
const sessions = Array.isArray(status.sessions) ? status.sessions : [];
|
|
402
|
+
const existing = sessions.find((s) => s.ai_model === model.id && s.time_remaining_seconds > 0);
|
|
403
|
+
if (existing) {
|
|
404
|
+
const remaining = formatDuration(existing.time_remaining_seconds);
|
|
405
|
+
throw new Error((model.display_name || model.id) + ' still has ' + remaining + ' of active credits. A new plan cannot be purchased until the remaining credits are used up or expire. Check credits with get_credits and buy again once they run out.');
|
|
406
|
+
}
|
|
407
|
+
|
|
301
408
|
// Fail fast on insufficient balance instead of sending a txn that cannot
|
|
302
409
|
// fund the plan (mirrors the proxy's 402 flow). Only applies to BCH — the
|
|
303
410
|
// LIFT path funds the plan by selling tokens, so no BCH balance is required.
|
|
@@ -335,6 +442,9 @@ async function buyPlan(args) {
|
|
|
335
442
|
'X-Model-Id': model.id,
|
|
336
443
|
'X-Duration-Minutes': String(tier.minutes),
|
|
337
444
|
};
|
|
445
|
+
if (paymentMethod === 'lift') {
|
|
446
|
+
extraHeaders['X-Payment-Method'] = 'lift';
|
|
447
|
+
}
|
|
338
448
|
|
|
339
449
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paytaca-buy-plan-'));
|
|
340
450
|
const bodyFile = path.join(tmpDir, 'body.json');
|
|
@@ -381,7 +491,24 @@ async function buyPlan(args) {
|
|
|
381
491
|
throw new Error(result.error || 'Payment failed (status ' + result.status + ').');
|
|
382
492
|
}
|
|
383
493
|
|
|
494
|
+
const paid = result.payment && result.payment.required;
|
|
495
|
+
if (!paid) {
|
|
496
|
+
// No payment was required — the model already had active credits, so the
|
|
497
|
+
// server served the request for free. Report this instead of claiming a
|
|
498
|
+
// purchase so the user knows nothing was charged.
|
|
499
|
+
return 'No purchase made: ' + (model.display_name || model.id) + ' already has active credits, so the request was served without payment. Check your credits with get_credits.';
|
|
500
|
+
}
|
|
501
|
+
|
|
384
502
|
const lines = ['Plan purchased for ' + (model.display_name || model.id) + ': ' + tier.minutes + ' minutes' + (paymentMethod === 'lift' ? ' (paid with LIFT).' : '.')];
|
|
503
|
+
if (paymentMethod === 'lift') {
|
|
504
|
+
const discountPercent = await getLiftDiscountPercent();
|
|
505
|
+
if (discountPercent > 0) {
|
|
506
|
+
const discountSats = Math.round(priceSats * (discountPercent / 100));
|
|
507
|
+
lines.push('**LIFT discount applied: ' + discountPercent + '% — you saved ' + formatBch(discountSats) + ' BCH.**');
|
|
508
|
+
} else {
|
|
509
|
+
lines.push('Paid with LIFT tokens.');
|
|
510
|
+
}
|
|
511
|
+
}
|
|
385
512
|
if (result.payment && result.payment.txid) {
|
|
386
513
|
lines.push('Transaction: ' + result.payment.txid);
|
|
387
514
|
}
|
|
@@ -396,6 +523,85 @@ async function buyPlan(args) {
|
|
|
396
523
|
return lines.join('\\n');
|
|
397
524
|
}
|
|
398
525
|
|
|
526
|
+
// Arm/disarm automatic plan refills for the current session. When armed, the
|
|
527
|
+
// proxy (which sits in front of the AI backend) silently buys another plan of
|
|
528
|
+
// the configured size every time the model's credits run out mid-request and
|
|
529
|
+
// retries, instead of pausing for the interactive tier-selection prompt. It
|
|
530
|
+
// stops automatically once the cumulative max_minutes budget is reached. State
|
|
531
|
+
// lives in ~/.opencode-paytaca/auto-refill.json, shared with the proxy.
|
|
532
|
+
const AUTO_REFILL_FILE = path.join(CONFIG_DIR, 'auto-refill.json');
|
|
533
|
+
|
|
534
|
+
async function setAutoRefill(args) {
|
|
535
|
+
const enabled = args.enabled === true;
|
|
536
|
+
const stateFile = AUTO_REFILL_FILE;
|
|
537
|
+
|
|
538
|
+
const readState = () => {
|
|
539
|
+
try {
|
|
540
|
+
return JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
541
|
+
} catch (e) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
const prev = readState();
|
|
546
|
+
|
|
547
|
+
if (!enabled) {
|
|
548
|
+
const spent = (prev && prev.spentMinutes) ? Number(prev.spentMinutes) : 0;
|
|
549
|
+
fs.writeFileSync(stateFile, JSON.stringify(Object.assign({}, prev || {}, { enabled: false }), null, 2), 'utf8');
|
|
550
|
+
if (prev && prev.enabled) {
|
|
551
|
+
return 'Auto-refill is now OFF. It purchased ' + spent + ' minutes before being stopped.';
|
|
552
|
+
}
|
|
553
|
+
return 'Auto-refill is already off. Nothing changed.';
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const modelFilter = String(args.model || '').trim();
|
|
557
|
+
if (!modelFilter) {
|
|
558
|
+
throw new Error('Missing model. Pass the model id or display name to auto-refill, e.g. deepseek/deepseek-v4-flash.');
|
|
559
|
+
}
|
|
560
|
+
const minutes = Number(args.minutes);
|
|
561
|
+
if (isNaN(minutes) || minutes <= 0) {
|
|
562
|
+
throw new Error('Missing or invalid minutes. Pass the plan size to buy on each refill, e.g. 15.');
|
|
563
|
+
}
|
|
564
|
+
const maxMinutes = Number(args.max_minutes);
|
|
565
|
+
if (isNaN(maxMinutes) || maxMinutes < minutes) {
|
|
566
|
+
throw new Error('Missing or invalid max_minutes. It caps total auto-bought minutes and must be at least the plan size (' + minutes + '). E.g. 120 for 2 hours.');
|
|
567
|
+
}
|
|
568
|
+
const paymentMethod = args.payment_method === 'lift' ? 'lift' : 'bch';
|
|
569
|
+
|
|
570
|
+
const { model, tier } = await resolvePlan(modelFilter, minutes);
|
|
571
|
+
const priceSats = Number(tier.price_sats) || 0;
|
|
572
|
+
const perRefillBch = (priceSats / 100000000).toFixed(8);
|
|
573
|
+
const refillCount = Math.max(1, Math.ceil(maxMinutes / minutes));
|
|
574
|
+
const maxCostBch = (priceSats * refillCount / 100000000).toFixed(8);
|
|
575
|
+
|
|
576
|
+
const state = {
|
|
577
|
+
enabled: true,
|
|
578
|
+
minutes: minutes,
|
|
579
|
+
maxMinutes: maxMinutes,
|
|
580
|
+
spentMinutes: 0,
|
|
581
|
+
model: model.id,
|
|
582
|
+
paymentMethod: paymentMethod,
|
|
583
|
+
startedAt: new Date().toISOString(),
|
|
584
|
+
lastRefillAt: null,
|
|
585
|
+
};
|
|
586
|
+
try {
|
|
587
|
+
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
|
|
588
|
+
} catch (e) {
|
|
589
|
+
throw new Error('Failed to save auto-refill state: ' + e.message);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const paidWith = paymentMethod === 'lift' ? 'LIFT tokens' : 'BCH';
|
|
593
|
+
const lines = [
|
|
594
|
+
'Auto-refill is now ARMED for ' + (model.display_name || model.id) + '.',
|
|
595
|
+
'',
|
|
596
|
+
'- Refill: when credits run out, the plugin buys another ' + minutes + '-minute plan (' + perRefillBch + ' BCH, paid with ' + paidWith + ') automatically and keeps working.',
|
|
597
|
+
'- Cap: up to ' + maxMinutes + ' total minutes (' + refillCount + ' refills, max ' + maxCostBch + ' BCH). Auto-refill turns itself off once the cap is reached or when you stop it (auto_refill with enabled=false).',
|
|
598
|
+
'- It also stops automatically if the wallet runs out of funds or if no refill happens for 24 hours.',
|
|
599
|
+
'',
|
|
600
|
+
'Continue the task — the next time credits run out, the plugin resumes by itself.',
|
|
601
|
+
];
|
|
602
|
+
return lines.join('\\n');
|
|
603
|
+
}
|
|
604
|
+
|
|
399
605
|
// Recent wallet transactions via the paytaca CLI
|
|
400
606
|
async function getTransactions(args) {
|
|
401
607
|
const cmdArgs = ['history'];
|
|
@@ -458,6 +664,11 @@ async function sendFunds(args) {
|
|
|
458
664
|
|
|
459
665
|
// Tool schemas (concise descriptions so MCP tool context stays small)
|
|
460
666
|
const TOOLS = [
|
|
667
|
+
{
|
|
668
|
+
name: 'get_help',
|
|
669
|
+
description: 'Get a Q&A guide about the Paytaca wallet, funding it, buying plans, paying with BCH or LIFT, what makes Paytaca AI unique, Bitcoin Cash, CashTokens/LIFT, and Paytaca itself. Use when the user asks how the wallet or buying works, how to fund or top up, what Paytaca AI is, what makes it different, or anything about Bitcoin Cash, CashTokens, or LIFT.',
|
|
670
|
+
inputSchema: { type: 'object', properties: {} },
|
|
671
|
+
},
|
|
461
672
|
{
|
|
462
673
|
name: 'get_credits',
|
|
463
674
|
description: 'Get remaining Paytaca AI time credits (active model sessions, time left). Use when the user asks about credits, remaining time, session status, or how much usage they have left.',
|
|
@@ -485,7 +696,7 @@ const TOOLS = [
|
|
|
485
696
|
},
|
|
486
697
|
{
|
|
487
698
|
name: 'buy_plan',
|
|
488
|
-
description: 'Purchase Paytaca AI time credits for a specific model and plan duration. SPENDS FUNDS FROM THE WALLET (BCH, or LIFT tokens sold via Cauldron when payment_method=lift) — only call when the user explicitly asks to buy, purchase, or pay for a plan; opencode prompts the user for approval. Show pricing with get_plans first, then call with the model and minutes the user picked. Works for any model, even one not active in the current session. IMPORTANT — LIFT phrasing: when the user says "pay with LIFT", "pay with LIFT tokens", "use LIFT", or mentions paying a plan with their LIFT token balance, set payment_method to "lift". LIFT is the Paytaca token users hold to pay for AI plans; "pay with LIFT" is NOT asking to buy a plan called LIFT. Default to "bch" unless the user explicitly mentions LIFT/tokens.',
|
|
699
|
+
description: 'Purchase Paytaca AI time credits for a specific model and plan duration. SPENDS FUNDS FROM THE WALLET (BCH, or LIFT tokens sold via Cauldron when payment_method=lift) — only call when the user explicitly asks to buy, purchase, or pay for a plan; opencode prompts the user for approval. IMPORTANT RESTRICTION: a plan CANNOT be purchased while the model still has active credits — the backend serves requests without payment once credits are active, so buying again charges nothing and does not stack time. Before calling, check the model\\'s credits with get_credits; if the model still has time remaining, do NOT buy — tell the user to use up or wait out the remaining credits first. Show pricing with get_plans first, then call with the model and minutes the user picked. Works for any model, even one not active in the current session. IMPORTANT — LIFT phrasing: when the user says "pay with LIFT", "pay with LIFT tokens", "use LIFT", or mentions paying a plan with their LIFT token balance, set payment_method to "lift". LIFT is the Paytaca token users hold to pay for AI plans; "pay with LIFT" is NOT asking to buy a plan called LIFT. Default to "bch" unless the user explicitly mentions LIFT/tokens. Paying with LIFT gets a discount (rate set server-side; see get_help or get_plans for the current percent).',
|
|
489
700
|
inputSchema: {
|
|
490
701
|
type: 'object',
|
|
491
702
|
required: ['model', 'minutes'],
|
|
@@ -496,6 +707,21 @@ const TOOLS = [
|
|
|
496
707
|
},
|
|
497
708
|
},
|
|
498
709
|
},
|
|
710
|
+
{
|
|
711
|
+
name: 'auto_refill',
|
|
712
|
+
description: 'Arm (or disarm) automatic plan refills. When armed, if the model\\'s credits run out mid-task, the plugin silently buys another plan of the chosen size and continues working without the interactive buy prompt, up to a cap. Use when the user wants an uninterrupted long session, e.g. "keep buying 15-minute plans until the task is done, max 2 hours" or "auto-refill the plan when credits run out". Call with enabled=true plus model, minutes (plan size per refill) and max_minutes (cap on total auto-bought minutes; e.g. 120 for 2 hours). Each refill is a real wallet payment. Call with enabled=false to stop early. After arming, tell the user to keep working.',
|
|
713
|
+
inputSchema: {
|
|
714
|
+
type: 'object',
|
|
715
|
+
required: ['enabled'],
|
|
716
|
+
properties: {
|
|
717
|
+
enabled: { type: 'boolean', description: 'Arm (true) or disarm (false) automatic refills.' },
|
|
718
|
+
model: { type: 'string', description: 'Model id or display name to auto-refill (required when enabling), e.g. deepseek/deepseek-v4-flash.' },
|
|
719
|
+
minutes: { type: 'number', description: 'Plan size in minutes bought per refill (required when enabling), e.g. 15.' },
|
|
720
|
+
max_minutes: { type: 'number', description: 'Maximum total minutes to auto-buy across all refills (required when enabling), e.g. 120 for a 2-hour cap.' },
|
|
721
|
+
payment_method: { type: 'string', enum: ['bch', 'lift'], description: 'Payment method for each refill. Default bch. Set to lift when the user wants to pay with LIFT tokens (discount applies).' },
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
},
|
|
499
725
|
{
|
|
500
726
|
name: 'get_transactions',
|
|
501
727
|
description: 'Get recent Paytaca wallet transactions (sent/received BCH). Use when the user asks about transaction history or latest transactions.',
|
|
@@ -574,11 +800,13 @@ function handleMessage(msg) {
|
|
|
574
800
|
let text;
|
|
575
801
|
try {
|
|
576
802
|
switch (name) {
|
|
803
|
+
case 'get_help': text = await getHelp(); break;
|
|
577
804
|
case 'get_credits': text = await getCredits(); break;
|
|
578
805
|
case 'get_balance': text = await getBalance(); break;
|
|
579
806
|
case 'get_models': text = await getModels(); break;
|
|
580
807
|
case 'get_plans': text = await getPlans(args.model); break;
|
|
581
808
|
case 'buy_plan': text = await buyPlan(args); break;
|
|
809
|
+
case 'auto_refill': text = await setAutoRefill(args); break;
|
|
582
810
|
case 'get_transactions': text = await getTransactions(args); break;
|
|
583
811
|
case 'get_receiving_address': text = await getReceivingAddress(args); break;
|
|
584
812
|
case 'get_tokens': text = await getTokens(args); break;
|