@paytaca/opencode-plugin 0.1.1 → 0.1.2

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.
@@ -1,2 +1,2 @@
1
- export declare const PROXY_SCRIPT_CONTENT = "#!/usr/bin/env node\n/**\n * Paytaca AI Proxy\n * \n * Sits between OpenCode and the Django backend.\n * - Auto-starts by OpenCode plugin\n * - On 402, returns SSE typewriter loading sequence + synthetic payment prompt\n * - Stores pending payments; handles \"yes\"/\"no\" approval internally\n * - Uses only Node.js built-in modules\n * \n * Usage: node proxy.js [backend_url] [proxy_port]\n * Example: node proxy.js https://api.paytaca.ai 8001\n */\n\nconst http = require('http');\nconst { spawn } = require('child_process');\nconst { Transform } = require('stream');\nconst fs = require('fs');\nconst path = require('path');\nconst os = require('os');\n\nconst PROXY_PORT = parseInt(process.argv[3]) || 8001;\nconst BACKEND_URL = process.argv[2] || 'https://api.paytaca.ai';\nconst parsedUrl = new URL(BACKEND_URL);\nconst DJANGO_HOST = parsedUrl.hostname;\nconst DJANGO_PORT = parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80);\n\n// Logging setup: write to file instead of console\nconst LOG_DIR = path.join(os.homedir(), '.opencode-paytaca');\nif (!fs.existsSync(LOG_DIR)) {\n fs.mkdirSync(LOG_DIR, { recursive: true });\n}\nconst LOG_FILE = path.join(LOG_DIR, 'proxy.log');\nconst logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });\n\nfunction log(message) {\n const timestamp = new Date().toISOString();\n logStream.write(timestamp + ' [Proxy] ' + message + '\\n');\n}\n\n// Heartbeat monitoring - proxy exits if heartbeat is stale\nconst HEARTBEAT_FILE = path.join(LOG_DIR, 'heartbeat');\nconst HEARTBEAT_TIMEOUT = 15000; // 15 seconds\n\nfunction checkHeartbeat() {\n try {\n if (!fs.existsSync(HEARTBEAT_FILE)) {\n // No heartbeat file yet, wait a bit\n return true;\n }\n const heartbeat = parseInt(fs.readFileSync(HEARTBEAT_FILE, 'utf8'));\n if (heartbeat === 0) {\n // Special value: plugin is stopping\n log('Heartbeat = 0, shutting down...');\n return false;\n }\n const elapsed = Date.now() - heartbeat;\n if (elapsed > HEARTBEAT_TIMEOUT) {\n log('Heartbeat stale (' + elapsed + 'ms), shutting down...');\n return false;\n }\n return true;\n } catch (err) {\n // If we can't read heartbeat, keep running (graceful degradation)\n return true;\n }\n}\n\n// Heartbeat checker reference (will be started after server creation)\nlet heartbeatChecker = null;\n\n// Store pending payment requests per wallet hash\nconst pendingPayments = new Map();\n\n// Utility: run shell command and return output\nfunction runCommand(cmd, args = []) {\n return new Promise((resolve, reject) => {\n const child = spawn(cmd, args, { shell: false });\n let stdout = '';\n let stderr = '';\n \n child.stdout.on('data', (data) => { stdout += data.toString(); });\n child.stderr.on('data', (data) => { stderr += data.toString(); });\n \n child.on('close', (code) => {\n if (code === 0) resolve(stdout.trim());\n else reject(new Error(stderr.trim() || 'Command exited with code ' + code));\n });\n \n child.on('error', (err) => reject(err));\n });\n}\n\n// Get paytaca command from environment or default to 'paytaca'\nconst PAYTACA_CMD = process.env.PAYTACA_CMD || 'paytaca';\n\n// Utility: check if paytaca CLI exists\nasync function checkPaytacaCli() {\n try {\n // Try to run version check\n await runCommand(PAYTACA_CMD, ['--version']);\n return true;\n } catch {\n return false;\n }\n}\n\n// Utility: get wallet balance in sats\nasync function getWalletBalance() {\n try {\n const output = await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n const match = output.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);\n if (match) {\n const bch = parseFloat(match[1]);\n return Math.floor(bch * 100000000);\n }\n return null;\n } catch (err) {\n log('Failed to get wallet balance: ' + err.message);\n return null;\n }\n}\n\n// Utility: get receiving address\nasync function getReceivingAddress() {\n try {\n const output = await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n const match = output.match(/Address:\\s*(bitcoincash:[a-zA-Z0-9]+)/i);\n return match ? match[1] : null;\n } catch {\n return null;\n }\n}\n\n// Utility: check if wallet exists\nasync function checkWallet() {\n try {\n await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n return true;\n } catch {\n return false;\n }\n}\n\n// SSE helper: write a data line\nfunction sseLine(res, data) {\n res.write('data: ' + JSON.stringify(data) + '\\n\\n');\n}\n\n// SSE helper: write [DONE]\nfunction sseDone(res) {\n res.write('data: [DONE]\\n\\n');\n}\n\n// Build and stream SSE loading sequence + payment prompt\nasync function streamPaymentPrompt(res, walletHash, isRenewal = false, tokensUsed = 0, tokenLimit = 50000, carryoverDeadline = null) {\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'X-Payment-Required': 'true',\n 'Connection': 'keep-alive',\n });\n\n // Fetch dynamic pricing from backend config\n let costPhp = 10.00;\n let costBch = '0.00080000';\n let costSats = 80000;\n let usingDefaultRate = false;\n \n try {\n const configRes = await fetch(BACKEND_URL + '/v1/config');\n if (configRes.ok) {\n const config = await configRes.json();\n costPhp = config.cost_php || 10.00;\n costBch = config.cost_bch || '0.00080000';\n costSats = config.cost_sats || 80000;\n }\n } catch (e) {\n // Backend unreachable \u2014 will warn user below\n usingDefaultRate = true;\n }\n \n const baseId = isRenewal ? 'renewal' : 'payment';\n\n sseLine(res, {\n id: baseId + '-1',\n object: 'chat.completion.chunk',\n created: Math.floor(Date.now() / 1000),\n model: 'deepseek-ai/DeepSeek-V4-Flash',\n choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],\n });\n\n let balanceStr;\n let hasCli, hasWallet, balanceSats;\n\n if (isRenewal) {\n // For renewals, skip the full loading sequence and fetch balance quietly\n hasCli = await checkPaytacaCli();\n hasWallet = hasCli ? await checkWallet() : false;\n balanceSats = hasWallet ? await getWalletBalance() : null;\n if (balanceSats !== null) {\n balanceStr = (balanceSats / 100000000).toFixed(8) + ' BCH';\n } else {\n balanceStr = 'Unable to check (try restarting)';\n }\n } else {\n // First-time users: show full loading sequence\n sseLine(res, {\n id: baseId + '-2',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\u23F3 Initializing Paytaca AI provider...\\n' }, finish_reason: null }],\n });\n\n hasCli = await checkPaytacaCli();\n sseLine(res, {\n id: baseId + '-3',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Checking Paytaca CLI... ' }, finish_reason: null }],\n });\n sseLine(res, {\n id: baseId + '-4',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: hasCli ? '\u2705\\n' : '\u274C Not found\\n' }, finish_reason: null }],\n });\n\n hasWallet = hasCli ? await checkWallet() : false;\n sseLine(res, {\n id: baseId + '-5',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Checking wallet... ' }, finish_reason: null }],\n });\n sseLine(res, {\n id: baseId + '-6',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: hasWallet ? '\u2705\\n' : '\u274C Not found\\n' }, finish_reason: null }],\n });\n\n balanceSats = hasWallet ? await getWalletBalance() : null;\n sseLine(res, {\n id: baseId + '-7',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Fetching balance... ' }, finish_reason: null }],\n });\n\n if (balanceSats !== null) {\n balanceStr = (balanceSats / 100000000).toFixed(8) + ' BCH';\n sseLine(res, {\n id: baseId + '-8',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\u2705\\n\\n' }, finish_reason: null }],\n });\n } else {\n balanceStr = 'Unable to check (try restarting)';\n sseLine(res, {\n id: baseId + '-8',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\u274C\\n\\n' }, finish_reason: null }],\n });\n }\n }\n\n let promptHeader = isRenewal\n ? '\uD83D\uDCB3 Session Expired \u2014 Payment Required to Continue\\n\\n'\n : '\uD83D\uDCB3 Paytaca AI \u2014 Payment Required\\n\\n';\n \n sseLine(res, {\n id: baseId + '-9',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: promptHeader }, finish_reason: null }],\n });\n \n sseLine(res, {\n id: baseId + '-10',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Cost: ' + costPhp.toFixed(2) + ' PHP (~' + costBch + ' BCH)\\n' }, finish_reason: null }],\n });\n \n if (usingDefaultRate) {\n sseLine(res, {\n id: baseId + '-10b',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\u26A0\uFE0F Could not reach backend for live pricing. Using default rate.\\n' }, finish_reason: null }],\n });\n }\n \n if (isRenewal) {\n const unusedTokens = Math.max(0, tokenLimit - tokensUsed);\n sseLine(res, {\n id: baseId + '-11',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Previous Session: ' + tokensUsed.toLocaleString() + ' / ' + tokenLimit.toLocaleString() + ' tokens used\\n' }, finish_reason: null }],\n });\n sseLine(res, {\n id: baseId + '-12',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Unused Tokens Carried Over: +' + unusedTokens.toLocaleString() + '\\n' }, finish_reason: null }],\n });\n if (carryoverDeadline) {\n const minutesLeft = Math.max(0, Math.floor((new Date(carryoverDeadline) - Date.now()) / 60000));\n const timeStr = minutesLeft > 0\n ? minutesLeft + ' min' + (minutesLeft !== 1 ? 's' : '') + ' remaining'\n : 'expired \u2014 renew now to keep them';\n sseLine(res, {\n id: baseId + '-12b',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\u23F0 Carryover expires in ' + timeStr + '\\n' }, finish_reason: null }],\n });\n }\n }\n \n sseLine(res, {\n id: baseId + '-13',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Wallet Balance: ' + balanceStr + '\\n' }, finish_reason: null }],\n });\n \n if (balanceSats !== null) {\n const affordable = Math.floor(balanceSats / costSats);\n sseLine(res, {\n id: baseId + '-14',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'You could afford about ~' + affordable + ' sessions\\n\\n' }, finish_reason: null }],\n });\n }\n \n if (balanceSats !== null && balanceSats < costSats) {\n const addr = await getReceivingAddress();\n if (addr) {\n sseLine(res, {\n id: baseId + '-15',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\u26A0\uFE0F Insufficient balance for a session.\\nFund your wallet: ' + addr + '\\n\\n' }, finish_reason: null }],\n });\n }\n }\n \n sseLine(res, {\n id: baseId + '-16',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Approve payment? (yes/no)' }, finish_reason: 'stop' }],\n });\n \n sseLine(res, {\n id: baseId + '-17',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],\n usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n });\n \n sseDone(res);\n res.end();\n}\n\n// Forward request to Django and return response (buffered, for non-streaming)\nfunction forwardToDjango(req, body, callback) {\n const options = {\n hostname: DJANGO_HOST,\n port: DJANGO_PORT,\n path: req.url,\n method: req.method,\n headers: {\n 'Content-Type': req.headers['content-type'] || 'application/json',\n 'X-Wallet-Hash': req.headers['x-wallet-hash'] || '',\n 'Content-Length': Buffer.byteLength(body),\n },\n };\n\n const startTime = Date.now();\n log('forwardToDjango -> ' + options.method + ' ' + options.hostname + ':' + options.port + options.path);\n\n const djangoReq = http.request(options, (djangoRes) => {\n let responseBody = '';\n djangoRes.on('data', chunk => { responseBody += chunk; });\n djangoRes.on('end', () => {\n const elapsed = Date.now() - startTime;\n log('Django responded in ' + elapsed + 'ms: status=' + djangoRes.statusCode + ', bodyLen=' + responseBody.length);\n callback(null, djangoRes.statusCode, djangoRes.headers, responseBody);\n });\n });\n\n djangoReq.setTimeout(30000, () => {\n djangoReq.destroy();\n callback(new Error('Django request timed out after 30s'));\n });\n\n djangoReq.on('error', (err) => {\n log('Django request error: ' + err.message);\n callback(err);\n });\n\n djangoReq.write(body);\n djangoReq.end();\n}\n\n// Forward streaming request to Django\nfunction forwardStreaming(req, res, body, callback) {\n const options = {\n hostname: DJANGO_HOST,\n port: DJANGO_PORT,\n path: req.url,\n method: req.method,\n headers: {\n 'Content-Type': req.headers['content-type'] || 'application/json',\n 'X-Wallet-Hash': req.headers['x-wallet-hash'] || '',\n 'Content-Length': Buffer.byteLength(body),\n },\n };\n\n const startTime = Date.now();\n log('forwardStreaming -> ' + options.method + ' ' + options.hostname + ':' + options.port + options.path);\n\n const djangoReq = http.request(options, (djangoRes) => {\n const elapsed = Date.now() - startTime;\n log('Django response started in ' + elapsed + 'ms: status=' + djangoRes.statusCode);\n\n if (djangoRes.statusCode === 402) {\n let responseBody = '';\n djangoRes.on('data', chunk => { responseBody += chunk; });\n djangoRes.on('end', () => {\n callback(null, 402, djangoRes.headers, responseBody);\n });\n return;\n }\n\n res.writeHead(djangoRes.statusCode, {\n 'Content-Type': djangoRes.headers['content-type'] || 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n });\n\n djangoRes.pipe(res);\n \n res.on('close', () => {\n log('Client connection closed');\n });\n \n djangoRes.on('end', () => {\n callback(null, djangoRes.statusCode, {}, '');\n });\n });\n\n djangoReq.setTimeout(30000, () => {\n djangoReq.destroy();\n callback(new Error('Django streaming request timed out after 30s'));\n });\n\n djangoReq.on('error', (err) => {\n log('Django streaming request error: ' + err.message);\n callback(err);\n });\n\n djangoReq.write(body);\n djangoReq.end();\n}\n\n// Force stream=false in body because paytaca pay reads the response as text\nfunction forceNonStreaming(body) {\n try {\n const data = JSON.parse(body);\n data.stream = false;\n return JSON.stringify(data);\n } catch {\n return body;\n }\n}\n\n// Convert a chat.completion JSON object to SSE format\nfunction jsonToSse(res, chatCompletion) {\n const content = chatCompletion.choices?.[0]?.message?.content || '';\n const model = chatCompletion.model || 'deepseek-ai/DeepSeek-V4-Flash';\n const created = chatCompletion.created || Math.floor(Date.now() / 1000);\n\n\n try {\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n });\n } catch (e) {\n return;\n }\n\n try {\n sseLine(res, {\n id: 'chatcmpl-1',\n object: 'chat.completion.chunk',\n created,\n model,\n choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],\n });\n } catch (e) {\n }\n\n const chunkSize = 20;\n let chunksWritten = 0;\n for (let i = 0; i < content.length; i += chunkSize) {\n try {\n sseLine(res, {\n id: 'chatcmpl-' + (i + 2),\n object: 'chat.completion.chunk',\n created,\n model,\n choices: [{ index: 0, delta: { content: content.slice(i, i + chunkSize) }, finish_reason: null }],\n });\n chunksWritten++;\n } catch (e) {\n break;\n }\n }\n\n try {\n sseLine(res, {\n id: 'chatcmpl-done',\n object: 'chat.completion.chunk',\n created,\n model,\n choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],\n usage: chatCompletion.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n });\n } catch (e) {\n }\n\n try {\n sseDone(res);\n } catch (e) {\n }\n\n try {\n res.end();\n } catch (e) {\n }\n}\n\n// Run paytaca pay internally and return the response\nfunction runPaytacaPay(djangoUrl, body, walletHash, callback) {\n const url = djangoUrl + '/chat/completions?wallet_hash=' + encodeURIComponent(walletHash || '');\n const payBody = forceNonStreaming(body);\n\n // Write body to a temp file to avoid CLI arg length limits\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paytaca-pay-'));\n const bodyFile = path.join(tmpDir, 'body.json');\n const configFile = path.join(tmpDir, 'config.json');\n\n try {\n fs.writeFileSync(bodyFile, payBody, 'utf8');\n } catch (err) {\n return callback(new Error('Failed to write temp body file: ' + err.message));\n }\n\n const config = {\n url,\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n bodyFile,\n confirmed: true,\n };\n\n try {\n fs.writeFileSync(configFile, JSON.stringify(config), 'utf8');\n } catch (err) {\n return callback(new Error('Failed to write temp config file: ' + err.message));\n }\n\n // Path to the wrapper script\n const wrapperScript = path.join(LOG_DIR, 'paytaca-pay-wrapper.mjs');\n log('Running paytaca pay via wrapper script...');\n\n const child = spawn('node', [wrapperScript, configFile], { shell: false });\n let stdout = '';\n let stderr = '';\n\n child.stdout.on('data', (data) => { \n stdout += data.toString(); \n });\n child.stderr.on('data', (data) => { \n stderr += data.toString(); \n });\n\n child.on('close', (code) => {\n // Clean up temp files\n try {\n fs.unlinkSync(bodyFile);\n fs.unlinkSync(configFile);\n fs.rmdirSync(tmpDir);\n } catch {}\n\n if (code === 0) {\n try {\n const responseJson = JSON.parse(stdout.trim());\n callback(null, responseJson);\n } catch (err) {\n callback(new Error('Could not parse paytaca pay response: ' + err.message));\n }\n } else {\n callback(new Error(stderr.trim() || 'paytaca pay wrapper exited with code ' + code));\n }\n });\n\n child.on('error', (err) => {\n // Clean up temp files on error\n try {\n fs.unlinkSync(bodyFile);\n fs.unlinkSync(configFile);\n fs.rmdirSync(tmpDir);\n } catch {}\n callback(new Error('Failed to run paytaca pay wrapper: ' + err.message));\n });\n}\n\n// Extract the last user message content from a chat payload\nfunction getLastUserMessageContent(body) {\n try {\n const data = JSON.parse(body);\n const messages = data.messages || [];\n for (let i = messages.length - 1; i >= 0; i--) {\n if (messages[i].role === 'user') {\n const content = messages[i].content;\n if (Array.isArray(content)) {\n const parts = [];\n for (const part of content) {\n if (part && typeof part === 'object' && part.type === 'text') {\n parts.push(part.text || '');\n } else if (typeof part === 'string') {\n parts.push(part);\n } else {\n parts.push(JSON.stringify(part));\n }\n }\n return parts.join('').trim().toLowerCase();\n }\n return String(content || '').trim().toLowerCase();\n }\n }\n return '';\n } catch {\n return '';\n }\n}\n\n// Main proxy server\nconst server = http.createServer(async (req, res) => {\n // Enable CORS\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Wallet-Hash, Authorization');\n \n if (req.method === 'OPTIONS') {\n res.writeHead(200);\n res.end();\n return;\n }\n \n // Discovery endpoint - fetch from backend to get actual config\n if (req.url === '/v1/config' && req.method === 'GET') {\n try {\n const backendConfig = await fetch(BACKEND_URL + '/v1/config');\n if (backendConfig.ok) {\n const config = await backendConfig.json();\n // Add proxy-specific info\n config.proxy_url = 'http://localhost:' + PROXY_PORT + '/v1';\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify(config));\n return;\n }\n } catch (err) {\n log('Failed to fetch backend config: ' + err.message);\n }\n \n // Fallback to static values if backend unavailable\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({\n proxy_url: 'http://localhost:' + PROXY_PORT + '/v1',\n django_url: BACKEND_URL + '/v1',\n cost_sats: 6000,\n cost_bch: '0.00006',\n payment_address: '',\n session_duration_minutes: 5,\n token_limit: 50000,\n context_retention_hours: 2,\n }));\n return;\n }\n \n // All other endpoints \u2014 read body and forward to Django\n let body = '';\n req.on('data', chunk => { body += chunk; });\n req.on('end', async () => {\n try {\n const walletHash = req.headers['x-wallet-hash'];\n const lastContent = getLastUserMessageContent(body);\n \n // DEBUG: Log full body and parsed content\n \n log('Request received: wallet=' + (walletHash?.substring(0, 16) || 'none') + '..., bodyLen=' + body.length + ', pending=' + pendingPayments.has(walletHash));\n \n // Guard: wallet hash is required for payment flow\n if (!walletHash) {\n res.writeHead(400, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'X-Wallet-Hash header required' }));\n return;\n }\n \n // Check if there's a pending payment for this wallet\n const pendingPayload = pendingPayments.get(walletHash);\n \n \n if (pendingPayload) {\n // User responded to a payment prompt\n if (lastContent === 'yes') {\n // User approved \u2014 run paytaca pay with the stored original payload\n log('Payment approved by wallet ' + walletHash?.substring(0, 16) + '...');\n pendingPayments.delete(walletHash);\n \n runPaytacaPay(BACKEND_URL + '/v1', pendingPayload, walletHash, (err, responseJson) => {\n if (err) {\n log('paytaca pay failed: ' + err.message);\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ \n error: 'Payment failed', \n message: err.message,\n details: 'Please check your wallet balance and try again.'\n }));\n return;\n }\n \n \n // Check if the response indicates success\n if (!responseJson.success) {\n res.writeHead(responseJson.status || 500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ \n error: 'Payment failed', \n message: responseJson.error,\n details: 'Payment was not successful. Please check your balance and try again.'\n }));\n return;\n }\n \n const chatCompletion = responseJson?.data || responseJson;\n if (chatCompletion.choices) {\n }\n \n let wasStreaming = false;\n try {\n wasStreaming = JSON.parse(pendingPayload).stream === true;\n } catch {}\n \n log('paytaca pay succeeded. Returning chat response.');\n \n if (wasStreaming) {\n try {\n jsonToSse(res, chatCompletion);\n } catch (e) {\n }\n } else {\n try {\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify(chatCompletion));\n } catch (e) {\n }\n }\n });\n return;\n \n } else if (lastContent === 'no') {\n // User declined\n log('Payment declined by wallet ' + walletHash?.substring(0, 16) + '...');\n pendingPayments.delete(walletHash);\n\n const addr = await getReceivingAddress();\n const fundMsg = addr\n ? 'Fund your wallet:\\n' + addr + '\\n\\nOr run: paytaca receive --no-qr'\n : 'You can fund your wallet by running: paytaca receive --no-qr';\n\n const declineCompletion = {\n id: 'payment-declined',\n object: 'chat.completion',\n created: Math.floor(Date.now() / 1000),\n model: 'deepseek-ai/DeepSeek-V4-Flash',\n choices: [{\n index: 0,\n message: {\n role: 'assistant',\n content: 'Payment declined. Chat cannot continue without funding.\\n\\n' + fundMsg,\n },\n finish_reason: 'stop',\n }],\n usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n };\n jsonToSse(res, declineCompletion);\n return;\n \n } else {\n log('New message while payment pending for wallet ' + walletHash?.substring(0, 16) + '...');\n }\n }\n \n let isStreaming = true;\n try { isStreaming = JSON.parse(body).stream !== false; } catch {}\n\n const handleResponse = async (err, statusCode, headers, responseBody) => {\n if (err) {\n if (!res.headersSent) {\n log('Django connection error: ' + err.message);\n res.writeHead(502, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Backend unavailable', details: err.message }));\n }\n return;\n }\n\n if (statusCode === 402) {\n log('402 intercepted for wallet ' + walletHash?.substring(0, 16) + '...');\n pendingPayments.set(walletHash, body);\n \n // Check session status to determine if this is a renewal\n let isRenewal = false;\n let tokensUsed = 0;\n let tokenLimit = 50000;\n let carryoverDeadline = null;\n \n try {\n const statusResponse = await new Promise((resolve, reject) => {\n const statusReq = http.get({\n hostname: DJANGO_HOST,\n port: DJANGO_PORT,\n path: '/v1/wallet/status',\n headers: { 'X-Wallet-Hash': walletHash }\n }, (res) => {\n let data = '';\n res.on('data', chunk => data += chunk);\n res.on('end', () => {\n try {\n resolve(JSON.parse(data));\n } catch {\n resolve({});\n }\n });\n });\n statusReq.on('error', reject);\n statusReq.setTimeout(5000, () => reject(new Error('timeout')));\n });\n \n if (statusResponse) {\n tokensUsed = statusResponse.tokens_used || 0;\n tokenLimit = statusResponse.token_limit || 50000;\n carryoverDeadline = statusResponse.carryover_deadline || null;\n \n const hasExpiredSession = !statusResponse.session_active && tokensUsed > 0;\n const carryoverStillValid = (statusResponse.carryover_remaining_minutes || 0) > 0;\n \n // Renewal if: (1) session active but tokens exhausted, OR (2) session expired with valid carryover\n isRenewal = (statusResponse.session_active && tokensUsed >= tokenLimit) ||\n (hasExpiredSession && carryoverStillValid);\n }\n } catch (err) {\n log('Failed to check session status: ' + err.message);\n }\n \n await streamPaymentPrompt(res, walletHash, isRenewal, tokensUsed, tokenLimit, carryoverDeadline);\n } else {\n if (res.headersSent) {\n log('Streaming response completed and already sent');\n pendingPayments.delete(walletHash);\n return;\n }\n\n log('Forwarding normal response to OpenCode: status=' + statusCode + ', bodyLen=' + responseBody.length);\n pendingPayments.delete(walletHash);\n res.writeHead(statusCode, {\n 'Content-Type': headers['content-type'] || 'application/json',\n });\n res.end(responseBody);\n }\n };\n\n if (isStreaming) {\n forwardStreaming(req, res, body, handleResponse);\n } else {\n forwardToDjango(req, body, handleResponse);\n }\n \n } catch (err) {\n log('Error: ' + err.message);\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Internal proxy error' }));\n }\n });\n});\n\nserver.on('error', (err) => {\n if (err.code === 'EADDRINUSE') {\n log('Port ' + PROXY_PORT + ' is already in use. Another proxy instance may be running.');\n log('Exiting cleanly (code 0) so the plugin can detect the existing proxy.');\n process.exit(0);\n }\n log('Server error: ' + err.message);\n process.exit(1);\n});\n\nserver.listen(PROXY_PORT, () => {\n log('Paytaca AI Proxy running on http://localhost:' + PROXY_PORT);\n log('Forwarding to Django at ' + BACKEND_URL);\n log('Discovery: http://localhost:' + PROXY_PORT + '/v1/config');\n log('Managed by OpenCode plugin');\n});\n\n// Start heartbeat checker after server is created\nheartbeatChecker = setInterval(() => {\n if (!checkHeartbeat()) {\n clearInterval(heartbeatChecker);\n log('Closing server due to missing heartbeat');\n server.close(() => {\n process.exit(0);\n });\n // Force exit after 2 seconds if graceful shutdown fails\n setTimeout(() => process.exit(0), 2000);\n }\n}, 5000);\n\n// Graceful shutdown\nprocess.on('SIGTERM', () => {\n log('Shutting down proxy...');\n server.close(() => process.exit(0));\n});\n\nprocess.on('SIGINT', () => {\n log('Shutting down proxy...');\n server.close(() => process.exit(0));\n});\n";
1
+ export declare const PROXY_SCRIPT_CONTENT = "#!/usr/bin/env node\n/**\n * Paytaca AI Proxy\n * \n * Sits between OpenCode and the Django backend.\n * - Auto-starts by OpenCode plugin\n * - On 402, returns SSE typewriter loading sequence + synthetic payment prompt\n * - Stores pending payments; handles \"yes\"/\"no\" approval internally\n * - Uses only Node.js built-in modules\n * \n * Usage: node proxy.js [backend_url] [proxy_port]\n * Example: node proxy.js https://api.paytaca.ai 8001\n */\n\nconst http = require('http');\nconst https = require('https');\nconst { spawn } = require('child_process');\nconst { Transform } = require('stream');\nconst fs = require('fs');\nconst path = require('path');\nconst os = require('os');\n\nconst PROXY_PORT = parseInt(process.argv[3]) || 8001;\nconst BACKEND_URL = process.argv[2] || 'https://api.paytaca.ai';\nconst parsedUrl = new URL(BACKEND_URL);\nconst DJANGO_HOST = parsedUrl.hostname;\nconst DJANGO_PORT = parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80);\nconst REQUester = parsedUrl.protocol === 'https:' ? https : http;\n\n// Logging setup: write to file instead of console\nconst LOG_DIR = path.join(os.homedir(), '.opencode-paytaca');\nif (!fs.existsSync(LOG_DIR)) {\n fs.mkdirSync(LOG_DIR, { recursive: true });\n}\nconst LOG_FILE = path.join(LOG_DIR, 'proxy.log');\nconst logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });\n\nfunction log(message) {\n const timestamp = new Date().toISOString();\n logStream.write(timestamp + ' [Proxy] ' + message + '\\n');\n}\n\n// Heartbeat monitoring - proxy exits if heartbeat is stale\nconst HEARTBEAT_FILE = path.join(LOG_DIR, 'heartbeat');\nconst HEARTBEAT_TIMEOUT = 15000; // 15 seconds\n\nfunction checkHeartbeat() {\n try {\n if (!fs.existsSync(HEARTBEAT_FILE)) {\n // No heartbeat file yet, wait a bit\n return true;\n }\n const heartbeat = parseInt(fs.readFileSync(HEARTBEAT_FILE, 'utf8'));\n if (heartbeat === 0) {\n // Special value: plugin is stopping\n log('Heartbeat = 0, shutting down...');\n return false;\n }\n const elapsed = Date.now() - heartbeat;\n if (elapsed > HEARTBEAT_TIMEOUT) {\n log('Heartbeat stale (' + elapsed + 'ms), shutting down...');\n return false;\n }\n return true;\n } catch (err) {\n // If we can't read heartbeat, keep running (graceful degradation)\n return true;\n }\n}\n\n// Heartbeat checker reference (will be started after server creation)\nlet heartbeatChecker = null;\n\n// Store pending payment requests per wallet hash\nconst pendingPayments = new Map();\n\n// Utility: run shell command and return output\nfunction runCommand(cmd, args = []) {\n return new Promise((resolve, reject) => {\n const child = spawn(cmd, args, { shell: false });\n let stdout = '';\n let stderr = '';\n \n child.stdout.on('data', (data) => { stdout += data.toString(); });\n child.stderr.on('data', (data) => { stderr += data.toString(); });\n \n child.on('close', (code) => {\n if (code === 0) resolve(stdout.trim());\n else reject(new Error(stderr.trim() || 'Command exited with code ' + code));\n });\n \n child.on('error', (err) => reject(err));\n });\n}\n\n// Get paytaca command from environment or default to 'paytaca'\nconst PAYTACA_CMD = process.env.PAYTACA_CMD || 'paytaca';\n\n// Utility: check if paytaca CLI exists\nasync function checkPaytacaCli() {\n try {\n // Try to run version check\n await runCommand(PAYTACA_CMD, ['--version']);\n return true;\n } catch {\n return false;\n }\n}\n\n// Utility: get wallet balance in sats\nasync function getWalletBalance() {\n try {\n const output = await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n const match = output.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);\n if (match) {\n const bch = parseFloat(match[1]);\n return Math.floor(bch * 100000000);\n }\n return null;\n } catch (err) {\n log('Failed to get wallet balance: ' + err.message);\n return null;\n }\n}\n\n// Utility: get receiving address\nasync function getReceivingAddress() {\n try {\n const output = await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n const match = output.match(/Address:\\s*(bitcoincash:[a-zA-Z0-9]+)/i);\n return match ? match[1] : null;\n } catch {\n return null;\n }\n}\n\n// Utility: check if wallet exists\nasync function checkWallet() {\n try {\n await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n return true;\n } catch {\n return false;\n }\n}\n\n// SSE helper: write a data line\nfunction sseLine(res, data) {\n res.write('data: ' + JSON.stringify(data) + '\\n\\n');\n}\n\n// SSE helper: write [DONE]\nfunction sseDone(res) {\n res.write('data: [DONE]\\n\\n');\n}\n\n// Build and stream SSE loading sequence + payment prompt\nasync function streamPaymentPrompt(res, walletHash, isRenewal = false, tokensUsed = 0, tokenLimit = 50000, carryoverDeadline = null) {\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'X-Payment-Required': 'true',\n 'Connection': 'keep-alive',\n });\n\n // Fetch dynamic pricing from backend config\n let costPhp = 10.00;\n let costBch = '0.00080000';\n let costSats = 80000;\n let usingDefaultRate = false;\n \n try {\n const configRes = await fetch(BACKEND_URL + '/v1/config');\n if (configRes.ok) {\n const config = await configRes.json();\n costPhp = config.cost_php || 10.00;\n costBch = config.cost_bch || '0.00080000';\n costSats = config.cost_sats || 80000;\n }\n } catch (e) {\n // Backend unreachable \u2014 will warn user below\n usingDefaultRate = true;\n }\n \n const baseId = isRenewal ? 'renewal' : 'payment';\n\n sseLine(res, {\n id: baseId + '-1',\n object: 'chat.completion.chunk',\n created: Math.floor(Date.now() / 1000),\n model: 'deepseek-ai/DeepSeek-V4-Flash',\n choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],\n });\n\n let balanceStr;\n let hasCli, hasWallet, balanceSats;\n\n if (isRenewal) {\n // For renewals, skip the full loading sequence and fetch balance quietly\n hasCli = await checkPaytacaCli();\n hasWallet = hasCli ? await checkWallet() : false;\n balanceSats = hasWallet ? await getWalletBalance() : null;\n if (balanceSats !== null) {\n balanceStr = (balanceSats / 100000000).toFixed(8) + ' BCH';\n } else {\n balanceStr = 'Unable to check (try restarting)';\n }\n } else {\n // First-time users: show full loading sequence\n sseLine(res, {\n id: baseId + '-2',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\u23F3 Initializing Paytaca AI provider...\\n' }, finish_reason: null }],\n });\n\n hasCli = await checkPaytacaCli();\n sseLine(res, {\n id: baseId + '-3',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Checking Paytaca CLI... ' }, finish_reason: null }],\n });\n sseLine(res, {\n id: baseId + '-4',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: hasCli ? '\u2705\\n' : '\u274C Not found\\n' }, finish_reason: null }],\n });\n\n hasWallet = hasCli ? await checkWallet() : false;\n sseLine(res, {\n id: baseId + '-5',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Checking wallet... ' }, finish_reason: null }],\n });\n sseLine(res, {\n id: baseId + '-6',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: hasWallet ? '\u2705\\n' : '\u274C Not found\\n' }, finish_reason: null }],\n });\n\n balanceSats = hasWallet ? await getWalletBalance() : null;\n sseLine(res, {\n id: baseId + '-7',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Fetching balance... ' }, finish_reason: null }],\n });\n\n if (balanceSats !== null) {\n balanceStr = (balanceSats / 100000000).toFixed(8) + ' BCH';\n sseLine(res, {\n id: baseId + '-8',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\u2705\\n\\n' }, finish_reason: null }],\n });\n } else {\n balanceStr = 'Unable to check (try restarting)';\n sseLine(res, {\n id: baseId + '-8',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\u274C\\n\\n' }, finish_reason: null }],\n });\n }\n }\n\n let promptHeader = isRenewal\n ? '\uD83D\uDCB3 Session Expired \u2014 Payment Required to Continue\\n\\n'\n : '\uD83D\uDCB3 Paytaca AI \u2014 Payment Required\\n\\n';\n \n sseLine(res, {\n id: baseId + '-9',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: promptHeader }, finish_reason: null }],\n });\n \n sseLine(res, {\n id: baseId + '-10',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Cost: ' + costPhp.toFixed(2) + ' PHP (~' + costBch + ' BCH)\\n' }, finish_reason: null }],\n });\n \n if (usingDefaultRate) {\n sseLine(res, {\n id: baseId + '-10b',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\u26A0\uFE0F Could not reach backend for live pricing. Using default rate.\\n' }, finish_reason: null }],\n });\n }\n \n if (isRenewal) {\n const unusedTokens = Math.max(0, tokenLimit - tokensUsed);\n sseLine(res, {\n id: baseId + '-11',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Previous Session: ' + tokensUsed.toLocaleString() + ' / ' + tokenLimit.toLocaleString() + ' tokens used\\n' }, finish_reason: null }],\n });\n sseLine(res, {\n id: baseId + '-12',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Unused Tokens Carried Over: +' + unusedTokens.toLocaleString() + '\\n' }, finish_reason: null }],\n });\n if (carryoverDeadline) {\n const minutesLeft = Math.max(0, Math.floor((new Date(carryoverDeadline) - Date.now()) / 60000));\n const timeStr = minutesLeft > 0\n ? minutesLeft + ' min' + (minutesLeft !== 1 ? 's' : '') + ' remaining'\n : 'expired \u2014 renew now to keep them';\n sseLine(res, {\n id: baseId + '-12b',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\u23F0 Carryover expires in ' + timeStr + '\\n' }, finish_reason: null }],\n });\n }\n }\n \n sseLine(res, {\n id: baseId + '-13',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Wallet Balance: ' + balanceStr + '\\n' }, finish_reason: null }],\n });\n \n if (balanceSats !== null) {\n const affordable = Math.floor(balanceSats / costSats);\n sseLine(res, {\n id: baseId + '-14',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'You could afford about ~' + affordable + ' sessions\\n\\n' }, finish_reason: null }],\n });\n }\n \n if (balanceSats !== null && balanceSats < costSats) {\n const addr = await getReceivingAddress();\n if (addr) {\n sseLine(res, {\n id: baseId + '-15',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\u26A0\uFE0F Insufficient balance for a session.\\nFund your wallet: ' + addr + '\\n\\n' }, finish_reason: null }],\n });\n }\n }\n \n sseLine(res, {\n id: baseId + '-16',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Approve payment? (yes/no)' }, finish_reason: 'stop' }],\n });\n \n sseLine(res, {\n id: baseId + '-17',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],\n usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n });\n \n sseDone(res);\n res.end();\n}\n\n// Forward request to Django and return response (buffered, for non-streaming)\nfunction forwardToDjango(req, body, callback) {\n const options = {\n hostname: DJANGO_HOST,\n port: DJANGO_PORT,\n path: req.url,\n method: req.method,\n headers: {\n 'Content-Type': req.headers['content-type'] || 'application/json',\n 'X-Wallet-Hash': req.headers['x-wallet-hash'] || '',\n 'Content-Length': Buffer.byteLength(body),\n },\n };\n\n const startTime = Date.now();\n log('forwardToDjango -> ' + options.method + ' ' + options.hostname + ':' + options.port + options.path);\n\n const djangoReq = REQUester.request(options, (djangoRes) => {\n let responseBody = '';\n djangoRes.on('data', chunk => { responseBody += chunk; });\n djangoRes.on('end', () => {\n const elapsed = Date.now() - startTime;\n log('Django responded in ' + elapsed + 'ms: status=' + djangoRes.statusCode + ', bodyLen=' + responseBody.length);\n callback(null, djangoRes.statusCode, djangoRes.headers, responseBody);\n });\n });\n\n djangoReq.setTimeout(30000, () => {\n djangoReq.destroy();\n callback(new Error('Django request timed out after 30s'));\n });\n\n djangoReq.on('error', (err) => {\n log('Django request error: ' + err.message);\n callback(err);\n });\n\n djangoReq.write(body);\n djangoReq.end();\n}\n\n// Forward streaming request to Django\nfunction forwardStreaming(req, res, body, callback) {\n const options = {\n hostname: DJANGO_HOST,\n port: DJANGO_PORT,\n path: req.url,\n method: req.method,\n headers: {\n 'Content-Type': req.headers['content-type'] || 'application/json',\n 'X-Wallet-Hash': req.headers['x-wallet-hash'] || '',\n 'Content-Length': Buffer.byteLength(body),\n },\n };\n\n const startTime = Date.now();\n log('forwardStreaming -> ' + options.method + ' ' + options.hostname + ':' + options.port + options.path);\n\n const djangoReq = REQUester.request(options, (djangoRes) => {\n const elapsed = Date.now() - startTime;\n log('Django response started in ' + elapsed + 'ms: status=' + djangoRes.statusCode);\n\n if (djangoRes.statusCode === 402) {\n let responseBody = '';\n djangoRes.on('data', chunk => { responseBody += chunk; });\n djangoRes.on('end', () => {\n callback(null, 402, djangoRes.headers, responseBody);\n });\n return;\n }\n\n res.writeHead(djangoRes.statusCode, {\n 'Content-Type': djangoRes.headers['content-type'] || 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n });\n\n djangoRes.pipe(res);\n \n res.on('close', () => {\n log('Client connection closed');\n });\n \n djangoRes.on('end', () => {\n callback(null, djangoRes.statusCode, {}, '');\n });\n });\n\n djangoReq.setTimeout(30000, () => {\n djangoReq.destroy();\n callback(new Error('Django streaming request timed out after 30s'));\n });\n\n djangoReq.on('error', (err) => {\n log('Django streaming request error: ' + err.message);\n callback(err);\n });\n\n djangoReq.write(body);\n djangoReq.end();\n}\n\n// Force stream=false in body because paytaca pay reads the response as text\nfunction forceNonStreaming(body) {\n try {\n const data = JSON.parse(body);\n data.stream = false;\n return JSON.stringify(data);\n } catch {\n return body;\n }\n}\n\n// Convert a chat.completion JSON object to SSE format\nfunction jsonToSse(res, chatCompletion) {\n const content = chatCompletion.choices?.[0]?.message?.content || '';\n const model = chatCompletion.model || 'deepseek-ai/DeepSeek-V4-Flash';\n const created = chatCompletion.created || Math.floor(Date.now() / 1000);\n\n\n try {\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n });\n } catch (e) {\n return;\n }\n\n try {\n sseLine(res, {\n id: 'chatcmpl-1',\n object: 'chat.completion.chunk',\n created,\n model,\n choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],\n });\n } catch (e) {\n }\n\n const chunkSize = 20;\n let chunksWritten = 0;\n for (let i = 0; i < content.length; i += chunkSize) {\n try {\n sseLine(res, {\n id: 'chatcmpl-' + (i + 2),\n object: 'chat.completion.chunk',\n created,\n model,\n choices: [{ index: 0, delta: { content: content.slice(i, i + chunkSize) }, finish_reason: null }],\n });\n chunksWritten++;\n } catch (e) {\n break;\n }\n }\n\n try {\n sseLine(res, {\n id: 'chatcmpl-done',\n object: 'chat.completion.chunk',\n created,\n model,\n choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],\n usage: chatCompletion.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n });\n } catch (e) {\n }\n\n try {\n sseDone(res);\n } catch (e) {\n }\n\n try {\n res.end();\n } catch (e) {\n }\n}\n\n// Run paytaca pay internally and return the response\nfunction runPaytacaPay(djangoUrl, body, walletHash, callback) {\n const url = djangoUrl + '/chat/completions?wallet_hash=' + encodeURIComponent(walletHash || '');\n const payBody = forceNonStreaming(body);\n\n // Write body to a temp file to avoid CLI arg length limits\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paytaca-pay-'));\n const bodyFile = path.join(tmpDir, 'body.json');\n const configFile = path.join(tmpDir, 'config.json');\n\n try {\n fs.writeFileSync(bodyFile, payBody, 'utf8');\n } catch (err) {\n return callback(new Error('Failed to write temp body file: ' + err.message));\n }\n\n const config = {\n url,\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n bodyFile,\n confirmed: true,\n };\n\n try {\n fs.writeFileSync(configFile, JSON.stringify(config), 'utf8');\n } catch (err) {\n return callback(new Error('Failed to write temp config file: ' + err.message));\n }\n\n // Path to the wrapper script\n const wrapperScript = path.join(LOG_DIR, 'paytaca-pay-wrapper.mjs');\n log('Running paytaca pay via wrapper script...');\n\n const child = spawn('node', [wrapperScript, configFile], { shell: false });\n let stdout = '';\n let stderr = '';\n\n child.stdout.on('data', (data) => { \n stdout += data.toString(); \n });\n child.stderr.on('data', (data) => { \n stderr += data.toString(); \n });\n\n child.on('close', (code) => {\n // Clean up temp files\n try {\n fs.unlinkSync(bodyFile);\n fs.unlinkSync(configFile);\n fs.rmdirSync(tmpDir);\n } catch {}\n\n if (code === 0) {\n try {\n const responseJson = JSON.parse(stdout.trim());\n callback(null, responseJson);\n } catch (err) {\n callback(new Error('Could not parse paytaca pay response: ' + err.message));\n }\n } else {\n callback(new Error(stderr.trim() || 'paytaca pay wrapper exited with code ' + code));\n }\n });\n\n child.on('error', (err) => {\n // Clean up temp files on error\n try {\n fs.unlinkSync(bodyFile);\n fs.unlinkSync(configFile);\n fs.rmdirSync(tmpDir);\n } catch {}\n callback(new Error('Failed to run paytaca pay wrapper: ' + err.message));\n });\n}\n\n// Extract the last user message content from a chat payload\nfunction getLastUserMessageContent(body) {\n try {\n const data = JSON.parse(body);\n const messages = data.messages || [];\n for (let i = messages.length - 1; i >= 0; i--) {\n if (messages[i].role === 'user') {\n const content = messages[i].content;\n if (Array.isArray(content)) {\n const parts = [];\n for (const part of content) {\n if (part && typeof part === 'object' && part.type === 'text') {\n parts.push(part.text || '');\n } else if (typeof part === 'string') {\n parts.push(part);\n } else {\n parts.push(JSON.stringify(part));\n }\n }\n return parts.join('').trim().toLowerCase();\n }\n return String(content || '').trim().toLowerCase();\n }\n }\n return '';\n } catch {\n return '';\n }\n}\n\n// Main proxy server\nconst server = http.createServer(async (req, res) => {\n // Enable CORS\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Wallet-Hash, Authorization');\n \n if (req.method === 'OPTIONS') {\n res.writeHead(200);\n res.end();\n return;\n }\n \n // Discovery endpoint - fetch from backend to get actual config\n if (req.url === '/v1/config' && req.method === 'GET') {\n try {\n const backendConfig = await fetch(BACKEND_URL + '/v1/config');\n if (backendConfig.ok) {\n const config = await backendConfig.json();\n // Add proxy-specific info\n config.proxy_url = 'http://localhost:' + PROXY_PORT + '/v1';\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify(config));\n return;\n }\n } catch (err) {\n log('Failed to fetch backend config: ' + err.message);\n }\n \n // Fallback to static values if backend unavailable\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({\n proxy_url: 'http://localhost:' + PROXY_PORT + '/v1',\n django_url: BACKEND_URL + '/v1',\n cost_sats: 6000,\n cost_bch: '0.00006',\n payment_address: '',\n session_duration_minutes: 5,\n token_limit: 50000,\n context_retention_hours: 2,\n }));\n return;\n }\n \n // All other endpoints \u2014 read body and forward to Django\n let body = '';\n req.on('data', chunk => { body += chunk; });\n req.on('end', async () => {\n try {\n const walletHash = req.headers['x-wallet-hash'];\n const lastContent = getLastUserMessageContent(body);\n \n // DEBUG: Log full body and parsed content\n \n log('Request received: wallet=' + (walletHash?.substring(0, 16) || 'none') + '..., bodyLen=' + body.length + ', pending=' + pendingPayments.has(walletHash));\n \n // Guard: wallet hash is required for payment flow\n if (!walletHash) {\n res.writeHead(400, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'X-Wallet-Hash header required' }));\n return;\n }\n \n // Check if there's a pending payment for this wallet\n const pendingPayload = pendingPayments.get(walletHash);\n \n \n if (pendingPayload) {\n // User responded to a payment prompt\n if (lastContent === 'yes') {\n // User approved \u2014 run paytaca pay with the stored original payload\n log('Payment approved by wallet ' + walletHash?.substring(0, 16) + '...');\n pendingPayments.delete(walletHash);\n \n runPaytacaPay(BACKEND_URL + '/v1', pendingPayload, walletHash, (err, responseJson) => {\n if (err) {\n log('paytaca pay failed: ' + err.message);\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ \n error: 'Payment failed', \n message: err.message,\n details: 'Please check your wallet balance and try again.'\n }));\n return;\n }\n \n \n // Check if the response indicates success\n if (!responseJson.success) {\n res.writeHead(responseJson.status || 500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ \n error: 'Payment failed', \n message: responseJson.error,\n details: 'Payment was not successful. Please check your balance and try again.'\n }));\n return;\n }\n \n const chatCompletion = responseJson?.data || responseJson;\n if (chatCompletion.choices) {\n }\n \n let wasStreaming = false;\n try {\n wasStreaming = JSON.parse(pendingPayload).stream === true;\n } catch {}\n \n log('paytaca pay succeeded. Returning chat response.');\n \n if (wasStreaming) {\n try {\n jsonToSse(res, chatCompletion);\n } catch (e) {\n }\n } else {\n try {\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify(chatCompletion));\n } catch (e) {\n }\n }\n });\n return;\n \n } else if (lastContent === 'no') {\n // User declined\n log('Payment declined by wallet ' + walletHash?.substring(0, 16) + '...');\n pendingPayments.delete(walletHash);\n\n const addr = await getReceivingAddress();\n const fundMsg = addr\n ? 'Fund your wallet:\\n' + addr + '\\n\\nOr run: paytaca receive --no-qr'\n : 'You can fund your wallet by running: paytaca receive --no-qr';\n\n const declineCompletion = {\n id: 'payment-declined',\n object: 'chat.completion',\n created: Math.floor(Date.now() / 1000),\n model: 'deepseek-ai/DeepSeek-V4-Flash',\n choices: [{\n index: 0,\n message: {\n role: 'assistant',\n content: 'Payment declined. Chat cannot continue without funding.\\n\\n' + fundMsg,\n },\n finish_reason: 'stop',\n }],\n usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n };\n jsonToSse(res, declineCompletion);\n return;\n \n } else {\n log('New message while payment pending for wallet ' + walletHash?.substring(0, 16) + '...');\n }\n }\n \n let isStreaming = true;\n try { isStreaming = JSON.parse(body).stream !== false; } catch {}\n\n const handleResponse = async (err, statusCode, headers, responseBody) => {\n if (err) {\n if (!res.headersSent) {\n log('Django connection error: ' + err.message);\n res.writeHead(502, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Backend unavailable', details: err.message }));\n }\n return;\n }\n\n if (statusCode === 402) {\n log('402 intercepted for wallet ' + walletHash?.substring(0, 16) + '...');\n pendingPayments.set(walletHash, body);\n \n // Check session status to determine if this is a renewal\n let isRenewal = false;\n let tokensUsed = 0;\n let tokenLimit = 50000;\n let carryoverDeadline = null;\n \n try {\n const statusResponse = await new Promise((resolve, reject) => {\n const statusReq = REQUester.get({\n hostname: DJANGO_HOST,\n port: DJANGO_PORT,\n path: '/v1/wallet/status',\n headers: { 'X-Wallet-Hash': walletHash }\n }, (res) => {\n let data = '';\n res.on('data', chunk => data += chunk);\n res.on('end', () => {\n try {\n resolve(JSON.parse(data));\n } catch {\n resolve({});\n }\n });\n });\n statusReq.on('error', reject);\n statusReq.setTimeout(5000, () => reject(new Error('timeout')));\n });\n \n if (statusResponse) {\n tokensUsed = statusResponse.tokens_used || 0;\n tokenLimit = statusResponse.token_limit || 50000;\n carryoverDeadline = statusResponse.carryover_deadline || null;\n \n const hasExpiredSession = !statusResponse.session_active && tokensUsed > 0;\n const carryoverStillValid = (statusResponse.carryover_remaining_minutes || 0) > 0;\n \n // Renewal if: (1) session active but tokens exhausted, OR (2) session expired with valid carryover\n isRenewal = (statusResponse.session_active && tokensUsed >= tokenLimit) ||\n (hasExpiredSession && carryoverStillValid);\n }\n } catch (err) {\n log('Failed to check session status: ' + err.message);\n }\n \n await streamPaymentPrompt(res, walletHash, isRenewal, tokensUsed, tokenLimit, carryoverDeadline);\n } else {\n if (res.headersSent) {\n log('Streaming response completed and already sent');\n pendingPayments.delete(walletHash);\n return;\n }\n\n log('Forwarding normal response to OpenCode: status=' + statusCode + ', bodyLen=' + responseBody.length);\n pendingPayments.delete(walletHash);\n res.writeHead(statusCode, {\n 'Content-Type': headers['content-type'] || 'application/json',\n });\n res.end(responseBody);\n }\n };\n\n if (isStreaming) {\n forwardStreaming(req, res, body, handleResponse);\n } else {\n forwardToDjango(req, body, handleResponse);\n }\n \n } catch (err) {\n log('Error: ' + err.message);\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Internal proxy error' }));\n }\n });\n});\n\nserver.on('error', (err) => {\n if (err.code === 'EADDRINUSE') {\n log('Port ' + PROXY_PORT + ' is already in use. Another proxy instance may be running.');\n log('Exiting cleanly (code 0) so the plugin can detect the existing proxy.');\n process.exit(0);\n }\n log('Server error: ' + err.message);\n process.exit(1);\n});\n\nserver.listen(PROXY_PORT, () => {\n log('Paytaca AI Proxy running on http://localhost:' + PROXY_PORT);\n log('Forwarding to Django at ' + BACKEND_URL);\n log('Discovery: http://localhost:' + PROXY_PORT + '/v1/config');\n log('Managed by OpenCode plugin');\n});\n\n// Start heartbeat checker after server is created\nheartbeatChecker = setInterval(() => {\n if (!checkHeartbeat()) {\n clearInterval(heartbeatChecker);\n log('Closing server due to missing heartbeat');\n server.close(() => {\n process.exit(0);\n });\n // Force exit after 2 seconds if graceful shutdown fails\n setTimeout(() => process.exit(0), 2000);\n }\n}, 5000);\n\n// Graceful shutdown\nprocess.on('SIGTERM', () => {\n log('Shutting down proxy...');\n server.close(() => process.exit(0));\n});\n\nprocess.on('SIGINT', () => {\n log('Shutting down proxy...');\n server.close(() => process.exit(0));\n});\n";
2
2
  //# sourceMappingURL=proxy.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,oBAAoB,ow8BAi6BhC,CAAC"}
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,oBAAoB,u38BAm6BhC,CAAC"}
@@ -18,6 +18,7 @@ exports.PROXY_SCRIPT_CONTENT = `#!/usr/bin/env node
18
18
  */
19
19
 
20
20
  const http = require('http');
21
+ const https = require('https');
21
22
  const { spawn } = require('child_process');
22
23
  const { Transform } = require('stream');
23
24
  const fs = require('fs');
@@ -29,6 +30,7 @@ const BACKEND_URL = process.argv[2] || 'https://api.paytaca.ai';
29
30
  const parsedUrl = new URL(BACKEND_URL);
30
31
  const DJANGO_HOST = parsedUrl.hostname;
31
32
  const DJANGO_PORT = parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80);
33
+ const REQUester = parsedUrl.protocol === 'https:' ? https : http;
32
34
 
33
35
  // Logging setup: write to file instead of console
34
36
  const LOG_DIR = path.join(os.homedir(), '.opencode-paytaca');
@@ -373,7 +375,7 @@ function forwardToDjango(req, body, callback) {
373
375
  const startTime = Date.now();
374
376
  log('forwardToDjango -> ' + options.method + ' ' + options.hostname + ':' + options.port + options.path);
375
377
 
376
- const djangoReq = http.request(options, (djangoRes) => {
378
+ const djangoReq = REQUester.request(options, (djangoRes) => {
377
379
  let responseBody = '';
378
380
  djangoRes.on('data', chunk => { responseBody += chunk; });
379
381
  djangoRes.on('end', () => {
@@ -414,7 +416,7 @@ function forwardStreaming(req, res, body, callback) {
414
416
  const startTime = Date.now();
415
417
  log('forwardStreaming -> ' + options.method + ' ' + options.hostname + ':' + options.port + options.path);
416
418
 
417
- const djangoReq = http.request(options, (djangoRes) => {
419
+ const djangoReq = REQUester.request(options, (djangoRes) => {
418
420
  const elapsed = Date.now() - startTime;
419
421
  log('Django response started in ' + elapsed + 'ms: status=' + djangoRes.statusCode);
420
422
 
@@ -825,7 +827,7 @@ const server = http.createServer(async (req, res) => {
825
827
 
826
828
  try {
827
829
  const statusResponse = await new Promise((resolve, reject) => {
828
- const statusReq = http.get({
830
+ const statusReq = REQUester.get({
829
831
  hostname: DJANGO_HOST,
830
832
  port: DJANGO_PORT,
831
833
  path: '/v1/wallet/status',
@@ -1 +1 @@
1
- {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":";AAAA,0DAA0D;AAC1D,6DAA6D;;;AAEhD,QAAA,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAi6BnC,CAAC"}
1
+ {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":";AAAA,0DAA0D;AAC1D,6DAA6D;;;AAEhD,QAAA,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAm6BnC,CAAC"}
package/dist/index.d.ts CHANGED
@@ -1,41 +1,13 @@
1
- export default function OpencodePlugin(): Promise<{
2
- connect: {
3
- types: {
4
- name: string;
5
- description: string;
6
- connect(): Promise<{
7
- error: string;
8
- }>;
9
- }[];
10
- };
11
- onExit?: undefined;
1
+ declare function OpencodePlugin(_input?: any, _options?: any): Promise<{
2
+ config?: undefined;
3
+ "chat.headers"?: undefined;
12
4
  } | {
13
- connect: {
14
- types: {
15
- name: string;
16
- description: string;
17
- connect(): Promise<{
18
- provider: {
19
- 'paytaca-ai': {
20
- npm: string;
21
- name: string;
22
- options: {
23
- baseURL: string;
24
- };
25
- models: {
26
- 'deepseek-ai/DeepSeek-V4-Flash': {
27
- name: string;
28
- limit: {
29
- context: number;
30
- output: number;
31
- };
32
- };
33
- };
34
- };
35
- };
36
- }>;
37
- }[];
38
- };
39
- onExit(): Promise<void>;
5
+ config: (cfg: any) => Promise<void>;
6
+ "chat.headers": (_input: any, output: any) => Promise<void>;
40
7
  }>;
8
+ declare const _default: {
9
+ id: string;
10
+ server: typeof OpencodePlugin;
11
+ };
12
+ export = _default;
41
13
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,90 +1,62 @@
1
1
  "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.default = OpencodePlugin;
4
2
  const config_1 = require("./config");
5
3
  const wallet_1 = require("./wallet");
6
4
  const proxy_1 = require("./proxy");
7
- async function OpencodePlugin() {
5
+ async function OpencodePlugin(_input, _options) {
8
6
  const configDir = (0, config_1.getConfigDir)();
9
7
  (0, config_1.ensureConfigDir)(configDir);
10
8
  let config = (0, config_1.loadConfig)(configDir);
11
9
  // Check if paytaca-cli is installed
12
10
  const hasPaytacaCli = await (0, wallet_1.checkPaytacaCli)();
13
11
  if (!hasPaytacaCli) {
14
- return {
15
- connect: {
16
- types: [{
17
- name: 'paytaca-ai',
18
- description: 'Connect to Paytaca AI (requires paytaca-cli)',
19
- async connect() {
20
- return {
21
- error: `paytaca-cli could not be found. This is unexpected — try reinstalling opencode-plugin:\n\n npm install @paytaca/opencode-plugin\n\nIf the issue persists, run:\n\n npm install -g paytaca-cli`
22
- };
23
- }
24
- }]
25
- }
26
- };
12
+ console.error('paytaca-cli not found. Install it with: npm install -g paytaca-cli');
13
+ return {};
27
14
  }
28
15
  // Ensure wallet exists (auto-create if needed)
29
16
  try {
30
17
  const wallet = await (0, wallet_1.ensureWallet)();
31
18
  // Save wallet hash to config
32
- if (wallet.hash && wallet.hash !== config.walletHash) {
19
+ if (wallet.hash) {
33
20
  config.walletHash = wallet.hash;
34
21
  (0, config_1.saveConfig)(configDir, config);
35
22
  }
36
23
  }
37
24
  catch (err) {
38
25
  console.error('Wallet setup failed:', err.message);
39
- return {
40
- connect: {
41
- types: [{
42
- name: 'paytaca-ai',
43
- description: 'Connect to Paytaca AI (requires wallet setup)',
44
- async connect() {
45
- return {
46
- error: 'Wallet setup required. Please run: paytaca wallet create (for new) or paytaca wallet import (for existing)'
47
- };
48
- }
49
- }]
50
- }
51
- };
26
+ return {};
52
27
  }
53
28
  // Start or reuse proxy
54
29
  const proxy = await (0, proxy_1.startProxy)(configDir, config);
55
30
  return {
56
- // Simple return - just connection type and exit handler
57
- connect: {
58
- types: [{
59
- name: 'paytaca-ai',
60
- description: 'Connect to Paytaca AI (AI inference powered by Bitcoin Cash micropayments)',
61
- async connect() {
62
- return {
63
- provider: {
64
- 'paytaca-ai': {
65
- npm: '@ai-sdk/openai-compatible',
66
- name: 'Paytaca AI',
67
- options: {
68
- baseURL: `http://localhost:${proxy.port}/v1`
69
- },
70
- models: {
71
- 'deepseek-ai/DeepSeek-V4-Flash': {
72
- name: 'DeepSeek V4 Flash',
73
- limit: {
74
- context: 128000,
75
- output: 8192
76
- }
77
- }
78
- }
79
- }
80
- }
81
- };
31
+ config: async (cfg) => {
32
+ cfg.provider = cfg.provider || {};
33
+ cfg.provider['paytaca-ai'] = {
34
+ npm: '@ai-sdk/openai-compatible',
35
+ name: 'Paytaca AI',
36
+ options: {
37
+ baseURL: `http://localhost:${proxy.port}/v1`
38
+ },
39
+ models: {
40
+ 'deepseek-ai/DeepSeek-V4-Flash': {
41
+ name: 'DeepSeek V4 Flash',
42
+ limit: {
43
+ context: 128000,
44
+ output: 8192
45
+ }
82
46
  }
83
- }]
47
+ }
48
+ };
84
49
  },
85
- async onExit() {
86
- await (0, proxy_1.stopProxy)(configDir);
50
+ "chat.headers": async (_input, output) => {
51
+ let wallet = await (0, wallet_1.checkWallet)();
52
+ if (!wallet.exists) {
53
+ wallet = await (0, wallet_1.ensureWallet)();
54
+ }
55
+ output.headers = {
56
+ 'X-Wallet-Hash': wallet.hash || ''
57
+ };
87
58
  }
88
59
  };
89
60
  }
61
+ module.exports = { id: '@paytaca/opencode-plugin', server: OpencodePlugin };
90
62
  //# sourceMappingURL=index.js.map
package/dist/proxy.js CHANGED
@@ -156,11 +156,10 @@ async function startProxy(configDir, config) {
156
156
  }
157
157
  // Write PID file
158
158
  fs.writeFileSync(pidFile, proxy.pid.toString());
159
- // Update config - new proxy
160
- const updatedConfig = (0, config_1.loadConfig)(configDir);
161
- updatedConfig.proxyPort = port;
162
- updatedConfig.proxyPid = proxy.pid;
163
- (0, config_1.saveConfig)(configDir, updatedConfig);
159
+ // Update config - new proxy (preserve wallet hash from passed config)
160
+ config.proxyPort = port;
161
+ config.proxyPid = proxy.pid;
162
+ (0, config_1.saveConfig)(configDir, config);
164
163
  // Initialize heartbeat file
165
164
  const heartbeatFile = (0, config_1.getHeartbeatFile)(configDir);
166
165
  fs.writeFileSync(heartbeatFile, Date.now().toString());
package/dist/wallet.js CHANGED
@@ -41,14 +41,14 @@ exports.importWallet = importWallet;
41
41
  exports.extractWalletHash = extractWalletHash;
42
42
  exports.getReceivingAddress = getReceivingAddress;
43
43
  exports.getWalletBalance = getWalletBalance;
44
+ const fs = __importStar(require("fs"));
44
45
  const util_1 = require("util");
45
46
  const path = __importStar(require("path"));
46
47
  const execAsync = (0, util_1.promisify)(require('child_process').exec);
47
48
  // Get the path to paytaca binary (local or global)
48
49
  function getPaytacaCommand() {
49
- // When installed as dependency, paytaca-cli will be in our node_modules
50
50
  const localPaytaca = path.join(__dirname, '..', 'node_modules', '.bin', 'paytaca');
51
- return localPaytaca;
51
+ return fs.existsSync(localPaytaca) ? localPaytaca : 'paytaca';
52
52
  }
53
53
  const PAYTACA_CMD = getPaytacaCommand();
54
54
  async function checkWallet() {
@@ -56,13 +56,16 @@ async function checkWallet() {
56
56
  const { stdout } = await execAsync(`"${PAYTACA_CMD}" wallet info`);
57
57
  const output = stdout.toString();
58
58
  // Extract wallet hash
59
- const hashMatch = output.match(/Wallet hash:\\s*(.+)/i);
59
+ const hashMatch = output.match(/Wallet hash:\s*(.+)/i);
60
60
  const hash = hashMatch ? hashMatch[1].trim() : undefined;
61
+ if (!hash) {
62
+ return { exists: false };
63
+ }
61
64
  // Extract address
62
- const addressMatch = output.match(/Address:\\s*(.+)/i);
65
+ const addressMatch = output.match(/Address:\s*(.+)/i);
63
66
  const address = addressMatch ? addressMatch[1].trim() : undefined;
64
67
  // Extract balance
65
- const balanceMatch = output.match(/Balance:\\s*(.+)/i);
68
+ const balanceMatch = output.match(/Balance:\s*(.+)/i);
66
69
  const balance = balanceMatch ? balanceMatch[1].trim() : undefined;
67
70
  return {
68
71
  exists: true,
@@ -126,9 +129,9 @@ async function createWallet() {
126
129
  // Fall through to regex parsing
127
130
  }
128
131
  // Parse from text output
129
- const hashMatch = output.match(/Wallet hash:\\s*(.+)/i);
130
- const addressMatch = output.match(/Address:\\s*(.+)/i);
131
- const mnemonicMatch = output.match(/Recovery phrase[\\s\\S]*?([a-z]+(?:\\s+[a-z]+){11,23})/i);
132
+ const hashMatch = output.match(/Wallet hash:\s*(.+)/i);
133
+ const addressMatch = output.match(/Address:\s*(.+)/i);
134
+ const mnemonicMatch = output.match(/Recovery phrase[\s\S]*?([a-z]+(?:\s+[a-z]+){11,23})/i);
132
135
  if (mnemonicMatch) {
133
136
  console.log('\\n⚠️ IMPORTANT: Your wallet has been created!');
134
137
  console.log('\\nSAVE THIS RECOVERY PHRASE SECURELY:');
@@ -179,8 +182,8 @@ async function importWallet(mnemonic) {
179
182
  // Import wallet using provided mnemonic
180
183
  const { stdout } = await execAsync(`echo "${mnemonic}" | "${PAYTACA_CMD}" wallet import --stdin`);
181
184
  const output = stdout.toString();
182
- const hashMatch = output.match(/Wallet hash:\\s*(.+)/i);
183
- const addressMatch = output.match(/Address:\\s*(.+)/i);
185
+ const hashMatch = output.match(/Wallet hash:\s*(.+)/i);
186
+ const addressMatch = output.match(/Address:\s*(.+)/i);
184
187
  return {
185
188
  exists: true,
186
189
  hash: hashMatch ? hashMatch[1].trim() : undefined,
@@ -196,14 +199,14 @@ async function importWallet(mnemonic) {
196
199
  }
197
200
  }
198
201
  function extractWalletHash(output) {
199
- const match = output.match(/Wallet hash:\\s*(.+)/i);
202
+ const match = output.match(/Wallet hash:\s*(.+)/i);
200
203
  return match ? match[1].trim() : undefined;
201
204
  }
202
205
  async function getReceivingAddress() {
203
206
  try {
204
207
  const { stdout } = await execAsync(`"${PAYTACA_CMD}" wallet info`);
205
208
  const output = stdout.toString();
206
- const addressMatch = output.match(/Address:\\s*(.+)/i);
209
+ const addressMatch = output.match(/Address:\s*(.+)/i);
207
210
  return addressMatch ? addressMatch[1].trim() : null;
208
211
  }
209
212
  catch {
@@ -214,7 +217,7 @@ async function getWalletBalance() {
214
217
  try {
215
218
  const { stdout } = await execAsync(`"${PAYTACA_CMD}" wallet info`);
216
219
  const output = stdout.toString();
217
- const match = output.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);
220
+ const match = output.match(/Balance:\s*([\d.]+)\s*BCH/i);
218
221
  if (match) {
219
222
  const bch = parseFloat(match[1]);
220
223
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paytaca/opencode-plugin",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "OpenCode plugin for Paytaca AI - AI inference provider powered by Bitcoin Cash micropayments",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -25,7 +25,7 @@
25
25
  "author": "Paytaca",
26
26
  "license": "MIT",
27
27
  "dependencies": {
28
- "@opencode-ai/plugin": "^1.16.0",
28
+ "@opencode-ai/plugin": "^1.17.8",
29
29
  "paytaca-cli": "^0.3.2"
30
30
  },
31
31
  "devDependencies": {
@@ -16,6 +16,7 @@ export const PROXY_SCRIPT_CONTENT = `#!/usr/bin/env node
16
16
  */
17
17
 
18
18
  const http = require('http');
19
+ const https = require('https');
19
20
  const { spawn } = require('child_process');
20
21
  const { Transform } = require('stream');
21
22
  const fs = require('fs');
@@ -27,6 +28,7 @@ const BACKEND_URL = process.argv[2] || 'https://api.paytaca.ai';
27
28
  const parsedUrl = new URL(BACKEND_URL);
28
29
  const DJANGO_HOST = parsedUrl.hostname;
29
30
  const DJANGO_PORT = parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80);
31
+ const REQUester = parsedUrl.protocol === 'https:' ? https : http;
30
32
 
31
33
  // Logging setup: write to file instead of console
32
34
  const LOG_DIR = path.join(os.homedir(), '.opencode-paytaca');
@@ -371,7 +373,7 @@ function forwardToDjango(req, body, callback) {
371
373
  const startTime = Date.now();
372
374
  log('forwardToDjango -> ' + options.method + ' ' + options.hostname + ':' + options.port + options.path);
373
375
 
374
- const djangoReq = http.request(options, (djangoRes) => {
376
+ const djangoReq = REQUester.request(options, (djangoRes) => {
375
377
  let responseBody = '';
376
378
  djangoRes.on('data', chunk => { responseBody += chunk; });
377
379
  djangoRes.on('end', () => {
@@ -412,7 +414,7 @@ function forwardStreaming(req, res, body, callback) {
412
414
  const startTime = Date.now();
413
415
  log('forwardStreaming -> ' + options.method + ' ' + options.hostname + ':' + options.port + options.path);
414
416
 
415
- const djangoReq = http.request(options, (djangoRes) => {
417
+ const djangoReq = REQUester.request(options, (djangoRes) => {
416
418
  const elapsed = Date.now() - startTime;
417
419
  log('Django response started in ' + elapsed + 'ms: status=' + djangoRes.statusCode);
418
420
 
@@ -823,7 +825,7 @@ const server = http.createServer(async (req, res) => {
823
825
 
824
826
  try {
825
827
  const statusResponse = await new Promise((resolve, reject) => {
826
- const statusReq = http.get({
828
+ const statusReq = REQUester.get({
827
829
  hostname: DJANGO_HOST,
828
830
  port: DJANGO_PORT,
829
831
  path: '/v1/wallet/status',
package/src/index.ts CHANGED
@@ -1,92 +1,67 @@
1
1
  import { ensureConfigDir, getConfigDir, loadConfig, saveConfig } from './config';
2
- import { ensureWallet, checkWallet, checkPaytacaCli } from './wallet';
3
- import { startProxy, stopProxy } from './proxy';
2
+ import { checkWallet, ensureWallet, checkPaytacaCli } from './wallet';
3
+ import { startProxy } from './proxy';
4
4
 
5
- export default async function OpencodePlugin() {
5
+ async function OpencodePlugin(_input?: any, _options?: any) {
6
6
  const configDir = getConfigDir();
7
7
  ensureConfigDir(configDir);
8
-
8
+
9
9
  let config = loadConfig(configDir);
10
-
10
+
11
11
  // Check if paytaca-cli is installed
12
12
  const hasPaytacaCli = await checkPaytacaCli();
13
13
  if (!hasPaytacaCli) {
14
- return {
15
- connect: {
16
- types: [{
17
- name: 'paytaca-ai',
18
- description: 'Connect to Paytaca AI (requires paytaca-cli)',
19
- async connect() {
20
- return {
21
- error: `paytaca-cli could not be found. This is unexpected — try reinstalling opencode-plugin:\n\n npm install @paytaca/opencode-plugin\n\nIf the issue persists, run:\n\n npm install -g paytaca-cli`
22
- };
23
- }
24
- }]
25
- }
26
- };
14
+ console.error('paytaca-cli not found. Install it with: npm install -g paytaca-cli');
15
+ return {};
27
16
  }
28
-
17
+
29
18
  // Ensure wallet exists (auto-create if needed)
30
19
  try {
31
20
  const wallet = await ensureWallet();
32
-
21
+
33
22
  // Save wallet hash to config
34
- if (wallet.hash && wallet.hash !== config.walletHash) {
23
+ if (wallet.hash) {
35
24
  config.walletHash = wallet.hash;
36
25
  saveConfig(configDir, config);
37
26
  }
38
27
  } catch (err: any) {
39
28
  console.error('Wallet setup failed:', err.message);
40
- return {
41
- connect: {
42
- types: [{
43
- name: 'paytaca-ai',
44
- description: 'Connect to Paytaca AI (requires wallet setup)',
45
- async connect() {
46
- return {
47
- error: 'Wallet setup required. Please run: paytaca wallet create (for new) or paytaca wallet import (for existing)'
48
- };
49
- }
50
- }]
51
- }
52
- };
29
+ return {};
53
30
  }
54
-
31
+
55
32
  // Start or reuse proxy
56
33
  const proxy = await startProxy(configDir, config);
57
-
34
+
58
35
  return {
59
- // Simple return - just connection type and exit handler
60
- connect: {
61
- types: [{
62
- name: 'paytaca-ai',
63
- description: 'Connect to Paytaca AI (AI inference powered by Bitcoin Cash micropayments)',
64
- async connect() {
65
- return {
66
- provider: {
67
- 'paytaca-ai': {
68
- npm: '@ai-sdk/openai-compatible',
69
- name: 'Paytaca AI',
70
- options: {
71
- baseURL: `http://localhost:${proxy.port}/v1`
72
- },
73
- models: {
74
- 'deepseek-ai/DeepSeek-V4-Flash': {
75
- name: 'DeepSeek V4 Flash',
76
- limit: {
77
- context: 128000,
78
- output: 8192
79
- }
80
- }
81
- }
82
- }
36
+ config: async (cfg: any) => {
37
+ cfg.provider = cfg.provider || {};
38
+ cfg.provider['paytaca-ai'] = {
39
+ npm: '@ai-sdk/openai-compatible',
40
+ name: 'Paytaca AI',
41
+ options: {
42
+ baseURL: `http://localhost:${proxy.port}/v1`
43
+ },
44
+ models: {
45
+ 'deepseek-ai/DeepSeek-V4-Flash': {
46
+ name: 'DeepSeek V4 Flash',
47
+ limit: {
48
+ context: 128000,
49
+ output: 8192
83
50
  }
84
- };
51
+ }
85
52
  }
86
- }]
53
+ };
87
54
  },
88
- async onExit() {
89
- await stopProxy(configDir);
55
+ "chat.headers": async (_input: any, output: any) => {
56
+ let wallet = await checkWallet();
57
+ if (!wallet.exists) {
58
+ wallet = await ensureWallet();
59
+ }
60
+ output.headers = {
61
+ 'X-Wallet-Hash': wallet.hash || ''
62
+ };
90
63
  }
91
64
  };
92
65
  }
66
+
67
+ export = { id: '@paytaca/opencode-plugin', server: OpencodePlugin };
package/src/proxy.ts CHANGED
@@ -144,11 +144,10 @@ export async function startProxy(configDir: string, config: Config): Promise<Pro
144
144
  // Write PID file
145
145
  fs.writeFileSync(pidFile, proxy.pid.toString());
146
146
 
147
- // Update config - new proxy
148
- const updatedConfig = loadConfig(configDir);
149
- updatedConfig.proxyPort = port;
150
- updatedConfig.proxyPid = proxy.pid;
151
- saveConfig(configDir, updatedConfig);
147
+ // Update config - new proxy (preserve wallet hash from passed config)
148
+ config.proxyPort = port;
149
+ config.proxyPid = proxy.pid;
150
+ saveConfig(configDir, config);
152
151
 
153
152
  // Initialize heartbeat file
154
153
  const heartbeatFile = getHeartbeatFile(configDir);
package/src/wallet.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'child_process';
2
+ import * as fs from 'fs';
2
3
  import { promisify } from 'util';
3
4
  import * as path from 'path';
4
5
  import { WalletInfo } from './types';
@@ -7,9 +8,8 @@ const execAsync = promisify(require('child_process').exec);
7
8
 
8
9
  // Get the path to paytaca binary (local or global)
9
10
  function getPaytacaCommand(): string {
10
- // When installed as dependency, paytaca-cli will be in our node_modules
11
11
  const localPaytaca = path.join(__dirname, '..', 'node_modules', '.bin', 'paytaca');
12
- return localPaytaca;
12
+ return fs.existsSync(localPaytaca) ? localPaytaca : 'paytaca';
13
13
  }
14
14
 
15
15
  const PAYTACA_CMD = getPaytacaCommand();
@@ -20,15 +20,19 @@ export async function checkWallet(): Promise<WalletInfo> {
20
20
  const output = stdout.toString();
21
21
 
22
22
  // Extract wallet hash
23
- const hashMatch = output.match(/Wallet hash:\\s*(.+)/i);
23
+ const hashMatch = output.match(/Wallet hash:\s*(.+)/i);
24
24
  const hash = hashMatch ? hashMatch[1].trim() : undefined;
25
25
 
26
+ if (!hash) {
27
+ return { exists: false };
28
+ }
29
+
26
30
  // Extract address
27
- const addressMatch = output.match(/Address:\\s*(.+)/i);
31
+ const addressMatch = output.match(/Address:\s*(.+)/i);
28
32
  const address = addressMatch ? addressMatch[1].trim() : undefined;
29
33
 
30
34
  // Extract balance
31
- const balanceMatch = output.match(/Balance:\\s*(.+)/i);
35
+ const balanceMatch = output.match(/Balance:\s*(.+)/i);
32
36
  const balance = balanceMatch ? balanceMatch[1].trim() : undefined;
33
37
 
34
38
  return {
@@ -96,9 +100,9 @@ export async function createWallet(): Promise<WalletInfo> {
96
100
  }
97
101
 
98
102
  // Parse from text output
99
- const hashMatch = output.match(/Wallet hash:\\s*(.+)/i);
100
- const addressMatch = output.match(/Address:\\s*(.+)/i);
101
- const mnemonicMatch = output.match(/Recovery phrase[\\s\\S]*?([a-z]+(?:\\s+[a-z]+){11,23})/i);
103
+ const hashMatch = output.match(/Wallet hash:\s*(.+)/i);
104
+ const addressMatch = output.match(/Address:\s*(.+)/i);
105
+ const mnemonicMatch = output.match(/Recovery phrase[\s\S]*?([a-z]+(?:\s+[a-z]+){11,23})/i);
102
106
 
103
107
  if (mnemonicMatch) {
104
108
  console.log('\\n⚠️ IMPORTANT: Your wallet has been created!');
@@ -158,8 +162,8 @@ export async function importWallet(mnemonic: string): Promise<WalletInfo> {
158
162
  const { stdout } = await execAsync(`echo "${mnemonic}" | "${PAYTACA_CMD}" wallet import --stdin`);
159
163
  const output = stdout.toString();
160
164
 
161
- const hashMatch = output.match(/Wallet hash:\\s*(.+)/i);
162
- const addressMatch = output.match(/Address:\\s*(.+)/i);
165
+ const hashMatch = output.match(/Wallet hash:\s*(.+)/i);
166
+ const addressMatch = output.match(/Address:\s*(.+)/i);
163
167
 
164
168
  return {
165
169
  exists: true,
@@ -176,7 +180,7 @@ export async function importWallet(mnemonic: string): Promise<WalletInfo> {
176
180
  }
177
181
 
178
182
  export function extractWalletHash(output: string): string | undefined {
179
- const match = output.match(/Wallet hash:\\s*(.+)/i);
183
+ const match = output.match(/Wallet hash:\s*(.+)/i);
180
184
  return match ? match[1].trim() : undefined;
181
185
  }
182
186
 
@@ -184,7 +188,7 @@ export async function getReceivingAddress(): Promise<string | null> {
184
188
  try {
185
189
  const { stdout } = await execAsync(`"${PAYTACA_CMD}" wallet info`);
186
190
  const output = stdout.toString();
187
- const addressMatch = output.match(/Address:\\s*(.+)/i);
191
+ const addressMatch = output.match(/Address:\s*(.+)/i);
188
192
  return addressMatch ? addressMatch[1].trim() : null;
189
193
  } catch {
190
194
  return null;
@@ -195,7 +199,7 @@ export async function getWalletBalance(): Promise<{ bch: number; sats: number }
195
199
  try {
196
200
  const { stdout } = await execAsync(`"${PAYTACA_CMD}" wallet info`);
197
201
  const output = stdout.toString();
198
- const match = output.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);
202
+ const match = output.match(/Balance:\s*([\d.]+)\s*BCH/i);
199
203
  if (match) {
200
204
  const bch = parseFloat(match[1]);
201
205
  return {
Binary file