@paytaca/opencode-plugin 0.1.3 → 0.1.5

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/bin/paytaca.js CHANGED
File without changes
@@ -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 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";
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 + '\\nOr run: paytaca receive (in another terminal) for QR code\\n\\n' }, finish_reason: null }],\n });\n }\n }\n \n if (balanceSats === null || balanceSats > 0) {\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 \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: ' + addr\n : 'You can fund your wallet by running: paytaca receive';\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,u38BAm6BhC,CAAC"}
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,oBAAoB,g88BAq6BhC,CAAC"}
@@ -336,16 +336,18 @@ async function streamPaymentPrompt(res, walletHash, isRenewal = false, tokensUse
336
336
  sseLine(res, {
337
337
  id: baseId + '-15',
338
338
  object: 'chat.completion.chunk',
339
- choices: [{ index: 0, delta: { content: '⚠️ Insufficient balance for a session.\\nFund your wallet: ' + addr + '\\n\\n' }, finish_reason: null }],
339
+ choices: [{ index: 0, delta: { content: '⚠️ Insufficient balance for a session.\\nFund your wallet: ' + addr + '\\nOr run: paytaca receive (in another terminal) for QR code\\n\\n' }, finish_reason: null }],
340
340
  });
341
341
  }
342
342
  }
343
343
 
344
- sseLine(res, {
345
- id: baseId + '-16',
346
- object: 'chat.completion.chunk',
347
- choices: [{ index: 0, delta: { content: 'Approve payment? (yes/no)' }, finish_reason: 'stop' }],
348
- });
344
+ if (balanceSats === null || balanceSats > 0) {
345
+ sseLine(res, {
346
+ id: baseId + '-16',
347
+ object: 'chat.completion.chunk',
348
+ choices: [{ index: 0, delta: { content: 'Approve payment? (yes/no)' }, finish_reason: 'stop' }],
349
+ });
350
+ }
349
351
 
350
352
  sseLine(res, {
351
353
  id: baseId + '-17',
@@ -776,8 +778,8 @@ const server = http.createServer(async (req, res) => {
776
778
 
777
779
  const addr = await getReceivingAddress();
778
780
  const fundMsg = addr
779
- ? 'Fund your wallet:\\n' + addr + '\\n\\nOr run: paytaca receive --no-qr'
780
- : 'You can fund your wallet by running: paytaca receive --no-qr';
781
+ ? 'Fund your wallet: ' + addr
782
+ : 'You can fund your wallet by running: paytaca receive';
781
783
 
782
784
  const declineCompletion = {
783
785
  id: 'payment-declined',
@@ -1 +1 @@
1
- {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":";AAAA,0DAA0D;AAC1D,6DAA6D;;;AAEhD,QAAA,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAm6BnC,CAAC"}
1
+ {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":";AAAA,0DAA0D;AAC1D,6DAA6D;;;AAEhD,QAAA,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAq6BnC,CAAC"}
package/dist/index.js CHANGED
@@ -6,6 +6,8 @@ async function OpencodePlugin(_input, _options) {
6
6
  const configDir = (0, config_1.getConfigDir)();
7
7
  (0, config_1.ensureConfigDir)(configDir);
8
8
  let config = (0, config_1.loadConfig)(configDir);
9
+ // Ensure paytaca binary is on PATH for internal use
10
+ (0, wallet_1.ensurePaytacaOnPath)();
9
11
  // Check if paytaca-cli is installed
10
12
  const hasPaytacaCli = await (0, wallet_1.checkPaytacaCli)();
11
13
  if (!hasPaytacaCli) {
package/dist/proxy.js CHANGED
@@ -48,10 +48,55 @@ const proxy_1 = require("./bundled/proxy");
48
48
  const wrapper_1 = require("./bundled/wrapper");
49
49
  // Store heartbeat interval reference
50
50
  let heartbeatInterval = null;
51
- // Get path to local paytaca binary
51
+ // Get path to paytaca binary (multi-strategy resolution)
52
52
  function getPaytacaCommand() {
53
+ // Priority 1: Local node_modules (via require.resolve)
54
+ try {
55
+ const paytacaCliPkg = require.resolve('paytaca-cli/package.json');
56
+ return path.resolve(path.dirname(paytacaCliPkg), 'bin', 'paytaca.js');
57
+ }
58
+ catch { }
59
+ // Priority 2: Local .bin symlink
53
60
  const localPaytaca = path.join(__dirname, '..', 'node_modules', '.bin', 'paytaca');
54
- return fs.existsSync(localPaytaca) ? localPaytaca : 'paytaca';
61
+ if (fs.existsSync(localPaytaca)) {
62
+ return localPaytaca;
63
+ }
64
+ // Priority 3: Global npm root
65
+ try {
66
+ const globalRoot = (0, child_process_1.execSync)('npm root -g', { encoding: 'utf8' }).trim();
67
+ const pathsToCheck = [
68
+ path.join(globalRoot, 'paytaca-cli', 'bin', 'paytaca.js'),
69
+ path.join(globalRoot, '@paytaca', 'opencode-plugin', 'node_modules', 'paytaca-cli', 'bin', 'paytaca.js'),
70
+ ];
71
+ for (const p of pathsToCheck) {
72
+ if (fs.existsSync(p)) {
73
+ return p;
74
+ }
75
+ }
76
+ }
77
+ catch { }
78
+ // Priority 4: Common global installation paths
79
+ const commonPaths = [
80
+ '/usr/lib/node_modules/paytaca-cli/bin/paytaca.js',
81
+ '/usr/local/lib/node_modules/paytaca-cli/bin/paytaca.js',
82
+ '/opt/homebrew/lib/node_modules/paytaca-cli/bin/paytaca.js',
83
+ ];
84
+ for (const p of commonPaths) {
85
+ if (fs.existsSync(p)) {
86
+ return p;
87
+ }
88
+ }
89
+ // Priority 5: which/where on PATH
90
+ try {
91
+ const which = process.platform === 'win32' ? 'where' : 'which';
92
+ const result = (0, child_process_1.execSync)(`${which} paytaca`, { encoding: 'utf8' }).trim().split('\n')[0];
93
+ if (result) {
94
+ return result;
95
+ }
96
+ }
97
+ catch { }
98
+ // Priority 6: Bare command (rely on PATH at runtime)
99
+ return 'paytaca';
55
100
  }
56
101
  async function isPortAvailable(port) {
57
102
  return new Promise((resolve) => {
package/dist/wallet.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { WalletInfo } from './types';
2
2
  export declare function checkWallet(): Promise<WalletInfo>;
3
+ export declare function ensurePaytacaOnPath(): string | null;
3
4
  export declare function checkPaytacaCli(): Promise<boolean>;
4
5
  export declare function createWallet(): Promise<WalletInfo>;
5
6
  export declare function ensureWallet(): Promise<WalletInfo>;
package/dist/wallet.js CHANGED
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.checkWallet = checkWallet;
37
+ exports.ensurePaytacaOnPath = ensurePaytacaOnPath;
37
38
  exports.checkPaytacaCli = checkPaytacaCli;
38
39
  exports.createWallet = createWallet;
39
40
  exports.ensureWallet = ensureWallet;
@@ -41,20 +42,65 @@ exports.importWallet = importWallet;
41
42
  exports.extractWalletHash = extractWalletHash;
42
43
  exports.getReceivingAddress = getReceivingAddress;
43
44
  exports.getWalletBalance = getWalletBalance;
45
+ const child_process_1 = require("child_process");
44
46
  const fs = __importStar(require("fs"));
47
+ const os = __importStar(require("os"));
45
48
  const util_1 = require("util");
46
49
  const path = __importStar(require("path"));
47
50
  const execAsync = (0, util_1.promisify)(require('child_process').exec);
51
+ function getGlobalNpmRoot() {
52
+ try {
53
+ return (0, child_process_1.execSync)('npm root -g', { encoding: 'utf8' }).trim();
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
48
59
  function getPaytacaCommand() {
60
+ // Priority 1: Local node_modules (via require.resolve)
49
61
  try {
50
62
  const paytacaCliPkg = require.resolve('paytaca-cli/package.json');
51
63
  return path.resolve(path.dirname(paytacaCliPkg), 'bin', 'paytaca.js');
52
64
  }
53
65
  catch { }
66
+ // Priority 2: Local .bin symlink
54
67
  const localPaytaca = path.join(__dirname, '..', 'node_modules', '.bin', 'paytaca');
55
68
  if (fs.existsSync(localPaytaca)) {
56
69
  return localPaytaca;
57
70
  }
71
+ // Priority 3: Global npm root
72
+ const globalRoot = getGlobalNpmRoot();
73
+ if (globalRoot) {
74
+ const globalPaytaca = path.join(globalRoot, 'paytaca-cli', 'bin', 'paytaca.js');
75
+ if (fs.existsSync(globalPaytaca)) {
76
+ return globalPaytaca;
77
+ }
78
+ const scopedPaytaca = path.join(globalRoot, '@paytaca', 'opencode-plugin', 'node_modules', 'paytaca-cli', 'bin', 'paytaca.js');
79
+ if (fs.existsSync(scopedPaytaca)) {
80
+ return scopedPaytaca;
81
+ }
82
+ }
83
+ // Priority 4: Common global installation paths
84
+ const commonPaths = [
85
+ '/usr/lib/node_modules/paytaca-cli/bin/paytaca.js',
86
+ '/usr/local/lib/node_modules/paytaca-cli/bin/paytaca.js',
87
+ '/opt/homebrew/lib/node_modules/paytaca-cli/bin/paytaca.js',
88
+ ];
89
+ for (const p of commonPaths) {
90
+ if (fs.existsSync(p)) {
91
+ return p;
92
+ }
93
+ }
94
+ // Priority 5: which/where on PATH
95
+ try {
96
+ const which = process.platform === 'win32' ? 'where' : 'which';
97
+ const result = (0, child_process_1.execSync)(`${which} paytaca`, { encoding: 'utf8' }).trim().split('\n')[0];
98
+ if (result) {
99
+ return result;
100
+ }
101
+ }
102
+ catch { }
103
+ // Priority 6: Bare command (rely on PATH at runtime)
58
104
  return 'paytaca';
59
105
  }
60
106
  const PAYTACA_CMD = getPaytacaCommand();
@@ -87,6 +133,49 @@ async function checkWallet() {
87
133
  };
88
134
  }
89
135
  }
136
+ function addToPath(dir) {
137
+ if (dir && fs.existsSync(dir) && !process.env.PATH?.includes(dir)) {
138
+ process.env.PATH = `${dir}${path.delimiter}${process.env.PATH}`;
139
+ }
140
+ }
141
+ function ensurePaytacaOnPath() {
142
+ // Priority 1: Local node_modules .bin dir
143
+ try {
144
+ const paytacaCliPkg = require.resolve('paytaca-cli/package.json');
145
+ const binDir = path.resolve(path.dirname(paytacaCliPkg), '..', '.bin');
146
+ const paytacaBin = path.join(binDir, 'paytaca');
147
+ if (fs.existsSync(paytacaBin)) {
148
+ addToPath(binDir);
149
+ return binDir;
150
+ }
151
+ }
152
+ catch { }
153
+ // Priority 2: Global npm root .bin dir
154
+ const globalRoot = getGlobalNpmRoot();
155
+ if (globalRoot) {
156
+ const globalBinDir = path.resolve(globalRoot, '..', '.bin');
157
+ const globalPaytaca = path.join(globalBinDir, 'paytaca');
158
+ if (fs.existsSync(globalPaytaca)) {
159
+ addToPath(globalBinDir);
160
+ return globalBinDir;
161
+ }
162
+ }
163
+ // Priority 3: Common global bin directories
164
+ const commonBinDirs = [
165
+ '/usr/local/bin',
166
+ '/usr/bin',
167
+ path.join(os.homedir(), '.npm-global', 'bin'),
168
+ process.env.NVM_BIN,
169
+ ].filter((p) => !!p);
170
+ for (const binDir of commonBinDirs) {
171
+ const paytacaBin = path.join(binDir, 'paytaca');
172
+ if (fs.existsSync(paytacaBin)) {
173
+ addToPath(binDir);
174
+ return binDir;
175
+ }
176
+ }
177
+ return null;
178
+ }
90
179
  async function checkPaytacaCli() {
91
180
  try {
92
181
  await execAsync(`"${PAYTACA_CMD}" --version`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paytaca/opencode-plugin",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",
@@ -334,16 +334,18 @@ async function streamPaymentPrompt(res, walletHash, isRenewal = false, tokensUse
334
334
  sseLine(res, {
335
335
  id: baseId + '-15',
336
336
  object: 'chat.completion.chunk',
337
- choices: [{ index: 0, delta: { content: '⚠️ Insufficient balance for a session.\\nFund your wallet: ' + addr + '\\n\\n' }, finish_reason: null }],
337
+ choices: [{ index: 0, delta: { content: '⚠️ Insufficient balance for a session.\\nFund your wallet: ' + addr + '\\nOr run: paytaca receive (in another terminal) for QR code\\n\\n' }, finish_reason: null }],
338
338
  });
339
339
  }
340
340
  }
341
341
 
342
- sseLine(res, {
343
- id: baseId + '-16',
344
- object: 'chat.completion.chunk',
345
- choices: [{ index: 0, delta: { content: 'Approve payment? (yes/no)' }, finish_reason: 'stop' }],
346
- });
342
+ if (balanceSats === null || balanceSats > 0) {
343
+ sseLine(res, {
344
+ id: baseId + '-16',
345
+ object: 'chat.completion.chunk',
346
+ choices: [{ index: 0, delta: { content: 'Approve payment? (yes/no)' }, finish_reason: 'stop' }],
347
+ });
348
+ }
347
349
 
348
350
  sseLine(res, {
349
351
  id: baseId + '-17',
@@ -774,8 +776,8 @@ const server = http.createServer(async (req, res) => {
774
776
 
775
777
  const addr = await getReceivingAddress();
776
778
  const fundMsg = addr
777
- ? 'Fund your wallet:\\n' + addr + '\\n\\nOr run: paytaca receive --no-qr'
778
- : 'You can fund your wallet by running: paytaca receive --no-qr';
779
+ ? 'Fund your wallet: ' + addr
780
+ : 'You can fund your wallet by running: paytaca receive';
779
781
 
780
782
  const declineCompletion = {
781
783
  id: 'payment-declined',
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ensureConfigDir, getConfigDir, loadConfig, saveConfig } from './config';
2
- import { checkWallet, ensureWallet, checkPaytacaCli } from './wallet';
2
+ import { checkWallet, ensureWallet, checkPaytacaCli, ensurePaytacaOnPath } from './wallet';
3
3
  import { startProxy } from './proxy';
4
4
 
5
5
  async function OpencodePlugin(_input?: any, _options?: any) {
@@ -8,6 +8,9 @@ async function OpencodePlugin(_input?: any, _options?: any) {
8
8
 
9
9
  let config = loadConfig(configDir);
10
10
 
11
+ // Ensure paytaca binary is on PATH for internal use
12
+ ensurePaytacaOnPath();
13
+
11
14
  // Check if paytaca-cli is installed
12
15
  const hasPaytacaCli = await checkPaytacaCli();
13
16
  if (!hasPaytacaCli) {
package/src/proxy.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import { spawn } from 'child_process';
3
+ import { spawn, execSync } from 'child_process';
4
4
  import { Config, ProxyInfo } from './types';
5
5
  import {
6
6
  getConfigDir,
@@ -19,10 +19,57 @@ import { WRAPPER_SCRIPT_CONTENT } from './bundled/wrapper';
19
19
  // Store heartbeat interval reference
20
20
  let heartbeatInterval: NodeJS.Timeout | null = null;
21
21
 
22
- // Get path to local paytaca binary
22
+ // Get path to paytaca binary (multi-strategy resolution)
23
23
  function getPaytacaCommand(): string {
24
+ // Priority 1: Local node_modules (via require.resolve)
25
+ try {
26
+ const paytacaCliPkg = require.resolve('paytaca-cli/package.json');
27
+ return path.resolve(path.dirname(paytacaCliPkg), 'bin', 'paytaca.js');
28
+ } catch {}
29
+
30
+ // Priority 2: Local .bin symlink
24
31
  const localPaytaca = path.join(__dirname, '..', 'node_modules', '.bin', 'paytaca');
25
- return fs.existsSync(localPaytaca) ? localPaytaca : 'paytaca';
32
+ if (fs.existsSync(localPaytaca)) {
33
+ return localPaytaca;
34
+ }
35
+
36
+ // Priority 3: Global npm root
37
+ try {
38
+ const globalRoot = execSync('npm root -g', { encoding: 'utf8' }).trim();
39
+ const pathsToCheck = [
40
+ path.join(globalRoot, 'paytaca-cli', 'bin', 'paytaca.js'),
41
+ path.join(globalRoot, '@paytaca', 'opencode-plugin', 'node_modules', 'paytaca-cli', 'bin', 'paytaca.js'),
42
+ ];
43
+ for (const p of pathsToCheck) {
44
+ if (fs.existsSync(p)) {
45
+ return p;
46
+ }
47
+ }
48
+ } catch {}
49
+
50
+ // Priority 4: Common global installation paths
51
+ const commonPaths = [
52
+ '/usr/lib/node_modules/paytaca-cli/bin/paytaca.js',
53
+ '/usr/local/lib/node_modules/paytaca-cli/bin/paytaca.js',
54
+ '/opt/homebrew/lib/node_modules/paytaca-cli/bin/paytaca.js',
55
+ ];
56
+ for (const p of commonPaths) {
57
+ if (fs.existsSync(p)) {
58
+ return p;
59
+ }
60
+ }
61
+
62
+ // Priority 5: which/where on PATH
63
+ try {
64
+ const which = process.platform === 'win32' ? 'where' : 'which';
65
+ const result = execSync(`${which} paytaca`, { encoding: 'utf8' }).trim().split('\n')[0];
66
+ if (result) {
67
+ return result;
68
+ }
69
+ } catch {}
70
+
71
+ // Priority 6: Bare command (rely on PATH at runtime)
72
+ return 'paytaca';
26
73
  }
27
74
 
28
75
  export async function isPortAvailable(port: number): Promise<boolean> {
package/src/wallet.ts CHANGED
@@ -1,22 +1,68 @@
1
- import { spawn } from 'child_process';
1
+ import { spawn, execSync } from 'child_process';
2
2
  import * as fs from 'fs';
3
+ import * as os from 'os';
3
4
  import { promisify } from 'util';
4
5
  import * as path from 'path';
5
6
  import { WalletInfo } from './types';
6
7
 
7
8
  const execAsync = promisify(require('child_process').exec);
8
9
 
10
+ function getGlobalNpmRoot(): string | null {
11
+ try {
12
+ return execSync('npm root -g', { encoding: 'utf8' }).trim();
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
9
18
  function getPaytacaCommand(): string {
19
+ // Priority 1: Local node_modules (via require.resolve)
10
20
  try {
11
21
  const paytacaCliPkg = require.resolve('paytaca-cli/package.json');
12
22
  return path.resolve(path.dirname(paytacaCliPkg), 'bin', 'paytaca.js');
13
23
  } catch {}
14
24
 
25
+ // Priority 2: Local .bin symlink
15
26
  const localPaytaca = path.join(__dirname, '..', 'node_modules', '.bin', 'paytaca');
16
27
  if (fs.existsSync(localPaytaca)) {
17
28
  return localPaytaca;
18
29
  }
19
30
 
31
+ // Priority 3: Global npm root
32
+ const globalRoot = getGlobalNpmRoot();
33
+ if (globalRoot) {
34
+ const globalPaytaca = path.join(globalRoot, 'paytaca-cli', 'bin', 'paytaca.js');
35
+ if (fs.existsSync(globalPaytaca)) {
36
+ return globalPaytaca;
37
+ }
38
+ const scopedPaytaca = path.join(globalRoot, '@paytaca', 'opencode-plugin', 'node_modules', 'paytaca-cli', 'bin', 'paytaca.js');
39
+ if (fs.existsSync(scopedPaytaca)) {
40
+ return scopedPaytaca;
41
+ }
42
+ }
43
+
44
+ // Priority 4: Common global installation paths
45
+ const commonPaths = [
46
+ '/usr/lib/node_modules/paytaca-cli/bin/paytaca.js',
47
+ '/usr/local/lib/node_modules/paytaca-cli/bin/paytaca.js',
48
+ '/opt/homebrew/lib/node_modules/paytaca-cli/bin/paytaca.js',
49
+ ];
50
+ for (const p of commonPaths) {
51
+ if (fs.existsSync(p)) {
52
+ return p;
53
+ }
54
+ }
55
+
56
+ // Priority 5: which/where on PATH
57
+ try {
58
+ const which = process.platform === 'win32' ? 'where' : 'which';
59
+ const result = execSync(`${which} paytaca`, { encoding: 'utf8' }).trim().split('\n')[0];
60
+ if (result) {
61
+ return result;
62
+ }
63
+ } catch {}
64
+
65
+ // Priority 6: Bare command (rely on PATH at runtime)
20
66
  return 'paytaca';
21
67
  }
22
68
 
@@ -56,6 +102,54 @@ export async function checkWallet(): Promise<WalletInfo> {
56
102
  }
57
103
  }
58
104
 
105
+ function addToPath(dir: string): void {
106
+ if (dir && fs.existsSync(dir) && !process.env.PATH?.includes(dir)) {
107
+ process.env.PATH = `${dir}${path.delimiter}${process.env.PATH}`;
108
+ }
109
+ }
110
+
111
+ export function ensurePaytacaOnPath(): string | null {
112
+ // Priority 1: Local node_modules .bin dir
113
+ try {
114
+ const paytacaCliPkg = require.resolve('paytaca-cli/package.json');
115
+ const binDir = path.resolve(path.dirname(paytacaCliPkg), '..', '.bin');
116
+ const paytacaBin = path.join(binDir, 'paytaca');
117
+ if (fs.existsSync(paytacaBin)) {
118
+ addToPath(binDir);
119
+ return binDir;
120
+ }
121
+ } catch {}
122
+
123
+ // Priority 2: Global npm root .bin dir
124
+ const globalRoot = getGlobalNpmRoot();
125
+ if (globalRoot) {
126
+ const globalBinDir = path.resolve(globalRoot, '..', '.bin');
127
+ const globalPaytaca = path.join(globalBinDir, 'paytaca');
128
+ if (fs.existsSync(globalPaytaca)) {
129
+ addToPath(globalBinDir);
130
+ return globalBinDir;
131
+ }
132
+ }
133
+
134
+ // Priority 3: Common global bin directories
135
+ const commonBinDirs = [
136
+ '/usr/local/bin',
137
+ '/usr/bin',
138
+ path.join(os.homedir(), '.npm-global', 'bin'),
139
+ process.env.NVM_BIN,
140
+ ].filter((p): p is string => !!p);
141
+
142
+ for (const binDir of commonBinDirs) {
143
+ const paytacaBin = path.join(binDir, 'paytaca');
144
+ if (fs.existsSync(paytacaBin)) {
145
+ addToPath(binDir);
146
+ return binDir;
147
+ }
148
+ }
149
+
150
+ return null;
151
+ }
152
+
59
153
  export async function checkPaytacaCli(): Promise<boolean> {
60
154
  try {
61
155
  await execAsync(`"${PAYTACA_CMD}" --version`);
package/dist/package.json DELETED
@@ -1,43 +0,0 @@
1
- {
2
- "name": "@paytaca/opencode-plugin",
3
- "version": "0.1.0",
4
- "description": "OpenCode plugin for Paytaca AI - BCH micropayment provider for DeepSeek V4 Flash",
5
- "main": "index.js",
6
- "types": "index.d.ts",
7
- "engines": {
8
- "node": ">=20.0.0"
9
- },
10
- "scripts": {
11
- "build": "tsc",
12
- "prepare": "npm run build"
13
- },
14
- "keywords": [
15
- "opencode",
16
- "plugin",
17
- "paytaca",
18
- "bch",
19
- "ai",
20
- "provider"
21
- ],
22
- "author": "Paytaca",
23
- "license": "MIT",
24
- "dependencies": {
25
- "@opencode-ai/plugin": "^1.16.0",
26
- "paytaca-cli": "^0.3.2"
27
- },
28
- "devDependencies": {
29
- "@types/node": "^20.0.0",
30
- "typescript": "^5.0.0"
31
- },
32
- "peerDependencies": {
33
- "opencode": ">=1.0.0"
34
- },
35
- "peerDependenciesMeta": {
36
- "opencode": {
37
- "optional": true
38
- }
39
- },
40
- "publishConfig": {
41
- "access": "public"
42
- }
43
- }