diff --git a/.gitignore b/.gitignore index 1aa0d11b..16a9bc6b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ node_modules .env.* *.log .gstack/ +dist/ +vscode-extension/out/ diff --git a/dist/agent/commands.d.ts b/dist/agent/commands.d.ts deleted file mode 100644 index b513d97f..00000000 --- a/dist/agent/commands.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Slash command registry for runcode. - * Extracted from loop.ts for maintainability. - * - * Two types of commands: - * 1. "Handled" — execute directly, emit events, return { handled: true } - * 2. "Rewrite" — transform input into a prompt for the agent, return { handled: false, rewritten } - */ -import type { ModelClient } from './llm.js'; -import type { AgentConfig, Dialogue, StreamEvent } from './types.js'; -type EventEmitter = (event: StreamEvent) => void; -interface CommandContext { - history: Dialogue[]; - config: AgentConfig; - client: ModelClient; - sessionId: string; - onEvent: EventEmitter; -} -interface CommandResult { - handled: boolean; - rewritten?: string; -} -/** - * Handle a slash command. Returns result indicating what happened. - */ -export declare function handleSlashCommand(input: string, ctx: CommandContext): Promise; -export {}; diff --git a/dist/agent/commands.js b/dist/agent/commands.js deleted file mode 100644 index d1f8902e..00000000 --- a/dist/agent/commands.js +++ /dev/null @@ -1,806 +0,0 @@ -/** - * Slash command registry for runcode. - * Extracted from loop.ts for maintainability. - * - * Two types of commands: - * 1. "Handled" — execute directly, emit events, return { handled: true } - * 2. "Rewrite" — transform input into a prompt for the agent, return { handled: false, rewritten } - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { execSync } from 'node:child_process'; -import { BLOCKRUN_DIR, VERSION } from '../config.js'; -import { estimateHistoryTokens, getAnchoredTokenCount, getContextWindow, resetTokenAnchor } from './tokens.js'; -import { forceCompact } from './compact.js'; -import { getStatsSummary } from '../stats/tracker.js'; -import { resolveModel } from '../ui/model-picker.js'; -import { listSessions, loadSessionHistory, } from '../session/storage.js'; -// ─── Git helpers ────────────────────────────────────────────────────────── -function gitExec(cmd, cwd, timeout = 5000, maxBuffer) { - return execSync(cmd, { - cwd, - encoding: 'utf-8', - timeout, - maxBuffer: maxBuffer || 1024 * 1024, - stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); -} -function gitCmd(ctx, cmd, timeout, maxBuffer) { - try { - return gitExec(cmd, ctx.config.workingDir || process.cwd(), timeout, maxBuffer); - } - catch (e) { - // Prefer stderr (actual git error message) over the noisy "Command failed: ..." header - const errObj = e; - const stderr = errObj.stderr ? String(errObj.stderr).trim() : ''; - // Take only the first meaningful line (git sometimes dumps full usage on errors) - const firstLine = (stderr || errObj.message || 'unknown').split('\n')[0].trim(); - ctx.onEvent({ kind: 'text_delta', text: `Git: ${firstLine}\n` }); - return null; - } -} -function emitDone(ctx) { - ctx.onEvent({ kind: 'turn_done', reason: 'completed' }); -} -function buildExchanges(history) { - const exchanges = []; - let i = 0; - while (i < history.length) { - const msg = history[i]; - if (msg.role !== 'user') { - i++; - continue; - } - const userText = extractText(msg); - if (!userText) { - i++; - continue; - } // skip tool_result-only user messages - const startIdx = i; - let endIdx = i; - let assistantText = ''; - const toolNames = []; - let j = i + 1; - while (j < history.length) { - const next = history[j]; - if (next.role === 'user' && extractText(next)) - break; // next exchange - if (next.role === 'assistant') { - const t = extractText(next); - if (t && !assistantText) - assistantText = t; - if (Array.isArray(next.content)) { - for (const p of next.content) { - if (p.type === 'tool_use' && !toolNames.includes(p.name)) - toolNames.push(p.name); - } - } - } - endIdx = j; - j++; - } - exchanges.push({ - userText: userText.slice(0, 120) + (userText.length > 120 ? '…' : ''), - assistantText: (assistantText.slice(0, 80) + (assistantText.length > 80 ? '…' : '')) || '(no text)', - toolNames, - startIdx, - endIdx, - }); - i = j; - } - return exchanges; -} -function extractText(msg) { - if (typeof msg.content === 'string') - return msg.content.trim(); - if (!Array.isArray(msg.content)) - return ''; - for (const p of msg.content) { - if (p.type === 'text' && p.text.trim()) - return p.text.trim(); - } - return ''; -} -// ─── Command Definitions ────────────────────────────────────────────────── -// Direct-handled commands (don't go to agent) -const DIRECT_COMMANDS = { - '/stash': (ctx) => { - const r = gitCmd(ctx, 'git stash push -m "runcode auto-stash"', 10000); - if (r !== null) - ctx.onEvent({ kind: 'text_delta', text: r ? `${r}\n` : 'No changes to stash.\n' }); - emitDone(ctx); - }, - '/unstash': (ctx) => { - const r = gitCmd(ctx, 'git stash pop', 10000); - if (r !== null) - ctx.onEvent({ kind: 'text_delta', text: r ? `${r}\n` : 'Stash applied.\n' }); - emitDone(ctx); - }, - '/log': (ctx) => { - const r = gitCmd(ctx, 'git log --oneline -15 --no-color'); - if (r !== null) - ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`\n${r}\n\`\`\`\n` : 'No commits yet.\n' }); - emitDone(ctx); - }, - '/status': (ctx) => { - const r = gitCmd(ctx, 'git status --short --branch'); - if (r !== null) - ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`\n${r}\n\`\`\`\n` : 'Working tree clean.\n' }); - emitDone(ctx); - }, - '/diff': (ctx) => { - // git diff with stat header then full diff - const stat = gitCmd(ctx, 'git diff --stat --no-color'); - if (stat === null) { - emitDone(ctx); - return; - } - const full = gitCmd(ctx, 'git diff --no-color'); - if (full === null) { - emitDone(ctx); - return; - } - if (!stat && !full) { - ctx.onEvent({ kind: 'text_delta', text: 'No unstaged changes.\n' }); - } - else { - ctx.onEvent({ kind: 'text_delta', text: `\`\`\`diff\n${[stat, full].filter(Boolean).join('\n---\n')}\n\`\`\`\n` }); - } - emitDone(ctx); - }, - '/undo': (ctx) => { - const r = gitCmd(ctx, 'git reset --soft HEAD~1'); - if (r !== null) - ctx.onEvent({ kind: 'text_delta', text: `Last commit undone. Changes preserved in staging.\n` }); - emitDone(ctx); - }, - '/tokens': (ctx) => { - const { estimated, apiAnchored } = getAnchoredTokenCount(ctx.history); - const contextWindow = getContextWindow(ctx.config.model); - const pct = (estimated / contextWindow) * 100; - // Count tool results and thinking blocks - let toolResults = 0; - let thinkingBlocks = 0; - let totalToolChars = 0; - for (const msg of ctx.history) { - if (typeof msg.content === 'string') - continue; - if (!Array.isArray(msg.content)) - continue; - for (const part of msg.content) { - if ('type' in part) { - if (part.type === 'tool_result') { - toolResults++; - const c = typeof part.content === 'string' ? part.content : JSON.stringify(part.content); - totalToolChars += c.length; - } - if (part.type === 'thinking') - thinkingBlocks++; - } - } - } - ctx.onEvent({ kind: 'text_delta', text: `**Token Usage**\n` + - ` Estimated: ~${estimated.toLocaleString()} tokens ${apiAnchored ? '(API-anchored)' : '(estimated)'}\n` + - ` Context: ${(contextWindow / 1000).toFixed(0)}k window (${pct.toFixed(1)}% used)\n` + - ` Messages: ${ctx.history.length}\n` + - ` Tool results: ${toolResults} (${(totalToolChars / 1024).toFixed(0)}KB)\n` + - ` Thinking: ${thinkingBlocks} blocks\n` + - (pct > 80 ? ' ⚠ Near limit — run /compact\n' : '') + - (pct > 60 ? '' : ' ✓ Healthy\n') - }); - emitDone(ctx); - }, - '/help': (ctx) => { - const ultrathinkOn = ctx.config.ultrathink; - ctx.onEvent({ kind: 'text_delta', text: `**RunCode Commands**\n\n` + - ` **Coding:** /commit /review /test /fix /debug /explain /search /find /refactor /scaffold\n` + - ` **Git:** /push /pr /undo /status /diff /log /branch /stash /unstash\n` + - ` **Analysis:** /security /lint /optimize /todo /deps /clean /migrate /doc\n` + - ` **Session:** /plan /ultraplan /execute /compact /retry /sessions /resume /session-search /context /tasks\n` + - ` **Power:** /ultrathink [query] /ultraplan /dump\n` + - ` **Info:** /model /wallet /cost /tokens /learnings /brain /mcp /doctor /version /bug /help\n` + - ` **UI:** /clear /exit\n` + - (ultrathinkOn ? `\n Ultrathink: ON\n` : '') - }); - emitDone(ctx); - }, - '/history': (ctx) => { - const { history, config } = ctx; - const modelName = config.model.split('/').pop() || config.model; - const exchanges = buildExchanges(history); - let output = '**Conversation History**\n\n'; - if (exchanges.length === 0) { - output += 'No history in the current session yet.\n'; - } - else { - for (let i = 0; i < exchanges.length; i++) { - const ex = exchanges[i]; - const tools = ex.toolNames.length > 0 ? ` · used: ${ex.toolNames.join(', ')}` : ''; - output += `[${i + 1}] [user] ${ex.userText}\n`; - output += ` [${modelName}] ${ex.assistantText}${tools}\n\n`; - } - } - output += 'Use `/delete ` to remove exchanges (e.g., `/delete 2` or `/delete 3-5`).\n'; - ctx.onEvent({ kind: 'text_delta', text: output }); - emitDone(ctx); - }, - '/bug': (ctx) => { - ctx.onEvent({ kind: 'text_delta', text: 'Report issues at: https://github.com/BlockRunAI/runcode/issues\n' }); - emitDone(ctx); - }, - '/version': (ctx) => { - ctx.onEvent({ kind: 'text_delta', text: `RunCode v${VERSION}\n` }); - emitDone(ctx); - }, - '/mcp': async (ctx) => { - const { listMcpServers } = await import('../mcp/client.js'); - const servers = listMcpServers(); - if (servers.length === 0) { - ctx.onEvent({ kind: 'text_delta', text: 'No MCP servers connected.\nAdd servers to `~/.blockrun/mcp.json` or `.mcp.json` in your project.\n' }); - } - else { - let text = `**${servers.length} MCP server(s) connected:**\n\n`; - for (const s of servers) { - text += ` **${s.name}** — ${s.toolCount} tools\n`; - for (const t of s.tools) - text += ` · ${t}\n`; - } - ctx.onEvent({ kind: 'text_delta', text }); - } - emitDone(ctx); - }, - '/context': async (ctx) => { - const { estimated, apiAnchored } = getAnchoredTokenCount(ctx.history); - const contextWindow = getContextWindow(ctx.config.model); - const pct = (estimated / contextWindow) * 100; - const usagePct = pct.toFixed(1); - const warning = pct > 80 ? ' ⚠ Near limit — consider /compact\n' : ''; - ctx.onEvent({ kind: 'text_delta', text: `**Session Context**\n` + - ` Model: ${ctx.config.model}\n` + - ` Mode: ${ctx.config.permissionMode || 'default'}\n` + - ` Messages: ${ctx.history.length}\n` + - ` Tokens: ~${estimated.toLocaleString()} / ${(contextWindow / 1000).toFixed(0)}k (${usagePct}%)${apiAnchored ? ' ✓' : ' ~'}\n` + - warning + - ` Session: ${ctx.sessionId}\n` + - ` Directory: ${ctx.config.workingDir || process.cwd()}\n` - }); - emitDone(ctx); - }, - '/doctor': async (ctx) => { - const checks = []; - try { - execSync('git --version', { stdio: 'pipe' }); - checks.push('✓ git available'); - } - catch { - checks.push('✗ git not found'); - } - try { - execSync('rg --version', { stdio: 'pipe' }); - checks.push('✓ ripgrep available'); - } - catch { - checks.push('⚠ ripgrep not found (using native grep fallback)'); - } - const hasWallet = fs.existsSync(path.join(BLOCKRUN_DIR, 'wallet.json')) - || fs.existsSync(path.join(BLOCKRUN_DIR, 'solana-wallet.json')); - checks.push(hasWallet ? '✓ wallet configured' : '⚠ no wallet — run: runcode setup'); - checks.push(fs.existsSync(path.join(BLOCKRUN_DIR, 'runcode-config.json')) ? '✓ config file exists' : '⚠ no config — using defaults'); - // Check MCP - const { listMcpServers } = await import('../mcp/client.js'); - const mcpServers = listMcpServers(); - checks.push(mcpServers.length > 0 - ? `✓ MCP: ${mcpServers.length} server(s), ${mcpServers.reduce((a, s) => a + s.toolCount, 0)} tools` - : '⚠ no MCP servers connected'); - checks.push(`✓ model: ${ctx.config.model}`); - checks.push(`✓ history: ${ctx.history.length} messages, ~${estimateHistoryTokens(ctx.history).toLocaleString()} tokens`); - checks.push(`✓ session: ${ctx.sessionId}`); - checks.push(`✓ version: v${VERSION}`); - ctx.onEvent({ kind: 'text_delta', text: `**Health Check**\n${checks.map(c => ' ' + c).join('\n')}\n` }); - emitDone(ctx); - }, - '/plan': (ctx) => { - if (ctx.config.permissionMode === 'plan') { - ctx.onEvent({ kind: 'text_delta', text: 'Already in plan mode. Use /execute to exit.\n' }); - } - else { - ctx.config.permissionMode = 'plan'; - ctx.onEvent({ kind: 'text_delta', text: '**Plan mode active.** Tools restricted to read-only. Use /execute when ready to implement.\n' }); - } - emitDone(ctx); - }, - '/ultrathink': (ctx) => { - const cfg = ctx.config; - cfg.ultrathink = !cfg.ultrathink; - if (cfg.ultrathink) { - ctx.onEvent({ kind: 'text_delta', text: '**Ultrathink mode ON.** Extended reasoning active — the model will think deeply before responding.\n' + - 'Use `/ultrathink` again to disable, or `/ultrathink ` to send a one-shot deep analysis.\n' - }); - } - else { - ctx.onEvent({ kind: 'text_delta', text: '**Ultrathink mode OFF.** Normal response mode restored.\n' }); - } - emitDone(ctx); - }, - '/dump': (ctx) => { - const instructions = ctx.config.systemInstructions; - const joined = instructions.join('\n\n---\n\n'); - ctx.onEvent({ kind: 'text_delta', text: `**System Prompt** (${instructions.length} section${instructions.length !== 1 ? 's' : ''}):\n\n` + - `\`\`\`\n${joined.slice(0, 4000)}${joined.length > 4000 ? `\n... (${joined.length - 4000} chars truncated)` : ''}\n\`\`\`\n` - }); - emitDone(ctx); - }, - '/execute': (ctx) => { - if (ctx.config.permissionMode !== 'plan') { - ctx.onEvent({ kind: 'text_delta', text: 'Not in plan mode. Use /plan to enter.\n' }); - } - else { - ctx.config.permissionMode = 'default'; - ctx.onEvent({ kind: 'text_delta', text: '**Execution mode.** All tools enabled with permissions.\n' }); - } - emitDone(ctx); - }, - '/sessions': async (ctx) => { - const sessions = listSessions(); - if (sessions.length === 0) { - ctx.onEvent({ kind: 'text_delta', text: 'No saved sessions.\n' }); - } - else { - const { formatTokens, formatUsd, shortModelName } = await import('../stats/format.js'); - let text = `**${sessions.length} saved sessions:**\n\n`; - for (const s of sessions.slice(0, 10)) { - const date = new Date(s.updatedAt).toLocaleString(); - const dir = s.workDir ? path.basename(s.workDir) : ''; - const current = s.id === ctx.sessionId ? ' (current)' : ''; - const model = shortModelName(s.model); - const tokens = (s.inputTokens || s.outputTokens) - ? ` ${formatTokens(s.inputTokens ?? 0)} in / ${formatTokens(s.outputTokens ?? 0)} out` - : ''; - const cost = s.costUsd ? ` ${formatUsd(s.costUsd)}` : ''; - const saved = s.savedVsOpusUsd && s.savedVsOpusUsd > 0.001 - ? ` saved ${formatUsd(s.savedVsOpusUsd)}` - : ''; - text += ` ${model} — ${s.messageCount} messages${tokens}${cost}${saved}\n`; - text += ` ${date} · ${dir}${current}\n\n`; - } - if (sessions.length > 10) - text += ` ... and ${sessions.length - 10} more\n`; - text += 'Use /resume to restore the latest session, or /resume for a specific one.\n'; - ctx.onEvent({ kind: 'text_delta', text }); - } - emitDone(ctx); - }, - '/cost': async (ctx) => { - const { stats, saved } = getStatsSummary(); - const { getSessionModelBreakdown } = await import('../stats/session-tracker.js'); - const { formatTokens, formatUsd, shortModelName } = await import('../stats/format.js'); - const breakdown = getSessionModelBreakdown(); - let text = `**Session Cost**\n` + - ` Requests: ${stats.totalRequests}\n` + - ` Cost: $${stats.totalCostUsd.toFixed(4)} USDC\n` + - ` Saved: $${saved.toFixed(2)} vs Claude Opus\n` + - ` Tokens: ${formatTokens(stats.totalInputTokens)} in / ${formatTokens(stats.totalOutputTokens)} out\n`; - if (breakdown.length > 0) { - text += `\n **By model:**\n`; - for (const m of breakdown) { - const name = shortModelName(m.model).padEnd(28); - const cost = formatUsd(m.costUsd).padStart(8); - const reqs = `${m.requests} req`.padStart(6); - const tier = m.lastTier ? ` ${m.lastTier}` : ''; - text += ` ${name} ${cost} ${reqs}${tier}\n`; - } - } - ctx.onEvent({ kind: 'text_delta', text }); - emitDone(ctx); - }, - '/wallet': async (ctx) => { - const chain = (await import('../config.js')).loadChain(); - try { - let address; - let balance; - const fetchTimeout = (ms) => new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms)); - if (chain === 'solana') { - const { getOrCreateSolanaWallet, setupAgentSolanaWallet } = await import('@blockrun/llm'); - const w = await getOrCreateSolanaWallet(); - address = w.address; - try { - const client = await setupAgentSolanaWallet({ silent: true }); - const bal = await Promise.race([client.getBalance(), fetchTimeout(5000)]); - balance = `$${bal.toFixed(2)} USDC`; - } - catch { - balance = '(unavailable)'; - } - } - else { - const { getOrCreateWallet, setupAgentWallet } = await import('@blockrun/llm'); - const w = getOrCreateWallet(); - address = w.address; - try { - const client = setupAgentWallet({ silent: true }); - const bal = await Promise.race([client.getBalance(), fetchTimeout(5000)]); - balance = `$${bal.toFixed(2)} USDC`; - } - catch { - balance = '(unavailable)'; - } - } - ctx.onEvent({ kind: 'text_delta', text: `**Wallet**\n` + - ` Chain: ${chain}\n` + - ` Address: ${address}\n` + - ` Balance: ${balance}\n` - }); - } - catch (err) { - ctx.onEvent({ kind: 'text_delta', text: `Wallet error: ${err.message}\n` }); - } - emitDone(ctx); - }, - '/clear': (ctx) => { - ctx.history.length = 0; - resetTokenAnchor(); - ctx.onEvent({ kind: 'text_delta', text: 'Conversation history cleared.\n' }); - emitDone(ctx); - }, - '/failures': async (ctx) => { - const { getFailureStats } = await import('../stats/failures.js'); - const stats = getFailureStats(); - if (stats.total === 0) { - ctx.onEvent({ kind: 'text_delta', text: 'No failures recorded.\n' }); - emitDone(ctx); - return; - } - let text = `**Failure Log** (${stats.total} total)\n\n`; - if (stats.byType.size > 0) { - text += ' **By type:**\n'; - for (const [type, count] of [...stats.byType.entries()].sort((a, b) => b[1] - a[1])) { - text += ` ${type.padEnd(20)} ${count}\n`; - } - } - if (stats.byTool.size > 0) { - text += '\n **By tool:**\n'; - for (const [tool, count] of [...stats.byTool.entries()].sort((a, b) => b[1] - a[1])) { - text += ` ${tool.padEnd(20)} ${count}\n`; - } - } - if (stats.recentFailures.length > 0) { - text += '\n **Recent:**\n'; - for (const f of stats.recentFailures.slice(-5)) { - const date = new Date(f.timestamp).toLocaleDateString(); - const tool = f.toolName ? ` ${f.toolName}:` : ''; - text += ` [${date}]${tool} ${f.errorMessage.slice(0, 80)}\n`; - } - } - ctx.onEvent({ kind: 'text_delta', text }); - emitDone(ctx); - }, - '/compact': async (ctx) => { - const beforeTokens = estimateHistoryTokens(ctx.history); - const { history: compacted, compacted: didCompact } = await forceCompact(ctx.history, ctx.config.model, ctx.client, ctx.config.debug); - if (didCompact) { - ctx.history.length = 0; - ctx.history.push(...compacted); - resetTokenAnchor(); - const afterTokens = estimateHistoryTokens(ctx.history); - const saved = beforeTokens - afterTokens; - const pct = Math.round((saved / beforeTokens) * 100); - ctx.onEvent({ kind: 'text_delta', text: `Compacted: ~${beforeTokens.toLocaleString()} → ~${afterTokens.toLocaleString()} tokens (saved ${pct}%)\n` - }); - } - else { - ctx.onEvent({ kind: 'text_delta', text: `Nothing to compact — history is already minimal (${beforeTokens.toLocaleString()} tokens, ${ctx.history.length} messages).\n` - }); - } - emitDone(ctx); - }, -}; -// Prompt-rewrite commands (transformed into agent prompts) -const REWRITE_COMMANDS = { - '/commit': 'Review the current git diff and staged changes. Stage relevant files with `git add`, then create a commit with a concise message summarizing the changes. Do NOT push to remote.', - '/push': 'Push the current branch to the remote repository using `git push`. Show the result.', - '/pr': 'Create a pull request for the current branch. First check `git log --oneline main..HEAD` to see commits, then use `gh pr create` with a descriptive title and body summarizing the changes. If gh CLI is not available, show the manual steps.', - '/review': 'Review the current git diff. For each changed file, check for: bugs, security issues, missing error handling, performance problems, and style issues. Provide a brief summary of findings.', - '/fix': 'Look at the most recent error or issue we discussed and fix it. Check the relevant files, identify the root cause, and apply the fix.', - '/test': 'Detect the project test framework (look for package.json scripts, pytest, etc.) and run the test suite. Show a summary of results.', - '/debug': 'Look at the most recent error in this session. Read the relevant source files, analyze the root cause, and suggest a fix with specific code changes.', - '/init': 'Read the project structure: check package.json (or equivalent), README, and key config files. Summarize: what this project is, main language/framework, entry points, and how to run/test it.', - '/todo': 'Search the codebase for TODO, FIXME, HACK, and XXX comments using Grep. Show the results grouped by file.', - '/deps': 'Read the project dependency file (package.json, requirements.txt, go.mod, Cargo.toml, etc.) and list key dependencies with their versions.', - '/optimize': 'Analyze the codebase for performance issues. Check for: unnecessary re-renders, N+1 queries, missing indexes, unoptimized loops, large bundle sizes, and memory leaks. Provide specific recommendations.', - '/security': 'Audit the codebase for security issues. Check for: SQL injection, XSS, command injection, hardcoded secrets, insecure dependencies, OWASP top 10 vulnerabilities. Report findings with severity.', - '/lint': 'Check for code quality issues: unused imports, inconsistent naming, missing type annotations, long functions, duplicated code. Suggest improvements.', - '/migrate': 'Check for pending database migrations, outdated dependencies, or breaking changes that need addressing. List required migration steps.', - '/clean': 'Find and remove dead code: unused imports, unreachable code, commented-out blocks, unused variables and functions. Show what would be removed before making changes.', - '/tasks': 'List all current tasks using the Task tool.', - '/ultraplan': 'Enter ultraplan mode: create a detailed, step-by-step implementation plan before writing any code. ' + - 'First, thoroughly read ALL relevant files. Map out every dependency and potential side effect. ' + - 'Identify edge cases, security considerations, and performance implications. ' + - 'Then produce a numbered implementation plan with specific file paths, function names, and code changes. ' + - 'Do NOT write any code yet — only the plan.', -}; -// Commands with arguments (prefix match → rewrite) -const ARG_COMMANDS = [ - { prefix: '/ultrathink ', rewrite: (a) => `Think deeply, carefully, and thoroughly before responding. ` + - `Consider multiple approaches, check edge cases, reason through implications step by step, ` + - `and challenge your initial assumptions. Take your time — quality of reasoning matters more than speed. ` + - `Now respond to: ${a}` - }, - { prefix: '/explain ', rewrite: (a) => `Read and explain the code in ${a}. Cover: what it does, key functions/classes, how it connects to the rest of the codebase.` }, - { prefix: '/search ', rewrite: (a) => `Search the codebase for "${a}" using Grep. Show the matching files and relevant code context.` }, - { prefix: '/find ', rewrite: (a) => `Find files matching the pattern "${a}" using Glob. Show the results.` }, - { prefix: '/refactor ', rewrite: (a) => `Refactor: ${a}. Read the relevant code first, then make targeted changes. Explain each change.` }, - { prefix: '/scaffold ', rewrite: (a) => `Create the scaffolding/boilerplate for: ${a}. Generate the file structure and initial code. Ask me if you need clarification on requirements.` }, - { prefix: '/doc ', rewrite: (a) => `Generate documentation for ${a}. Include: purpose, API/interface description, usage examples, and important notes.` }, -]; -// ─── Main dispatch ──────────────────────────────────────────────────────── -/** - * Handle a slash command. Returns result indicating what happened. - */ -export async function handleSlashCommand(input, ctx) { - // Direct-handled commands - if (input in DIRECT_COMMANDS) { - await DIRECT_COMMANDS[input](ctx); - return { handled: true }; - } - // /session-search — full-text search past sessions - if (input === '/session-search' || - input.startsWith('/session-search ') || - input === '/ssearch' || - input.startsWith('/ssearch ')) { - const prefix = input.startsWith('/ssearch') ? '/ssearch' : '/session-search'; - const query = input === prefix ? '' : input.slice(prefix.length + 1).trim(); - if (!query) { - ctx.onEvent({ kind: 'text_delta', text: 'Usage: /session-search \n' + - 'Finds past sessions whose messages match the query.\n' + - 'Use quotes for phrase search: /session-search "payment loop"\n' - }); - emitDone(ctx); - return { handled: true }; - } - const { searchSessions, formatSearchResults } = await import('../session/search.js'); - const matches = searchSessions(query, { limit: 10 }); - ctx.onEvent({ kind: 'text_delta', text: formatSearchResults(matches, query) }); - emitDone(ctx); - return { handled: true }; - } - // /insights [--days N] — rich usage insights - if (input === '/insights' || input.startsWith('/insights ')) { - const daysMatch = input.match(/--days\s+(\d+)/); - const days = daysMatch ? parseInt(daysMatch[1], 10) : 30; - const { generateInsights, formatInsights } = await import('../stats/insights.js'); - const report = generateInsights(days); - ctx.onEvent({ kind: 'text_delta', text: formatInsights(report, days) }); - emitDone(ctx); - return { handled: true }; - } - // /learnings — view or clear per-user learnings - if (input === '/learnings' || input.startsWith('/learnings ')) { - const { loadLearnings, decayLearnings, saveLearnings } = await import('../learnings/store.js'); - const arg = input.slice('/learnings'.length).trim(); - if (arg === 'clear') { - saveLearnings([]); - ctx.onEvent({ kind: 'text_delta', text: 'All learnings cleared.\n' }); - } - else { - let learnings = loadLearnings(); - if (learnings.length === 0) { - ctx.onEvent({ kind: 'text_delta', text: 'No learnings yet. Franklin learns your preferences over time.\n' }); - } - else { - learnings = decayLearnings(learnings); - const sorted = [...learnings].sort((a, b) => (b.confidence * b.times_confirmed) - (a.confidence * a.times_confirmed)); - let text = `**Personal Learnings** (${sorted.length})\n\n`; - for (const l of sorted) { - const conf = l.confidence >= 0.8 ? 'high' : l.confidence >= 0.5 ? 'mid' : 'low'; - text += ` [${conf}] ${l.learning} (×${l.times_confirmed})\n`; - } - text += '\nUse `/learnings clear` to reset.\n'; - ctx.onEvent({ kind: 'text_delta', text }); - } - } - emitDone(ctx); - return { handled: true }; - } - // /brain — view knowledge graph entities - if (input === '/brain' || input.startsWith('/brain ')) { - const { searchEntities, loadEntities, getEntityObservations, getEntityRelations, getBrainStats, loadObservations } = await import('../brain/store.js'); - const arg = input.slice('/brain'.length).trim(); - if (!arg) { - const stats = getBrainStats(); - if (stats.entities === 0) { - ctx.onEvent({ kind: 'text_delta', text: 'Brain is empty. Franklin learns entities (people, projects, companies) from your conversations over time.\n' }); - } - else { - const entities = loadEntities().sort((a, b) => b.reference_count - a.reference_count); - let text = `**Franklin Brain** (${stats.entities} entities, ${stats.observations} facts, ${stats.relations} relations)\n\n`; - for (const e of entities.slice(0, 20)) { - text += ` ${e.type === 'person' ? '👤' : e.type === 'company' ? '🏢' : e.type === 'project' ? '📦' : '💡'} **${e.name}** (${e.type}, ×${e.reference_count})\n`; - } - if (entities.length > 20) - text += ` ... and ${entities.length - 20} more\n`; - text += '\nSearch: `/brain ` for details.\n'; - ctx.onEvent({ kind: 'text_delta', text }); - } - } - else { - const results = searchEntities(arg, 5); - if (results.length === 0) { - ctx.onEvent({ kind: 'text_delta', text: `No entities matching "${arg}".\n` }); - } - else { - let text = ''; - for (const e of results) { - text += `**${e.name}** (${e.type})\n`; - if (e.aliases.length > 0) - text += ` Aliases: ${e.aliases.join(', ')}\n`; - const obs = getEntityObservations(e.id).slice(0, 5); - for (const o of obs) { - text += ` - ${o.content}\n`; - } - const rels = getEntityRelations(e.id); - const allEntities = loadEntities(); - for (const r of rels.slice(0, 3)) { - const other = allEntities.find(x => x.id === (r.from_id === e.id ? r.to_id : r.from_id)); - if (other) - text += ` → ${r.type} ${other.name}\n`; - } - text += '\n'; - } - ctx.onEvent({ kind: 'text_delta', text }); - } - } - emitDone(ctx); - return { handled: true }; - } - // /model — show current model or switch with /model - if (input === '/model' || input.startsWith('/model ')) { - if (input === '/model') { - ctx.onEvent({ kind: 'text_delta', text: `Current model: **${ctx.config.model}**\n` + - `Switch with: \`/model \` (e.g. \`/model sonnet\`, \`/model free\`, \`/model gemini\`)\n` - }); - } - else { - const newModel = resolveModel(input.slice(7).trim()); - ctx.config.model = newModel; - ctx.config.onModelChange?.(newModel); - ctx.onEvent({ kind: 'text_delta', text: `Model → **${newModel}**\n` }); - } - emitDone(ctx); - return { handled: true }; - } - // /branch has both no-arg and with-arg forms - if (input === '/branch' || input.startsWith('/branch ')) { - const cwd = ctx.config.workingDir || process.cwd(); - if (input === '/branch') { - const r = gitCmd(ctx, 'git branch -v --no-color'); - if (r !== null) - ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`\n${r}\n\`\`\`\n` : 'No branches yet.\n' }); - } - else { - const branchName = input.slice(8).trim(); - const r = gitCmd(ctx, `git checkout -b ${branchName}`); - if (r !== null) - ctx.onEvent({ kind: 'text_delta', text: `Created and switched to branch: **${branchName}**\n` }); - } - emitDone(ctx); - return { handled: true }; - } - // /delete <...> - if (input.startsWith('/delete ')) { - const arg = input.slice('/delete '.length).trim(); - if (!arg) { - ctx.onEvent({ kind: 'text_delta', text: 'Usage: /delete (e.g., /delete 3, /delete 2,5, /delete 4-7)\n' }); - emitDone(ctx); - return { handled: true }; - } - // Parse exchange numbers (1-based) into a set of 0-based exchange indices - const exchangeIndicesToDelete = new Set(); - const parts = arg.split(',').map(p => p.trim()); - for (const part of parts) { - if (part.includes('-')) { - const [start, end] = part.split('-').map(n => parseInt(n, 10)); - if (!isNaN(start) && !isNaN(end) && start <= end) { - for (let i = start; i <= end; i++) - exchangeIndicesToDelete.add(i - 1); - } - } - else { - const n = parseInt(part, 10); - if (!isNaN(n)) - exchangeIndicesToDelete.add(n - 1); - } - } - if (exchangeIndicesToDelete.size === 0) { - ctx.onEvent({ kind: 'text_delta', text: 'No valid exchange numbers provided.\n' }); - emitDone(ctx); - return { handled: true }; - } - // Map exchange indices → raw history index ranges, then delete descending. - // This preserves valid user/assistant alternation — each exchange covers the - // full unit: user prompt + all tool calls/results + assistant replies. - const exchanges = buildExchanges(ctx.history); - const rawToDelete = new Set(); - const deletedNums = []; - for (const exIdx of exchangeIndicesToDelete) { - const ex = exchanges[exIdx]; - if (!ex) - continue; - for (let i = ex.startIdx; i <= ex.endIdx; i++) - rawToDelete.add(i); - deletedNums.push(exIdx + 1); - } - const sorted = Array.from(rawToDelete).sort((a, b) => b - a); - for (const idx of sorted) { - if (idx >= 0 && idx < ctx.history.length) - ctx.history.splice(idx, 1); - } - if (deletedNums.length > 0) { - resetTokenAnchor(); - ctx.onEvent({ kind: 'text_delta', text: `Deleted exchange(s) ${deletedNums.sort((a, b) => a - b).join(', ')} from history.\n` }); - } - else { - ctx.onEvent({ kind: 'text_delta', text: 'No matching exchanges found to delete.\n' }); - } - emitDone(ctx); - return { handled: true }; - } - // /resume or /resume - if (input === '/resume' || input.startsWith('/resume ')) { - const targetId = input === '/resume' - ? listSessions().find((session) => session.id !== ctx.sessionId)?.id ?? '' - : input.slice(8).trim(); - if (!targetId) { - ctx.onEvent({ kind: 'text_delta', text: 'No previous session available to resume.\n' }); - emitDone(ctx); - return { handled: true }; - } - const restored = loadSessionHistory(targetId); - if (restored.length === 0) { - ctx.onEvent({ kind: 'text_delta', text: `Session "${targetId}" not found or empty.\n` }); - } - else { - ctx.history.length = 0; - ctx.history.push(...restored); - resetTokenAnchor(); - ctx.onEvent({ kind: 'text_delta', text: `Restored ${restored.length} messages from ${targetId}. Continue where you left off.\n` }); - } - emitDone(ctx); - return { handled: true }; - } - // Simple rewrite commands (exact match) - if (input in REWRITE_COMMANDS) { - return { handled: false, rewritten: REWRITE_COMMANDS[input] }; - } - // Argument-based rewrite commands (prefix match) - for (const { prefix, rewrite } of ARG_COMMANDS) { - if (input.startsWith(prefix)) { - const arg = input.slice(prefix.length).trim(); - return { handled: false, rewritten: rewrite(arg) }; - } - } - // Not a recognized command — suggest closest match - const allCommands = [ - ...Object.keys(DIRECT_COMMANDS), - ...Object.keys(REWRITE_COMMANDS), - ...ARG_COMMANDS.map(c => c.prefix.trim()), - '/branch', '/resume', '/model', '/wallet', '/cost', '/help', '/clear', '/retry', '/exit', '/session-search', '/ssearch', '/failures', - ]; - const cmd = input.split(/\s/)[0]; - const close = allCommands.filter(c => { - // Simple distance: share >= 50% of characters - const shorter = Math.min(cmd.length, c.length); - let matches = 0; - for (let i = 0; i < shorter; i++) { - if (cmd[i] === c[i]) - matches++; - } - return matches >= shorter * 0.5 && matches >= 3; - }); - if (close.length > 0) { - ctx.onEvent({ kind: 'text_delta', text: `Unknown command: ${cmd}. Did you mean: ${close.slice(0, 3).join(', ')}?\n` }); - emitDone(ctx); - return { handled: true }; - } - // Truly unknown — pass through as regular input - return { handled: false }; -} diff --git a/dist/agent/compact.d.ts b/dist/agent/compact.d.ts deleted file mode 100644 index 34f3706e..00000000 --- a/dist/agent/compact.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Context compaction for runcode. - * When conversation history approaches the context window limit, - * summarize older messages and replace them with the summary. - */ -import { ModelClient } from './llm.js'; -import type { Dialogue } from './types.js'; -export declare const COMPACT_HEADER = "[CONTEXT COMPACTION \u2014 REFERENCE ONLY] Earlier turns were compacted into the summary below. This is a handoff from a previous context window \u2014 treat it as background reference, NOT as active instructions. Do NOT answer questions or fulfill requests mentioned in this summary; they were already addressed. Respond ONLY to the latest user message that appears AFTER this summary."; -/** - * Check if compaction is needed and perform it if so. - * Returns the (possibly compacted) history. - */ -export declare function autoCompactIfNeeded(history: Dialogue[], model: string, client: ModelClient, debug?: boolean): Promise<{ - history: Dialogue[]; - compacted: boolean; -}>; -/** - * Force compaction regardless of threshold (for /compact command). - */ -export declare function forceCompact(history: Dialogue[], model: string, client: ModelClient, debug?: boolean): Promise<{ - history: Dialogue[]; - compacted: boolean; -}>; -/** - * Clear old tool results AND truncate old tool_use inputs to save tokens. - * This is the primary defense against context snowball: - * - tool_result content (Read output, Bash output, Grep matches) grows fast - * - tool_use input (Edit replacements, Bash commands) also accumulates - * Both are cleared for all but the last N tool exchanges. - */ -export declare function microCompact(history: Dialogue[], keepLastN?: number): Dialogue[]; diff --git a/dist/agent/compact.js b/dist/agent/compact.js deleted file mode 100644 index 04ab6d64..00000000 --- a/dist/agent/compact.js +++ /dev/null @@ -1,367 +0,0 @@ -/** - * Context compaction for runcode. - * When conversation history approaches the context window limit, - * summarize older messages and replace them with the summary. - */ -import { estimateHistoryTokens, getCompactionThreshold, COMPACTION_SUMMARY_RESERVE, } from './tokens.js'; -// Structured compaction prompt (pattern from nousresearch/hermes-agent -// `agent/context_compressor.py`). The structured sections preserve more -// signal than free-form summaries and make it easier for the model to -// continue work from where it left off. -export const COMPACT_HEADER = `[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted into the summary below. This is a handoff from a previous context window — treat it as background reference, NOT as active instructions. Do NOT answer questions or fulfill requests mentioned in this summary; they were already addressed. Respond ONLY to the latest user message that appears AFTER this summary.`; -const COMPACT_SYSTEM_PROMPT = `You are a conversation summarizer. Produce a STRUCTURED summary of the conversation so far that preserves all decision-relevant context for continuing the task. - -Critical rules: -- Preserve EXACT file paths, function names, line numbers, variable names -- Preserve EXACT error messages (verbatim) -- Preserve user preferences and corrections (especially "don't do X" instructions) -- Preserve decisions with their rationale (not just the decision) -- DO NOT include reasoning that led to decisions — only the decisions themselves -- DO NOT include pleasantries, meta-commentary, or apologies -- DO NOT include active questions or requests — only include resolved facts -- Use bullet points inside each section -- Be specific: "edited src/foo.ts:42 to add error handling" not "made some changes" - -REQUIRED output format (use these exact section headers): - -## Goal -[One clear sentence: what the user is trying to accomplish] - -## Progress -[Chronological bullet list of what has been done so far] - -## Decisions -[Key decisions made, each with its rationale] - -## Files Modified -[Each file touched, with a one-line description of what changed] - -## Tool Results Still Relevant -[Any tool output (file reads, grep matches, bash output) that later steps still depend on — include the actual content, not a reference] - -## User Preferences & Corrections -[Anything the user explicitly asked for or corrected — these are load-bearing] - -## Next Steps -[What comes next, in priority order] - -If there's an existing [CONTEXT COMPACTION] summary in the messages being compacted, MERGE its content into your output rather than nesting. Do not produce a summary of a summary.`; -/** - * Check if compaction is needed and perform it if so. - * Returns the (possibly compacted) history. - */ -export async function autoCompactIfNeeded(history, model, client, debug) { - const currentTokens = estimateHistoryTokens(history); - const threshold = getCompactionThreshold(model); - if (currentTokens < threshold) { - return { history, compacted: false }; - } - if (debug) { - console.error(`[runcode] Auto-compacting: ~${currentTokens} tokens, threshold=${threshold}`); - } - const beforeTokens = estimateHistoryTokens(history); - try { - const compacted = await compactHistory(history, model, client, debug); - const afterTokens = estimateHistoryTokens(compacted); - if (afterTokens >= beforeTokens) { - if (debug) { - console.error(`[runcode] Auto-compaction grew history (${beforeTokens} → ${afterTokens}) — skipping`); - } - return { history, compacted: false }; - } - return { history: compacted, compacted: true }; - } - catch (err) { - if (debug) { - console.error(`[runcode] Compaction failed: ${err.message}`); - } - // Fallback: truncate oldest messages instead of crashing - const truncated = emergencyTruncate(history, threshold); - return { history: truncated, compacted: true }; - } -} -/** - * Force compaction regardless of threshold (for /compact command). - */ -export async function forceCompact(history, model, client, debug) { - if (history.length <= 4) { - return { history, compacted: false }; - } - const beforeTokens = estimateHistoryTokens(history); - try { - const compacted = await compactHistory(history, model, client, debug); - const afterTokens = estimateHistoryTokens(compacted); - // Only accept compaction if it actually reduces tokens - if (afterTokens >= beforeTokens) { - if (debug) { - console.error(`[runcode] Compaction produced larger history (${beforeTokens} → ${afterTokens}) — reverting`); - } - return { history, compacted: false }; - } - return { history: compacted, compacted: true }; - } - catch (err) { - if (debug) { - console.error(`[runcode] Force compaction failed: ${err.message}`); - } - const threshold = getCompactionThreshold(model); - const truncated = emergencyTruncate(history, threshold); - return { history: truncated, compacted: true }; - } -} -/** - * Compact conversation history by summarizing older messages. - */ -async function compactHistory(history, model, client, debug) { - if (history.length <= 4) { - // Too few messages to compact meaningfully - return history; - } - // Split: keep the most recent messages, summarize the rest - const keepCount = findKeepBoundary(history); - const toSummarize = history.slice(0, history.length - keepCount); - const toKeep = history.slice(history.length - keepCount); - if (toSummarize.length === 0) { - return history; - } - if (debug) { - console.error(`[runcode] Summarizing ${toSummarize.length} messages, keeping ${toKeep.length}`); - } - // Build summary request - const summaryMessages = [ - { - role: 'user', - content: formatForSummarization(toSummarize), - }, - ]; - const { content: summaryParts } = await client.complete({ - model: pickCompactionModel(model), - messages: summaryMessages, - system: COMPACT_SYSTEM_PROMPT, - max_tokens: COMPACTION_SUMMARY_RESERVE, - stream: true, - }); - // Extract summary text - let summaryText = ''; - for (const part of summaryParts) { - if (part.type === 'text') { - summaryText += part.text; - } - } - if (!summaryText) { - throw new Error('Empty summary returned from model'); - } - // Build compacted history: summary as first message, then kept messages. - // The COMPACT_HEADER prefix lets future compactions detect and merge rather - // than nest summaries. - const compacted = [ - { - role: 'user', - content: `${COMPACT_HEADER}\n\n${summaryText}`, - }, - { - role: 'assistant', - content: 'Got it. I have the structured context from earlier work and will continue from where things left off.', - }, - ...toKeep, - ]; - if (debug) { - const newTokens = estimateHistoryTokens(compacted); - console.error(`[runcode] Compacted: ${estimateHistoryTokens(history)} → ${newTokens} tokens`); - } - return compacted; -} -/** - * Find how many recent messages to keep (don't summarize). - * Keeps the most recent tool exchange + the last few user/assistant turns. - */ -function findKeepBoundary(history) { - // Keep the last 8-20 messages (absolute range, not percentage) - // Prevents "never compacts" bug when history grows large - const minKeep = Math.min(8, history.length); - const maxKeep = Math.min(20, history.length - 1); - let keep = Math.max(minKeep, Math.min(maxKeep, Math.ceil(history.length * 0.3))); - // Make sure we don't split in the middle of a tool exchange - // (assistant with tool_use must be followed by user with tool_result) - while (keep < history.length) { - const boundary = history.length - keep; - const msgAtBoundary = history[boundary]; - // If boundary is a user message with tool_results, include the prior assistant message - if (msgAtBoundary.role === 'user' && - Array.isArray(msgAtBoundary.content) && - msgAtBoundary.content.length > 0 && - typeof msgAtBoundary.content[0] !== 'string' && - 'type' in msgAtBoundary.content[0] && - msgAtBoundary.content[0].type === 'tool_result') { - keep++; - continue; - } - break; - } - return Math.min(keep, history.length - 1); // Always summarize at least 1 message -} -/** - * Format messages for the summarization model. - */ -function formatForSummarization(messages) { - const parts = ['Here is the conversation to summarize:\n']; - for (const msg of messages) { - const role = msg.role.toUpperCase(); - if (typeof msg.content === 'string') { - parts.push(`[${role}]: ${msg.content}`); - } - else { - const textParts = []; - for (const part of msg.content) { - if ('type' in part) { - switch (part.type) { - case 'text': - textParts.push(part.text); - break; - case 'tool_use': - textParts.push(`[Called tool: ${part.name}(${JSON.stringify(part.input).slice(0, 200)})]`); - break; - case 'tool_result': { - const content = typeof part.content === 'string' ? part.content : JSON.stringify(part.content); - const truncated = content.length > 500 ? content.slice(0, 500) + '...' : content; - textParts.push(`[Tool result${part.is_error ? ' (ERROR)' : ''}: ${truncated}]`); - break; - } - case 'thinking': - // Skip thinking blocks in summary - break; - } - } - } - if (textParts.length > 0) { - parts.push(`[${role}]: ${textParts.join('\n')}`); - } - } - } - return parts.join('\n\n'); -} -/** - * Pick a cheaper/faster model for compaction to save cost. - */ -function pickCompactionModel(primaryModel) { - // Use cheapest capable model for summarization to save cost - // Tier down: opus/pro → sonnet, sonnet → haiku, everything else → flash (cheapest capable) - if (primaryModel.includes('opus') || primaryModel.includes('pro')) { - return 'anthropic/claude-sonnet-4.6'; - } - if (primaryModel.includes('sonnet') || primaryModel.includes('gpt-5.4') || primaryModel.includes('gemini-2.5-pro')) { - return 'anthropic/claude-haiku-4.5-20251001'; - } - if (primaryModel.includes('haiku') || primaryModel.includes('mini') || primaryModel.includes('nano')) { - return 'google/gemini-2.5-flash'; // Cheapest capable model - } - // Free/unknown models — use flash - return 'google/gemini-2.5-flash'; -} -/** - * Emergency fallback: drop oldest messages until under threshold. - * Used when the summarization model call itself fails. - */ -function emergencyTruncate(history, targetTokens) { - const result = [...history]; - while (result.length > 2 && estimateHistoryTokens(result) > targetTokens) { - result.shift(); - } - // Ensure first message is from user (API requirement) - if (result.length > 0 && result[0].role === 'assistant') { - result.unshift({ - role: 'user', - content: '[Earlier conversation truncated due to context limit]', - }); - } - return result; -} -/** - * Clear old tool results AND truncate old tool_use inputs to save tokens. - * This is the primary defense against context snowball: - * - tool_result content (Read output, Bash output, Grep matches) grows fast - * - tool_use input (Edit replacements, Bash commands) also accumulates - * Both are cleared for all but the last N tool exchanges. - */ -export function microCompact(history, keepLastN = 3) { - // Find all tool_use IDs in assistant messages, in order - const allToolUseIds = []; - for (const msg of history) { - if (msg.role === 'assistant' && Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part.type === 'tool_use') { - allToolUseIds.push(part.id); - } - } - } - } - if (allToolUseIds.length <= keepLastN) { - return history; - } - // IDs to clear (all except the most recent N) - const clearIds = new Set(allToolUseIds.slice(0, -keepLastN)); - if (clearIds.size === 0) - return history; - const result = []; - let changed = false; - for (const msg of history) { - if (msg.role === 'user' && Array.isArray(msg.content)) { - // Clear old tool_result content - let modified = false; - const cleared = msg.content.map((part) => { - if (part.type === 'tool_result' && clearIds.has(part.tool_use_id)) { - // Already cleared — skip - if (part.content === '[Tool result cleared to save context]') - return part; - modified = true; - return { - type: 'tool_result', - tool_use_id: part.tool_use_id, - content: '[Tool result cleared to save context]', - is_error: part.is_error, - }; - } - return part; - }); - if (modified) { - changed = true; - result.push({ role: 'user', content: cleared }); - } - else { - result.push(msg); - } - } - else if (msg.role === 'assistant' && Array.isArray(msg.content)) { - // Truncate old tool_use inputs (keep name + id, shrink input) - let modified = false; - const truncated = msg.content.map((part) => { - if (part.type === 'tool_use' && clearIds.has(part.id)) { - const inputStr = JSON.stringify(part.input); - if (inputStr.length > 200) { - modified = true; - // Keep just enough to know what was called - const summary = {}; - const input = part.input; - for (const [k, v] of Object.entries(input)) { - const val = typeof v === 'string' ? v.slice(0, 100) : v; - summary[k] = val; - } - return { ...part, input: summary }; - } - } - return part; - }); - if (modified) { - changed = true; - result.push({ role: 'assistant', content: truncated }); - } - else { - result.push(msg); - } - } - else { - result.push(msg); - } - } - return changed ? result : history; -} diff --git a/dist/agent/context.d.ts b/dist/agent/context.d.ts deleted file mode 100644 index 665d468d..00000000 --- a/dist/agent/context.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Context Manager for runcode - * Assembles system instructions, reads project config, injects environment info. - */ -/** - * Build the full system instructions array for a session. - * Result is memoized per workingDir for the process lifetime. - */ -export declare function assembleInstructions(workingDir: string, model?: string): string[]; -/** - * Model-family-specific execution guidance. - * Weak models get strict guardrails. Strong models get quality standards. - */ -export declare function getModelGuidance(model: string): string; -/** Invalidate cache for a workingDir (call after /clear or session reset). */ -export declare function invalidateInstructionCache(workingDir?: string): void; diff --git a/dist/agent/context.js b/dist/agent/context.js deleted file mode 100644 index 3fb73111..00000000 --- a/dist/agent/context.js +++ /dev/null @@ -1,292 +0,0 @@ -/** - * Context Manager for runcode - * Assembles system instructions, reads project config, injects environment info. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { execSync } from 'node:child_process'; -import { loadLearnings, decayLearnings, saveLearnings, formatForPrompt } from '../learnings/store.js'; -// ─── System Instructions Assembly ────────────────────────────────────────── -const BASE_INSTRUCTIONS = `You are runcode, an AI coding agent that helps users with software engineering tasks. -You have access to tools for reading, writing, editing files, running shell commands, searching codebases, web browsing, and more. - -# Core Principles -- Read before writing: always understand existing code before making changes. -- Be precise: make minimal, targeted changes. Don't refactor code you weren't asked to touch. -- Be safe: never introduce security vulnerabilities. Validate at system boundaries. -- Be honest: if you're unsure, say so. Don't guess at implementation details. - -# Tool Usage -- **Read**: Read files with line numbers. Use offset/limit for large files. -- **Edit**: Targeted string replacement (preferred for existing files). old_string must be unique. -- **Write**: Create new files or full rewrites. -- **Bash**: Run shell commands. Default timeout 2min. Batch sequential commands with && to reduce round-trips. -- **Glob**: Find files by pattern. Skips node_modules/.git. -- **Grep**: Regex search. Default: file paths. output_mode "content" for matching lines. -- **WebFetch** / **WebSearch**: Fetch pages or search the web. -- **Task**: Track multi-step work. -- **Agent**: Spawn parallel sub-agents. - -# Best Practices -- Glob/Grep before Read; Read before Edit. -- **Parallel**: call independent tools together in one response. -- **Batch bash**: combine sequential shell commands into one Bash call with && or a script. Only split when you need to inspect intermediate output. -- **AskUser**: Only use AskUser when you are about to perform a destructive action (deleting files, dropping databases) and need explicit confirmation. NEVER use AskUser to ask what the user wants — just answer their message directly. If their request is vague, make a reasonable assumption and proceed. -- Never write to /etc, /usr, ~/.ssh, ~/.aws. Don't commit secrets. -- Type /help to see all slash commands. - -# Tool-Use Enforcement -You MUST use tools to take action — do not describe what you would do without doing it. -Never end your turn with a promise of future action — execute it now. -Every response should either (a) contain tool calls that make progress, or (b) deliver a final result to the user. -Responses that only describe intentions without acting are not acceptable. - -# Missing Access -Always deliver results first using whatever tools work (WebSearch, WebFetch, etc.). Never let missing access block you. -After delivering results, if a better data source exists, add one line at the end: -"Tip: run franklin social setup && franklin social login x for live X data." -Do NOT check access before acting. Do NOT explain what you tried. Just deliver, then tip. - -# X / Social Marketing — STRICT RULES -SearchX is the ONLY tool that can access X.com. WebSearch and WebFetch CANNOT access X.com content. - -RULES (violations will produce garbage output): -1. Make ONE SearchX call per topic. Never retry with variations. -2. If SearchX returns empty, tell the user "No posts found" and suggest a different keyword. Do NOT fall back to WebSearch/WebFetch — they will return non-X content that you must NEVER present as X posts. -3. NEVER fabricate X post URLs. Every link you show MUST come from SearchX results. If a URL doesn't start with "https://x.com/", do NOT present it as an X post. -4. Present results as a numbered list. Each item: author, snippet, URL from SearchX, and a 1-2 sentence suggested reply. -5. Reply drafts must sound like a real human: short, specific to the post content, conversational. NO marketing speak, NO "Great point about...", NO corporate tone. Write like a smart friend, not a LinkedIn bot. -6. End with: "Reply to any? Give me the number." -7. Do NOT auto-post. Do NOT explain how the social system works. - -When checking notifications/mentions: Use SearchX with mode="notifications". One call, done. - -# Token Efficiency -- **Search once, not 10 times.** Do NOT run WebSearch with slight query variations. 3-5 searches MAX per topic. If results are empty, stop searching — do not rephrase and retry. -- **Stop after repeated misses.** If 2 similar searches for the same topic return empty/low-signal results, stop and synthesize what you have. -- **Read files once.** Do NOT re-read files you already read in this conversation. The content is already in your context. -- **Parallel tool calls.** When you need multiple independent pieces of information, call all tools in a single response. Never call them one-by-one in separate turns. -- **Present results early.** After 3 searches, present what you found. Do not keep searching for "more" — the user can ask if they want more. - -# Before Responding (verification checklist) -- Correctness: does your output satisfy the user's request? -- Grounding: are all factual claims backed by tool results, not your memory? -- URLs: does every link come from a tool result? NEVER fabricate URLs. -- Conciseness: is the response direct and actionable, not verbose filler?`; -// Cache assembled instructions per workingDir — avoids re-running git commands -// when sub-agents are spawned (common in parallel tool use patterns). -const _instructionCache = new Map(); -/** - * Build the full system instructions array for a session. - * Result is memoized per workingDir for the process lifetime. - */ -export function assembleInstructions(workingDir, model) { - const cacheKey = model ? `${workingDir}::${model}` : workingDir; - const cached = _instructionCache.get(cacheKey); - if (cached) - return cached; - const parts = [BASE_INSTRUCTIONS]; - // Read RUNCODE.md or CLAUDE.md from the project - const projectConfig = readProjectConfig(workingDir); - if (projectConfig) { - parts.push(`# Project Instructions\n\n${projectConfig}`); - } - // Inject environment info - parts.push(buildEnvironmentSection(workingDir)); - // Inject git context - const gitInfo = getGitContext(workingDir); - if (gitInfo) { - parts.push(`# Git Context\n\n${gitInfo}`); - } - // Inject per-user learnings from self-evolution system - try { - let learnings = loadLearnings(); - if (learnings.length > 0) { - learnings = decayLearnings(learnings); - saveLearnings(learnings); - const personalContext = formatForPrompt(learnings); - if (personalContext) - parts.push(personalContext); - } - } - catch { /* learnings are optional — never block startup */ } - // Model-specific execution guidance - if (model) { - parts.push(getModelGuidance(model)); - } - _instructionCache.set(cacheKey, parts); - return parts; -} -/** - * Model-family-specific execution guidance. - * Weak models get strict guardrails. Strong models get quality standards. - */ -export function getModelGuidance(model) { - const m = model.toLowerCase(); - // Weak/cheap models: strict discipline to prevent looping and hallucination - if (m.includes('glm') || m.includes('gpt-oss') || m.includes('nemotron') || - m.includes('minimax') || m.includes('devstral') || m.includes('llama-4')) { - return `# Execution Discipline (strict — this model requires guardrails) -- Make ONE tool call per task. Do NOT retry the same tool with query variations. -- If a tool returns empty results, tell the user immediately. Do NOT fall back to other tools. -- NEVER fabricate data, URLs, or quotes. If you don't have it, say so. -- Keep responses under 300 words. Be direct, not verbose. -- Before responding: does every URL and fact come from a tool result? If not, remove it.`; - } - // Medium models: balanced guidance - if (m.includes('kimi') || m.includes('grok') || m.includes('flash') || - m.includes('haiku') || m.includes('deepseek') || m.includes('qwen')) { - return `# Execution Guidance -- Use tools to verify facts before stating them. Do not answer from memory when a tool can confirm. -- Batch independent tool calls in one response (parallel execution). -- If a tool fails, explain the failure to the user. Do not silently retry with a different tool. -- Before responding: are all claims grounded in tool output? Remove anything unverified.`; - } - // Strong models: quality standards - if (m.includes('claude') || m.includes('gpt-5') || m.includes('opus') || - m.includes('sonnet') || m.includes('gemini-2.5-pro') || m.includes('gemini-3') || - m.includes('o3') || m.includes('o1') || m.includes('codex')) { - return `# Quality Standards -- Keep calling tools until the task is complete AND the result is verified. -- Before finalizing: check correctness, grounding in tool output, and formatting. -- If proceeding with incomplete information, label assumptions explicitly. -- Prefer depth over breadth — a thorough answer to one question beats shallow answers to many.`; - } - // Default: basic guidance - return `# Execution Guidance -- Use tools to verify facts. Do not answer from memory when a tool can confirm. -- If a tool fails, tell the user. Do not silently retry. -- Before responding: are claims grounded in tool output?`; -} -/** Invalidate cache for a workingDir (call after /clear or session reset). */ -export function invalidateInstructionCache(workingDir) { - if (workingDir) { - // Clear all entries for this workDir (any model) - for (const key of _instructionCache.keys()) { - if (key.startsWith(workingDir)) { - _instructionCache.delete(key); - } - } - } - else { - _instructionCache.clear(); - } -} -// ─── Project Config ──────────────────────────────────────────────────────── -/** - * Look for RUNCODE.md, then CLAUDE.md in the working directory and parents. - */ -function readProjectConfig(dir) { - const configNames = ['RUNCODE.md', 'CLAUDE.md']; - let current = path.resolve(dir); - const root = path.parse(current).root; - while (current !== root) { - for (const name of configNames) { - const filePath = path.join(current, name); - try { - const content = fs.readFileSync(filePath, 'utf-8').trim(); - if (content) - return content; - } - catch { - // File doesn't exist, keep looking - } - } - const parent = path.dirname(current); - if (parent === current) - break; - current = parent; - } - return null; -} -// ─── Environment ─────────────────────────────────────────────────────────── -function buildEnvironmentSection(workingDir) { - const lines = ['# Environment']; - lines.push(`- Working directory: ${workingDir}`); - lines.push(`- Platform: ${process.platform}`); - lines.push(`- Node.js: ${process.version}`); - // Detect shell - const shell = process.env.SHELL || process.env.COMSPEC || 'unknown'; - lines.push(`- Shell: ${path.basename(shell)}`); - // Date - lines.push(`- Date: ${new Date().toISOString().split('T')[0]}`); - return lines.join('\n'); -} -// ─── Git Context ─────────────────────────────────────────────────────────── -const GIT_TIMEOUT_MS = 5_000; -// Max chars for git log output — long commit messages can bloat the system prompt -const MAX_GIT_LOG_CHARS = 2_000; -function getGitContext(workingDir) { - try { - const isGit = execSync('git rev-parse --is-inside-work-tree', { - cwd: workingDir, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: GIT_TIMEOUT_MS, - }).trim(); - if (isGit !== 'true') - return null; - const lines = []; - // Current branch - try { - const branch = execSync('git branch --show-current', { - cwd: workingDir, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: GIT_TIMEOUT_MS, - }).trim(); - if (branch) - lines.push(`Branch: ${branch}`); - } - catch { /* detached HEAD or error */ } - // Git status (brief) - try { - const status = execSync('git status --short', { - cwd: workingDir, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: GIT_TIMEOUT_MS, - }).trim(); - if (status) { - const fileCount = status.split('\n').length; - lines.push(`Changed files: ${fileCount}`); - } - else { - lines.push('Status: clean'); - } - } - catch { /* ignore */ } - // Recent commits (last 5) — capped to prevent huge messages bloating context - try { - let log = execSync('git log --oneline -5', { - cwd: workingDir, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: GIT_TIMEOUT_MS, - }).trim(); - if (log) { - if (log.length > MAX_GIT_LOG_CHARS) { - log = log.slice(0, MAX_GIT_LOG_CHARS) + '\n... (truncated)'; - } - lines.push(`\nRecent commits:\n${log}`); - } - } - catch { /* ignore */ } - // Git user - try { - const user = execSync('git config user.name', { - cwd: workingDir, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: GIT_TIMEOUT_MS, - }).trim(); - if (user) - lines.push(`User: ${user}`); - } - catch { /* ignore */ } - return lines.length > 0 ? lines.join('\n') : null; - } - catch { - return null; - } -} diff --git a/dist/agent/error-classifier.d.ts b/dist/agent/error-classifier.d.ts deleted file mode 100644 index 1291edfa..00000000 --- a/dist/agent/error-classifier.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Classify model/runtime errors so recovery and UX can be more consistent. - */ -export type AgentErrorCategory = 'rate_limit' | 'payment' | 'network' | 'timeout' | 'context_limit' | 'server' | 'unknown'; -export interface AgentErrorInfo { - category: AgentErrorCategory; - label: 'RateLimit' | 'Payment' | 'Network' | 'Timeout' | 'Context' | 'Server' | 'Unknown'; - isTransient: boolean; -} -export declare function classifyAgentError(message: string): AgentErrorInfo; diff --git a/dist/agent/error-classifier.js b/dist/agent/error-classifier.js deleted file mode 100644 index 071cdf1f..00000000 --- a/dist/agent/error-classifier.js +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Classify model/runtime errors so recovery and UX can be more consistent. - */ -function includesAny(text, patterns) { - return patterns.some((p) => text.includes(p)); -} -export function classifyAgentError(message) { - const err = message.toLowerCase(); - if (includesAny(err, [ - 'insufficient', - 'payment', - 'verification failed', - 'balance', - '402', - 'free tier', - ])) { - return { category: 'payment', label: 'Payment', isTransient: false }; - } - if (includesAny(err, [ - '429', - 'rate limit', - 'too many requests', - ])) { - return { category: 'rate_limit', label: 'RateLimit', isTransient: true }; - } - if (includesAny(err, [ - 'prompt is too long', - 'context length', - 'maximum context', - ])) { - return { category: 'context_limit', label: 'Context', isTransient: false }; - } - if (includesAny(err, [ - 'timeout', - 'timed out', - ])) { - return { category: 'timeout', label: 'Timeout', isTransient: true }; - } - if (includesAny(err, [ - 'fetch failed', - 'econnrefused', - 'econnreset', - 'enotfound', - 'network', - 'socket hang up', - ])) { - return { category: 'network', label: 'Network', isTransient: true }; - } - if (includesAny(err, [ - '500', - '502', - '503', - '504', - 'internal server error', - 'bad gateway', - 'service unavailable', - 'temporarily unavailable', // "Service temporarily unavailable" - 'workers are busy', // "All workers are busy" - 'server busy', - 'overloaded', - 'please retry later', - 'retry in a few', - 'upstream error', - ])) { - return { category: 'server', label: 'Server', isTransient: true }; - } - return { category: 'unknown', label: 'Unknown', isTransient: false }; -} diff --git a/dist/agent/llm.d.ts b/dist/agent/llm.d.ts deleted file mode 100644 index 11402a52..00000000 --- a/dist/agent/llm.d.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * LLM Client for runcode - * Calls BlockRun API directly with x402 payment handling and streaming. - * Original implementation — not derived from any existing codebase. - */ -import { type Chain } from '../config.js'; -import type { Dialogue, CapabilityDefinition, ContentPart, CapabilityInvocation } from './types.js'; -export interface ModelRequest { - model: string; - messages: Dialogue[]; - system?: string; - tools?: CapabilityDefinition[]; - max_tokens?: number; - stream?: boolean; - temperature?: number; -} -export interface StreamChunk { - kind: 'content_block_start' | 'content_block_delta' | 'content_block_stop' | 'message_start' | 'message_delta' | 'message_stop' | 'ping' | 'error'; - payload: Record; -} -export interface CompletionUsage { - inputTokens: number; - outputTokens: number; -} -export interface LLMClientOptions { - apiUrl: string; - chain: Chain; - debug?: boolean; -} -export declare class ModelClient { - private apiUrl; - private chain; - private debug; - private walletAddress; - private cachedBaseWallet; - private cachedSolanaWallet; - private walletCacheTime; - private static WALLET_CACHE_TTL; - constructor(opts: LLMClientOptions); - /** - * Stream a completion from the BlockRun API. - * Yields parsed SSE chunks as they arrive. - * Handles x402 payment automatically on 402 responses. - */ - /** - * Resolve virtual routing profiles (blockrun/auto, blockrun/eco, etc.) - * to concrete models. This is the final safety net — if the router in - * loop.ts didn't resolve it (e.g. old global install without router), - * we resolve it here before hitting the API. - */ - private resolveVirtualModel; - streamCompletion(request: ModelRequest, signal?: AbortSignal): AsyncGenerator; - /** - * Non-streaming completion for simple requests. - */ - complete(request: ModelRequest, signal?: AbortSignal, onToolReady?: (tool: CapabilityInvocation) => void, onStreamDelta?: (delta: { - type: 'text' | 'thinking'; - text: string; - }) => void): Promise<{ - content: ContentPart[]; - usage: CompletionUsage; - stopReason: string; - }>; - private signPayment; - private signBasePayment; - private signSolanaPayment; - private extractPaymentReq; - private parseSSEStream; - private mapEventType; -} diff --git a/dist/agent/llm.js b/dist/agent/llm.js deleted file mode 100644 index 5fbd6b18..00000000 --- a/dist/agent/llm.js +++ /dev/null @@ -1,503 +0,0 @@ -/** - * LLM Client for runcode - * Calls BlockRun API directly with x402 payment handling and streaming. - * Original implementation — not derived from any existing codebase. - */ -import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm'; -import { USER_AGENT } from '../config.js'; -// ─── Anthropic Prompt Caching ───────────────────────────────────────────── -/** - * Apply Anthropic prompt caching using the `system_and_3` strategy. - * Pattern from nousresearch/hermes-agent `agent/prompt_caching.py`. - * - * Places 4 cache_control breakpoints (Anthropic's max): - * 1. System prompt (stable across all turns) - * 2-4. Last 3 non-system messages (rolling window) - * - * Also caches the last tool definition (tools are stable across turns). - * - * This keeps the cache warm: each new turn extends the cached prefix rather - * than invalidating it. Multi-turn conversations see ~75% input token savings - * on Anthropic models. - */ -function applyAnthropicPromptCaching(payload, request) { - const out = { ...payload }; - const cacheMarker = { type: 'ephemeral' }; - // 1. System prompt → wrap as array with cache_control on the text block - if (typeof request.system === 'string' && request.system.length > 0) { - out['system'] = [ - { type: 'text', text: request.system, cache_control: cacheMarker }, - ]; - } - // 2. Tools → cache_control on the last tool (stable across turns) - if (request.tools && request.tools.length > 0) { - const toolsCopy = request.tools.map(t => ({ ...t })); - toolsCopy[toolsCopy.length - 1]['cache_control'] = cacheMarker; - out['tools'] = toolsCopy; - } - // 3. Messages → rolling cache_control on last 3 messages (user/assistant). - // System is a separate field in ModelRequest, so all messages here are non-system. - // Strategy: mark the last 3 messages so the cached prefix extends as the - // conversation grows. Older cached prefixes expire after 5 min but newer - // ones keep the cache warm. - if (request.messages && request.messages.length > 0) { - const messagesCopy = request.messages.map(m => ({ ...m })); - // Mark last 3 messages (or fewer if history is shorter) - const start = Math.max(0, messagesCopy.length - 3); - for (let idx = start; idx < messagesCopy.length; idx++) { - const msg = messagesCopy[idx]; - if (typeof msg.content === 'string') { - messagesCopy[idx]['content'] = [ - { type: 'text', text: msg.content, cache_control: cacheMarker }, - ]; - } - else if (Array.isArray(msg.content) && msg.content.length > 0) { - const contentCopy = msg.content.map(c => ({ ...c })); - // cache_control goes on the last content block - contentCopy[contentCopy.length - 1]['cache_control'] = cacheMarker; - messagesCopy[idx]['content'] = contentCopy; - } - } - out['messages'] = messagesCopy; - } - return out; -} -// ─── Client ──────────────────────────────────────────────────────────────── -export class ModelClient { - apiUrl; - chain; - debug; - walletAddress = ''; - cachedBaseWallet = null; - cachedSolanaWallet = null; - walletCacheTime = 0; - static WALLET_CACHE_TTL = 30 * 60 * 1000; // 30 min TTL - constructor(opts) { - this.apiUrl = opts.apiUrl; - this.chain = opts.chain; - this.debug = opts.debug ?? false; - } - /** - * Stream a completion from the BlockRun API. - * Yields parsed SSE chunks as they arrive. - * Handles x402 payment automatically on 402 responses. - */ - /** - * Resolve virtual routing profiles (blockrun/auto, blockrun/eco, etc.) - * to concrete models. This is the final safety net — if the router in - * loop.ts didn't resolve it (e.g. old global install without router), - * we resolve it here before hitting the API. - */ - resolveVirtualModel(model) { - if (!model.startsWith('blockrun/')) - return model; - // Import router dynamically to avoid circular deps - try { - const { routeRequest, parseRoutingProfile } = require('../router/index.js'); - const profile = parseRoutingProfile(model); - if (profile) { - const result = routeRequest('', profile); - if (result?.model && !result.model.startsWith('blockrun/')) { - return result.model; - } - } - } - catch { - // Router not available (e.g. old build) — use hardcoded fallback table - } - // Static fallback if router is unavailable - const FALLBACKS = { - 'blockrun/auto': 'zai/glm-5.1', - 'blockrun/eco': 'nvidia/nemotron-ultra-253b', - 'blockrun/premium': 'anthropic/claude-sonnet-4.6', - 'blockrun/free': 'nvidia/nemotron-ultra-253b', - }; - return FALLBACKS[model] || 'zai/glm-5.1'; - } - async *streamCompletion(request, signal) { - // Resolve virtual models before any API call - const resolvedModel = this.resolveVirtualModel(request.model); - if (resolvedModel !== request.model) { - request = { ...request, model: resolvedModel }; - } - const isAnthropic = request.model.startsWith('anthropic/'); - const isGLM = request.model.startsWith('zai/') || request.model.includes('glm'); - // Build the request payload, injecting model-specific optimizations - let requestPayload = { ...request, stream: true }; - // ── GLM-specific optimizations ─────────────────────────────────────────── - // GLM models work best with temperature=0.8 per official zai spec. - // Enable thinking mode only for explicit reasoning variants (-thinking-). - if (isGLM) { - if (requestPayload['temperature'] === undefined) { - requestPayload['temperature'] = 0.8; - } - // Only enable thinking for models that explicitly ship reasoning mode - if (request.model.includes('-thinking-')) { - requestPayload['thinking'] = { type: 'enabled' }; - } - } - if (isAnthropic) { - // ─ Anthropic prompt caching: `system_and_3` strategy ───────────────── - // 4 cache_control breakpoints (Anthropic max): - // 1. System prompt (stable across turns) - // 2-4. Last 3 non-system messages (rolling window) - // - // This keeps the cache warm across turns: each new turn extends the - // cache instead of invalidating it. ~75% input token savings on - // multi-turn conversations. Pattern adopted from nousresearch/hermes-agent. - requestPayload = applyAnthropicPromptCaching(requestPayload, request); - } - // ── GPT-5 / Codex: use "developer" role for system prompt ────────────── - // OpenAI GPT models give stronger instruction-following weight to the - // "developer" role. Move the top-level system prompt into messages[0] - // with role "developer" instead of the default "system". - const isGPT5OrCodex = request.model.includes('gpt-5') || request.model.includes('codex'); - if (isGPT5OrCodex && typeof request.system === 'string' && request.system.length > 0) { - const systemRole = 'developer'; - const existingMessages = requestPayload['messages'] || []; - requestPayload['messages'] = [ - { role: systemRole, content: request.system }, - ...existingMessages, - ]; - delete requestPayload['system']; - } - const body = JSON.stringify(requestPayload); - const endpoint = `${this.apiUrl}/v1/messages`; - const headers = { - 'Content-Type': 'application/json', - 'anthropic-version': '2023-06-01', - 'x-api-key': 'x402-agent-handles-auth', - 'User-Agent': USER_AGENT, - }; - // Enable prompt caching beta for Anthropic models - if (isAnthropic) { - headers['anthropic-beta'] = 'prompt-caching-2024-07-31'; - } - if (this.debug) { - console.error(`[runcode] POST ${endpoint} model=${request.model}`); - } - let response = await fetch(endpoint, { - method: 'POST', - headers, - body, - signal, - }); - // Handle x402 payment - if (response.status === 402) { - if (this.debug) - console.error('[runcode] Payment required — signing...'); - const paymentHeader = await this.signPayment(response); - if (!paymentHeader) { - yield { kind: 'error', payload: { message: 'Payment signing failed' } }; - return; - } - response = await fetch(endpoint, { - method: 'POST', - headers: { ...headers, ...paymentHeader }, - body, - signal, - }); - } - if (!response.ok) { - const errorBody = await response.text().catch(() => 'unknown error'); - // Extract human-readable message from JSON error bodies ({"error":{"message":"..."}}) - let message = errorBody; - try { - const parsed = JSON.parse(errorBody); - message = parsed?.error?.message || parsed?.message || errorBody; - } - catch { /* not JSON — use raw text */ } - yield { - kind: 'error', - payload: { status: response.status, message }, - }; - return; - } - // Parse SSE stream - yield* this.parseSSEStream(response, signal); - } - /** - * Non-streaming completion for simple requests. - */ - async complete(request, signal, onToolReady, onStreamDelta) { - const collected = []; - let usage = { inputTokens: 0, outputTokens: 0 }; - let stopReason = 'end_turn'; - // Accumulate from stream - let currentText = ''; - let currentThinking = ''; - let currentToolId = ''; - let currentToolName = ''; - let currentToolInput = ''; - for await (const chunk of this.streamCompletion(request, signal)) { - switch (chunk.kind) { - case 'content_block_start': { - const block = chunk.payload; - const cblock = block['content_block']; - if (cblock?.type === 'tool_use') { - currentToolId = cblock.id || ''; - currentToolName = cblock.name || ''; - currentToolInput = ''; - } - else if (cblock?.type === 'thinking') { - currentThinking = ''; - } - else if (cblock?.type === 'text') { - currentText = ''; - } - break; - } - case 'content_block_delta': { - const delta = chunk.payload['delta']; - if (!delta) - break; - if (delta.type === 'text_delta') { - const text = delta.text || ''; - currentText += text; - if (text) - onStreamDelta?.({ type: 'text', text }); - } - else if (delta.type === 'thinking_delta') { - const text = delta.thinking || ''; - currentThinking += text; - if (text) - onStreamDelta?.({ type: 'thinking', text }); - } - else if (delta.type === 'input_json_delta') { - currentToolInput += delta.partial_json || ''; - } - break; - } - case 'content_block_stop': { - if (currentToolId) { - let parsedInput = {}; - try { - parsedInput = JSON.parse(currentToolInput || '{}'); - } - catch (parseErr) { - // Log malformed JSON instead of silently defaulting to {} - if (this.debug) { - console.error(`[runcode] Malformed tool input JSON for ${currentToolName}: ${parseErr.message}`); - } - } - const toolInvocation = { - type: 'tool_use', - id: currentToolId, - name: currentToolName, - input: parsedInput, - }; - collected.push(toolInvocation); - // Notify caller so concurrent tools can start immediately - onToolReady?.(toolInvocation); - currentToolId = ''; - currentToolName = ''; - currentToolInput = ''; - } - else if (currentThinking) { - collected.push({ - type: 'thinking', - thinking: currentThinking, - }); - currentThinking = ''; - } - else if (currentText) { - collected.push({ - type: 'text', - text: currentText, - }); - currentText = ''; - } - break; - } - case 'message_delta': { - const msgUsage = chunk.payload['usage']; - if (msgUsage) { - usage.outputTokens = msgUsage['output_tokens'] ?? usage.outputTokens; - } - const delta = chunk.payload['delta']; - if (delta?.['stop_reason']) { - stopReason = delta['stop_reason']; - } - break; - } - case 'message_start': { - const msg = chunk.payload['message']; - const msgUsage = msg?.['usage']; - if (msgUsage) { - usage.inputTokens = msgUsage['input_tokens'] ?? 0; - usage.outputTokens = msgUsage['output_tokens'] ?? 0; - } - break; - } - case 'error': { - const errMsg = chunk.payload['message'] || 'API error'; - const status = chunk.payload['status']; - // Prefix with HTTP status so classifyAgentError() can match on it - // (the inner JSON .message field often strips the status code, e.g. - // "Service temporarily unavailable" doesn't contain "503"). - throw new Error(status ? `HTTP ${status}: ${errMsg}` : errMsg); - } - } - } - // Flush any remaining text - if (currentText) { - collected.push({ type: 'text', text: currentText }); - } - return { content: collected, usage, stopReason }; - } - // ─── Payment ─────────────────────────────────────────────────────────── - async signPayment(response) { - try { - if (this.chain === 'solana') { - return await this.signSolanaPayment(response); - } - return await this.signBasePayment(response); - } - catch (err) { - const msg = err.message || ''; - if (msg.includes('insufficient') || msg.includes('balance')) { - console.error(`[runcode] Insufficient USDC balance. Run 'runcode balance' to check.`); - } - else if (this.debug) { - console.error('[runcode] Payment error:', msg); - } - else { - console.error(`[runcode] Payment failed: ${msg.slice(0, 100)}`); - } - return null; - } - } - async signBasePayment(response) { - // Refresh wallet cache after TTL to pick up balance/key changes - if (!this.cachedBaseWallet || (Date.now() - this.walletCacheTime > ModelClient.WALLET_CACHE_TTL)) { - const w = getOrCreateWallet(); - this.walletCacheTime = Date.now(); - this.cachedBaseWallet = { privateKey: w.privateKey, address: w.address }; - } - const wallet = this.cachedBaseWallet; - this.walletAddress = wallet.address; - // Extract payment requirements from 402 response - const paymentHeader = await this.extractPaymentReq(response); - if (!paymentHeader) - throw new Error('No payment requirements in 402 response'); - const paymentRequired = parsePaymentRequired(paymentHeader); - const details = extractPaymentDetails(paymentRequired); - const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', { - resourceUrl: details.resource?.url || this.apiUrl, - resourceDescription: details.resource?.description || 'BlockRun AI API call', - maxTimeoutSeconds: details.maxTimeoutSeconds || 300, - extra: details.extra, - }); - return { 'PAYMENT-SIGNATURE': payload }; - } - async signSolanaPayment(response) { - if (!this.cachedSolanaWallet || (Date.now() - this.walletCacheTime > ModelClient.WALLET_CACHE_TTL)) { - const w = await getOrCreateSolanaWallet(); - this.walletCacheTime = Date.now(); - this.cachedSolanaWallet = { privateKey: w.privateKey, address: w.address }; - } - const wallet = this.cachedSolanaWallet; - this.walletAddress = wallet.address; - const paymentHeader = await this.extractPaymentReq(response); - if (!paymentHeader) - throw new Error('No payment requirements in 402 response'); - const paymentRequired = parsePaymentRequired(paymentHeader); - const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK); - const secretBytes = await solanaKeyToBytes(wallet.privateKey); - const feePayer = details.extra?.feePayer || details.recipient; - const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, { - resourceUrl: details.resource?.url || this.apiUrl, - resourceDescription: details.resource?.description || 'BlockRun AI API call', - maxTimeoutSeconds: details.maxTimeoutSeconds || 300, - extra: details.extra, - }); - return { 'PAYMENT-SIGNATURE': payload }; - } - async extractPaymentReq(response) { - let header = response.headers.get('payment-required'); - if (!header) { - try { - const body = (await response.json()); - if (body.x402 || body.accepts) { - header = btoa(JSON.stringify(body)); - } - } - catch { /* ignore parse errors */ } - } - return header; - } - // ─── SSE Parsing ─────────────────────────────────────────────────────── - async *parseSSEStream(response, signal) { - const reader = response.body?.getReader(); - if (!reader) { - yield { kind: 'error', payload: { message: 'No response body' } }; - return; - } - const decoder = new TextDecoder(); - let buffer = ''; - // Persist across read() calls — event: and data: may arrive in separate chunks - let currentEvent = ''; - const MAX_BUFFER = 1_000_000; // 1MB buffer cap - try { - while (true) { - if (signal?.aborted) - break; - const { done, value } = await reader.read(); - if (done) - break; - buffer += decoder.decode(value, { stream: true }); - // Safety: if buffer grows too large without newlines, something is wrong - if (buffer.length > MAX_BUFFER) { - if (this.debug) { - console.error(`[runcode] SSE buffer overflow (${(buffer.length / 1024).toFixed(0)}KB) — truncating to prevent OOM`); - } - buffer = buffer.slice(-MAX_BUFFER / 2); - } - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed === '') { - // Blank line = end of SSE event (reset for next event) - currentEvent = ''; - continue; - } - if (trimmed.startsWith('event:')) { - currentEvent = trimmed.slice(6).trim(); - } - else if (trimmed.startsWith('data:')) { - const data = trimmed.slice(5).trim(); - if (data === '[DONE]') - return; - try { - const parsed = JSON.parse(data); - const mappedKind = this.mapEventType(currentEvent, parsed); - if (mappedKind) { - yield { kind: mappedKind, payload: parsed }; - } - } - catch { - // Skip malformed JSON lines - } - } - } - } - } - finally { - reader.releaseLock(); - } - } - mapEventType(event, _payload) { - switch (event) { - case 'message_start': return 'message_start'; - case 'message_delta': return 'message_delta'; - case 'message_stop': return 'message_stop'; - case 'content_block_start': return 'content_block_start'; - case 'content_block_delta': return 'content_block_delta'; - case 'content_block_stop': return 'content_block_stop'; - case 'ping': return 'ping'; - case 'error': return 'error'; - default: return null; - } - } -} diff --git a/dist/agent/loop.d.ts b/dist/agent/loop.d.ts deleted file mode 100644 index 2522a10b..00000000 --- a/dist/agent/loop.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * runcode Agent Loop - * The core reasoning-action cycle: prompt → model → extract capabilities → execute → repeat. - * Original implementation with different architecture from any reference codebase. - */ -import type { AgentConfig, Dialogue, StreamEvent } from './types.js'; -/** - * Run a multi-turn interactive session. - * Each user message triggers a full agent loop. - * Returns the accumulated conversation history. - */ -export declare function interactiveSession(config: AgentConfig, getUserInput: () => Promise, onEvent: (event: StreamEvent) => void, onAbortReady?: (abort: () => void) => void): Promise; diff --git a/dist/agent/loop.js b/dist/agent/loop.js deleted file mode 100644 index c5a93b92..00000000 --- a/dist/agent/loop.js +++ /dev/null @@ -1,560 +0,0 @@ -/** - * runcode Agent Loop - * The core reasoning-action cycle: prompt → model → extract capabilities → execute → repeat. - * Original implementation with different architecture from any reference codebase. - */ -import { ModelClient } from './llm.js'; -import { autoCompactIfNeeded, microCompact } from './compact.js'; -import { estimateHistoryTokens, updateActualTokens, resetTokenAnchor, getAnchoredTokenCount, getContextWindow } from './tokens.js'; -import { handleSlashCommand } from './commands.js'; -import { reduceTokens } from './reduce.js'; -import { PermissionManager } from './permissions.js'; -import { StreamingExecutor } from './streaming-executor.js'; -import { optimizeHistory, CAPPED_MAX_TOKENS, ESCALATED_MAX_TOKENS, getMaxOutputTokens } from './optimize.js'; -import { classifyAgentError } from './error-classifier.js'; -import { SessionToolGuard } from './tool-guard.js'; -import { recordUsage } from '../stats/tracker.js'; -import { recordSessionUsage } from '../stats/session-tracker.js'; -import { estimateCost, OPUS_PRICING } from '../pricing.js'; -import { routeRequest, parseRoutingProfile } from '../router/index.js'; -import { recordOutcome } from '../router/local-elo.js'; -import { createSessionId, appendToSession, updateSessionMeta, pruneOldSessions, } from '../session/storage.js'; -/** - * Sanitize history: fix orphaned tool results and missing results. - * Inspired by Hermes _sanitize_api_messages(). - */ -function sanitizeHistory(history) { - // Collect all tool_use IDs from assistant messages - const callIds = new Set(); - // Collect all tool_result IDs from user messages - const resultIds = new Set(); - for (const msg of history) { - if (msg.role === 'assistant' && Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part.type === 'tool_use' && part.id) { - callIds.add(part.id); - } - } - } - if (msg.role === 'user' && Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part.type === 'tool_result' && part.tool_use_id) { - resultIds.add(part.tool_use_id); - } - } - } - } - // Remove orphaned tool results (results without matching calls) - const orphaned = new Set([...resultIds].filter(id => !callIds.has(id))); - if (orphaned.size === 0) - return history; - return history.map(msg => { - if (msg.role === 'user' && Array.isArray(msg.content)) { - const filtered = msg.content.filter(p => !(p.type === 'tool_result' && orphaned.has(p.tool_use_id))); - if (filtered.length === 0) - return null; - return { ...msg, content: filtered }; - } - return msg; - }).filter(Boolean); -} -// ─── Interactive Session ─────────────────────────────────────────────────── -/** - * Run a multi-turn interactive session. - * Each user message triggers a full agent loop. - * Returns the accumulated conversation history. - */ -export async function interactiveSession(config, getUserInput, onEvent, onAbortReady) { - const client = new ModelClient({ - apiUrl: config.apiUrl, - chain: config.chain, - debug: config.debug, - }); - const capabilityMap = new Map(); - for (const cap of config.capabilities) { - capabilityMap.set(cap.spec.name, cap); - } - const toolDefs = config.capabilities.map((c) => c.spec); - const maxTurns = config.maxTurns ?? 100; - const workDir = config.workingDir ?? process.cwd(); - const permissions = new PermissionManager(config.permissionMode ?? 'default', config.permissionPromptFn); - const history = []; - let lastUserInput = ''; // For /retry - const failedModels = new Set(); // Models that failed payment/rate-limit (session-level) - // Session persistence - const sessionId = createSessionId(); - let turnCount = 0; - let tokenBudgetWarned = false; // Emit token budget warning at most once per session - let lastSessionActivity = Date.now(); - let lastRoutedModel = ''; // last model chosen by router (for local elo) - let lastRoutedCategory = ''; // last category detected (for local elo) - let sessionInputTokens = 0; - let sessionOutputTokens = 0; - let sessionCostUsd = 0; - let sessionSavedVsOpus = 0; - const toolGuard = new SessionToolGuard(); - const persistSessionMeta = () => { - updateSessionMeta(sessionId, { - model: config.model, - workDir, - turnCount, - messageCount: history.length, - inputTokens: sessionInputTokens, - outputTokens: sessionOutputTokens, - costUsd: sessionCostUsd, - savedVsOpusUsd: sessionSavedVsOpus, - }); - }; - const persistSessionMessage = (message) => { - appendToSession(sessionId, message); - persistSessionMeta(); - }; - pruneOldSessions(sessionId); // Cleanup old sessions on start, protect current - persistSessionMeta(); - while (true) { - let input = await getUserInput(); - if (input === null) - break; // User wants to exit - if (input === '') - continue; // Empty input → re-prompt - // ── Slash command dispatch ── - if (input.startsWith('/')) { - // /retry re-sends the last user message - if (input === '/retry') { - // Record retry as negative signal for local elo - if (lastRoutedCategory && lastRoutedModel) { - recordOutcome(lastRoutedCategory, lastRoutedModel, 'retried'); - } - if (!lastUserInput) { - onEvent({ kind: 'text_delta', text: 'No previous message to retry.\n' }); - onEvent({ kind: 'turn_done', reason: 'completed' }); - continue; - } - input = lastUserInput; - } - else { - const cmdResult = await handleSlashCommand(input, { - history, config, client, sessionId, onEvent, - }); - if (cmdResult.handled) - continue; - if (cmdResult.rewritten) - input = cmdResult.rewritten; - } - } - lastUserInput = input; - history.push({ role: 'user', content: input }); - turnCount++; - toolGuard.startTurn(); - persistSessionMessage({ role: 'user', content: input }); - const abort = new AbortController(); - onAbortReady?.(() => abort.abort()); - let loopCount = 0; - let recoveryAttempts = 0; - let compactFailures = 0; - let maxTokensOverride; - const turnIdleReference = lastSessionActivity; - lastSessionActivity = Date.now(); - // ── Tool call guardrails (inspired by hermes-agent) ── - let turnToolCalls = 0; // Total tool calls this user turn - const turnToolCounts = new Map(); // Per-tool-name counts this turn - const readFileCache = new Set(); // Files already read (dedup) - const MAX_TOOL_CALLS_PER_TURN = 25; // Hard cap per user turn - const SAME_TOOL_WARN_THRESHOLD = 5; // Warn after N calls to same tool - // Agent loop for this user message - while (loopCount < maxTurns) { - loopCount++; - // Signal UI that a new LLM round is starting (shows spinner between tool results and next response) - if (loopCount > 1) { - onEvent({ kind: 'thinking_delta', text: '' }); - } - // ── Token optimization pipeline ── - // 1. Strip thinking, budget tool results, time-based cleanup (always — cheap) - const optimized = optimizeHistory(history, { - debug: config.debug, - lastActivityTimestamp: loopCount === 1 ? turnIdleReference : lastSessionActivity, - }); - if (optimized !== history) { - history.length = 0; - history.push(...optimized); - } - // 2. Token reduction: age old results, normalize whitespace, trim verbose messages - const reduced = reduceTokens(history, config.debug); - if (reduced !== history) { - history.length = 0; - history.push(...reduced); - } - // 3. Microcompact: clear old tool results to prevent context snowball - if (history.length > 6) { - const microCompacted = microCompact(history, 3); - if (microCompacted !== history) { - history.length = 0; - history.push(...microCompacted); - resetTokenAnchor(); // History shrunk — resync token tracking - } - } - // 3. Auto-compact: summarize history if approaching context limit - // Circuit breaker: stop retrying after 3 consecutive failures - if (compactFailures < 3) { - try { - const { history: compacted, compacted: didCompact } = await autoCompactIfNeeded(history, config.model, client, config.debug); - if (didCompact) { - history.length = 0; - history.push(...compacted); - resetTokenAnchor(); - compactFailures = 0; - if (config.debug) { - console.error(`[runcode] History compacted: ~${estimateHistoryTokens(history)} tokens`); - } - } - } - catch (compactErr) { - compactFailures++; - if (config.debug) { - console.error(`[runcode] Compaction failed (${compactFailures}/3): ${compactErr.message}`); - } - } - } - // Inject ultrathink instruction when mode is active - const systemParts = [...config.systemInstructions]; - if (config.ultrathink) { - systemParts.push('# Ultrathink Mode\n' + - 'You are in deep reasoning mode. Before responding to any request:\n' + - '1. Thoroughly analyze the problem from multiple angles\n' + - '2. Consider edge cases, failure modes, and second-order effects\n' + - '3. Challenge your initial assumptions before committing to an approach\n' + - '4. Think step by step — show your reasoning explicitly when it adds value\n' + - 'Prioritize correctness and thoroughness over speed.'); - } - const systemPrompt = systemParts.join('\n\n'); - const modelMaxOut = getMaxOutputTokens(config.model); - let maxTokens = Math.min(maxTokensOverride ?? CAPPED_MAX_TOKENS, modelMaxOut); - let responseParts = []; - let usage; - let stopReason; - // Create streaming executor for concurrent tool execution - const streamExec = new StreamingExecutor({ - handlers: capabilityMap, - scope: { workingDir: workDir, abortSignal: abort.signal, onAskUser: config.onAskUser }, - permissions, - guard: toolGuard, - onStart: (id, name, preview) => onEvent({ kind: 'capability_start', id, name, preview }), - onProgress: (id, text) => onEvent({ kind: 'capability_progress', id, text }), - }); - // ── Router: resolve routing profiles to concrete models ── - const routingProfile = parseRoutingProfile(config.model); - let resolvedModel = config.model; - let routingTier; - let routingConfidence; - let routingSavings; - if (routingProfile) { - // Extract latest user text for classification - const lastUser = [...history].reverse().find((m) => m.role === 'user'); - const userText = typeof lastUser?.content === 'string' - ? lastUser.content - : Array.isArray(lastUser?.content) - ? lastUser.content - .filter(p => p.type === 'text') - .map(p => p.text ?? '') - .join(' ') - : ''; - const routing = routeRequest(userText, routingProfile); - resolvedModel = routing.model; - routingTier = routing.tier; - routingConfidence = routing.confidence; - routingSavings = routing.savings; - lastRoutedModel = routing.model; - lastRoutedCategory = routing.signals[0] || ''; - } - // Safety net: handled in llm.ts resolveVirtualModel() - // Sanitize: remove orphaned tool results that could confuse the API - const sanitized = sanitizeHistory(history); - if (sanitized.length !== history.length) { - history.length = 0; - history.push(...sanitized); - } - try { - const result = await client.complete({ - model: resolvedModel, - messages: history, - system: systemPrompt, - tools: toolDefs, - max_tokens: maxTokens, - stream: true, - }, abort.signal, - // Start concurrent tools as soon as their input is fully received - (tool) => streamExec.onToolReceived(tool), - // Stream text/thinking deltas to UI in real-time - (delta) => { - if (delta.type === 'text') { - onEvent({ kind: 'text_delta', text: delta.text }); - } - else if (delta.type === 'thinking') { - onEvent({ kind: 'thinking_delta', text: delta.text }); - } - }); - responseParts = result.content; - usage = result.usage; - stopReason = result.stopReason; - // ── Empty response recovery (inspired by Hermes _empty_content_retries) ── - const hasText = responseParts.some(p => p.type === 'text' && p.text?.trim()); - const hasTools = responseParts.some(p => p.type === 'tool_use'); - const hasThinking = responseParts.some(p => p.type === 'thinking'); - if (!hasText && !hasTools && !hasThinking && recoveryAttempts < 3) { - recoveryAttempts++; - if (config.debug) { - console.error(`[runcode] Empty response — retrying (${recoveryAttempts}/3)`); - } - onEvent({ kind: 'text_delta', text: `\n*Empty response — retrying (${recoveryAttempts}/3)...*\n` }); - continue; - } - } - catch (err) { - // ── User abort (Esc key) ── - if (err.name === 'AbortError' || abort.signal.aborted) { - // Save any partial response that was streamed before abort - if (responseParts && responseParts.length > 0) { - const partialAssistant = { role: 'assistant', content: responseParts }; - history.push(partialAssistant); - persistSessionMessage(partialAssistant); - } - lastSessionActivity = Date.now(); - persistSessionMeta(); - onEvent({ kind: 'turn_done', reason: 'aborted' }); - break; - } - const errMsg = err.message || ''; - const classified = classifyAgentError(errMsg); - // ── Prompt too long recovery ── - if (classified.category === 'context_limit' && recoveryAttempts < 3) { - recoveryAttempts++; - if (config.debug) { - console.error(`[runcode] Prompt too long — forcing compact (attempt ${recoveryAttempts})`); - } - const { history: compactedAgain } = await autoCompactIfNeeded(history, config.model, client, config.debug); - history.length = 0; - history.push(...compactedAgain); - continue; // Retry - } - // ── Transient error recovery (network, rate limit, server errors) ── - if (classified.isTransient && recoveryAttempts < 3) { - recoveryAttempts++; - const backoffMs = Math.pow(2, recoveryAttempts) * 1000; - if (config.debug) { - console.error(`[runcode] ${classified.label} error — retrying in ${backoffMs / 1000}s (attempt ${recoveryAttempts}): ${errMsg.slice(0, 100)}`); - } - onEvent({ - kind: 'text_delta', - text: `\n*Retrying (${recoveryAttempts}/3) after ${classified.label} error...*\n`, - }); - await new Promise(r => setTimeout(r, backoffMs)); - continue; - } - // Add recovery suggestions based on error type - let suggestion = ''; - if (classified.category === 'rate_limit') { - suggestion = '\nTip: Try /model to switch to a different model, or wait a moment and /retry.'; - } - else if (classified.category === 'payment') { - // Auto-fallback to free models on payment/rate limit failure - // Track failed models at session level to prevent ping-pong loops - failedModels.add(config.model); - const FREE_MODELS = ['nvidia/qwen3-coder-480b', 'nvidia/nemotron-ultra-253b', 'nvidia/devstral-2-123b']; - const nextFree = FREE_MODELS.find(m => !failedModels.has(m)); - if (nextFree) { - const oldModel = config.model; - config.model = nextFree; - config.onModelChange?.(nextFree); - onEvent({ kind: 'text_delta', text: `\n*${oldModel} failed — switching to ${nextFree}*\n` }); - continue; // Retry with next model - } - suggestion = '\nTip: Run `runcode balance` to check funds. Try /model free for free models.'; - } - else if (classified.category === 'timeout' || classified.category === 'network') { - suggestion = '\nTip: Check your network connection. Use /retry to try again.'; - } - else if (classified.category === 'context_limit') { - suggestion = '\nTip: Run /compact to compress conversation history.'; - } - onEvent({ - kind: 'turn_done', - reason: 'error', - error: `[${classified.label}] ${errMsg}${suggestion}`, - }); - lastSessionActivity = Date.now(); - persistSessionMeta(); - break; - } - // When API doesn't return input tokens (some models return 0), estimate from history - const inputTokens = usage.inputTokens > 0 - ? usage.inputTokens - : estimateHistoryTokens(history); - // Anchor token tracking to actual API counts - updateActualTokens(inputTokens, usage.outputTokens, history.length); - const { contextUsagePct } = getAnchoredTokenCount(history); - onEvent({ - kind: 'usage', - inputTokens, - outputTokens: usage.outputTokens, - model: resolvedModel, - calls: 1, - tier: routingTier, - confidence: routingConfidence, - savings: routingSavings, - contextPct: Math.round(contextUsagePct), - }); - // Record usage for stats tracking (runcode stats command) - const costEstimate = estimateCost(resolvedModel, inputTokens, usage.outputTokens, 1); - recordUsage(resolvedModel, inputTokens, usage.outputTokens, costEstimate, 0); - recordSessionUsage(resolvedModel, inputTokens, usage.outputTokens, costEstimate, routingTier); - // Accumulate session-level totals for session meta - sessionInputTokens += inputTokens; - sessionOutputTokens += usage.outputTokens; - sessionCostUsd += costEstimate; - const opusCost = (inputTokens / 1_000_000) * OPUS_PRICING.input - + (usage.outputTokens / 1_000_000) * OPUS_PRICING.output; - sessionSavedVsOpus += Math.max(0, opusCost - costEstimate); - // ── Max output tokens recovery ── - if (stopReason === 'max_tokens' && recoveryAttempts < 3) { - recoveryAttempts++; - if (maxTokensOverride === undefined) { - // First hit: escalate to 64K - maxTokensOverride = ESCALATED_MAX_TOKENS; - if (config.debug) { - console.error(`[runcode] Max tokens hit — escalating to ${maxTokensOverride}`); - } - } - // Append what we got + a continuation prompt (text already streamed) - const partialAssistant = { role: 'assistant', content: responseParts }; - const continuationPrompt = { - role: 'user', - content: 'Continue where you left off. Do not repeat what you already said.', - }; - history.push(partialAssistant); - persistSessionMessage(partialAssistant); - history.push(continuationPrompt); - persistSessionMessage(continuationPrompt); - lastSessionActivity = Date.now(); - continue; // Retry with higher limit - } - // Reset recovery counter on successful completion - recoveryAttempts = 0; - // Extract tool invocations (text/thinking already streamed in real-time) - const invocations = []; - for (const part of responseParts) { - if (part.type === 'tool_use') { - invocations.push(part); - } - } - const assistantMessage = { role: 'assistant', content: responseParts }; - history.push(assistantMessage); - persistSessionMessage(assistantMessage); - // No more capabilities → done with this user message - if (invocations.length === 0) { - lastSessionActivity = Date.now(); - persistSessionMeta(); - // Token budget warning — emit once per session when crossing 70% - if (!tokenBudgetWarned) { - const { estimated } = getAnchoredTokenCount(history); - const contextWindow = getContextWindow(config.model); - const pct = (estimated / contextWindow) * 100; - if (pct >= 70) { - tokenBudgetWarned = true; - onEvent({ - kind: 'text_delta', - text: `\n\n> **Token budget: ${pct.toFixed(0)}% used** (~${estimated.toLocaleString()} / ${(contextWindow / 1000).toFixed(0)}k tokens). Run \`/compact\` to free up space.\n`, - }); - } - } - // Record success for local Elo learning (include tool call count for efficiency) - if (lastRoutedCategory && lastRoutedModel) { - recordOutcome(lastRoutedCategory, lastRoutedModel, 'continued', turnToolCalls); - } - onEvent({ kind: 'turn_done', reason: 'completed' }); - break; - } - // Collect results — concurrent tools may already be running from streaming - const results = await streamExec.collectResults(invocations); - for (const [inv, result] of results) { - onEvent({ kind: 'capability_done', id: inv.id, result }); - } - // ── Tool call guardrails ── - turnToolCalls += results.length; - for (const [inv] of results) { - const name = inv.name; - turnToolCounts.set(name, (turnToolCounts.get(name) || 0) + 1); - // Read file dedup: track paths already read - if (name === 'Read' && inv.input.file_path) { - readFileCache.add(inv.input.file_path); - } - } - // Refresh activity timestamp after tool execution - lastSessionActivity = Date.now(); - // Append outcomes (with guardrail injections) - const outcomeContent = results.map(([inv, result]) => { - // Read file dedup: if this file was already read earlier in this turn, - // replace content with a stub to save tokens - if (inv.name === 'Read' && !result.isError) { - const fp = inv.input.file_path; - const count = results.filter(([i]) => i.name === 'Read' && i.input.file_path === fp).length; - if (count > 1 && inv !== results.filter(([i]) => i.name === 'Read' && i.input.file_path === fp).pop()?.[0]) { - return { - type: 'tool_result', - tool_use_id: inv.id, - content: `File already read in this turn. Refer to the other Read result for ${fp}.`, - is_error: false, - }; - } - } - return { - type: 'tool_result', - tool_use_id: inv.id, - content: result.output, - is_error: result.isError, - }; - }); - // ── Guardrail injections ── - // Warn about same-tool repetition - for (const [name, count] of turnToolCounts) { - if (count === SAME_TOOL_WARN_THRESHOLD) { - outcomeContent.push({ - type: 'tool_result', - tool_use_id: `guardrail-warn-${name}`, - content: `[SYSTEM] You have called ${name} ${count} times this turn. Stop and present your results now. Do not make more ${name} calls.`, - is_error: true, - }); - } - } - // Hard cap: stop the turn if too many tool calls - if (turnToolCalls >= MAX_TOOL_CALLS_PER_TURN) { - outcomeContent.push({ - type: 'tool_result', - tool_use_id: 'guardrail-cap', - content: `[SYSTEM] Tool call limit reached (${MAX_TOOL_CALLS_PER_TURN}). Present your results to the user NOW. Do not make any more tool calls.`, - is_error: true, - }); - } - const toolResultMessage = { role: 'user', content: outcomeContent }; - history.push(toolResultMessage); - persistSessionMessage(toolResultMessage); - // Hard stop: if cap exceeded, force end this agent loop iteration - if (turnToolCalls >= MAX_TOOL_CALLS_PER_TURN) { - if (config.debug) { - console.error(`[runcode] Tool call cap hit: ${turnToolCalls} calls this turn`); - } - // Don't break — let the model respond one more time to summarize, - // but inject the stop signal above so it knows to finish up. - } - } - if (loopCount >= maxTurns) { - lastSessionActivity = Date.now(); - persistSessionMeta(); - if (lastRoutedCategory && lastRoutedModel) { - recordOutcome(lastRoutedCategory, lastRoutedModel, 'max_turns', turnToolCalls); - } - onEvent({ kind: 'turn_done', reason: 'max_turns' }); - } - } - return history; -} -// Cost estimation now uses shared pricing from src/pricing.ts diff --git a/dist/agent/optimize.d.ts b/dist/agent/optimize.d.ts deleted file mode 100644 index 1b62e138..00000000 --- a/dist/agent/optimize.d.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Token optimization strategies for runcode. - * - * Five layers of optimization to minimize token usage: - * 1. Tool result size budgeting — cap large outputs, keep preview - * 2. Thinking block stripping — remove old thinking from history - * 3. Time-based cleanup — clear stale tool results after idle gap - * 4. Adaptive max_tokens — start low (8K), escalate on hit - * 5. Pre-compact stripping — remove images/docs before summarization - */ -import type { Dialogue } from './types.js'; -/** Default max_tokens (low to save output slot reservation) */ -export declare const CAPPED_MAX_TOKENS = 16384; -/** Escalated max_tokens after hitting the cap */ -export declare const ESCALATED_MAX_TOKENS = 65536; -/** Get max output tokens for a model */ -export declare function getMaxOutputTokens(model: string): number; -/** - * Cap tool result sizes to prevent context bloat. - * Large results (>50K chars) are truncated with a preview. - * Per-message aggregate is also capped at 200K chars. - */ -export declare function budgetToolResults(history: Dialogue[]): Dialogue[]; -export declare function stripOldThinking(history: Dialogue[]): Dialogue[]; -/** - * After an idle gap (>60 min), clear old tool results. - * When the user comes back after being away, old results are stale anyway. - */ -export declare function timeBasedCleanup(history: Dialogue[], lastActivityTimestamp?: number): { - history: Dialogue[]; - cleaned: boolean; -}; -/** - * Strip heavy content before sending to compaction model. - * Removes image/document references since the summarizer can't see them anyway. - */ -export declare function stripHeavyContent(history: Dialogue[]): Dialogue[]; -export interface OptimizeOptions { - debug?: boolean; - lastActivityTimestamp?: number; -} -/** - * Run the full optimization pipeline on conversation history. - * Called before each model request to minimize token usage. - * - * Pipeline order (cheapest first): - * 1. Strip old thinking blocks (free, local) - * 2. Budget tool results (free, local) - * 3. Time-based cleanup (free, local, only after idle) - * - * Returns the optimized history (may be same reference if no changes). - */ -export declare function optimizeHistory(history: Dialogue[], opts?: OptimizeOptions): Dialogue[]; diff --git a/dist/agent/optimize.js b/dist/agent/optimize.js deleted file mode 100644 index 950ccea3..00000000 --- a/dist/agent/optimize.js +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Token optimization strategies for runcode. - * - * Five layers of optimization to minimize token usage: - * 1. Tool result size budgeting — cap large outputs, keep preview - * 2. Thinking block stripping — remove old thinking from history - * 3. Time-based cleanup — clear stale tool results after idle gap - * 4. Adaptive max_tokens — start low (8K), escalate on hit - * 5. Pre-compact stripping — remove images/docs before summarization - */ -// ─── Constants ───────────────────────────────────────────────────────────── -/** Max chars per individual tool result before truncation (history-level safety net) */ -const MAX_TOOL_RESULT_CHARS = 32_000; -/** Max aggregate tool result chars per user message */ -const MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 100_000; -/** Preview size when truncating */ -const PREVIEW_CHARS = 2_000; -/** Default max_tokens (low to save output slot reservation) */ -export const CAPPED_MAX_TOKENS = 16_384; -/** Escalated max_tokens after hitting the cap */ -export const ESCALATED_MAX_TOKENS = 65_536; -/** Per-model max output tokens — prevents requesting more than the model supports */ -const MODEL_MAX_OUTPUT = { - 'anthropic/claude-opus-4.6': 32_000, - 'anthropic/claude-sonnet-4.6': 64_000, - 'anthropic/claude-haiku-4.5-20251001': 16_384, - 'openai/gpt-5.4': 32_768, - 'openai/gpt-5-mini': 16_384, - 'google/gemini-2.5-pro': 65_536, - 'google/gemini-2.5-flash': 65_536, - 'deepseek/deepseek-chat': 8_192, -}; -/** Get max output tokens for a model */ -export function getMaxOutputTokens(model) { - return MODEL_MAX_OUTPUT[model] ?? 16_384; -} -/** Idle gap (minutes) after which old tool results are cleared */ -const IDLE_GAP_THRESHOLD_MINUTES = 5; -/** Number of recent tool results to keep during time-based cleanup */ -const KEEP_RECENT_TOOL_RESULTS = 3; -// ─── 1. Tool Result Size Budgeting ───────────────────────────────────────── -/** - * Cap tool result sizes to prevent context bloat. - * Large results (>50K chars) are truncated with a preview. - * Per-message aggregate is also capped at 200K chars. - */ -export function budgetToolResults(history) { - const result = []; - for (const msg of history) { - if (msg.role !== 'user' || typeof msg.content === 'string' || !Array.isArray(msg.content)) { - result.push(msg); - continue; - } - let messageTotal = 0; - let modified = false; - const budgeted = []; - for (const part of msg.content) { - if (part.type !== 'tool_result') { - budgeted.push(part); - continue; - } - const content = typeof part.content === 'string' ? part.content : JSON.stringify(part.content); - const size = content.length; - // Per-tool cap - if (size > MAX_TOOL_RESULT_CHARS) { - modified = true; - // Truncate at line boundary for cleaner output - let preview = content.slice(0, PREVIEW_CHARS); - const lastNewline = preview.lastIndexOf('\n'); - if (lastNewline > PREVIEW_CHARS * 0.5) { - preview = preview.slice(0, lastNewline); - } - budgeted.push({ - type: 'tool_result', - tool_use_id: part.tool_use_id, - content: `[Output truncated: ${size.toLocaleString()} chars → ${PREVIEW_CHARS} preview]\n\n${preview}\n\n... (${size - PREVIEW_CHARS} chars omitted)`, - is_error: part.is_error, - }); - messageTotal += PREVIEW_CHARS + 200; - continue; - } - // Per-message aggregate cap — once exceeded, truncate remaining results - if (messageTotal + size > MAX_TOOL_RESULTS_PER_MESSAGE_CHARS) { - modified = true; - budgeted.push({ - type: 'tool_result', - tool_use_id: part.tool_use_id, - content: `[Output omitted: message budget exceeded (${MAX_TOOL_RESULTS_PER_MESSAGE_CHARS / 1000}K chars/msg)]`, - is_error: part.is_error, - }); - messageTotal = MAX_TOOL_RESULTS_PER_MESSAGE_CHARS; - continue; - } - budgeted.push(part); - messageTotal += size; - } - result.push(modified ? { role: 'user', content: budgeted } : msg); - } - return result; -} -// ─── 2. Thinking Block Stripping ─────────────────────────────────────────── -/** - * Remove thinking blocks from older assistant messages. - * Keeps thinking only in the most recent N assistant messages (default: last 2 turns). - * Older thinking blocks are large and not needed after the decision is made. - */ -const KEEP_THINKING_TURNS = 2; -export function stripOldThinking(history) { - // Find the last N assistant message indices to preserve their thinking - const assistantIndices = []; - for (let i = history.length - 1; i >= 0; i--) { - if (history[i].role === 'assistant') { - assistantIndices.push(i); - if (assistantIndices.length >= KEEP_THINKING_TURNS) - break; - } - } - if (assistantIndices.length === 0) - return history; - const keepSet = new Set(assistantIndices); - const result = []; - let modified = false; - for (let i = 0; i < history.length; i++) { - const msg = history[i]; - // Strip thinking from assistant messages NOT in the keep set - if (msg.role === 'assistant' && !keepSet.has(i) && Array.isArray(msg.content)) { - const filtered = msg.content.filter((part) => part.type !== 'thinking'); - if (filtered.length < msg.content.length) { - modified = true; - result.push({ - role: 'assistant', - content: filtered.length > 0 ? filtered : [{ type: 'text', text: '[thinking omitted]' }], - }); - continue; - } - } - result.push(msg); - } - return modified ? result : history; -} -// ─── 3. Time-Based Cleanup ───────────────────────────────────────────────── -/** - * After an idle gap (>60 min), clear old tool results. - * When the user comes back after being away, old results are stale anyway. - */ -export function timeBasedCleanup(history, lastActivityTimestamp) { - if (!lastActivityTimestamp) { - return { history, cleaned: false }; - } - const gapMs = Date.now() - lastActivityTimestamp; - if (gapMs < 0) - return { history, cleaned: false }; // Clock skew protection - const gapMinutes = gapMs / 60_000; - if (gapMinutes < IDLE_GAP_THRESHOLD_MINUTES) { - return { history, cleaned: false }; - } - // Find all tool_result positions - const toolPositions = []; - for (let i = 0; i < history.length; i++) { - const msg = history[i]; - if (msg.role === 'user' && - Array.isArray(msg.content) && - msg.content.length > 0 && - typeof msg.content[0] !== 'string' && - 'type' in msg.content[0] && - msg.content[0].type === 'tool_result') { - toolPositions.push(i); - } - } - if (toolPositions.length <= KEEP_RECENT_TOOL_RESULTS) { - return { history, cleaned: false }; - } - // Clear all but the most recent N - const toClear = toolPositions.slice(0, -KEEP_RECENT_TOOL_RESULTS); - const result = [...history]; - for (const pos of toClear) { - const msg = result[pos]; - if (!Array.isArray(msg.content)) - continue; - const cleared = msg.content.map((part) => { - if (part.type === 'tool_result') { - return { - type: 'tool_result', - tool_use_id: part.tool_use_id, - content: '[Stale tool result cleared after idle gap]', - is_error: part.is_error, - }; - } - return part; - }); - result[pos] = { role: 'user', content: cleared }; - } - return { history: result, cleaned: true }; -} -// ─── 4. Pre-Compact Stripping ────────────────────────────────────────────── -/** - * Strip heavy content before sending to compaction model. - * Removes image/document references since the summarizer can't see them anyway. - */ -export function stripHeavyContent(history) { - return history.map((msg) => { - if (typeof msg.content === 'string') - return msg; - if (!Array.isArray(msg.content)) - return msg; - let modified = false; - const stripped = msg.content.map((part) => { - // Strip image blocks (if they ever appear) - if ('type' in part && part.type === 'image') { - modified = true; - return { type: 'text', text: '[image]' }; - } - // Strip document blocks - if ('type' in part && part.type === 'document') { - modified = true; - return { type: 'text', text: '[document]' }; - } - return part; - }); - return modified ? { ...msg, content: stripped } : msg; - }); -} -/** - * Run the full optimization pipeline on conversation history. - * Called before each model request to minimize token usage. - * - * Pipeline order (cheapest first): - * 1. Strip old thinking blocks (free, local) - * 2. Budget tool results (free, local) - * 3. Time-based cleanup (free, local, only after idle) - * - * Returns the optimized history (may be same reference if no changes). - */ -export function optimizeHistory(history, opts) { - let result = history; - let changed = false; - // 1. Strip old thinking - const stripped = stripOldThinking(result); - if (stripped !== result) { - result = stripped; - changed = true; - if (opts?.debug) - console.error('[runcode] Stripped old thinking blocks'); - } - // 2. Budget tool results - const budgeted = budgetToolResults(result); - if (budgeted !== result) { - result = budgeted; - changed = true; - if (opts?.debug) - console.error('[runcode] Budgeted oversized tool results'); - } - // 3. Time-based cleanup - const { history: cleaned, cleaned: didClean } = timeBasedCleanup(result, opts?.lastActivityTimestamp); - if (didClean) { - result = cleaned; - changed = true; - if (opts?.debug) - console.error('[runcode] Cleared stale tool results after idle gap'); - } - return result; -} diff --git a/dist/agent/permissions.d.ts b/dist/agent/permissions.d.ts deleted file mode 100644 index 9f92bf96..00000000 --- a/dist/agent/permissions.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Permission system for runcode. - * Controls which tools can execute automatically vs. require user approval. - */ -export type PermissionBehavior = 'allow' | 'deny' | 'ask'; -export interface PermissionRules { - allow: string[]; - deny: string[]; - ask: string[]; -} -export type PermissionMode = 'default' | 'trust' | 'deny-all' | 'plan'; -export interface PermissionDecision { - behavior: PermissionBehavior; - reason?: string; -} -export declare class PermissionManager { - private rules; - private mode; - private sessionAllowed; - private promptFn?; - constructor(mode?: PermissionMode, promptFn?: (toolName: string, description: string) => Promise<'yes' | 'no' | 'always'>); - /** - * Check if a tool can be used. Returns the decision. - */ - check(toolName: string, input: Record): Promise; - /** - * Prompt the user interactively for permission. - * Uses injected promptFn (Ink UI) when available, falls back to readline. - * pendingCount: how many more operations of this type are waiting (including this one). - * Returns true if allowed, false if denied. - */ - promptUser(toolName: string, input: Record, pendingCount?: number): Promise; - private loadRules; - private matchesRule; - private getPrimaryInputValue; - private globMatch; - private sessionKey; - private describeAction; -} diff --git a/dist/agent/permissions.js b/dist/agent/permissions.js deleted file mode 100644 index 4a2c5d54..00000000 --- a/dist/agent/permissions.js +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Permission system for runcode. - * Controls which tools can execute automatically vs. require user approval. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import readline from 'node:readline'; -import chalk from 'chalk'; -import { BLOCKRUN_DIR } from '../config.js'; -// ─── Default Rules ───────────────────────────────────────────────────────── -const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX']); -const DESTRUCTIVE_TOOLS = new Set(['Write', 'Edit', 'Bash']); -const DEFAULT_RULES = { - allow: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'AskUser', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX'], - deny: [], - ask: ['Write', 'Edit', 'Bash', 'Agent', 'PostToX'], -}; -// ─── Permission Manager ──────────────────────────────────────────────────── -export class PermissionManager { - rules; - mode; - sessionAllowed = new Set(); // "always allow" for this session - promptFn; - constructor(mode = 'default', promptFn) { - this.mode = mode; - this.rules = this.loadRules(); - this.promptFn = promptFn; - } - /** - * Check if a tool can be used. Returns the decision. - */ - async check(toolName, input) { - // Trust mode: allow everything - if (this.mode === 'trust') { - return { behavior: 'allow', reason: 'trust mode' }; - } - // Plan mode: only allow read-only tools - if (this.mode === 'plan') { - if (READ_ONLY_TOOLS.has(toolName)) { - return { behavior: 'allow', reason: 'plan mode — read-only' }; - } - return { behavior: 'deny', reason: 'plan mode — use /execute to enable writes' }; - } - // Deny-all mode: deny everything that isn't read-only - if (this.mode === 'deny-all') { - if (READ_ONLY_TOOLS.has(toolName)) { - return { behavior: 'allow', reason: 'read-only tool' }; - } - return { behavior: 'deny', reason: 'deny-all mode' }; - } - // Check session-level always-allow - const sessionKey = this.sessionKey(toolName, input); - if (this.sessionAllowed.has(toolName) || this.sessionAllowed.has(sessionKey)) { - return { behavior: 'allow', reason: 'session allow' }; - } - // Check explicit deny rules - if (this.matchesRule(toolName, input, this.rules.deny)) { - return { behavior: 'deny', reason: 'denied by rule' }; - } - // Check explicit allow rules - if (this.matchesRule(toolName, input, this.rules.allow)) { - return { behavior: 'allow', reason: 'allowed by rule' }; - } - // Check explicit ask rules - if (this.matchesRule(toolName, input, this.rules.ask)) { - return { behavior: 'ask' }; - } - // Default: read-only tools are auto-allowed, others ask - if (READ_ONLY_TOOLS.has(toolName)) { - return { behavior: 'allow', reason: 'read-only default' }; - } - return { behavior: 'ask' }; - } - /** - * Prompt the user interactively for permission. - * Uses injected promptFn (Ink UI) when available, falls back to readline. - * pendingCount: how many more operations of this type are waiting (including this one). - * Returns true if allowed, false if denied. - */ - async promptUser(toolName, input, pendingCount = 1) { - const description = this.describeAction(toolName, input); - // Append pending-count hint so user knows to press [a] to skip all - const hint = pendingCount > 1 - ? `${description}\n │ \x1b[33m${pendingCount} pending — press [a] to allow all\x1b[0m` - : description; - // Ink UI path: use injected prompt function to avoid stdin conflict. - // Ink owns stdin in raw mode; a second readline would get EOF immediately. - if (this.promptFn) { - const result = await this.promptFn(toolName, hint); - if (result === 'always') { - this.sessionAllowed.add(toolName); - return true; - } - return result === 'yes'; - } - // Readline fallback (basic terminal / piped mode) - console.error(''); - console.error(chalk.yellow(' ╭─ Permission required ─────────────────')); - console.error(chalk.yellow(` │ ${toolName}`)); - console.error(chalk.dim(` │ ${description}`)); - if (pendingCount > 1) { - console.error(chalk.yellow(` │ ${pendingCount} pending — press [a] to allow all`)); - } - console.error(chalk.yellow(' ╰─────────────────────────────────────')); - const answer = await askQuestion(chalk.bold(' Allow? ') + chalk.dim('[Y/a/n] ')); - const normalized = answer.trim().toLowerCase(); - if (normalized === 'a' || normalized === 'always') { - this.sessionAllowed.add(toolName); - console.error(chalk.green(` ✓ ${toolName} allowed for this session`)); - return true; - } - if (normalized === 'y' || normalized === 'yes' || normalized === '') { - return true; - } - console.error(chalk.red(` ✗ ${toolName} denied`)); - return false; - } - // ─── Internal ────────────────────────────────────────────────────────── - loadRules() { - const configPath = path.join(BLOCKRUN_DIR, 'runcode-permissions.json'); - try { - if (fs.existsSync(configPath)) { - const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - return { - allow: [...DEFAULT_RULES.allow, ...(raw.allow || [])], - deny: [...(raw.deny || [])], - ask: [...DEFAULT_RULES.ask, ...(raw.ask || [])], - }; - } - } - catch { /* use defaults */ } - return { ...DEFAULT_RULES }; - } - matchesRule(toolName, input, rules) { - for (const rule of rules) { - // Exact tool name match - if (rule === toolName) - return true; - // Pattern match: "Bash(git *)" matches Bash with command starting with "git " - const patternMatch = rule.match(/^(\w+)\((.+)\)$/); - if (patternMatch) { - const [, ruleTool, pattern] = patternMatch; - if (ruleTool !== toolName) - continue; - // Match against the primary input field - const primaryValue = this.getPrimaryInputValue(toolName, input); - if (primaryValue && this.globMatch(pattern, primaryValue)) { - return true; - } - } - } - return false; - } - getPrimaryInputValue(toolName, input) { - switch (toolName) { - case 'Bash': return input.command || null; - case 'Read': return input.file_path || null; - case 'Write': return input.file_path || null; - case 'Edit': return input.file_path || null; - default: return null; - } - } - globMatch(pattern, text) { - // Glob matching: * matches non-space chars, ** matches anything - const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp('^' + - escaped - .replace(/\*\*/g, '{{GLOB_STAR}}') - .replace(/\*/g, '[^ ]*') - .replace(/\{\{GLOB_STAR\}\}/g, '.*') - + '$'); - return regex.test(text); - } - sessionKey(toolName, input) { - const primary = this.getPrimaryInputValue(toolName, input); - return primary ? `${toolName}:${primary}` : toolName; - } - describeAction(toolName, input) { - switch (toolName) { - case 'Bash': { - const cmd = input.command || ''; - return `Execute: ${cmd.length > 100 ? cmd.slice(0, 100) + '...' : cmd}`; - } - case 'Write': { - const fp = input.file_path || ''; - return `Write file: ${fp}`; - } - case 'Edit': { - const fp = input.file_path || ''; - const old = input.old_string || ''; - return `Edit ${fp}: replace "${old.slice(0, 60)}${old.length > 60 ? '...' : ''}"`; - } - case 'Agent': - return `Launch sub-agent: ${input.description || input.prompt?.slice(0, 80) || 'task'}`; - default: - return JSON.stringify(input).slice(0, 120); - } - } -} -// ─── Helpers ─────────────────────────────────────────────────────────────── -function askQuestion(prompt) { - // Non-TTY (piped/scripted) input: cannot ask interactively — auto-allow. - // The caller (permissionMode logic in start.ts) already routes piped sessions - // to trust mode, so this path is rarely hit. Guard here for safety. - if (!process.stdin.isTTY) { - process.stderr.write(prompt + 'y (auto-approved: non-interactive mode)\n'); - return Promise.resolve('y'); - } - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr, - terminal: true, - }); - return new Promise((resolve) => { - let answered = false; - rl.question(prompt, (answer) => { - answered = true; - rl.close(); - resolve(answer); - }); - rl.on('close', () => { - if (!answered) - resolve('n'); // Default deny on EOF for safety - }); - }); -} diff --git a/dist/agent/reduce.d.ts b/dist/agent/reduce.d.ts deleted file mode 100644 index a6053fa3..00000000 --- a/dist/agent/reduce.d.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Token Reduction for runcode. - * Original implementation — reduces context size through intelligent pruning. - * - * Strategy: instead of compression/encoding, we PRUNE redundant content. - * The model doesn't need verbose tool outputs from 20 turns ago. - * - * Three reduction passes: - * 1. Tool result aging — progressively shorten old tool results - * 2. Whitespace normalization — remove excessive blank lines and indentation - * 3. Stale context removal — drop system info that's been superseded - */ -import type { Dialogue } from './types.js'; -/** - * Progressively shorten tool results based on age. - * Recent results: keep full. Older results: keep summary. Very old: keep one line. - * - * This is the biggest token saver — a 10KB bash output from 20 turns ago - * can be reduced to "✓ Bash: ran npm test (exit 0)" saving ~2500 tokens. - */ -export declare function ageToolResults(history: Dialogue[]): Dialogue[]; -/** - * Normalize whitespace in text messages. - * - Collapse 3+ blank lines to 2 - * - Remove trailing spaces - * - Reduce indentation beyond 8 spaces to 8 - */ -export declare function normalizeWhitespace(history: Dialogue[]): Dialogue[]; -/** - * Trim very long assistant text messages from old turns. - * Recent messages: keep full. Old long messages: keep first 1000 chars. - */ -export declare function trimOldAssistantMessages(history: Dialogue[]): Dialogue[]; -/** - * Remove consecutive duplicate messages (same role + same content). - */ -export declare function deduplicateMessages(history: Dialogue[]): Dialogue[]; -/** - * Collapse repeated consecutive lines within tool results. - * "Fetching...\nFetching...\nFetching...\n" → "Fetching... ×3" - * Also strips any residual ANSI escape codes from older tool results. - * RTK-inspired: dedup_lines + strip_ansi pipeline stages. - */ -export declare function deduplicateToolResultLines(history: Dialogue[]): Dialogue[]; -/** - * When the same tool (WebSearch, Grep, etc.) is called 6+ times, - * collapse all but the last 3 results to one-line summaries. - * Prevents context snowball from search spam (e.g. 96 WebSearches). - */ -export declare function collapseRepetitiveTools(history: Dialogue[]): Dialogue[]; -/** - * Run all token reduction passes on conversation history. - * Returns same reference if nothing changed (cheap identity check). - */ -export declare function reduceTokens(history: Dialogue[], debug?: boolean): Dialogue[]; diff --git a/dist/agent/reduce.js b/dist/agent/reduce.js deleted file mode 100644 index 719ede35..00000000 --- a/dist/agent/reduce.js +++ /dev/null @@ -1,399 +0,0 @@ -/** - * Token Reduction for runcode. - * Original implementation — reduces context size through intelligent pruning. - * - * Strategy: instead of compression/encoding, we PRUNE redundant content. - * The model doesn't need verbose tool outputs from 20 turns ago. - * - * Three reduction passes: - * 1. Tool result aging — progressively shorten old tool results - * 2. Whitespace normalization — remove excessive blank lines and indentation - * 3. Stale context removal — drop system info that's been superseded - */ -// ─── 1. Tool Result Aging ───────────────────────────────────────────────── -/** - * Progressively shorten tool results based on age. - * Recent results: keep full. Older results: keep summary. Very old: keep one line. - * - * This is the biggest token saver — a 10KB bash output from 20 turns ago - * can be reduced to "✓ Bash: ran npm test (exit 0)" saving ~2500 tokens. - */ -export function ageToolResults(history) { - // Find all tool_result positions - const toolPositions = []; - for (let i = 0; i < history.length; i++) { - const msg = history[i]; - if (msg.role === 'user' && - Array.isArray(msg.content) && - msg.content.some(p => p.type === 'tool_result')) { - toolPositions.push(i); - } - } - if (toolPositions.length <= 3) - return history; // Nothing to age - const result = [...history]; - const totalResults = toolPositions.length; - for (let idx = 0; idx < toolPositions.length; idx++) { - const pos = toolPositions[idx]; - const age = totalResults - idx; // Higher = older - const msg = result[pos]; - if (!Array.isArray(msg.content)) - continue; - const parts = msg.content; - let modified = false; - const aged = parts.map(part => { - if (part.type !== 'tool_result') - return part; - const content = typeof part.content === 'string' - ? part.content - : JSON.stringify(part.content); - const charLen = content.length; - // Recent 3 results: keep full - if (age <= 3) - return part; - // Age 4-8: keep first 500 chars - if (age <= 8 && charLen > 500) { - modified = true; - const truncated = content.slice(0, 500); - const lastNl = truncated.lastIndexOf('\n'); - const clean = lastNl > 250 ? truncated.slice(0, lastNl) : truncated; - return { - ...part, - content: `${clean}\n... (${charLen - clean.length} chars omitted, ${age} turns ago)`, - }; - } - // Age 9-15: keep first 200 chars - if (age <= 15 && charLen > 200) { - modified = true; - const firstLine = content.split('\n')[0].slice(0, 150); - return { - ...part, - content: `${firstLine}\n... (${charLen} chars, ${age} turns ago)`, - }; - } - // Age 16+: one line summary - if (age > 15 && charLen > 80) { - modified = true; - const summary = content.split('\n')[0].slice(0, 60); - return { - ...part, - content: part.is_error - ? `[Error: ${summary}...]` - : `[Result: ${summary}...]`, - }; - } - return part; - }); - if (modified) { - result[pos] = { role: 'user', content: aged }; - } - } - return result; -} -// ─── 2. Whitespace Normalization ────────────────────────────────────────── -/** - * Normalize whitespace in text messages. - * - Collapse 3+ blank lines to 2 - * - Remove trailing spaces - * - Reduce indentation beyond 8 spaces to 8 - */ -export function normalizeWhitespace(history) { - let modified = false; - const result = history.map(msg => { - if (typeof msg.content !== 'string') - return msg; - const original = msg.content; - const cleaned = original - .replace(/[ \t]+$/gm, '') // Trailing spaces - .replace(/\n{4,}/g, '\n\n\n') // Max 3 consecutive newlines - .replace(/^( {9,})/gm, ' '); // Cap indentation at 8 spaces - if (cleaned !== original) { - modified = true; - return { ...msg, content: cleaned }; - } - return msg; - }); - return modified ? result : history; -} -// ─── 3. Verbose Assistant Message Trimming ──────────────────────────────── -/** - * Trim very long assistant text messages from old turns. - * Recent messages: keep full. Old long messages: keep first 1000 chars. - */ -export function trimOldAssistantMessages(history) { - const MAX_OLD_ASSISTANT_CHARS = 1500; - const KEEP_RECENT = 4; // Keep last 4 assistant messages full - let assistantCount = 0; - for (const msg of history) { - if (msg.role === 'assistant') - assistantCount++; - } - if (assistantCount <= KEEP_RECENT) - return history; - let seenAssistant = 0; - let modified = false; - const result = history.map(msg => { - if (msg.role !== 'assistant') - return msg; - seenAssistant++; - // Keep recent messages full - if (assistantCount - seenAssistant < KEEP_RECENT) - return msg; - if (typeof msg.content === 'string' && msg.content.length > MAX_OLD_ASSISTANT_CHARS) { - modified = true; - const truncated = msg.content.slice(0, MAX_OLD_ASSISTANT_CHARS); - const lastNl = truncated.lastIndexOf('\n'); - const clean = lastNl > MAX_OLD_ASSISTANT_CHARS / 2 ? truncated.slice(0, lastNl) : truncated; - return { ...msg, content: clean + '\n... (response truncated)' }; - } - // Also handle content array with text parts - if (Array.isArray(msg.content)) { - const parts = msg.content; - let totalChars = 0; - for (const p of parts) { - if (p.type === 'text') - totalChars += p.text.length; - } - if (totalChars > MAX_OLD_ASSISTANT_CHARS) { - modified = true; - const trimmedParts = parts.map(p => { - if (p.type !== 'text' || p.text.length <= 500) - return p; - return { ...p, text: p.text.slice(0, 500) + '\n... (trimmed)' }; - }); - return { ...msg, content: trimmedParts }; - } - } - return msg; - }); - return modified ? result : history; -} -// ─── 4. Deduplication ───────────────────────────────────────────────────── -/** - * Remove consecutive duplicate messages (same role + same content). - */ -export function deduplicateMessages(history) { - if (history.length < 3) - return history; - const result = [history[0]]; - let modified = false; - for (let i = 1; i < history.length; i++) { - const prev = history[i - 1]; - const curr = history[i]; - if (curr.role === prev.role && typeof curr.content === 'string' && curr.content === prev.content) { - modified = true; - continue; - } - result.push(curr); - } - return modified ? result : history; -} -// ─── 5. Line-level deduplication in tool results ────────────────────────── -const ANSI_RE_REDUCE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g; -/** - * Collapse repeated consecutive lines within tool results. - * "Fetching...\nFetching...\nFetching...\n" → "Fetching... ×3" - * Also strips any residual ANSI escape codes from older tool results. - * RTK-inspired: dedup_lines + strip_ansi pipeline stages. - */ -export function deduplicateToolResultLines(history) { - let modified = false; - const result = history.map(msg => { - if (msg.role !== 'user' || !Array.isArray(msg.content)) - return msg; - const parts = msg.content; - let partModified = false; - const newParts = parts.map(part => { - if (part.type !== 'tool_result') - return part; - const raw = typeof part.content === 'string' ? part.content : JSON.stringify(part.content); - // Strip ANSI codes - const stripped = raw.replace(ANSI_RE_REDUCE, ''); - // Collapse repeated consecutive lines - const lines = stripped.split('\n'); - const deduped = []; - let i = 0; - while (i < lines.length) { - const line = lines[i]; - let count = 1; - while (i + count < lines.length && lines[i + count] === line) - count++; - if (count > 2 && line.trim() !== '') { - deduped.push(`${line} ×${count}`); - } - else { - for (let k = 0; k < count; k++) - deduped.push(line); - } - i += count; - } - const result = deduped.join('\n'); - if (result === raw) - return part; - partModified = true; - return { ...part, content: result }; - }); - if (!partModified) - return msg; - modified = true; - return { ...msg, content: newParts }; - }); - return modified ? result : history; -} -// ─── 6. Repetitive Tool Collapse ───────────────────────────────────────── -/** - * When the same tool (WebSearch, Grep, etc.) is called 6+ times, - * collapse all but the last 3 results to one-line summaries. - * Prevents context snowball from search spam (e.g. 96 WebSearches). - */ -export function collapseRepetitiveTools(history) { - // Count tool_use by name - const toolCounts = new Map(); - for (const msg of history) { - if (msg.role !== 'assistant' || !Array.isArray(msg.content)) - continue; - for (const part of msg.content) { - if (part.type === 'tool_use') { - const name = part.name ?? ''; - toolCounts.set(name, (toolCounts.get(name) || 0) + 1); - } - } - } - // Only for tools called 6+ times - const repetitive = new Set(); - for (const [name, count] of toolCounts) { - if (count >= 6) - repetitive.add(name); - } - if (repetitive.size === 0) - return history; - // Map tool_use_id → name, track call order per tool - const idToName = new Map(); - const callOrder = new Map(); // name → [tool_use_id, ...] - for (const msg of history) { - if (msg.role !== 'assistant' || !Array.isArray(msg.content)) - continue; - for (const part of msg.content) { - if (part.type === 'tool_use' && repetitive.has(part.name ?? '')) { - const name = part.name ?? ''; - idToName.set(part.id, name); - if (!callOrder.has(name)) - callOrder.set(name, []); - callOrder.get(name).push(part.id); - } - } - } - // Mark old IDs (all but last 3 per tool) - const oldIds = new Set(); - for (const [, ids] of callOrder) { - for (let i = 0; i < ids.length - 3; i++) { - oldIds.add(ids[i]); - } - } - if (oldIds.size === 0) - return history; - // Collapse old results - let modified = false; - const result = history.map(msg => { - if (msg.role !== 'user' || !Array.isArray(msg.content)) - return msg; - let changed = false; - const parts = msg.content.map(part => { - if (part.type !== 'tool_result' || !oldIds.has(part.tool_use_id)) - return part; - const content = typeof part.content === 'string' ? part.content : JSON.stringify(part.content); - if (content.length <= 80) - return part; - changed = true; - const first = content.split('\n')[0].slice(0, 60); - return { ...part, content: `[${first}...]` }; - }); - if (!changed) - return msg; - modified = true; - return { ...msg, content: parts }; - }); - return modified ? result : history; -} -// ─── Pipeline ──────────────────────────────────────────────────────────── -/** - * Run all token reduction passes on conversation history. - * Returns same reference if nothing changed (cheap identity check). - */ -export function reduceTokens(history, debug) { - if (history.length < 8) - return history; // Skip for short conversations - let current = history; - let totalSaved = 0; - // Pass 0: Collapse repetitive tool results (e.g. 96 WebSearches with similar queries) - const collapsed = collapseRepetitiveTools(current); - if (collapsed !== current) { - const before = estimateChars(current); - current = collapsed; - totalSaved += before - estimateChars(current); - } - // Pass 1: Age old tool results - const aged = ageToolResults(current); - if (aged !== current) { - const before = estimateChars(current); - current = aged; - const saved = before - estimateChars(current); - totalSaved += saved; - } - // Pass 2: Normalize whitespace - const normalized = normalizeWhitespace(current); - if (normalized !== current) { - const before = estimateChars(current); - current = normalized; - totalSaved += before - estimateChars(current); - } - // Pass 3: Trim old verbose assistant messages - const trimmed = trimOldAssistantMessages(current); - if (trimmed !== current) { - const before = estimateChars(current); - current = trimmed; - totalSaved += before - estimateChars(current); - } - // Pass 4: Remove consecutive duplicate messages - const deduped = deduplicateMessages(current); - if (deduped !== current) { - const before = estimateChars(current); - current = deduped; - totalSaved += before - estimateChars(current); - } - // Pass 5: Strip ANSI + collapse repeated lines in tool results - const lineDeduped = deduplicateToolResultLines(current); - if (lineDeduped !== current) { - const before = estimateChars(current); - current = lineDeduped; - totalSaved += before - estimateChars(current); - } - if (debug && totalSaved > 500) { - const tokensSaved = Math.round(totalSaved / 4); - console.error(`[runcode] Token reduction: ~${tokensSaved} tokens saved`); - } - return current; -} -function estimateChars(history) { - let total = 0; - for (const msg of history) { - if (typeof msg.content === 'string') { - total += msg.content.length; - } - else if (Array.isArray(msg.content)) { - for (const p of msg.content) { - if ('type' in p) { - if (p.type === 'text') - total += p.text.length; - else if (p.type === 'tool_result') { - total += typeof p.content === 'string' ? p.content.length : JSON.stringify(p.content).length; - } - else if (p.type === 'tool_use') { - total += JSON.stringify(p.input).length; - } - } - } - } - } - return total; -} diff --git a/dist/agent/streaming-executor.d.ts b/dist/agent/streaming-executor.d.ts deleted file mode 100644 index 1b2a8c59..00000000 --- a/dist/agent/streaming-executor.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Streaming Tool Executor for runcode. - * Starts executing concurrent-safe tools while the model is still streaming. - * Non-concurrent tools wait until the full response is received. - */ -import type { CapabilityHandler, CapabilityInvocation, CapabilityResult, ExecutionScope } from './types.js'; -import type { PermissionManager } from './permissions.js'; -import type { SessionToolGuard } from './tool-guard.js'; -export declare class StreamingExecutor { - private handlers; - private scope; - private permissions?; - private guard?; - private onStart; - private onProgress?; - private pending; - constructor(opts: { - handlers: Map; - scope: ExecutionScope; - permissions?: PermissionManager; - guard?: SessionToolGuard; - onStart: (id: string, name: string, preview?: string) => void; - onProgress?: (id: string, text: string) => void; - }); - /** - * Called when a tool_use block is fully received from the stream. - * If the tool is concurrent-safe, start executing immediately. - * Otherwise, queue it for later. - */ - onToolReceived(invocation: CapabilityInvocation): void; - /** - * After the model finishes streaming, execute any non-concurrent tools - * and collect all results (including concurrent ones that may already be done). - */ - collectResults(allInvocations: CapabilityInvocation[]): Promise<[CapabilityInvocation, CapabilityResult][]>; - private executeWithPermissions; - /** Extract a short preview string from a tool invocation's input. */ - private inputPreview; -} diff --git a/dist/agent/streaming-executor.js b/dist/agent/streaming-executor.js deleted file mode 100644 index d0315d0b..00000000 --- a/dist/agent/streaming-executor.js +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Streaming Tool Executor for runcode. - * Starts executing concurrent-safe tools while the model is still streaming. - * Non-concurrent tools wait until the full response is received. - */ -import { recordFailure } from '../stats/failures.js'; -export class StreamingExecutor { - handlers; - scope; - permissions; - guard; - onStart; - onProgress; - pending = []; - constructor(opts) { - this.handlers = opts.handlers; - this.scope = opts.scope; - this.permissions = opts.permissions; - this.guard = opts.guard; - this.onStart = opts.onStart; - this.onProgress = opts.onProgress; - } - /** - * Called when a tool_use block is fully received from the stream. - * If the tool is concurrent-safe, start executing immediately. - * Otherwise, queue it for later. - */ - onToolReceived(invocation) { - const handler = this.handlers.get(invocation.name); - const isConcurrent = handler?.concurrent ?? false; - if (isConcurrent) { - // Concurrent tools are auto-allowed — start immediately and time from here - const preview = this.inputPreview(invocation); - this.onStart(invocation.id, invocation.name, preview); - const promise = this.executeWithPermissions(invocation, 1, false); - this.pending.push({ invocation, promise }); - } - // Non-concurrent tools are NOT started here — executed via collectResults - } - /** - * After the model finishes streaming, execute any non-concurrent tools - * and collect all results (including concurrent ones that may already be done). - */ - async collectResults(allInvocations) { - const results = []; - const alreadyStarted = new Set(this.pending.map(p => p.invocation.id)); - const pendingSnapshot = [...this.pending]; - this.pending = []; // Clear immediately so errors don't leave stale state - // Pre-count pending sequential invocations per tool type. - // Shown in permission dialog: "N pending — press [a] to allow all". - const pendingCounts = new Map(); - for (const inv of allInvocations) { - if (!alreadyStarted.has(inv.id)) { - pendingCounts.set(inv.name, (pendingCounts.get(inv.name) || 0) + 1); - } - } - const remainingCounts = new Map(pendingCounts); - try { - // Wait for concurrent results that were started during streaming - for (const p of pendingSnapshot) { - const result = await p.promise; - results.push([p.invocation, result]); - } - // Execute sequential (non-concurrent) tools now - for (const inv of allInvocations) { - if (alreadyStarted.has(inv.id)) - continue; - const remaining = remainingCounts.get(inv.name) ?? 1; - remainingCounts.set(inv.name, remaining - 1); - // NOTE: onStart is called INSIDE executeWithPermissions, AFTER permission is granted. - // This ensures elapsed time reflects actual execution time, not permission wait time. - const result = await this.executeWithPermissions(inv, remaining, true); - results.push([inv, result]); - } - } - catch (err) { - // Return partial results rather than losing them; caller handles errors - throw err; - } - return results; - } - async executeWithPermissions(invocation, pendingCount = 1, callStart = true // false for concurrent tools (already called in onToolReceived) - ) { - const guardResult = this.guard - ? await this.guard.beforeExecute(invocation, this.scope) - : null; - if (guardResult) { - return guardResult; - } - // Permission check - if (this.permissions) { - const decision = await this.permissions.check(invocation.name, invocation.input); - if (decision.behavior === 'deny') { - this.guard?.cancelInvocation(invocation.id); - return { - output: `Permission denied for ${invocation.name}: ${decision.reason || 'denied by policy'}. Do not retry — explain to the user what you were trying to do and ask how they'd like to proceed.`, - isError: true, - }; - } - if (decision.behavior === 'ask') { - const allowed = await this.permissions.promptUser(invocation.name, invocation.input, pendingCount); - if (!allowed) { - this.guard?.cancelInvocation(invocation.id); - return { - output: `User denied permission for ${invocation.name}. Do not retry — ask the user what they'd like to do instead.`, - isError: true, - }; - } - } - } - // Start timing AFTER permission is granted (accurate elapsed time) - if (callStart) { - const preview = this.inputPreview(invocation); - this.onStart(invocation.id, invocation.name, preview); - } - let handler = this.handlers.get(invocation.name); - if (!handler) { - // Attempt repair: lowercase, normalize hyphens/spaces → match - const attempted = invocation.name; - const lower = attempted.toLowerCase(); - for (const [name, h] of this.handlers) { - if (name.toLowerCase() === lower || name.toLowerCase().replace(/[-_ ]/g, '') === lower.replace(/[-_ ]/g, '')) { - handler = h; - invocation = { ...invocation, name }; - break; - } - } - if (!handler) { - this.guard?.cancelInvocation(invocation.id); - const available = [...this.handlers.keys()].join(', '); - return { - output: `Unknown tool "${attempted}". Available tools: ${available}. Check spelling and try again.`, - isError: true, - }; - } - } - // Wire per-invocation progress to onProgress callback - const progressScope = this.onProgress - ? { - ...this.scope, - onProgress: (text) => this.onProgress(invocation.id, text), - } - : this.scope; - try { - let result = await handler.execute(invocation.input, progressScope); - this.guard?.afterExecute(invocation, result); - // Cap tool result size to prevent context bloat (inspired by Hermes 200KB budget) - const MAX_RESULT_CHARS = 50_000; - if (result.output.length > MAX_RESULT_CHARS) { - result = { - output: result.output.slice(0, MAX_RESULT_CHARS) + - `\n\n[Truncated: original was ${result.output.length.toLocaleString()} chars. Use Read tool to access full content.]`, - isError: result.isError, - }; - } - return result; - } - catch (err) { - this.guard?.cancelInvocation(invocation.id); - recordFailure({ - timestamp: Date.now(), - model: '', // not available at tool level - failureType: 'tool_error', - toolName: invocation.name, - errorMessage: err.message, - }); - return { - output: `Error executing ${invocation.name}: ${err.message}`, - isError: true, - }; - } - } - /** Extract a short preview string from a tool invocation's input. */ - inputPreview(invocation) { - const input = invocation.input; - switch (invocation.name) { - case 'Bash': { - const cmd = input.command || ''; - return cmd.length > 80 ? cmd.slice(0, 80) + '…' : cmd; - } - case 'Write': - case 'Read': - case 'Edit': - return input.file_path || undefined; - case 'Grep': - return input.pattern || undefined; - case 'Glob': - return input.pattern || undefined; - case 'WebFetch': - case 'WebSearch': - return (input.url ?? input.query) || undefined; - default: - return undefined; - } - } -} diff --git a/dist/agent/tokens.d.ts b/dist/agent/tokens.d.ts deleted file mode 100644 index a0231fe7..00000000 --- a/dist/agent/tokens.d.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Token estimation for runcode. - * Uses byte-based heuristic (no external tokenizer dependency). - * Anchors to actual API counts when available, estimates on top for new messages. - */ -import type { Dialogue } from './types.js'; -/** - * Update with actual token counts from API response. - * This anchors our estimates to reality. - */ -export declare function updateActualTokens(inputTokens: number, outputTokens: number, messageCount: number): void; -/** - * Get token count using API anchor + estimation for new messages. - * More accurate than pure estimation because it's grounded in actual API counts. - */ -export declare function getAnchoredTokenCount(history: Dialogue[]): { - estimated: number; - apiAnchored: boolean; - contextUsagePct: number; -}; -/** - * Reset anchor (e.g., after compaction). - */ -export declare function resetTokenAnchor(): void; -/** - * Estimate token count for a string using byte-length heuristic. - * JSON-heavy content uses 2 bytes/token; general text uses 4. - */ -export declare function estimateTokens(text: string, bytesPerToken?: number): number; -/** - * Estimate total tokens for a message. - */ -export declare function estimateDialogueTokens(msg: Dialogue): number; -/** - * Estimate total tokens for the entire conversation history. - */ -export declare function estimateHistoryTokens(history: Dialogue[]): number; -/** - * Get the context window size for a model, with a conservative default. - */ -export declare function getContextWindow(model: string): number; -/** - * Reserved tokens for the compaction summary output. - */ -export declare const COMPACTION_SUMMARY_RESERVE = 16000; -/** - * Buffer before hitting the context limit to trigger auto-compact. - */ -export declare const COMPACTION_TRIGGER_BUFFER = 12000; -/** - * Calculate the threshold at which auto-compaction should trigger. - */ -export declare function getCompactionThreshold(model: string): number; diff --git a/dist/agent/tokens.js b/dist/agent/tokens.js deleted file mode 100644 index b3141759..00000000 --- a/dist/agent/tokens.js +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Token estimation for runcode. - * Uses byte-based heuristic (no external tokenizer dependency). - * Anchors to actual API counts when available, estimates on top for new messages. - */ -const DEFAULT_BYTES_PER_TOKEN = 4; -// ─── API-anchored token tracking ───────────────────────���────────────────── -/** Last known actual token count from API response */ -let lastApiInputTokens = 0; -let lastApiOutputTokens = 0; -let lastApiMessageCount = 0; -/** - * Update with actual token counts from API response. - * This anchors our estimates to reality. - */ -export function updateActualTokens(inputTokens, outputTokens, messageCount) { - lastApiInputTokens = inputTokens; - lastApiOutputTokens = outputTokens; - lastApiMessageCount = messageCount; -} -/** - * Get token count using API anchor + estimation for new messages. - * More accurate than pure estimation because it's grounded in actual API counts. - */ -export function getAnchoredTokenCount(history) { - if (lastApiInputTokens > 0 && lastApiMessageCount > 0 && history.length >= lastApiMessageCount) { - // Sanity check: if history was mutated (compaction, micro-compact), anchor may be stale. - // Detect by checking if new messages were only appended (length grew), not if content changed. - // If history grew by more than expected (e.g., resume injected many messages), fall through to estimation. - const growth = history.length - lastApiMessageCount; - if (growth <= 20) { // Reasonable growth since last API call - const newMessages = history.slice(lastApiMessageCount); - let newTokens = 0; - for (const msg of newMessages) { - newTokens += estimateDialogueTokens(msg); - } - const total = lastApiInputTokens + newTokens; - return { - estimated: total, - apiAnchored: true, - contextUsagePct: 0, - }; - } - // Too much growth — anchor is unreliable, fall through to estimation - resetTokenAnchor(); - } - // No anchor — pure estimation - return { - estimated: estimateHistoryTokens(history), - apiAnchored: false, - contextUsagePct: 0, - }; -} -/** - * Reset anchor (e.g., after compaction). - */ -export function resetTokenAnchor() { - lastApiInputTokens = 0; - lastApiOutputTokens = 0; - lastApiMessageCount = 0; -} -/** - * Estimate token count for a string using byte-length heuristic. - * JSON-heavy content uses 2 bytes/token; general text uses 4. - */ -export function estimateTokens(text, bytesPerToken = DEFAULT_BYTES_PER_TOKEN) { - // Pad by 4/3 (~33%) for conservative estimation — better to over-count than under-count - return Math.ceil(Buffer.byteLength(text, 'utf-8') / bytesPerToken * 1.33); -} -/** - * Estimate tokens for a content part. - */ -function estimateContentPartTokens(part) { - switch (part.type) { - case 'text': - return estimateTokens(part.text); - case 'tool_use': - // +16 tokens for tool_use framing (type, id, name fields, JSON structure) - return 16 + estimateTokens(part.name) + estimateTokens(JSON.stringify(part.input), 2); - case 'tool_result': { - const content = typeof part.content === 'string' - ? part.content - : JSON.stringify(part.content); - return estimateTokens(content, 2); - } - case 'thinking': - return estimateTokens(part.thinking); - default: - return 0; - } -} -/** - * Estimate total tokens for a message. - */ -export function estimateDialogueTokens(msg) { - const overhead = 4; // role, structure overhead - if (typeof msg.content === 'string') { - return overhead + estimateTokens(msg.content); - } - let total = overhead; - for (const part of msg.content) { - total += estimateContentPartTokens(part); - } - return total; -} -/** - * Estimate total tokens for the entire conversation history. - */ -export function estimateHistoryTokens(history) { - let total = 0; - for (const msg of history) { - total += estimateDialogueTokens(msg); - } - return total; -} -/** - * Context window sizes for known models. - */ -const MODEL_CONTEXT_WINDOWS = { - // Anthropic - 'anthropic/claude-opus-4.6': 200_000, - 'anthropic/claude-sonnet-4.6': 200_000, - 'anthropic/claude-sonnet-4': 200_000, - 'anthropic/claude-haiku-4.5': 200_000, - 'anthropic/claude-haiku-4.5-20251001': 200_000, - // OpenAI - 'openai/gpt-5.4': 128_000, - 'openai/gpt-5.4-pro': 128_000, - 'openai/gpt-5.3': 128_000, - 'openai/gpt-5.3-codex': 128_000, - 'openai/gpt-5.2': 128_000, - 'openai/gpt-5-mini': 128_000, - 'openai/gpt-5-nano': 128_000, - 'openai/gpt-4.1': 1_000_000, - 'openai/o3': 200_000, - 'openai/o4-mini': 200_000, - // Google - 'google/gemini-2.5-pro': 1_000_000, - 'google/gemini-2.5-flash': 1_000_000, - 'google/gemini-2.5-flash-lite': 1_000_000, - 'google/gemini-3.1-pro': 1_000_000, - // DeepSeek - 'deepseek/deepseek-chat': 64_000, - 'deepseek/deepseek-reasoner': 64_000, - // xAI - 'xai/grok-3': 131_072, - 'xai/grok-4-0709': 131_072, - 'xai/grok-4-1-fast-reasoning': 131_072, - // Others - 'zai/glm-5.1': 128_000, - 'moonshot/kimi-k2.5': 128_000, - 'minimax/minimax-m2.7': 128_000, -}; -/** - * Get the context window size for a model, with a conservative default. - */ -export function getContextWindow(model) { - if (MODEL_CONTEXT_WINDOWS[model]) - return MODEL_CONTEXT_WINDOWS[model]; - // Pattern-based inference for unknown models - if (model.includes('gemini')) - return 1_000_000; - if (model.includes('claude')) - return 200_000; - if (model.includes('gpt-4.1')) - return 1_000_000; - if (model.includes('nemotron') || model.includes('qwen')) - return 128_000; - return 128_000; -} -/** - * Reserved tokens for the compaction summary output. - */ -export const COMPACTION_SUMMARY_RESERVE = 16_000; -/** - * Buffer before hitting the context limit to trigger auto-compact. - */ -export const COMPACTION_TRIGGER_BUFFER = 12_000; -/** - * Calculate the threshold at which auto-compaction should trigger. - */ -export function getCompactionThreshold(model) { - const window = getContextWindow(model); - return window - COMPACTION_SUMMARY_RESERVE - COMPACTION_TRIGGER_BUFFER; -} diff --git a/dist/agent/tool-guard.d.ts b/dist/agent/tool-guard.d.ts deleted file mode 100644 index fb414c6f..00000000 --- a/dist/agent/tool-guard.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { CapabilityInvocation, CapabilityResult, ExecutionScope } from './types.js'; -export declare function normalizeSearchQuery(query: string): { - normalized: string; - tokens: string[]; -}; -export declare class SessionToolGuard { - private turn; - private webSearchesThisTurn; - private searchFamilies; - private searchCache; - private pendingSearches; - private recentReads; - private pendingReads; - private recentFetches; - private pendingFetches; - private toolErrorCounts; - startTurn(): void; - beforeExecute(invocation: CapabilityInvocation, scope: ExecutionScope): Promise; - afterExecute(invocation: CapabilityInvocation, result: CapabilityResult): void; - cancelInvocation(invocationId: string): void; - private beforeWebSearch; - private beforeRead; - private beforeWebFetch; - private afterWebSearch; - private afterRead; - private afterWebFetch; -} diff --git a/dist/agent/tool-guard.js b/dist/agent/tool-guard.js deleted file mode 100644 index bcacece6..00000000 --- a/dist/agent/tool-guard.js +++ /dev/null @@ -1,324 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -const MAX_WEBSEARCHES_PER_TURN = 8; -const MAX_SIMILAR_SEARCHES_PER_TURN = 4; -const MAX_NO_SIGNAL_SEARCHES_PER_FAMILY = 2; -const SEARCH_FAMILY_SIMILARITY = 0.58; -const DUPLICATE_READ_TURN_WINDOW = 1; -const DUPLICATE_FETCH_TURN_WINDOW = 1; -const MAX_PREVIEW_CHARS = 320; -const SEARCH_STOPWORDS = new Set([ - 'a', 'an', 'and', 'april', 'at', 'builder', 'builders', 'com', 'developer', - 'developers', 'for', 'from', 'in', 'latest', 'live', 'may', 'of', 'on', 'or', - 'post', 'posts', 'recent', 'reply', 'replies', 'site', 'status', 'the', 'to', - 'tweet', 'tweets', 'via', 'x', -]); -function stemToken(token) { - let result = token.toLowerCase(); - if (/^\d{4}$/.test(result)) - return ''; - if (result.endsWith('ing') && result.length > 6) - result = result.slice(0, -3); - else if (result.endsWith('ers') && result.length > 5) - result = result.slice(0, -3); - else if (result.endsWith('er') && result.length > 4) - result = result.slice(0, -2); - else if (result.endsWith('ed') && result.length > 4) - result = result.slice(0, -2); - else if (result.endsWith('es') && result.length > 4) - result = result.slice(0, -2); - else if (result.endsWith('s') && result.length > 4) - result = result.slice(0, -1); - return result; -} -export function normalizeSearchQuery(query) { - const tokens = query - .toLowerCase() - .replace(/https?:\/\/\S+/g, ' ') - .replace(/[^a-z0-9]+/g, ' ') - .split(/\s+/) - .map(stemToken) - .filter((token) => token.length >= 2 && !SEARCH_STOPWORDS.has(token)); - const normalized = [...new Set(tokens)].sort().join(' '); - return { normalized, tokens: [...new Set(tokens)] }; -} -function jaccardSimilarity(a, b) { - if (a.size === 0 || b.size === 0) - return 0; - let intersection = 0; - for (const token of a) { - if (b.has(token)) - intersection++; - } - const union = new Set([...a, ...b]).size; - return union === 0 ? 0 : intersection / union; -} -function summarizeOutput(output) { - const compact = output - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .slice(0, 4) - .join('\n'); - return compact.length > MAX_PREVIEW_CHARS - ? compact.slice(0, MAX_PREVIEW_CHARS - 3) + '...' - : compact; -} -function isNoSignalSearchResult(output, isError) { - const lower = output.toLowerCase(); - return Boolean(isError || - lower.startsWith('no results found for:') || - lower.startsWith('no candidate posts found') || - lower.startsWith('search timed out') || - lower.startsWith('search error:') || - lower.startsWith('searchx error:')); -} -function readKey(resolved, offset, limit) { - return `${resolved}::${offset ?? 1}::${limit ?? 2000}`; -} -function fetchKey(url, maxLength) { - return `${url}::${maxLength ?? 12288}`; -} -export class SessionToolGuard { - turn = 0; - webSearchesThisTurn = 0; - searchFamilies = []; - searchCache = new Map(); - pendingSearches = new Map(); - recentReads = new Map(); - pendingReads = new Map(); - recentFetches = new Map(); - pendingFetches = new Map(); - toolErrorCounts = new Map(); - startTurn() { - this.turn++; - this.webSearchesThisTurn = 0; - for (const family of this.searchFamilies) { - family.turnSearches = 0; - } - } - async beforeExecute(invocation, scope) { - // Hard-block tools that have failed too many times this session - const errorCount = this.toolErrorCounts.get(invocation.name) ?? 0; - if (errorCount >= 3) { - return { - output: `${invocation.name} has failed ${errorCount} times this session and is now disabled. ` + - 'Tell the user what went wrong and suggest alternatives.', - isError: true, - }; - } - switch (invocation.name) { - case 'WebSearch': - case 'SearchX': - return this.beforeWebSearch(invocation); - case 'Read': - return this.beforeRead(invocation, scope); - case 'WebFetch': - return this.beforeWebFetch(invocation); - default: - return null; - } - } - afterExecute(invocation, result) { - // Track per-tool error counts across the session - if (result.isError) { - this.toolErrorCounts.set(invocation.name, (this.toolErrorCounts.get(invocation.name) ?? 0) + 1); - } - switch (invocation.name) { - case 'WebSearch': - case 'SearchX': - this.afterWebSearch(invocation, result); - break; - case 'Read': - this.afterRead(invocation, result); - break; - case 'WebFetch': - this.afterWebFetch(invocation, result); - break; - default: - break; - } - } - cancelInvocation(invocationId) { - this.pendingSearches.delete(invocationId); - this.pendingReads.delete(invocationId); - this.pendingFetches.delete(invocationId); - } - beforeWebSearch(invocation) { - const query = String(invocation.input.query ?? '').trim(); - const fingerprint = normalizeSearchQuery(query); - const normalized = fingerprint.normalized || query.toLowerCase().trim(); - const cached = this.searchCache.get(normalized); - if (cached) { - const reason = cached.noSignal - ? 'That same WebSearch already returned no useful signal earlier in this session.' - : 'That same WebSearch already ran earlier in this session.'; - return { - output: `${reason} Reuse the prior result already in context instead of searching again.\n\n` + - `Previous search: ${cached.query}\n` + - `Summary:\n${cached.preview}`, - }; - } - if (this.webSearchesThisTurn >= MAX_WEBSEARCHES_PER_TURN) { - return { - output: `WebSearch budget reached for this turn (${MAX_WEBSEARCHES_PER_TURN} searches). ` + - 'Stop searching and synthesize the results already collected.', - }; - } - let bestFamily = null; - let bestSimilarity = 0; - const tokenSet = new Set(fingerprint.tokens); - for (const family of this.searchFamilies) { - const similarity = jaccardSimilarity(tokenSet, family.tokens); - if (similarity > bestSimilarity) { - bestSimilarity = similarity; - bestFamily = family; - } - } - if (bestFamily && bestSimilarity >= SEARCH_FAMILY_SIMILARITY) { - if (bestFamily.noSignalSearches >= MAX_NO_SIGNAL_SEARCHES_PER_FAMILY) { - return { - output: `Search stopped: ${bestFamily.noSignalSearches} similar WebSearch queries for this topic ` + - `already returned empty or low-signal results.\n\n` + - `Topic exemplar: ${bestFamily.exemplarQuery}\n` + - 'Present what you have instead of rephrasing the same search.', - }; - } - if (bestFamily.turnSearches >= MAX_SIMILAR_SEARCHES_PER_TURN) { - return { - output: `Search stopped: you already ran ${bestFamily.turnSearches} similar WebSearch queries ` + - `for this topic in the current turn.\n\n` + - `Topic exemplar: ${bestFamily.exemplarQuery}\n` + - 'Synthesize or switch to a materially different angle.', - }; - } - } - const family = bestFamily && bestSimilarity >= SEARCH_FAMILY_SIMILARITY - ? bestFamily - : { - exemplarQuery: query, - tokens: tokenSet, - totalSearches: 0, - turnSearches: 0, - noSignalSearches: 0, - }; - if (family === bestFamily) { - family.tokens = new Set([...family.tokens, ...tokenSet]); - } - else { - this.searchFamilies.push(family); - } - family.totalSearches++; - family.turnSearches++; - this.webSearchesThisTurn++; - this.pendingSearches.set(invocation.id, { normalized, family }); - return null; - } - beforeRead(invocation, scope) { - const filePath = String(invocation.input.file_path ?? ''); - if (!filePath) - return null; - const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(scope.workingDir, filePath); - let stat; - try { - stat = fs.statSync(resolved); - } - catch { - return null; - } - if (stat.isDirectory()) - return null; - const offset = Number(invocation.input.offset ?? 1); - const limit = Number(invocation.input.limit ?? 2000); - const key = readKey(resolved, offset, limit); - const pending = [...this.pendingReads.values()].find((snapshot) => snapshot.key === key); - if (pending && pending.mtimeMs === stat.mtimeMs && pending.size === stat.size) { - return { - output: `Skipped duplicate Read of ${resolved}. The same file and line range is already being read ` + - 'in this turn, so reuse that content instead of reading it again.', - }; - } - const previous = this.recentReads.get(key); - if (previous && - this.turn - previous.turn <= DUPLICATE_READ_TURN_WINDOW && - previous.mtimeMs === stat.mtimeMs && - previous.size === stat.size) { - return { - output: `Skipped duplicate Read of ${resolved}. Same file and line range were already read ` + - `${previous.turn === this.turn ? 'this turn' : 'in the previous turn'}, and the file has not changed.`, - }; - } - this.pendingReads.set(invocation.id, { - key, - resolved, - offset, - limit, - turn: this.turn, - mtimeMs: stat.mtimeMs, - size: stat.size, - }); - return null; - } - beforeWebFetch(invocation) { - const url = String(invocation.input.url ?? '').trim(); - if (!url) - return null; - const maxLength = Number(invocation.input.max_length ?? 12288); - const key = fetchKey(url, maxLength); - const pending = [...this.pendingFetches.values()].find((snapshot) => snapshot.key === key); - if (pending) { - return { - output: `Skipped duplicate WebFetch of ${url}. The same URL is already being fetched in this turn, ` + - 'so reuse that result instead of fetching it again.', - }; - } - const previous = this.recentFetches.get(key); - if (previous && this.turn - previous.turn <= DUPLICATE_FETCH_TURN_WINDOW) { - return { - output: `Skipped duplicate WebFetch of ${url}. The same URL was already fetched recently in this session; ` + - 'reuse that content already in context instead of fetching it again.', - }; - } - this.pendingFetches.set(invocation.id, { - key, - url, - maxLength, - turn: this.turn, - }); - return null; - } - afterWebSearch(invocation, result) { - const pending = this.pendingSearches.get(invocation.id); - if (!pending) - return; - this.pendingSearches.delete(invocation.id); - const query = String(invocation.input.query ?? '').trim(); - const noSignal = isNoSignalSearchResult(result.output, result.isError); - if (noSignal) { - pending.family.noSignalSearches++; - } - this.searchCache.set(pending.normalized, { - query, - preview: summarizeOutput(result.output), - noSignal, - }); - } - afterRead(invocation, result) { - const pending = this.pendingReads.get(invocation.id); - if (!pending) - return; - this.pendingReads.delete(invocation.id); - if (result.isError) - return; - this.recentReads.set(pending.key, pending); - } - afterWebFetch(invocation, result) { - const pending = this.pendingFetches.get(invocation.id); - if (!pending) - return; - this.pendingFetches.delete(invocation.id); - if (result.isError) - return; - this.recentFetches.set(pending.key, pending); - } -} diff --git a/dist/agent/types.d.ts b/dist/agent/types.d.ts deleted file mode 100644 index cb7b561d..00000000 --- a/dist/agent/types.d.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Core types for the runcode agent system. - * All type names and structures are original designs. - */ -export type Role = 'user' | 'assistant'; -export interface TextSegment { - type: 'text'; - text: string; -} -export interface CapabilityInvocation { - type: 'tool_use'; - id: string; - name: string; - input: Record; -} -export interface ThinkingSegment { - type: 'thinking'; - thinking: string; - signature?: string; -} -export interface CapabilityOutcome { - type: 'tool_result'; - tool_use_id: string; - content: string | ContentPart[]; - is_error?: boolean; -} -export type ContentPart = TextSegment | CapabilityInvocation | ThinkingSegment; -export type UserContentPart = TextSegment | CapabilityOutcome; -export interface Dialogue { - role: Role; - content: ContentPart[] | UserContentPart[] | string; -} -export interface CapabilitySchema { - type: 'object'; - properties: Record; - required?: string[]; - additionalProperties?: boolean; -} -export interface CapabilityDefinition { - name: string; - description: string; - input_schema: CapabilitySchema; -} -export interface CapabilityHandler { - spec: CapabilityDefinition; - execute(input: Record, ctx: ExecutionScope): Promise; - concurrent?: boolean; -} -export interface CapabilityResult { - output: string; - isError?: boolean; -} -export interface ExecutionScope { - workingDir: string; - abortSignal: AbortSignal; - onProgress?: (text: string) => void; - /** Routes AskUser questions through ink UI input to avoid raw-mode stdin conflict */ - onAskUser?: (question: string, options?: string[]) => Promise; -} -export interface StreamTextDelta { - kind: 'text_delta'; - text: string; -} -export interface StreamThinkingDelta { - kind: 'thinking_delta'; - text: string; -} -export interface StreamCapabilityStart { - kind: 'capability_start'; - id: string; - name: string; - preview?: string; -} -export interface StreamCapabilityInputDelta { - kind: 'capability_input_delta'; - id: string; - delta: string; -} -export interface StreamCapabilityProgress { - kind: 'capability_progress'; - id: string; - text: string; -} -export interface StreamCapabilityDone { - kind: 'capability_done'; - id: string; - result: CapabilityResult; -} -export interface StreamTurnDone { - kind: 'turn_done'; - reason: 'completed' | 'max_turns' | 'aborted' | 'error'; - error?: string; -} -export interface StreamUsageInfo { - kind: 'usage'; - inputTokens: number; - outputTokens: number; - model: string; - calls: number; - tier?: 'SIMPLE' | 'MEDIUM' | 'COMPLEX' | 'REASONING'; - confidence?: number; - savings?: number; - contextPct?: number; -} -export type StreamEvent = StreamTextDelta | StreamThinkingDelta | StreamCapabilityStart | StreamCapabilityInputDelta | StreamCapabilityProgress | StreamCapabilityDone | StreamTurnDone | StreamUsageInfo; -export interface AgentConfig { - model: string; - apiUrl: string; - chain: 'base' | 'solana'; - systemInstructions: string[]; - capabilities: CapabilityHandler[]; - maxTurns?: number; - workingDir?: string; - permissionMode?: 'default' | 'trust' | 'deny-all' | 'plan'; - onEvent?: (event: StreamEvent) => void; - debug?: boolean; - /** Ultrathink mode: inject deep-reasoning instruction into every prompt */ - ultrathink?: boolean; - /** - * Permission prompt function — injected by Ink UI to avoid stdin conflict. - * Replaces the readline-based askQuestion() when running in interactive mode. - * Returns 'yes' | 'no' | 'always' (always = allow for rest of session). - */ - permissionPromptFn?: (toolName: string, description: string) => Promise<'yes' | 'no' | 'always'>; - /** Routes AskUser questions through ink UI input to avoid raw-mode stdin conflict */ - onAskUser?: (question: string, options?: string[]) => Promise; - /** Notify UI when agent switches model (e.g. payment fallback) */ - onModelChange?: (model: string) => void; -} diff --git a/dist/agent/types.js b/dist/agent/types.js deleted file mode 100644 index cab03751..00000000 --- a/dist/agent/types.js +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Core types for the runcode agent system. - * All type names and structures are original designs. - */ -export {}; diff --git a/dist/banner.d.ts b/dist/banner.d.ts deleted file mode 100644 index d6b7f13b..00000000 --- a/dist/banner.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function printBanner(version: string): void; diff --git a/dist/banner.js b/dist/banner.js deleted file mode 100644 index 85574367..00000000 --- a/dist/banner.js +++ /dev/null @@ -1,159 +0,0 @@ -import chalk from 'chalk'; -// ─── Ben Franklin portrait ───────────────────────────────────────────────── -// -// Generated once, at build time, from the Joseph Duplessis 1785 oil painting -// of Benjamin Franklin (same source as the portrait on the US $100 bill). -// Public domain image from Wikimedia Commons: -// https://commons.wikimedia.org/wiki/File:BenFranklinDuplessis.jpg -// -// Pipeline: -// 1. Crop the 800×989 thumb to a 500×500 square centred on the face -// (sips --cropToHeightWidth 500 500 --cropOffset 140 150) -// 2. Convert via chafa: -// chafa --size=16x8 --symbols=block --colors=full ben-face.jpg -// 3. Strip cursor visibility control codes (\x1b[?25l / \x1b[?25h) -// 4. Paste here as hex-escaped string array (readable + diff-friendly) -// -// Visible dimensions: ~16 characters wide × 8 rows tall. -// -// Rendered best in a 256-color or truecolor terminal. Degrades gracefully -// on ancient terminals — but those are long gone and we don't support them. -const BEN_PORTRAIT_ROWS = [ - '\x1b[0m\x1b[38;2;7;0;0;48;2;8;0;0m▔ \x1b[38;2;9;1;0m▂\x1b[38;2;56;36;15;48;2;11;2;0m▗\x1b[38;2;100;73;36;48;2;31;16;6m▅\x1b[38;2;189;141;75;48;2;117;87;43m▅\x1b[38;2;217;162;85;48;2;152;111;51m▆\x1b[38;2;164;122;64;48;2;215;158;85m▔\x1b[38;2;124;90;46;48;2;217;160;93m▔\x1b[38;2;185;136;75;48;2;77;48;20m▅\x1b[38;2;100;61;24;48;2;39;18;4m▖\x1b[38;2;48;26;9;48;2;32;13;3m▃\x1b[38;2;39;18;4;48;2;30;11;2m▄\x1b[38;2;38;17;4;48;2;32;13;3m▄\x1b[38;2;40;20;5;48;2;35;15;2m▃\x1b[38;2;41;21;5;48;2;36;16;3m▂\x1b[0m', - '\x1b[7m\x1b[38;2;8;0;0m \x1b[0m\x1b[38;2;0;0;0;48;2;8;0;0m \x1b[38;2;13;2;1;48;2;45;26;10m▊\x1b[38;2;61;40;17;48;2;87;63;31m▎\x1b[38;2;88;61;29;48;2;134;94;42m▋\x1b[38;2;182;132;66;48;2;223;172;93m▏\x1b[38;2;140;91;38;48;2;233;193;106m▂\x1b[38;2;135;82;35;48;2;229;178;106m▂\x1b[38;2;201;145;78;48;2;223;166;95m▂\x1b[38;2;133;88;46;48;2;198;148;86m▁\x1b[38;2;144;96;47;48;2;96;57;21m▍\x1b[38;2;66;42;15;48;2;58;33;11m▗\x1b[38;2;59;36;13;48;2;47;25;9m▆\x1b[38;2;57;35;11;48;2;46;24;7m▅\x1b[38;2;58;36;11;48;2;50;29;8m▖\x1b[38;2;53;32;8;48;2;48;26;7m▃\x1b[0m', - '\x1b[38;2;12;3;3;48;2;9;0;0m▁\x1b[38;2;102;76;40;48;2;19;8;4m▗\x1b[38;2;110;83;45;48;2;56;35;15m▄\x1b[38;2;91;67;37;48;2;105;79;45m▌\x1b[38;2;96;64;31;48;2;186;135;70m▊\x1b[38;2;226;169;101;48;2;217;162;91m▗\x1b[38;2;216;159;89;48;2;144;93;44m▅\x1b[38;2;195;145;83;48;2;112;62;24m▅\x1b[38;2;233;178;110;48;2;206;151;81m▆\x1b[38;2;207;155;92;48;2;105;61;30m▎\x1b[38;2;145;94;46;48;2;94;50;19m▖\x1b[38;2;90;48;17;48;2;52;26;8m▎\x1b[38;2;59;33;9;48;2;64;40;14m▖\x1b[38;2;63;39;13;48;2;65;41;13m▊\x1b[38;2;58;36;11;48;2;64;40;14m▝\x1b[38;2;60;38;13;48;2;57;35;10m▍\x1b[0m', - '\x1b[38;2;37;22;12;48;2;11;2;2m▕\x1b[38;2;52;32;16;48;2;94;67;32m▘\x1b[38;2;77;53;21;48;2;125;96;52m▗\x1b[38;2;44;15;6;48;2;83;48;21m▞\x1b[38;2;122;73;33;48;2;195;138;72m▍\x1b[38;2;209;149;77;48;2;223;160;89m▋\x1b[38;2;228;157;84;48;2;234;173;98m▆\x1b[38;2;207;140;80;48;2;225;167;96m▝\x1b[38;2;213;151;88;48;2;193;135;79m▏\x1b[38;2;164;111;60;48;2;104;54;21m▍\x1b[38;2;175;110;52;48;2;136;78;32m▘\x1b[38;2;93;47;15;48;2;26;5;2m▎\x1b[38;2;39;13;4;48;2;54;28;8m▍\x1b[38;2;63;40;13;48;2;67;44;16m▔\x1b[38;2;68;44;15;48;2;65;41;16m▊\x1b[38;2;60;36;11;48;2;63;39;14m▝\x1b[0m', - '\x1b[38;2;12;1;0;48;2;55;33;13m▌\x1b[38;2;92;63;32;48;2;68;43;17m▝\x1b[38;2;75;51;24;48;2;93;65;34m▗\x1b[38;2;88;61;30;48;2;42;18;8m▘\x1b[38;2;62;35;18;48;2;191;150;83m▍\x1b[38;2;186;140;75;48;2;194;138;63m▁\x1b[38;2;189;130;61;48;2;219;157;79m▄\x1b[38;2;191;132;70;48;2;217;159;87m▂\x1b[38;2;179;105;60;48;2;207;146;83m▔\x1b[38;2;171;106;51;48;2;135;79;32m▋\x1b[38;2;64;30;8;48;2;120;69;27m▗\x1b[38;2;56;26;8;48;2;39;13;5m▂\x1b[38;2;44;18;7;48;2;72;44;16m▘\x1b[38;2;72;47;18;48;2;69;44;14m▖\x1b[38;2;70;46;14;48;2;68;44;14m▁\x1b[38;2;65;41;12;48;2;65;41;14m▘\x1b[0m', - '\x1b[38;2;77;56;35;48;2;22;8;3m▂\x1b[38;2;126;100;69;48;2;59;36;15m▃\x1b[38;2;131;105;70;48;2;80;54;27m▄\x1b[38;2;128;103;68;48;2;57;33;14m▄\x1b[38;2;191;174;117;48;2;125;103;69m▝\x1b[38;2;191;164;108;48;2;236;227;160m▞\x1b[38;2;220;202;137;48;2;173;123;63m▃\x1b[38;2;130;85;43;48;2;164;111;58m▄\x1b[38;2;117;68;26;48;2;185;116;58m▆\x1b[38;2;135;80;33;48;2;94;52;15m▘\x1b[38;2;51;28;9;48;2;80;50;16m▂\x1b[38;2;62;33;9;48;2;76;46;14m▘\x1b[38;2;75;50;16;48;2;74;47;15m▗\x1b[38;2;71;46;14;48;2;72;47;15m▝\x1b[38;2;73;48;16;48;2;69;44;14m▏\x1b[38;2;65;41;11;48;2;66;41;15m▆\x1b[0m', - '\x1b[38;2;125;101;70;48;2;159;129;87m▔\x1b[38;2;145;114;71;48;2;124;100;70m▆\x1b[38;2;152;123;81;48;2;121;100;69m▃\x1b[38;2;117;95;60;48;2;129;106;70m▖\x1b[38;2;115;91;61;48;2;131;105;69m▗\x1b[38;2;166;145;103;48;2;140;113;71m▔\x1b[38;2;162;135;87;48;2;231;217;147m▅\x1b[38;2;133;107;71;48;2;199;171;110m▂\x1b[38;2;131;100;59;48;2;107;75;37m▍\x1b[38;2;166;139;88;48;2;67;40;14m▃\x1b[38;2;204;179;121;48;2;39;19;8m▄\x1b[38;2;137;112;73;48;2;52;28;10m▖\x1b[38;2;54;32;10;48;2;76;49;16m▅\x1b[38;2;56;33;9;48;2;74;48;15m▃\x1b[38;2;60;37;10;48;2;70;47;14m▁\x1b[38;2;66;43;12;48;2;64;40;11m▅\x1b[0m', - '\x1b[38;2;157;128;85;48;2;167;138;98m▝\x1b[38;2;141;111;71;48;2;166;136;98m▝\x1b[38;2;149;119;83;48;2;126;96;60m▞\x1b[38;2;157;129;93;48;2;139;113;81m▅\x1b[38;2;144;117;79;48;2;117;92;58m▋\x1b[38;2;130;102;62;48;2;169;138;87m▋\x1b[38;2;171;141;87;48;2;143;117;77m▖\x1b[38;2;144;117;79;48;2;122;96;63m▊\x1b[38;2;132;105;68;48;2;144;117;82m▖\x1b[38;2;153;127;92;48;2;140;115;83m▞\x1b[38;2;134;108;71;48;2;217;193;135m▅\x1b[38;2;176;150;98;48;2;129;105;66m▋\x1b[38;2;118;94;61;48;2;54;32;14m▂\x1b[38;2;44;23;8;48;2;59;37;13m▃\x1b[38;2;62;41;16;48;2;48;26;9m▖\x1b[38;2;46;24;6;48;2;66;42;15m▖\x1b[0m', -]; -// ─── FRANKLIN text banner (gold → emerald gradient) ──────────────────────── -// -// Kept from v3.1.0. The text is laid out as 6 block-letter rows. Each row -// is tinted with a color interpolated between GOLD_START and EMERALD_END, -// giving the smooth vertical gradient that's been Franklin's banner since -// v3.1.0. -const FRANKLIN_ART = [ - ' ███████╗██████╗ █████╗ ███╗ ██╗██╗ ██╗██╗ ██╗███╗ ██╗', - ' ██╔════╝██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝██║ ██║████╗ ██║', - ' █████╗ ██████╔╝███████║██╔██╗ ██║█████╔╝ ██║ ██║██╔██╗ ██║', - ' ██╔══╝ ██╔══██╗██╔══██║██║╚██╗██║██╔═██╗ ██║ ██║██║╚██╗██║', - ' ██║ ██║ ██║██║ ██║██║ ╚████║██║ ██╗███████╗██║██║ ╚████║', - ' ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚═╝╚═╝ ╚═══╝', -]; -const GOLD_START = '#FFD700'; -const EMERALD_END = '#10B981'; -function hexToRgb(hex) { - const m = hex.replace('#', ''); - return [ - parseInt(m.slice(0, 2), 16), - parseInt(m.slice(2, 4), 16), - parseInt(m.slice(4, 6), 16), - ]; -} -function rgbToHex(r, g, b) { - const toHex = (n) => Math.round(n).toString(16).padStart(2, '0'); - return `#${toHex(r)}${toHex(g)}${toHex(b)}`; -} -function interpolateHex(start, end, t) { - const [r1, g1, b1] = hexToRgb(start); - const [r2, g2, b2] = hexToRgb(end); - return rgbToHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t); -} -// ─── Banner layout ───────────────────────────────────────────────────────── -// Minimum terminal width to show the side-by-side portrait + text layout. -// The portrait is ~16 chars, the FRANKLIN text is ~65 chars, plus a 3-char -// gap = 84 chars. We round up to 85 cols as the threshold. -const MIN_WIDTH_FOR_PORTRAIT = 85; -/** - * Pad a line to an exact visual width, ignoring ANSI escape codes when - * measuring. Used to align the portrait's right edge before the text block. - */ -function padVisible(s, targetWidth) { - // Strip ANSI color codes to measure visible length - // eslint-disable-next-line no-control-regex - const visible = s.replace(/\x1b\[[0-9;]*m/g, ''); - // Unicode block characters are width 1 (they're half-blocks, not double-width) - const current = [...visible].length; - if (current >= targetWidth) - return s; - // Append a reset + padding so background colors don't bleed into the gap - return s + '\x1b[0m' + ' '.repeat(targetWidth - current); -} -export function printBanner(version) { - const termWidth = process.stdout.columns ?? 80; - const useSideBySide = termWidth >= MIN_WIDTH_FOR_PORTRAIT; - if (useSideBySide) { - printSideBySide(version); - } - else { - printTextOnly(version); - } -} -/** - * Full layout: Ben Franklin portrait on the left, FRANKLIN text block on the - * right. Portrait is 8 rows × ~16 chars, text is 6 rows — text is vertically - * centred inside the portrait with 1 row of padding above. - * - * [portrait row 1] (empty) - * [portrait row 2] ███████╗██████╗ █████╗ ... - * [portrait row 3] ██╔════╝██╔══██╗██╔══██╗... - * [portrait row 4] █████╗ ██████╔╝███████║... - * [portrait row 5] ██╔══╝ ██╔══██╗██╔══██║... - * [portrait row 6] ██║ ██║ ██║██║ ██║... - * [portrait row 7] ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝... - * [portrait row 8] blockrun.ai · The AI agent with a wallet · vX - */ -function printSideBySide(version) { - const TEXT_TOP_OFFSET = 1; // rows of portrait above the text - const PORTRAIT_WIDTH = 17; // columns (char width) of the portrait + 1 pad - const GAP = ' '; // gap between portrait and text - const portraitRows = BEN_PORTRAIT_ROWS; - const textRows = FRANKLIN_ART.length; - const totalRows = Math.max(portraitRows.length, TEXT_TOP_OFFSET + textRows + 2); - for (let i = 0; i < totalRows; i++) { - const portraitLine = i < portraitRows.length - ? padVisible(portraitRows[i], PORTRAIT_WIDTH) - : ' '.repeat(PORTRAIT_WIDTH); - // Text column content - let textCol = ''; - const textIdx = i - TEXT_TOP_OFFSET; - if (textIdx >= 0 && textIdx < textRows) { - // FRANKLIN block letters with gradient colour - const t = textRows === 1 ? 0 : textIdx / (textRows - 1); - const color = interpolateHex(GOLD_START, EMERALD_END, t); - textCol = chalk.hex(color)(FRANKLIN_ART[textIdx]); - } - else if (textIdx === textRows) { - // Tagline row sits right under the FRANKLIN block. - // The big block-letter "FRANKLIN" above already says the product - // name — the tagline uses that real estate for the parent brand URL - // (blockrun.ai, which is a real live domain — unlike franklin.run - // which we own but haven't deployed yet, see v3.1.0 changelog). - textCol = - chalk.bold.hex(GOLD_START)(' blockrun.ai') + - chalk.dim(' · The AI agent with a wallet · v' + version); - } - // Write with a reset at the very start to prevent stray bg from the - // previous line bleeding into the current row's portrait column. - process.stdout.write('\x1b[0m' + portraitLine + GAP + textCol + '\x1b[0m\n'); - } - // Trailing blank line for breathing room - process.stdout.write('\n'); -} -/** - * Compact layout for narrow terminals: just the FRANKLIN text block with - * its gradient, no portrait. Matches the v3.1.0 banner exactly. - */ -function printTextOnly(version) { - const textRows = FRANKLIN_ART.length; - for (let i = 0; i < textRows; i++) { - const t = textRows === 1 ? 0 : i / (textRows - 1); - const color = interpolateHex(GOLD_START, EMERALD_END, t); - console.log(chalk.hex(color)(FRANKLIN_ART[i])); - } - console.log(chalk.bold.hex(GOLD_START)(' blockrun.ai') + - chalk.dim(' · The AI agent with a wallet · v' + version) + - '\n'); -} diff --git a/dist/brain/extract.d.ts b/dist/brain/extract.d.ts deleted file mode 100644 index d09b9e01..00000000 --- a/dist/brain/extract.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Franklin Brain — entity extraction from session traces. - * Uses cheap model to detect people, projects, companies from conversation. - */ -import { ModelClient } from '../agent/llm.js'; -import type { Dialogue } from '../agent/types.js'; -/** - * Extract entities from a session and store in the brain. - * Fire-and-forget — caller should not await. - */ -export declare function extractBrainEntities(history: Dialogue[], sessionId: string, client: ModelClient): Promise; diff --git a/dist/brain/extract.js b/dist/brain/extract.js deleted file mode 100644 index 7e5a4846..00000000 --- a/dist/brain/extract.js +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Franklin Brain — entity extraction from session traces. - * Uses cheap model to detect people, projects, companies from conversation. - */ -import { loadEntities, saveEntities, upsertEntity, addObservation, upsertRelation, } from './store.js'; -const EXTRACTION_MODELS = [ - 'google/gemini-2.5-flash-lite', - 'google/gemini-2.5-flash', - 'nvidia/nemotron-super-49b', -]; -const VALID_TYPES = new Set(['person', 'project', 'company', 'product', 'concept']); -const BRAIN_PROMPT = `You are analyzing a conversation between a user and an AI agent. Extract entities (people, projects, companies, products, concepts) mentioned in the conversation. - -For each entity, provide: -- name: canonical name (e.g. "Garry Tan" not "garry" or "Garry") -- type: person | project | company | product | concept -- aliases: other names used for the same entity (handles, abbreviations) -- observations: 1-3 facts learned about this entity from the conversation - -Also extract relationships between entities: -- from: entity name -- to: entity name -- type: founded | works_on | partnered_with | uses | mentioned | replied_to | depends_on - -Rules: -- Only extract entities with CLEAR evidence in the conversation. -- Do NOT extract the AI agent itself or generic concepts ("TypeScript", "JavaScript"). -- DO extract specific people, specific projects, specific companies, specific products. -- Observations should be concrete facts, not vague descriptions. -- If no entities are found, return empty arrays. - -Respond with ONLY a JSON object (no markdown fences): -{"entities":[{"name":"...","type":"person","aliases":["@handle"],"observations":["Founded X in 2025"]}],"relations":[{"from":"Person","to":"Project","type":"founded"}]}`; -function condenseForBrain(history) { - const parts = []; - let chars = 0; - for (const msg of history) { - if (chars >= 3000) - break; - let text = ''; - if (typeof msg.content === 'string') { - text = msg.content; - } - else if (Array.isArray(msg.content)) { - text = msg.content - .filter(p => p.type === 'text') - .map(p => p.text ?? '') - .join('\n'); - } - if (!text.trim()) - continue; - if (text.length > 400) - text = text.slice(0, 400) + '…'; - const role = msg.role === 'user' ? 'User' : 'Assistant'; - const line = `${role}: ${text}`; - parts.push(line); - chars += line.length; - } - return parts.join('\n\n'); -} -function parseExtraction(raw) { - let cleaned = raw.replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim(); - const start = cleaned.indexOf('{'); - const end = cleaned.lastIndexOf('}'); - if (start === -1 || end === -1) - return { entities: [], relations: [] }; - cleaned = cleaned.slice(start, end + 1); - try { - const parsed = JSON.parse(cleaned); - const entities = (parsed.entities || []) - .filter((e) => typeof e.name === 'string' && e.name.length > 1 && - typeof e.type === 'string' && VALID_TYPES.has(e.type)) - .map((e) => ({ - name: e.name.slice(0, 100), - type: e.type, - aliases: Array.isArray(e.aliases) ? e.aliases.slice(0, 5) : [], - observations: Array.isArray(e.observations) - ? e.observations.filter(o => typeof o === 'string' && o.length > 5).slice(0, 5) - : [], - })); - const relations = (parsed.relations || []) - .filter((r) => typeof r.from === 'string' && typeof r.to === 'string' && typeof r.type === 'string') - .map((r) => ({ - from: r.from, - to: r.to, - type: r.type.slice(0, 30), - })); - return { entities, relations }; - } - catch { - return { entities: [], relations: [] }; - } -} -/** - * Extract entities from a session and store in the brain. - * Fire-and-forget — caller should not await. - */ -export async function extractBrainEntities(history, sessionId, client) { - if (history.length < 4) - return 0; - const condensed = condenseForBrain(history); - if (condensed.length < 80) - return 0; - let result = null; - for (const model of EXTRACTION_MODELS) { - try { - const response = await client.complete({ - model, - messages: [{ role: 'user', content: condensed }], - system: BRAIN_PROMPT, - max_tokens: 1500, - temperature: 0.2, - }); - const text = response.content - .filter(p => p.type === 'text') - .map(p => p.text ?? '') - .join(''); - result = parseExtraction(text); - break; - } - catch { - continue; - } - } - if (!result || (result.entities.length === 0 && result.relations.length === 0)) - return 0; - // Store entities + observations - const entities = loadEntities(); - const nameToId = new Map(); - for (const extracted of result.entities) { - const entityId = upsertEntity(entities, extracted.name, extracted.type, extracted.aliases); - nameToId.set(extracted.name.toLowerCase(), entityId); - for (const obs of extracted.observations) { - addObservation(entityId, obs, sessionId); - } - } - saveEntities(entities); - // Store relations - for (const rel of result.relations) { - const fromId = nameToId.get(rel.from.toLowerCase()) || - findEntityIdByName(entities, rel.from); - const toId = nameToId.get(rel.to.toLowerCase()) || - findEntityIdByName(entities, rel.to); - if (fromId && toId) { - upsertRelation(fromId, toId, rel.type); - } - } - return result.entities.length; -} -function findEntityIdByName(entities, name) { - const lower = name.toLowerCase(); - return entities.find(e => e.name.toLowerCase() === lower || - e.aliases.some(a => a.toLowerCase() === lower))?.id; -} diff --git a/dist/brain/index.d.ts b/dist/brain/index.d.ts deleted file mode 100644 index 0cc879f5..00000000 --- a/dist/brain/index.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type { Entity, EntityType, Observation, Relation, BrainExtraction } from './types.js'; -export { loadEntities, saveEntities, findEntity, upsertEntity, loadObservations, getEntityObservations, addObservation, loadRelations, getEntityRelations, upsertRelation, searchEntities, buildEntityContext, getBrainStats, } from './store.js'; -export { extractBrainEntities } from './extract.js'; diff --git a/dist/brain/index.js b/dist/brain/index.js deleted file mode 100644 index 3e6ca777..00000000 --- a/dist/brain/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { loadEntities, saveEntities, findEntity, upsertEntity, loadObservations, getEntityObservations, addObservation, loadRelations, getEntityRelations, upsertRelation, searchEntities, buildEntityContext, getBrainStats, } from './store.js'; -export { extractBrainEntities } from './extract.js'; diff --git a/dist/brain/store.d.ts b/dist/brain/store.d.ts deleted file mode 100644 index c34610bf..00000000 --- a/dist/brain/store.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Franklin Brain — JSONL storage for entities, observations, relations. - * All in-memory with JSONL persistence. No database. - */ -import type { Entity, EntityType, Observation, Relation } from './types.js'; -export declare function loadEntities(): Entity[]; -export declare function saveEntities(entities: Entity[]): void; -/** - * Find entity by name or alias (case-insensitive). - */ -export declare function findEntity(entities: Entity[], nameOrAlias: string): Entity | undefined; -/** - * Create or update an entity. Returns the entity ID. - * If an entity with a matching name/alias exists, merges aliases and bumps reference_count. - */ -export declare function upsertEntity(entities: Entity[], name: string, type: EntityType, aliases?: string[]): string; -export declare function loadObservations(): Observation[]; -export declare function getEntityObservations(entityId: string): Observation[]; -/** - * Add an observation. Deduplicates by content similarity (exact match). - */ -export declare function addObservation(entityId: string, content: string, source: string, confidence?: number, tags?: string[]): void; -export declare function loadRelations(): Relation[]; -export declare function getEntityRelations(entityId: string): Relation[]; -/** - * Add or update a relation. If same from+to+type exists, bumps count. - */ -export declare function upsertRelation(fromId: string, toId: string, type: string, confidence?: number): void; -/** - * Search entities by name/alias substring match. - */ -export declare function searchEntities(query: string, limit?: number): Entity[]; -/** - * Build context string for entities mentioned in the conversation. - * Returns empty string if no relevant entities found. - */ -export declare function buildEntityContext(mentionedNames: string[]): string; -export declare function getBrainStats(): { - entities: number; - observations: number; - relations: number; -}; diff --git a/dist/brain/store.js b/dist/brain/store.js deleted file mode 100644 index a26426f2..00000000 --- a/dist/brain/store.js +++ /dev/null @@ -1,225 +0,0 @@ -/** - * Franklin Brain — JSONL storage for entities, observations, relations. - * All in-memory with JSONL persistence. No database. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import crypto from 'node:crypto'; -import { BLOCKRUN_DIR } from '../config.js'; -const BRAIN_DIR = path.join(BLOCKRUN_DIR, 'brain'); -const ENTITIES_FILE = path.join(BRAIN_DIR, 'entities.jsonl'); -const OBSERVATIONS_FILE = path.join(BRAIN_DIR, 'observations.jsonl'); -const RELATIONS_FILE = path.join(BRAIN_DIR, 'relations.jsonl'); -const MAX_ENTITIES = 200; -function uid() { return crypto.randomBytes(8).toString('hex'); } -function ensureDir() { - fs.mkdirSync(BRAIN_DIR, { recursive: true }); -} -// ─── Generic JSONL helpers ──────────────────────────────────────────────── -function loadJsonl(file) { - try { - const raw = fs.readFileSync(file, 'utf-8'); - const results = []; - for (const line of raw.split('\n')) { - if (!line.trim()) - continue; - try { - results.push(JSON.parse(line)); - } - catch { /* skip corrupt */ } - } - return results; - } - catch { - return []; - } -} -function saveJsonl(file, items) { - ensureDir(); - const tmp = file + '.tmp'; - fs.writeFileSync(tmp, items.map(i => JSON.stringify(i)).join('\n') + '\n'); - fs.renameSync(tmp, file); -} -function appendJsonl(file, item) { - ensureDir(); - fs.appendFileSync(file, JSON.stringify(item) + '\n'); -} -// ─── Entities ───────────────────────────────────────────────────────────── -export function loadEntities() { - return loadJsonl(ENTITIES_FILE); -} -export function saveEntities(entities) { - saveJsonl(ENTITIES_FILE, entities); -} -/** - * Find entity by name or alias (case-insensitive). - */ -export function findEntity(entities, nameOrAlias) { - const lower = nameOrAlias.toLowerCase().trim(); - return entities.find(e => e.name.toLowerCase() === lower || - e.aliases.some(a => a.toLowerCase() === lower)); -} -/** - * Create or update an entity. Returns the entity ID. - * If an entity with a matching name/alias exists, merges aliases and bumps reference_count. - */ -export function upsertEntity(entities, name, type, aliases = []) { - const existing = findEntity(entities, name) || - aliases.map(a => findEntity(entities, a)).find(Boolean); - if (existing) { - // Merge aliases - const allAliases = new Set([...existing.aliases, ...aliases, name]); - allAliases.delete(existing.name); // Don't alias canonical name - existing.aliases = [...allAliases]; - existing.reference_count++; - existing.updated_at = Date.now(); - return existing.id; - } - // New entity - const entity = { - id: uid(), - type, - name, - aliases: aliases.filter(a => a.toLowerCase() !== name.toLowerCase()), - created_at: Date.now(), - updated_at: Date.now(), - reference_count: 1, - }; - entities.push(entity); - // Cap at MAX_ENTITIES — prune least-referenced - if (entities.length > MAX_ENTITIES) { - entities.sort((a, b) => b.reference_count - a.reference_count); - entities.length = MAX_ENTITIES; - } - return entity.id; -} -// ─── Observations ───────────────────────────────────────────────────────── -export function loadObservations() { - return loadJsonl(OBSERVATIONS_FILE); -} -export function getEntityObservations(entityId) { - return loadObservations().filter(o => o.entity_id === entityId); -} -/** - * Add an observation. Deduplicates by content similarity (exact match). - */ -export function addObservation(entityId, content, source, confidence = 0.8, tags = ['fact']) { - const existing = loadObservations(); - const contentLower = content.toLowerCase().trim(); - // Skip exact duplicates for this entity - if (existing.some(o => o.entity_id === entityId && o.content.toLowerCase().trim() === contentLower)) { - return; - } - appendJsonl(OBSERVATIONS_FILE, { - id: uid(), - entity_id: entityId, - content, - source, - confidence, - tags, - created_at: Date.now(), - }); -} -// ─── Relations ──────────────────────────────────────────────────────────── -export function loadRelations() { - return loadJsonl(RELATIONS_FILE); -} -export function getEntityRelations(entityId) { - return loadRelations().filter(r => r.from_id === entityId || r.to_id === entityId); -} -/** - * Add or update a relation. If same from+to+type exists, bumps count. - */ -export function upsertRelation(fromId, toId, type, confidence = 0.8) { - const relations = loadRelations(); - const existing = relations.find(r => r.from_id === fromId && r.to_id === toId && r.type === type); - if (existing) { - existing.count++; - existing.last_seen = Date.now(); - existing.confidence = Math.min(existing.confidence + 0.05, 1.0); - saveJsonl(RELATIONS_FILE, relations); - } - else { - appendJsonl(RELATIONS_FILE, { - id: uid(), - from_id: fromId, - to_id: toId, - type, - confidence, - count: 1, - last_seen: Date.now(), - }); - } -} -// ─── Search ─────────────────────────────────────────────────────────────── -/** - * Search entities by name/alias substring match. - */ -export function searchEntities(query, limit = 10) { - const lower = query.toLowerCase().trim(); - if (!lower) - return []; - return loadEntities() - .filter(e => e.name.toLowerCase().includes(lower) || - e.aliases.some(a => a.toLowerCase().includes(lower))) - .sort((a, b) => b.reference_count - a.reference_count) - .slice(0, limit); -} -// ─── Context building (for system prompt injection) ─────────────────────── -const MAX_BRAIN_CHARS = 1500; -/** - * Build context string for entities mentioned in the conversation. - * Returns empty string if no relevant entities found. - */ -export function buildEntityContext(mentionedNames) { - if (mentionedNames.length === 0) - return ''; - const entities = loadEntities(); - const matched = []; - for (const name of mentionedNames) { - const entity = findEntity(entities, name); - if (entity) - matched.push(entity); - } - if (matched.length === 0) - return ''; - const lines = ['# Known Entities']; - let chars = lines[0].length; - for (const entity of matched) { - const observations = getEntityObservations(entity.id) - .sort((a, b) => b.confidence - a.confidence) - .slice(0, 5); - const relations = getEntityRelations(entity.id); - const header = `\n## ${entity.name} (${entity.type})`; - if (chars + header.length > MAX_BRAIN_CHARS) - break; - lines.push(header); - chars += header.length; - for (const obs of observations) { - const line = `- ${obs.content}`; - if (chars + line.length + 1 > MAX_BRAIN_CHARS) - break; - lines.push(line); - chars += line.length + 1; - } - for (const rel of relations.slice(0, 3)) { - const otherEntity = entities.find(e => e.id === (rel.from_id === entity.id ? rel.to_id : rel.from_id)); - if (!otherEntity) - continue; - const line = `- ${rel.type} → ${otherEntity.name}`; - if (chars + line.length + 1 > MAX_BRAIN_CHARS) - break; - lines.push(line); - chars += line.length + 1; - } - } - return lines.length > 1 ? lines.join('\n') : ''; -} -// ─── Stats ──────────────────────────────────────────────────────────────── -export function getBrainStats() { - return { - entities: loadEntities().length, - observations: loadObservations().length, - relations: loadRelations().length, - }; -} diff --git a/dist/brain/types.d.ts b/dist/brain/types.d.ts deleted file mode 100644 index 32b2c9a5..00000000 --- a/dist/brain/types.d.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Franklin Brain — entity-based knowledge graph. - * Inspired by GBrain (Garry Tan). Lightweight JSONL, no database. - */ -export type EntityType = 'person' | 'project' | 'company' | 'product' | 'concept'; -export interface Entity { - id: string; - type: EntityType; - name: string; - aliases: string[]; - created_at: number; - updated_at: number; - reference_count: number; -} -export interface Observation { - id: string; - entity_id: string; - content: string; - source: string; - confidence: number; - tags: string[]; - created_at: number; -} -export interface Relation { - id: string; - from_id: string; - to_id: string; - type: string; - confidence: number; - count: number; - last_seen: number; -} -export interface BrainExtraction { - entities: Array<{ - name: string; - type: EntityType; - aliases?: string[]; - observations: string[]; - }>; - relations: Array<{ - from: string; - to: string; - type: string; - }>; -} diff --git a/dist/brain/types.js b/dist/brain/types.js deleted file mode 100644 index b55de752..00000000 --- a/dist/brain/types.js +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Franklin Brain — entity-based knowledge graph. - * Inspired by GBrain (Garry Tan). Lightweight JSONL, no database. - */ -export {}; diff --git a/dist/commands/balance.d.ts b/dist/commands/balance.d.ts deleted file mode 100644 index 3082fe80..00000000 --- a/dist/commands/balance.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function balanceCommand(): Promise; diff --git a/dist/commands/balance.js b/dist/commands/balance.js deleted file mode 100644 index 39dfd8e9..00000000 --- a/dist/commands/balance.js +++ /dev/null @@ -1,40 +0,0 @@ -import chalk from 'chalk'; -import { setupAgentWallet, setupAgentSolanaWallet } from '@blockrun/llm'; -import { loadChain } from '../config.js'; -export async function balanceCommand() { - const chain = loadChain(); - try { - if (chain === 'solana') { - const client = await setupAgentSolanaWallet({ silent: true }); - const address = await client.getWalletAddress(); - const balance = await client.getBalance(); - console.log(`Chain: ${chalk.magenta('solana')}`); - console.log(`Wallet: ${chalk.cyan(address)}`); - console.log(`USDC Balance: ${chalk.green(`$${balance.toFixed(2)}`)}`); - if (balance === 0) { - console.log(chalk.dim(`\nSend USDC on Solana to ${address} to get started.`)); - } - } - else { - const client = setupAgentWallet({ silent: true }); - const address = client.getWalletAddress(); - const balance = await client.getBalance(); - console.log(`Chain: ${chalk.magenta('base')}`); - console.log(`Wallet: ${chalk.cyan(address)}`); - console.log(`USDC Balance: ${chalk.green(`$${balance.toFixed(2)}`)}`); - if (balance === 0) { - console.log(chalk.dim(`\nSend USDC on Base to ${address} to get started.`)); - } - } - } - catch (err) { - const msg = err instanceof Error ? err.message : ''; - if (msg.includes('ENOENT') || msg.includes('wallet') || msg.includes('key')) { - console.log(chalk.red('No wallet found. Run `runcode setup` first.')); - } - else { - console.log(chalk.red(`Error checking balance: ${msg || 'unknown error'}`)); - } - process.exit(1); - } -} diff --git a/dist/commands/config.d.ts b/dist/commands/config.d.ts deleted file mode 100644 index 43361ef7..00000000 --- a/dist/commands/config.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface AppConfig { - 'default-model'?: string; - 'sonnet-model'?: string; - 'opus-model'?: string; - 'haiku-model'?: string; - 'smart-routing'?: string; - 'permission-mode'?: string; - 'max-turns'?: string; - 'auto-compact'?: string; - 'session-save'?: string; - 'debug'?: string; -} -export declare function loadConfig(): AppConfig; -export declare function configCommand(action: string, keyOrUndefined?: string, value?: string): void; diff --git a/dist/commands/config.js b/dist/commands/config.js deleted file mode 100644 index 38df4de7..00000000 --- a/dist/commands/config.js +++ /dev/null @@ -1,107 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import chalk from 'chalk'; -import { BLOCKRUN_DIR } from '../config.js'; -const CONFIG_FILE = path.join(BLOCKRUN_DIR, 'runcode-config.json'); -const VALID_KEYS = [ - 'default-model', - 'sonnet-model', - 'opus-model', - 'haiku-model', - 'smart-routing', - 'permission-mode', - 'max-turns', - 'auto-compact', - 'session-save', - 'debug', -]; -export function loadConfig() { - try { - const content = fs.readFileSync(CONFIG_FILE, 'utf-8'); - return JSON.parse(content); - } - catch { - return {}; - } -} -function saveConfig(config) { - try { - fs.mkdirSync(BLOCKRUN_DIR, { recursive: true }); - fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', { - mode: 0o600, - }); - } - catch (err) { - console.error(chalk.red(`Failed to save config: ${err.message}`)); - } -} -function isValidKey(key) { - return VALID_KEYS.includes(key); -} -export function configCommand(action, keyOrUndefined, value) { - if (action === 'list') { - const config = loadConfig(); - const entries = Object.entries(config); - if (entries.length === 0) { - console.log(chalk.dim('No config set. Defaults will be used.')); - console.log(chalk.dim(`\nConfig file: ${CONFIG_FILE}`)); - return; - } - console.log(chalk.bold('runcode config\n')); - for (const [k, v] of entries) { - console.log(` ${chalk.cyan(k)} = ${chalk.green(v)}`); - } - console.log(chalk.dim(`\nConfig file: ${CONFIG_FILE}`)); - return; - } - if (action === 'get') { - if (!keyOrUndefined) { - console.log(chalk.red('Usage: runcode config get ')); - process.exit(1); - } - const config = loadConfig(); - const val = config[keyOrUndefined]; - if (val !== undefined) { - console.log(val); - } - else { - console.log(chalk.dim('(not set)')); - } - return; - } - if (action === 'set') { - if (!keyOrUndefined || value === undefined) { - console.log(chalk.red('Usage: runcode config set ')); - process.exit(1); - } - if (!isValidKey(keyOrUndefined)) { - console.log(chalk.red(`Unknown config key: ${keyOrUndefined}`)); - console.log(`Valid keys: ${VALID_KEYS.map((k) => chalk.cyan(k)).join(', ')}`); - process.exit(1); - } - const config = loadConfig(); - config[keyOrUndefined] = value; - saveConfig(config); - console.log(`${chalk.cyan(keyOrUndefined)} = ${chalk.green(value)}`); - return; - } - if (action === 'unset') { - if (!keyOrUndefined) { - console.log(chalk.red('Usage: runcode config unset ')); - process.exit(1); - } - if (!isValidKey(keyOrUndefined)) { - console.log(chalk.red(`Unknown config key: ${keyOrUndefined}`)); - console.log(`Valid keys: ${VALID_KEYS.map((k) => chalk.cyan(k)).join(', ')}`); - process.exit(1); - } - const config = loadConfig(); - delete config[keyOrUndefined]; - saveConfig(config); - console.log(chalk.dim(`Unset ${keyOrUndefined}`)); - return; - } - console.log(chalk.red(`Unknown action: ${action}`)); - console.log('Usage: runcode config [key] [value]'); - process.exit(1); -} diff --git a/dist/commands/daemon.d.ts b/dist/commands/daemon.d.ts deleted file mode 100644 index 881d3192..00000000 --- a/dist/commands/daemon.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export declare function daemonCommand(action: string, options: { - port?: string; -}): Promise; diff --git a/dist/commands/daemon.js b/dist/commands/daemon.js deleted file mode 100644 index 2a23d9c7..00000000 --- a/dist/commands/daemon.js +++ /dev/null @@ -1,117 +0,0 @@ -import { spawn, execSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import chalk from 'chalk'; -import { BLOCKRUN_DIR, DEFAULT_PROXY_PORT } from '../config.js'; -const PID_FILE = path.join(BLOCKRUN_DIR, 'runcode.pid'); -const LOG_FILE = path.join(BLOCKRUN_DIR, 'runcode-debug.log'); -function readPid() { - try { - const raw = fs.readFileSync(PID_FILE, 'utf-8').trim(); - const pid = parseInt(raw, 10); - return isNaN(pid) ? null : pid; - } - catch { - return null; - } -} -function isRunning(pid) { - try { - process.kill(pid, 0); - return true; - } - catch { - return false; - } -} -export async function daemonCommand(action, options) { - const port = parseInt(options.port || String(DEFAULT_PROXY_PORT)); - if (isNaN(port) || port < 1 || port > 65535) { - console.log(chalk.red(`Invalid port "${options.port}". Must be 1-65535. Default: ${DEFAULT_PROXY_PORT}`)); - return; - } - switch (action) { - case 'start': { - const existing = readPid(); - if (existing && isRunning(existing)) { - console.log(chalk.yellow(`runcode daemon already running (PID ${existing})`)); - console.log(chalk.dim(` Proxy: http://localhost:${port}/api`)); - return; - } - // Find runcode binary - let runcodeBin; - try { - runcodeBin = execSync('which runcode', { encoding: 'utf-8' }).trim(); - } - catch { - console.log(chalk.red('runcode binary not found in PATH.')); - return; - } - fs.mkdirSync(BLOCKRUN_DIR, { recursive: true }); - const child = spawn(runcodeBin, ['proxy', '--port', String(port)], { - detached: true, - stdio: ['ignore', fs.openSync(LOG_FILE, 'a'), fs.openSync(LOG_FILE, 'a')], - }); - child.unref(); - fs.writeFileSync(PID_FILE, String(child.pid)); - console.log(chalk.green(`✓ runcode daemon started (PID ${child.pid})`)); - console.log(chalk.dim(` Proxy: http://localhost:${port}/api`)); - console.log(chalk.dim(` Logs: ${LOG_FILE}`)); - break; - } - case 'stop': { - const pid = readPid(); - if (!pid) { - console.log(chalk.yellow('No runcode daemon found.')); - return; - } - if (!isRunning(pid)) { - fs.unlinkSync(PID_FILE); - console.log(chalk.yellow(`Daemon PID ${pid} not running — cleaned up.`)); - return; - } - try { - process.kill(pid, 'SIGTERM'); - // Wait for process to exit (up to 5s) - for (let i = 0; i < 50; i++) { - if (!isRunning(pid)) - break; - await new Promise(r => setTimeout(r, 100)); - } - if (isRunning(pid)) { - process.kill(pid, 'SIGKILL'); - } - try { - fs.unlinkSync(PID_FILE); - } - catch { /* already gone */ } - console.log(chalk.green(`✓ runcode daemon stopped (PID ${pid})`)); - } - catch (e) { - console.log(chalk.red(`Failed to stop daemon: ${e.message}`)); - } - break; - } - case 'status': { - const pid = readPid(); - if (!pid) { - console.log(chalk.dim('runcode daemon: not running')); - return; - } - if (isRunning(pid)) { - console.log(chalk.green(`✓ runcode daemon running`)); - console.log(` PID: ${chalk.bold(pid)}`); - console.log(` Proxy: ${chalk.cyan(`http://localhost:${port}/api`)}`); - console.log(chalk.dim(` Logs: ${LOG_FILE}`)); - } - else { - fs.unlinkSync(PID_FILE); - console.log(chalk.yellow('runcode daemon: not running (stale PID cleaned up)')); - } - break; - } - default: - console.log(chalk.red(`Unknown daemon action: ${action}`)); - console.log('Usage: runcode daemon '); - } -} diff --git a/dist/commands/history.d.ts b/dist/commands/history.d.ts deleted file mode 100644 index edabfcb9..00000000 --- a/dist/commands/history.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -interface HistoryOptions { - n?: string; -} -export declare function historyCommand(options: HistoryOptions): void; -export {}; diff --git a/dist/commands/history.js b/dist/commands/history.js deleted file mode 100644 index 54af1330..00000000 --- a/dist/commands/history.js +++ /dev/null @@ -1,31 +0,0 @@ -import chalk from 'chalk'; -import { loadStats } from '../stats/tracker.js'; -export function historyCommand(options) { - const { history } = loadStats(); - const limit = Math.min(parseInt(options.n || '20', 10), history.length); - console.log(chalk.bold(` -📜 Last ${limit} Requests\n`)); - console.log('─'.repeat(55)); - if (history.length === 0) { - console.log(chalk.gray('\n No history recorded yet.\n')); - console.log('─'.repeat(55) + '\n'); - return; - } - const recent = history.slice(-limit).reverse(); - for (const record of recent) { - const time = new Date(record.timestamp).toLocaleString(); - const model = record.model.split('/').pop() || record.model; - const cost = '$' + record.costUsd.toFixed(5); - const tokens = `${record.inputTokens}+${record.outputTokens}`.padEnd(10); - const latency = `${record.latencyMs}ms`.padEnd(8); - const fallbackMark = record.fallback ? chalk.yellow(' ↺') : ''; - console.log(chalk.gray(`[${time}]`) + - ` ${model.padEnd(20)}${fallbackMark} ` + - chalk.cyan(tokens) + - chalk.magenta(latency) + - chalk.green(cost)); - } - console.log('\n' + '─'.repeat(55)); - console.log(chalk.gray(` Showing ${limit} of ${history.length} total records.`)); - console.log(chalk.gray(' Run `runcode stats` for more detailed statistics.\n')); -} diff --git a/dist/commands/init.d.ts b/dist/commands/init.d.ts deleted file mode 100644 index 75c97e6f..00000000 --- a/dist/commands/init.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export declare function initCommand(options: { - port?: string; -}): Promise; diff --git a/dist/commands/init.js b/dist/commands/init.js deleted file mode 100644 index 26d33af9..00000000 --- a/dist/commands/init.js +++ /dev/null @@ -1,92 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import chalk from 'chalk'; -import { DEFAULT_PROXY_PORT } from '../config.js'; -const CLAUDE_SETTINGS_FILE = path.join(os.homedir(), '.claude', 'settings.json'); -const LAUNCH_AGENT_DIR = path.join(os.homedir(), 'Library', 'LaunchAgents'); -const LAUNCH_AGENT_PLIST = path.join(LAUNCH_AGENT_DIR, 'ai.blockrun.runcode.plist'); -export async function initCommand(options) { - const port = parseInt(options.port || String(DEFAULT_PROXY_PORT)); - if (isNaN(port) || port < 1 || port > 65535) { - console.error(chalk.red(`Error: invalid port "${options.port}". Must be 1-65535. Default: ${DEFAULT_PROXY_PORT}`)); - process.exit(1); - } - // ── 1. Write ~/.claude/settings.json ──────────────────────────────────── - let settings = {}; - try { - if (fs.existsSync(CLAUDE_SETTINGS_FILE)) { - settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_FILE, 'utf-8')); - } - } - catch { - console.log(chalk.yellow(` Warning: could not parse ${CLAUDE_SETTINGS_FILE}, starting fresh.`)); - } - settings.env = { - ...(settings.env ?? {}), - ANTHROPIC_BASE_URL: `http://localhost:${port}/api`, - ANTHROPIC_AUTH_TOKEN: 'x402-proxy-handles-auth', - ANTHROPIC_MODEL: 'blockrun/auto', - ANTHROPIC_DEFAULT_SONNET_MODEL: 'anthropic/claude-sonnet-4.6', - ANTHROPIC_DEFAULT_OPUS_MODEL: 'anthropic/claude-opus-4.6', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'anthropic/claude-haiku-4.5-20251001', - }; - fs.mkdirSync(path.dirname(CLAUDE_SETTINGS_FILE), { recursive: true }); - fs.writeFileSync(CLAUDE_SETTINGS_FILE, JSON.stringify(settings, null, 2)); - console.log(chalk.green(`✓ Configured ${CLAUDE_SETTINGS_FILE}`)); - // ── 2. Install macOS LaunchAgent (auto-start on login) ───────────────── - if (process.platform === 'darwin') { - let runcodeBin = ''; - try { - const { execSync } = await import('node:child_process'); - runcodeBin = execSync('which runcode', { encoding: 'utf-8' }).trim(); - } - catch { - console.log(chalk.yellow(' Warning: runcode not found in PATH — LaunchAgent not installed.')); - } - if (runcodeBin) { - const plist = ` - - - - Label - ai.blockrun.runcode - ProgramArguments - - ${runcodeBin} - proxy - --port - ${port} - - RunAtLoad - - KeepAlive - - StandardOutPath - ${os.homedir()}/.blockrun/runcode-debug.log - StandardErrorPath - ${os.homedir()}/.blockrun/runcode-debug.log - -`; - fs.mkdirSync(LAUNCH_AGENT_DIR, { recursive: true }); - fs.writeFileSync(LAUNCH_AGENT_PLIST, plist); - try { - const { execSync } = await import('node:child_process'); - execSync(`launchctl load -w "${LAUNCH_AGENT_PLIST}"`, { stdio: 'pipe' }); - console.log(chalk.green(`✓ LaunchAgent installed — runcode proxy starts automatically on login`)); - } - catch { - console.log(chalk.dim(` LaunchAgent written to ${LAUNCH_AGENT_PLIST}`)); - console.log(chalk.dim(` Load manually: launchctl load -w "${LAUNCH_AGENT_PLIST}"`)); - } - } - } - // ── 3. Start daemon now ────────────────────────────────────────────────── - console.log(''); - console.log(chalk.bold('runcode initialized (proxy mode for Claude Code).')); - console.log(`Run ${chalk.bold('runcode daemon start')} to start the background proxy now.`); - console.log(`Then just run ${chalk.bold('claude')} — runcode proxy handles payments automatically.`); - console.log(''); - console.log(chalk.dim('Or use runcode directly: runcode start')); - console.log(chalk.dim('Note: Claude Code will ask you to trust the proxy URL once.')); -} diff --git a/dist/commands/logs.d.ts b/dist/commands/logs.d.ts deleted file mode 100644 index ded4c78c..00000000 --- a/dist/commands/logs.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -export declare function logsCommand(options: { - follow?: boolean; - lines?: string; - clear?: boolean; -}): void; diff --git a/dist/commands/logs.js b/dist/commands/logs.js deleted file mode 100644 index 6ea4a14e..00000000 --- a/dist/commands/logs.js +++ /dev/null @@ -1,89 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import chalk from 'chalk'; -import { BLOCKRUN_DIR } from '../config.js'; -const LOG_FILE = path.join(BLOCKRUN_DIR, 'runcode-debug.log'); -const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10MB auto-rotate threshold -export function logsCommand(options) { - if (options.clear) { - try { - fs.unlinkSync(LOG_FILE); - console.log(chalk.green('Logs cleared.')); - } - catch { - console.log(chalk.dim('No log file to clear.')); - } - return; - } - if (!fs.existsSync(LOG_FILE)) { - console.log(chalk.dim('No logs yet. Start runcode with --debug to enable logging:')); - console.log(chalk.bold(' runcode start --debug')); - return; - } - // Auto-rotate: if file is over threshold, keep only last half - try { - const stat = fs.statSync(LOG_FILE); - if (stat.size > MAX_LOG_SIZE) { - const content = fs.readFileSync(LOG_FILE, 'utf-8'); - const lines = content.split('\n'); - const half = lines.slice(Math.floor(lines.length / 2)); - fs.writeFileSync(LOG_FILE, half.join('\n')); - console.log(chalk.dim(`(Rotated log — was ${(stat.size / 1024 / 1024).toFixed(1)}MB)`)); - } - } - catch { /* ignore rotation errors */ } - const parsed = parseInt(options.lines || '50', 10); - const tailLines = isNaN(parsed) ? 50 : Math.max(1, Math.min(10000, parsed)); - if (options.follow) { - // Tail -f mode: print last N lines then watch for changes - printLastLines(tailLines); - console.log(chalk.dim('--- watching for new entries (ctrl+c to stop) ---')); - let lastSize = fs.statSync(LOG_FILE).size; - const watcher = setInterval(() => { - try { - const stat = fs.statSync(LOG_FILE); - if (stat.size > lastSize) { - const fd = fs.openSync(LOG_FILE, 'r'); - const buf = Buffer.alloc(stat.size - lastSize); - fs.readSync(fd, buf, 0, buf.length, lastSize); - fs.closeSync(fd); - process.stdout.write(buf.toString('utf-8')); - lastSize = stat.size; - } - else if (stat.size < lastSize) { - // File was rotated/cleared - lastSize = 0; - } - } - catch { - /* file may have been deleted */ - } - }, 500); - process.on('SIGINT', () => { - clearInterval(watcher); - process.exit(0); - }); - } - else { - printLastLines(tailLines); - } -} -function printLastLines(n) { - try { - const content = fs.readFileSync(LOG_FILE, 'utf-8'); - const lines = content.split('\n').filter(Boolean); - const start = Math.max(0, lines.length - n); - const slice = lines.slice(start); - if (start > 0) { - console.log(chalk.dim(`... (${start} earlier entries, use --lines to see more)`)); - } - for (const line of slice) { - // Colorize timestamps - const colored = line.replace(/^\[(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\]/, chalk.dim('[$1]')); - console.log(colored); - } - } - catch { - console.log(chalk.dim('Could not read log file.')); - } -} diff --git a/dist/commands/migrate.d.ts b/dist/commands/migrate.d.ts deleted file mode 100644 index 8b489a40..00000000 --- a/dist/commands/migrate.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * franklin migrate — one-click import from other AI coding agents. - * - * Detects installed tools (Claude Code, Cline, Cursor, etc.), - * shows what can be migrated, and imports with user confirmation. - */ -export declare function migrateCommand(): Promise; -/** - * Check if other AI tools are installed and suggest migration. - * Only runs once — writes a marker file after first check. - * Returns true if the user chose to migrate (caller should re-run start after). - */ -export declare function checkAndSuggestMigration(): Promise; diff --git a/dist/commands/migrate.js b/dist/commands/migrate.js deleted file mode 100644 index b891384a..00000000 --- a/dist/commands/migrate.js +++ /dev/null @@ -1,413 +0,0 @@ -/** - * franklin migrate — one-click import from other AI coding agents. - * - * Detects installed tools (Claude Code, Cline, Cursor, etc.), - * shows what can be migrated, and imports with user confirmation. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import readline from 'node:readline'; -import chalk from 'chalk'; -import { BLOCKRUN_DIR } from '../config.js'; -function detectSources() { - const sources = []; - const home = os.homedir(); - // ── Claude Code ── - const claudeDir = path.join(home, '.claude'); - if (fs.existsSync(claudeDir)) { - const items = []; - // MCP servers - const claudeMcp = path.join(claudeDir, 'mcp.json'); - if (fs.existsSync(claudeMcp)) { - items.push({ - label: 'MCP servers', - source: claudeMcp, - target: path.join(BLOCKRUN_DIR, 'mcp.json'), - size: fileSize(claudeMcp), - transform: () => migrateMcp(claudeMcp), - }); - } - // Global instructions → learnings - const claudeMd = path.join(claudeDir, 'CLAUDE.md'); - if (fs.existsSync(claudeMd)) { - items.push({ - label: 'Global instructions (CLAUDE.md)', - source: claudeMd, - target: path.join(BLOCKRUN_DIR, 'learnings.jsonl'), - size: fileSize(claudeMd), - transform: () => migrateInstructions(claudeMd), - }); - } - // Session history - const claudeHistory = path.join(claudeDir, 'history.jsonl'); - if (fs.existsSync(claudeHistory)) { - const lines = countLines(claudeHistory); - items.push({ - label: `Session history (${lines.toLocaleString()} messages)`, - source: claudeHistory, - target: path.join(BLOCKRUN_DIR, 'sessions'), - size: fileSize(claudeHistory), - transform: () => migrateSessions(claudeHistory), - }); - } - // Project memory files - const projectsDir = path.join(claudeDir, 'projects'); - if (fs.existsSync(projectsDir)) { - const memoryFiles = findMemoryFiles(projectsDir); - if (memoryFiles.length > 0) { - items.push({ - label: `Project memories (${memoryFiles.length} files)`, - source: projectsDir, - target: path.join(BLOCKRUN_DIR, 'learnings.jsonl'), - size: `${memoryFiles.length} files`, - transform: () => migrateMemories(memoryFiles), - }); - } - } - if (items.length > 0) { - sources.push({ name: 'Claude Code', dir: claudeDir, items }); - } - } - // ── Cline / OpenClaw ── - const clineDir = path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev'); - if (fs.existsSync(clineDir)) { - const items = []; - // TODO: detect Cline data - if (items.length > 0) { - sources.push({ name: 'Cline', dir: clineDir, items }); - } - } - // ── Cursor ── - const cursorDir = path.join(home, 'Library', 'Application Support', 'Cursor'); - if (fs.existsSync(cursorDir)) { - const items = []; - // TODO: detect Cursor data - if (items.length > 0) { - sources.push({ name: 'Cursor', dir: cursorDir, items }); - } - } - return sources; -} -// ─── Transforms ─────────────────────────────────────────────────────────── -function migrateMcp(source) { - const target = path.join(BLOCKRUN_DIR, 'mcp.json'); - const raw = JSON.parse(fs.readFileSync(source, 'utf-8')); - // Claude Code format: { mcpServers: { name: { command, args, env } } } - // Franklin format: { mcpServers: { name: { transport, command, args, label } } } - const servers = {}; - const skipped = []; - if (raw.mcpServers) { - for (const [name, config] of Object.entries(raw.mcpServers)) { - // Skip MCP servers that require external credentials (OAuth, API keys, - // tokens) — importing them causes noisy startup errors because the - // credentials aren't available in Franklin's context. Users can add - // these manually via ~/.blockrun/mcp.json if they set up the credentials. - const configStr = JSON.stringify(config).toLowerCase(); - const needsCredentials = configStr.includes('oauth') || - configStr.includes('credential') || - configStr.includes('api_key') || - configStr.includes('api-key') || - configStr.includes('token') || - name.includes('calendar') || - name.includes('gmail') || - name.includes('google') || - name.includes('slack') || - name.includes('notion'); - if (needsCredentials) { - skipped.push(name); - continue; - } - servers[name] = { - transport: config.transport || 'stdio', - command: config.command, - args: config.args || [], - label: name, - ...(config.env ? { env: config.env } : {}), - }; - } - } - // Merge with existing Franklin MCP config - let existing = {}; - try { - if (fs.existsSync(target)) { - existing = JSON.parse(fs.readFileSync(target, 'utf-8')); - } - } - catch { /* start fresh */ } - const merged = { - mcpServers: { - ...(existing.mcpServers || {}), - ...servers, - }, - }; - fs.mkdirSync(BLOCKRUN_DIR, { recursive: true }); - fs.writeFileSync(target, JSON.stringify(merged, null, 2)); - const importedCount = Object.keys(servers).length; - console.log(chalk.green(` ✓ ${importedCount} MCP server(s) imported`)); - if (skipped.length > 0) { - console.log(chalk.dim(` · ${skipped.length} skipped (need credentials): ${skipped.join(', ')}`)); - } -} -function migrateInstructions(source) { - // Read CLAUDE.md and convert key preferences to learnings - const content = fs.readFileSync(source, 'utf-8'); - const learningsPath = path.join(BLOCKRUN_DIR, 'learnings.jsonl'); - // Extract simple preference lines as learnings - const lines = content.split('\n'); - const learnings = []; - const now = Date.now(); - let count = 0; - for (const line of lines) { - const trimmed = line.trim(); - // Skip empty lines, headers, and code blocks - if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('```') || trimmed.startsWith('|')) - continue; - // Skip very short or very long lines - if (trimmed.length < 15 || trimmed.length > 200) - continue; - // Skip lines that are just paths or URLs - if (trimmed.startsWith('/') || trimmed.startsWith('http')) - continue; - // Lines starting with - or * are likely preference rules - if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) { - const text = trimmed.slice(2).trim(); - if (text.length > 15) { - const entry = { - id: `migrate-${count++}`, - learning: text.slice(0, 200), - category: 'other', - confidence: 0.8, - source_session: 'migrate:claude-code', - created_at: now, - last_confirmed: now, - times_confirmed: 1, - }; - learnings.push(JSON.stringify(entry)); - } - } - } - if (learnings.length > 0) { - fs.mkdirSync(BLOCKRUN_DIR, { recursive: true }); - // Append to existing learnings - fs.appendFileSync(learningsPath, learnings.join('\n') + '\n'); - console.log(chalk.green(` ✓ ${learnings.length} preferences imported`)); - } - else { - console.log(chalk.dim(' ○ No extractable preferences found')); - } -} -function migrateSessions(source) { - const sessionsDir = path.join(BLOCKRUN_DIR, 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - const raw = fs.readFileSync(source, 'utf-8'); - const lines = raw.split('\n').filter(l => l.trim()); - // Group by conversation turns — each user+assistant pair is a chunk - // We'll create session files grouped by day - const sessions = new Map(); - for (const line of lines) { - try { - const msg = JSON.parse(line); - // Use date from the line or current date as session key - const dateKey = new Date().toISOString().split('T')[0]; - // Try to extract timestamp if present - const ts = msg.timestamp || msg.created_at || msg.ts; - const key = ts ? new Date(ts).toISOString().split('T')[0] : dateKey; - if (!sessions.has(key)) - sessions.set(key, []); - sessions.get(key).push(line); - } - catch { - // Skip unparseable lines - } - } - let imported = 0; - for (const [dateKey, msgs] of sessions) { - const sessionId = `imported-${dateKey}`; - const sessionFile = path.join(sessionsDir, `${sessionId}.jsonl`); - // Don't overwrite existing imported sessions - if (fs.existsSync(sessionFile)) - continue; - fs.writeFileSync(sessionFile, msgs.join('\n') + '\n'); - // Create metadata - const meta = { - id: sessionId, - model: 'imported', - workDir: os.homedir(), - createdAt: new Date(dateKey).getTime(), - updatedAt: Date.now(), - turnCount: Math.floor(msgs.length / 2), - messageCount: msgs.length, - }; - fs.writeFileSync(path.join(sessionsDir, `${sessionId}.meta.json`), JSON.stringify(meta, null, 2)); - imported++; - } - console.log(chalk.green(` ✓ ${lines.length.toLocaleString()} messages → ${imported} session(s)`)); -} -function migrateMemories(files) { - const learningsPath = path.join(BLOCKRUN_DIR, 'learnings.jsonl'); - const now = Date.now(); - let count = 0; - const entries = []; - for (const file of files) { - try { - const content = fs.readFileSync(file, 'utf-8'); - const lines = content.split('\n'); - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('```')) - continue; - if (trimmed.startsWith('- ') && trimmed.length > 20 && trimmed.length < 200) { - const text = trimmed.slice(2).trim(); - // Skip index entries (links to other files) - if (text.startsWith('[') && text.includes('](')) - continue; - entries.push(JSON.stringify({ - id: `memory-${count++}`, - learning: text.slice(0, 200), - category: 'other', - confidence: 0.7, - source_session: 'migrate:project-memory', - created_at: now, - last_confirmed: now, - times_confirmed: 1, - })); - } - } - } - catch { /* skip unreadable files */ } - } - if (entries.length > 0) { - fs.mkdirSync(BLOCKRUN_DIR, { recursive: true }); - fs.appendFileSync(learningsPath, entries.join('\n') + '\n'); - console.log(chalk.green(` ✓ ${entries.length} memories imported`)); - } - else { - console.log(chalk.dim(' ○ No extractable memories found')); - } -} -// ─── Helpers ────────────────────────────────────────────────────────────── -function fileSize(p) { - try { - const bytes = fs.statSync(p).size; - if (bytes < 1024) - return `${bytes} B`; - if (bytes < 1024 * 1024) - return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - } - catch { - return '?'; - } -} -function countLines(p) { - try { - return fs.readFileSync(p, 'utf-8').split('\n').filter(l => l.trim()).length; - } - catch { - return 0; - } -} -function findMemoryFiles(projectsDir) { - const files = []; - try { - for (const project of fs.readdirSync(projectsDir)) { - const memoryDir = path.join(projectsDir, project, 'memory'); - if (!fs.existsSync(memoryDir)) - continue; - for (const file of fs.readdirSync(memoryDir)) { - if (file.endsWith('.md') && file !== 'MEMORY.md') { - files.push(path.join(memoryDir, file)); - } - } - } - } - catch { /* ignore */ } - return files; -} -// ─── Interactive prompt ─────────────────────────────────────────────────── -function ask(question) { - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - return new Promise(resolve => { - rl.question(question, answer => { rl.close(); resolve(answer.trim().toLowerCase()); }); - }); -} -// ─── Main command ───────────────────────────────────────────────────────── -export async function migrateCommand() { - console.log(chalk.bold('\n franklin migrate\n')); - const sources = detectSources(); - if (sources.length === 0) { - console.log(chalk.dim(' No other AI tools detected. Nothing to migrate.\n')); - console.log(chalk.dim(' Supported: Claude Code, Cline, Cursor\n')); - return; - } - // Show what was found - for (const source of sources) { - console.log(chalk.bold(` ${chalk.green('●')} ${source.name}`) + chalk.dim(` (${source.dir})`)); - for (const item of source.items) { - console.log(chalk.dim(` ├─ ${item.label}`) + (item.size ? chalk.dim(` [${item.size}]`) : '')); - } - console.log(''); - } - const total = sources.reduce((n, s) => n + s.items.length, 0); - const answer = await ask(chalk.yellow(` Import ${total} item(s) into Franklin? [Y/n] `)); - if (answer && answer !== 'y' && answer !== 'yes') { - console.log(chalk.dim('\n Cancelled.\n')); - return; - } - console.log(''); - // Run migrations - for (const source of sources) { - console.log(chalk.bold(` Migrating from ${source.name}...`)); - for (const item of source.items) { - try { - item.transform(); - } - catch (err) { - console.log(chalk.red(` ✗ ${item.label}: ${err.message}`)); - } - } - console.log(''); - } - console.log(chalk.green(' Done.') + chalk.dim(' Run `franklin --trust` to start.\n')); -} -// ─── First-run detection (called from start.ts) ────────────────────────── -const MIGRATED_MARKER = path.join(BLOCKRUN_DIR, '.migrated'); -/** - * Check if other AI tools are installed and suggest migration. - * Only runs once — writes a marker file after first check. - * Returns true if the user chose to migrate (caller should re-run start after). - */ -export async function checkAndSuggestMigration() { - // Only suggest once - if (fs.existsSync(MIGRATED_MARKER)) - return false; - // Write marker immediately so we never ask again - fs.mkdirSync(BLOCKRUN_DIR, { recursive: true }); - fs.writeFileSync(MIGRATED_MARKER, new Date().toISOString()); - const sources = detectSources(); - if (sources.length === 0) - return false; - const names = sources.map(s => s.name).join(', '); - const total = sources.reduce((n, s) => n + s.items.length, 0); - console.log(chalk.bold(`\n ${chalk.green('●')} Found ${names} — ${total} items available to import.`)); - const answer = await ask(chalk.yellow(` Import into Franklin? [Y/n] `)); - if (answer && answer !== 'y' && answer !== 'yes') { - console.log(chalk.dim(' Skipped. Run `franklin migrate` anytime.\n')); - return false; - } - console.log(''); - for (const source of sources) { - console.log(chalk.bold(` Migrating from ${source.name}...`)); - for (const item of source.items) { - try { - item.transform(); - } - catch (err) { - console.log(chalk.red(` ✗ ${item.label}: ${err.message}`)); - } - } - } - console.log(chalk.green('\n Done.') + ' Starting Franklin...\n'); - return true; -} diff --git a/dist/commands/models.d.ts b/dist/commands/models.d.ts deleted file mode 100644 index 9762bd87..00000000 --- a/dist/commands/models.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function modelsCommand(): Promise; diff --git a/dist/commands/models.js b/dist/commands/models.js deleted file mode 100644 index 17954cd2..00000000 --- a/dist/commands/models.js +++ /dev/null @@ -1,56 +0,0 @@ -import chalk from 'chalk'; -import { loadChain, API_URLS } from '../config.js'; -export async function modelsCommand() { - const chain = loadChain(); - const apiUrl = API_URLS[chain]; - console.log(chalk.bold('Available Models\n')); - console.log(`Chain: ${chalk.magenta(chain)} — ${chalk.dim(apiUrl)}\n`); - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15_000); - const response = await fetch(`${apiUrl}/v1/models`, { signal: controller.signal }); - clearTimeout(timeout); - if (!response.ok) { - console.log(chalk.red(`Failed to fetch models: ${response.status}`)); - return; - } - const data = (await response.json()); - if (!data.data || data.data.length === 0) { - console.log(chalk.yellow('No models returned from API.')); - return; - } - const models = data.data - .sort((a, b) => (a.pricing?.input ?? 0) - (b.pricing?.input ?? 0)); - const free = models.filter((m) => m.billing_mode === 'free'); - const paid = models.filter((m) => m.billing_mode !== 'free'); - if (free.length > 0) { - console.log(chalk.green.bold('Free Models (no USDC needed)')); - console.log(chalk.dim('─'.repeat(70))); - for (const m of free) { - console.log(` ${chalk.cyan(m.id)}`); - } - console.log(''); - } - console.log(chalk.yellow.bold('Paid Models')); - console.log(chalk.dim('─'.repeat(70))); - console.log(chalk.dim(` ${'Model'.padEnd(35)} ${'Input'.padEnd(12)} ${'Output'.padEnd(12)} Context`)); - console.log(chalk.dim('─'.repeat(70))); - for (const m of paid) { - const input = `$${(m.pricing?.input ?? 0).toFixed(2)}/M`; - const output = `$${(m.pricing?.output ?? 0).toFixed(2)}/M`; - const ctx = ''; - console.log(` ${chalk.cyan(m.id.padEnd(35))} ${input.padEnd(12)} ${output.padEnd(12)} ${ctx}`); - } - console.log(`\n${chalk.dim(`${models.length} models available. Use:`)} ${chalk.bold('runcode start --model ')}`); - } - catch (err) { - const msg = err instanceof Error ? err.message : 'unknown error'; - if (msg.includes('fetch') || msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND')) { - console.log(chalk.red(`Cannot reach BlockRun API at ${apiUrl}`)); - console.log(chalk.dim('Check your internet connection or try again later.')); - } - else { - console.log(chalk.red(`Error: ${msg}`)); - } - } -} diff --git a/dist/commands/panel.d.ts b/dist/commands/panel.d.ts deleted file mode 100644 index b9c97155..00000000 --- a/dist/commands/panel.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * franklin panel — launch the local web dashboard. - */ -export declare function panelCommand(options: { - port?: string; -}): Promise; diff --git a/dist/commands/panel.js b/dist/commands/panel.js deleted file mode 100644 index 7e4024a5..00000000 --- a/dist/commands/panel.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * franklin panel — launch the local web dashboard. - */ -import chalk from 'chalk'; -import { createPanelServer } from '../panel/server.js'; -export async function panelCommand(options) { - const port = parseInt(options.port || '3100', 10); - const server = createPanelServer(port); - server.listen(port, () => { - console.log(''); - console.log(chalk.bold(' Franklin Panel')); - console.log(chalk.dim(` http://localhost:${port}`)); - console.log(''); - console.log(chalk.dim(' Press Ctrl+C to stop.')); - console.log(''); - // Try to open browser - const open = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; - import('node:child_process').then(({ exec }) => { - exec(`${open} http://localhost:${port}`); - }).catch(() => { }); - }); - // Graceful shutdown - const shutdown = () => { - server.close(); - process.exit(0); - }; - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); -} diff --git a/dist/commands/plugin.d.ts b/dist/commands/plugin.d.ts deleted file mode 100644 index 5bd56f47..00000000 --- a/dist/commands/plugin.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Generic plugin command dispatcher. - * - * `runcode ` works for ANY plugin that registers a workflow. - * Core stays plugin-agnostic — adding a new plugin requires zero changes here. - */ -export interface PluginCommandOptions { - dryRun?: boolean; - debug?: boolean; -} -/** Run a plugin command. Plugin id is the first arg. */ -export declare function pluginCommand(pluginId: string, action: string | undefined, options: PluginCommandOptions): Promise; -/** List all installed plugins */ -export declare function listAvailablePlugins(): void; diff --git a/dist/commands/plugin.js b/dist/commands/plugin.js deleted file mode 100644 index 1181a498..00000000 --- a/dist/commands/plugin.js +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Generic plugin command dispatcher. - * - * `runcode ` works for ANY plugin that registers a workflow. - * Core stays plugin-agnostic — adding a new plugin requires zero changes here. - */ -import chalk from 'chalk'; -import readline from 'node:readline'; -import { ModelClient } from '../agent/llm.js'; -import { loadChain, API_URLS } from '../config.js'; -import { loadAllPlugins, getPlugin, listWorkflowPlugins } from '../plugins/registry.js'; -import { loadWorkflowConfig, saveWorkflowConfig, runWorkflow, getStats, getByAction, formatWorkflowResult, formatWorkflowStats, } from '../plugins/runner.js'; -import { DEFAULT_MODEL_TIERS } from '../plugin-sdk/workflow.js'; -/** Run a plugin command. Plugin id is the first arg. */ -export async function pluginCommand(pluginId, action, options) { - await loadAllPlugins(); - const loaded = getPlugin(pluginId); - if (!loaded) { - console.log(chalk.red(`Plugin "${pluginId}" not found.`)); - listAvailablePlugins(); - return; - } - // Get the workflow this plugin provides (if any) - const workflows = loaded.plugin.workflows || {}; - const workflowFactory = workflows[pluginId] || workflows[Object.keys(workflows)[0] ?? '']; - if (!workflowFactory) { - console.log(chalk.red(`Plugin "${pluginId}" does not provide a workflow.`)); - return; - } - const workflow = workflowFactory(); - const chain = loadChain(); - const apiUrl = API_URLS[chain]; - const client = new ModelClient({ apiUrl, chain, debug: options.debug }); - const existingConfig = loadWorkflowConfig(workflow.id); - switch (action) { - case 'init': - case undefined: - case '': { - if (!existingConfig) { - const config = await runOnboarding(workflow, client); - if (config) - saveWorkflowConfig(workflow.id, config); - } - else if (action === 'init') { - console.log(chalk.yellow(`Already configured at ~/.blockrun/workflows/${workflow.id}.config.json`)); - console.log(chalk.dim('Delete the file to reconfigure.')); - } - else { - // No action and already configured: show stats + dry-run hint - const stats = getStats(workflow.id); - console.log(formatWorkflowStats(workflow, stats)); - console.log(chalk.dim(`Run "runcode ${pluginId} run --dry" to preview.\n`)); - } - break; - } - case 'run': { - const config = existingConfig ?? await runOnboarding(workflow, client); - if (!config) - return; - if (!existingConfig) - saveWorkflowConfig(workflow.id, config); - const dryRun = options.dryRun ?? false; - console.log(chalk.dim(`\nRunning ${workflow.name}${dryRun ? ' (dry-run)' : ''}...\n`)); - const result = await runWorkflow(workflow, config, client, { dryRun }); - console.log(formatWorkflowResult(workflow, result)); - break; - } - case 'stats': { - const stats = getStats(workflow.id); - console.log(formatWorkflowStats(workflow, stats)); - break; - } - case 'leads': { - const leads = getByAction(workflow.id, 'lead'); - if (leads.length === 0) { - console.log(chalk.dim(`\nNo leads found yet. Run "runcode ${pluginId} run" first.\n`)); - break; - } - console.log(chalk.bold(`\n LEADS (${leads.length})\n`)); - for (const lead of leads.slice(-20)) { - const m = lead.metadata; - const score = m.leadScore ?? 0; - const icon = score >= 8 ? '🔥' : score >= 6 ? '⭐' : '📋'; - console.log(` ${icon} [${score}/10] ${m.title?.slice(0, 60) ?? ''}`); - console.log(chalk.dim(` ${m.url} | ${m.platform} | ${m.urgency ?? ''}`)); - if (m.painPoints && Array.isArray(m.painPoints)) { - console.log(chalk.dim(` Pain: ${m.painPoints.join(', ')}`)); - } - console.log(); - } - break; - } - default: - console.log(chalk.red(`Unknown action: ${action}`)); - console.log(chalk.dim(` -Usage: - runcode ${pluginId} # show stats / first-run setup - runcode ${pluginId} init # interactive setup - runcode ${pluginId} run # execute workflow - runcode ${pluginId} run --dry # dry run (no side effects) - runcode ${pluginId} stats # show statistics - runcode ${pluginId} leads # show tracked leads (if applicable) -`)); - } -} -/** List all installed plugins */ -export function listAvailablePlugins() { - const plugins = listWorkflowPlugins(); - if (plugins.length === 0) { - console.log(chalk.dim('\nNo workflow plugins installed.\n')); - return; - } - console.log(chalk.bold('\n Installed plugins:\n')); - for (const p of plugins) { - console.log(` ${chalk.cyan(p.manifest.id.padEnd(15))} ${p.manifest.description}`); - } - console.log(); -} -// ─── Onboarding ─────────────────────────────────────────────────────────── -async function runOnboarding(workflow, client) { - console.log(chalk.bold(`\n ╭─ ${workflow.name} setup ${'─'.repeat(Math.max(0, 40 - workflow.name.length))}╮`)); - console.log(chalk.bold(' │ │')); - console.log(chalk.bold(` │ ${workflow.description.padEnd(48)}│`)); - console.log(chalk.bold(' │ │')); - console.log(chalk.bold(' ╰──────────────────────────────────────────────────╯\n')); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: process.stdin.isTTY ?? false, - }); - const ask = (prompt) => new Promise(resolve => rl.question(chalk.cyan(` ${prompt}\n > `), answer => resolve(answer.trim()))); - const answers = {}; - for (const q of workflow.onboardingQuestions) { - if (q.type === 'select' && q.options) { - console.log(chalk.cyan(` ${q.prompt}`)); - for (let i = 0; i < q.options.length; i++) { - console.log(chalk.dim(` ${i + 1}. ${q.options[i]}`)); - } - const choice = await ask('Pick a number'); - const idx = parseInt(choice) - 1; - answers[q.id] = q.options[idx] ?? q.options[0]; - } - else { - answers[q.id] = await ask(q.prompt); - } - console.log(); - } - rl.close(); - console.log(chalk.dim(' Building configuration...\n')); - // Provide an LLM helper for buildConfigFromAnswers - const llm = async (prompt) => { - const result = await client.complete({ - model: DEFAULT_MODEL_TIERS.cheap, - messages: [{ role: 'user', content: prompt }], - max_tokens: 2048, - stream: true, - }); - let text = ''; - for (const part of result.content) { - if (part.type === 'text') - text += part.text; - } - return text; - }; - try { - const config = await workflow.buildConfigFromAnswers(answers, llm); - console.log(chalk.green(' ✓ Configuration saved!\n')); - console.log(chalk.dim(` Config: ~/.blockrun/workflows/${workflow.id}.config.json\n`)); - console.log(chalk.dim(` Run "runcode ${workflow.id} run --dry" to preview.\n`)); - return config; - } - catch (err) { - console.error(chalk.red(` Setup failed: ${err.message}`)); - return null; - } -} diff --git a/dist/commands/proxy.d.ts b/dist/commands/proxy.d.ts deleted file mode 100644 index 2e0c069a..00000000 --- a/dist/commands/proxy.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Proxy-only mode — runs the BlockRun payment proxy for other tools (e.g. Claude Code). - * The proxy translates requests and handles x402 payments so Claude Code can use any model. - */ -interface ProxyOptions { - port?: string; - model?: string; - fallback?: boolean; - debug?: boolean; - version?: string; -} -export declare function proxyCommand(options: ProxyOptions): Promise; -export {}; diff --git a/dist/commands/proxy.js b/dist/commands/proxy.js deleted file mode 100644 index 5afdbe1e..00000000 --- a/dist/commands/proxy.js +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Proxy-only mode — runs the BlockRun payment proxy for other tools (e.g. Claude Code). - * The proxy translates requests and handles x402 payments so Claude Code can use any model. - */ -import chalk from 'chalk'; -import { getOrCreateWallet, getOrCreateSolanaWallet } from '@blockrun/llm'; -import { createProxy } from '../proxy/server.js'; -import { loadChain, API_URLS, DEFAULT_PROXY_PORT } from '../config.js'; -import { loadConfig } from './config.js'; -import { printBanner } from '../banner.js'; -export async function proxyCommand(options) { - const version = options.version ?? '1.0.0'; - const chain = loadChain(); - const apiUrl = API_URLS[chain]; - const fallbackEnabled = options.fallback !== false; - const config = loadConfig(); - const port = parseInt(options.port || String(DEFAULT_PROXY_PORT)); - if (isNaN(port) || port < 1 || port > 65535) { - console.log(chalk.red(`Invalid port: ${options.port}. Must be 1-65535.`)); - process.exit(1); - } - const model = options.model || config['default-model']; - if (chain === 'solana') { - const wallet = await getOrCreateSolanaWallet(); - if (wallet.isNew) { - console.log(chalk.yellow('No Solana wallet found — created a new one.')); - console.log(`Address: ${chalk.cyan(wallet.address)}`); - console.log(`\nSend USDC on Solana to this address, then run ${chalk.bold('runcode proxy')} again.\n`); - return; - } - printBanner(version); - console.log(`Mode: ${chalk.bold('proxy')}`); - console.log(`Chain: ${chalk.magenta('solana')}`); - console.log(`Wallet: ${chalk.cyan(wallet.address)}`); - if (model) - console.log(`Model: ${chalk.green(model)}`); - console.log(`Fallback: ${fallbackEnabled ? chalk.green('enabled') : chalk.yellow('disabled')}`); - console.log(`Proxy: ${chalk.cyan(`http://localhost:${port}`)}`); - console.log(`Backend: ${chalk.dim(apiUrl)}\n`); - const server = createProxy({ - port, - apiUrl, - chain: 'solana', - modelOverride: model, - debug: options.debug, - fallbackEnabled, - }); - launchProxy(server, port, options.debug); - } - else { - const wallet = getOrCreateWallet(); - if (wallet.isNew) { - console.log(chalk.yellow('No wallet found — created a new one.')); - console.log(`Address: ${chalk.cyan(wallet.address)}`); - console.log(`\nSend USDC on Base to this address, then run ${chalk.bold('runcode proxy')} again.\n`); - return; - } - printBanner(version); - console.log(`Mode: ${chalk.bold('proxy')}`); - console.log(`Chain: ${chalk.magenta('base')}`); - console.log(`Wallet: ${chalk.cyan(wallet.address)}`); - if (model) - console.log(`Model: ${chalk.green(model)}`); - console.log(`Fallback: ${fallbackEnabled ? chalk.green('enabled') : chalk.yellow('disabled')}`); - console.log(`Proxy: ${chalk.cyan(`http://localhost:${port}`)}`); - console.log(`Backend: ${chalk.dim(apiUrl)}\n`); - const server = createProxy({ - port, - apiUrl, - chain: 'base', - modelOverride: model, - debug: options.debug, - fallbackEnabled, - }); - launchProxy(server, port, options.debug); - } -} -function launchProxy(server, port, debug) { - server.on('error', (err) => { - if (err.code === 'EADDRINUSE') { - console.error(chalk.red(`Port ${port} is already in use. Try a different port with --port.`)); - } - else { - console.error(chalk.red(`Server error: ${err.message}`)); - } - process.exit(1); - }); - server.listen(port, () => { - console.log(chalk.green(`✓ Proxy running on port ${port}`)); - console.log(chalk.dim(` Usage tracking: ~/.blockrun/runcode-stats.json`)); - if (debug) - console.log(chalk.dim(` Debug log: ~/.blockrun/runcode-debug.log`)); - console.log(chalk.dim(` Run 'runcode stats' to view statistics\n`)); - console.log('Set this in your shell to use with Claude Code:\n'); - console.log(chalk.bold(` export ANTHROPIC_BASE_URL=http://localhost:${port}/api`)); - console.log(chalk.bold(` export ANTHROPIC_AUTH_TOKEN=x402-proxy-handles-auth`)); - console.log(`\nThen run ${chalk.bold('claude')} in another terminal.`); - }); - const shutdown = () => { - console.log('\nShutting down...'); - server.close(); - process.exit(0); - }; - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); -} diff --git a/dist/commands/setup.d.ts b/dist/commands/setup.d.ts deleted file mode 100644 index 33de33b1..00000000 --- a/dist/commands/setup.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function setupCommand(chainArg?: string): Promise; diff --git a/dist/commands/setup.js b/dist/commands/setup.js deleted file mode 100644 index beec3739..00000000 --- a/dist/commands/setup.js +++ /dev/null @@ -1,49 +0,0 @@ -import chalk from 'chalk'; -import { getOrCreateWallet, scanWallets, getOrCreateSolanaWallet, scanSolanaWallets, } from '@blockrun/llm'; -import { saveChain } from '../config.js'; -export async function setupCommand(chainArg) { - const chain = chainArg === 'solana' ? 'solana' : 'base'; - if (chain === 'solana') { - const wallets = scanSolanaWallets(); - if (wallets.length > 0) { - console.log(chalk.yellow('Solana wallet already exists.')); - console.log(`Address: ${chalk.cyan(wallets[0].publicKey)}`); - console.log(chalk.dim('\nNext steps:')); - console.log(chalk.dim(' runcode start — start coding')); - console.log(chalk.dim(' runcode balance — check USDC balance')); - console.log(chalk.dim(' runcode start -m free — use free models (no USDC needed)')); - saveChain('solana'); - return; - } - console.log('Creating new Solana wallet...\n'); - const { address, isNew } = await getOrCreateSolanaWallet(); - if (isNew) { - console.log(chalk.green('Solana wallet created!\n')); - } - console.log(`Address: ${chalk.cyan(address)}`); - console.log(`\nSend USDC on Solana to this address to fund your account.`); - } - else { - const wallets = scanWallets(); - if (wallets.length > 0) { - console.log(chalk.yellow('Wallet already exists.')); - console.log(`Address: ${chalk.cyan(wallets[0].address)}`); - console.log(chalk.dim('\nNext steps:')); - console.log(chalk.dim(' runcode start — start coding')); - console.log(chalk.dim(' runcode balance — check USDC balance')); - console.log(chalk.dim(' runcode start -m free — use free models (no USDC needed)')); - saveChain('base'); - return; - } - console.log('Creating new wallet...\n'); - const { address, isNew } = getOrCreateWallet(); - if (isNew) { - console.log(chalk.green('Wallet created!\n')); - } - console.log(`Address: ${chalk.cyan(address)}`); - console.log(`\nSend USDC on Base to this address to fund your account.`); - } - saveChain(chain); - console.log(`Then run ${chalk.bold('runcode start')} to begin.\n`); - console.log(chalk.dim(`Chain: ${chain} — saved to ~/.blockrun/`)); -} diff --git a/dist/commands/social.d.ts b/dist/commands/social.d.ts deleted file mode 100644 index b75738dd..00000000 --- a/dist/commands/social.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * franklin social - * - * Native X bot subsystem. No MCP, no plugin SDK, no external CLI deps. - * Ships as part of the core npm package; only runtime dep is playwright-core, - * which is lazy-imported so startup stays fast. - * - * Actions: - * setup — install chromium via playwright, write default config - * login x — open browser to x.com and wait for user to log in; save state - * run — search X, generate drafts, post (requires --live) or dry-run - * stats — show posted/skipped/drafted counts and total cost - * config — open ~/.blockrun/social-config.json for manual editing - */ -export interface SocialCommandOptions { - dryRun?: boolean; - live?: boolean; - model?: string; - debug?: boolean; -} -/** - * Entry point wired from src/index.ts as `franklin social [action] [arg]`. - */ -export declare function socialCommand(action: string | undefined, arg: string | undefined, options: SocialCommandOptions): Promise; diff --git a/dist/commands/social.js b/dist/commands/social.js deleted file mode 100644 index f0123e56..00000000 --- a/dist/commands/social.js +++ /dev/null @@ -1,258 +0,0 @@ -/** - * franklin social - * - * Native X bot subsystem. No MCP, no plugin SDK, no external CLI deps. - * Ships as part of the core npm package; only runtime dep is playwright-core, - * which is lazy-imported so startup stays fast. - * - * Actions: - * setup — install chromium via playwright, write default config - * login x — open browser to x.com and wait for user to log in; save state - * run — search X, generate drafts, post (requires --live) or dry-run - * stats — show posted/skipped/drafted counts and total cost - * config — open ~/.blockrun/social-config.json for manual editing - */ -import chalk from 'chalk'; -import fs from 'node:fs'; -import { spawn } from 'node:child_process'; -import { loadConfig as loadSocialConfig, saveConfig as saveSocialConfig, isConfigReady, CONFIG_PATH, } from '../social/config.js'; -import { SocialBrowser, SOCIAL_PROFILE_DIR } from '../social/browser.js'; -import { runX } from '../social/x.js'; -import { getStats } from '../social/db.js'; -import { loadChain, API_URLS } from '../config.js'; -import { loadConfig as loadAppConfig } from './config.js'; -/** - * Entry point wired from src/index.ts as `franklin social [action] [arg]`. - */ -export async function socialCommand(action, arg, options) { - switch (action) { - case undefined: - case 'help': - printHelp(); - return; - case 'setup': - await setupCommand(); - return; - case 'login': - await loginCommand(arg); - return; - case 'run': - await runCommand(options); - return; - case 'stats': - statsCommand(); - return; - case 'config': - configCommand(arg); - return; - default: - console.log(chalk.red(`Unknown social action: ${action}`)); - printHelp(); - process.exitCode = 1; - } -} -// ─── help ────────────────────────────────────────────────────────────────── -function printHelp() { - console.log(''); - console.log(chalk.bold(' franklin social') + chalk.dim(' — native X bot (no MCP, no plugin deps)')); - console.log(''); - console.log(' Actions:'); - console.log(` ${chalk.cyan('setup')} Install chromium, create default config`); - console.log(` ${chalk.cyan('login x')} Open browser to x.com, save login state`); - console.log(` ${chalk.cyan('run')} Search X, generate + (optionally) post replies`); - console.log(` ${chalk.dim('--dry-run (default) generate drafts, do NOT post')}`); - console.log(` ${chalk.dim('--live actually post to X')}`); - console.log(` ${chalk.dim('-m override the AI model')}`); - console.log(` ${chalk.cyan('stats')} Show posted / drafted / skipped totals`); - console.log(` ${chalk.cyan('config')} Print the path to the config file (or pass edit)`); - console.log(''); - console.log(` Config: ${chalk.dim(CONFIG_PATH)}`); - console.log(` Profile: ${chalk.dim(SOCIAL_PROFILE_DIR)}`); - console.log(''); - console.log(' Typical first-run flow:'); - console.log(` ${chalk.cyan('$')} franklin social setup`); - console.log(` ${chalk.cyan('$')} franklin social config edit ${chalk.dim('# set handle, products, queries')}`); - console.log(` ${chalk.cyan('$')} franklin social login x ${chalk.dim('# log in once; cookies persist')}`); - console.log(` ${chalk.cyan('$')} franklin social run ${chalk.dim('# dry-run, preview drafts')}`); - console.log(` ${chalk.cyan('$')} franklin social run --live ${chalk.dim('# actually post')}`); - console.log(''); -} -// ─── setup ──────────────────────────────────────────────────────────────── -async function setupCommand() { - console.log(chalk.bold('\n Franklin social — setup\n')); - // 1. Install chromium via playwright CLI (ships with playwright-core) - console.log(chalk.dim(' Installing chromium for the social browser…')); - console.log(chalk.dim(' (~150MB, one-time download to ~/.cache/ms-playwright)\n')); - await runChild('npx', ['playwright', 'install', 'chromium']); - // 2. Ensure profile dir exists - if (!fs.existsSync(SOCIAL_PROFILE_DIR)) { - fs.mkdirSync(SOCIAL_PROFILE_DIR, { recursive: true }); - console.log(chalk.green(` ✓ Created Chrome profile at ${SOCIAL_PROFILE_DIR}`)); - } - // 3. Write default config if missing - const config = loadSocialConfig(); - saveSocialConfig(config); // touches file so the user can edit - console.log(chalk.green(` ✓ Config ready at ${CONFIG_PATH}`)); - console.log(''); - console.log(chalk.bold(' Next steps:')); - console.log(` 1. ${chalk.cyan('franklin social config edit')} edit handle, products, search queries`); - console.log(` 2. ${chalk.cyan('franklin social login x')} log in to x.com (once — cookies persist)`); - console.log(` 3. ${chalk.cyan('franklin social run')} dry-run to preview drafts`); - console.log(''); -} -// ─── login ───────────────────────────────────────────────────────────────── -async function loginCommand(platform) { - if (platform !== 'x') { - console.log(chalk.red(`Only "x" is supported. Usage: franklin social login x`)); - process.exitCode = 1; - return; - } - console.log(chalk.bold('\n Opening x.com for login…\n')); - console.log(chalk.dim(' A Chrome window will open. Log in to your X account,')); - console.log(chalk.dim(' then close the window when done. Cookies will persist')); - console.log(chalk.dim(` at ${SOCIAL_PROFILE_DIR}\n`)); - const browser = new SocialBrowser({ headless: false }); - try { - await browser.launch(); - await browser.open('https://x.com/login'); - console.log(chalk.yellow(' Waiting for you to log in and close the browser…')); - await browser.waitForClose(); - console.log(chalk.green('\n ✓ Browser closed — session state saved.')); - console.log(chalk.dim(` Next: franklin social config edit (then: franklin social run)\n`)); - } - catch (err) { - console.error(chalk.red(` ✗ ${err.message}`)); - process.exitCode = 1; - } - finally { - await browser.close().catch(() => { }); - } -} -// ─── run ─────────────────────────────────────────────────────────────────── -async function runCommand(options) { - let config; - try { - config = loadSocialConfig(); - } - catch (err) { - console.error(chalk.red(` ✗ Config error: ${err.message}`)); - console.error(chalk.dim(` Run: franklin social setup`)); - process.exitCode = 1; - return; - } - const ready = isConfigReady(config); - if (!ready.ready) { - console.error(chalk.red(` ✗ Config not ready: ${ready.reason}`)); - console.error(chalk.dim(` Edit: ${CONFIG_PATH}`)); - process.exitCode = 1; - return; - } - const dryRun = !options.live; // --live overrides default dry-run - const mode = dryRun ? 'DRY-RUN' : chalk.bold.red('LIVE'); - console.log(''); - console.log(chalk.bold(` franklin social run ${chalk.dim(`(${mode})`)}\n`)); - console.log(` Handle: ${chalk.cyan(config.handle)}`); - console.log(` Products: ${config.products.map((p) => p.name).join(', ')}`); - console.log(` Queries: ${config.x.search_queries.length}`); - console.log(` Daily: ${config.x.daily_target} posts`); - console.log(''); - const chain = loadChain(); - const apiUrl = API_URLS[chain]; - const appConfig = loadAppConfig(); - const model = options.model || appConfig['default-model'] || 'nvidia/nemotron-ultra-253b'; - console.log(chalk.dim(` Model: ${model}`)); - console.log(''); - let result; - try { - result = await runX({ - config, - model, - apiUrl, - chain, - dryRun, - debug: options.debug, - onProgress: (msg) => process.stdout.write(msg + '\n'), - }); - } - catch (err) { - console.error(chalk.red(`\n ✗ Run failed: ${err.message}`)); - process.exitCode = 1; - return; - } - console.log(''); - console.log(chalk.bold(' Run summary:')); - console.log(` Considered: ${result.considered}`); - console.log(` Dedup skips: ${chalk.dim(result.dedupSkipped)}`); - console.log(` AI SKIPs: ${chalk.dim(result.llmSkipped)}`); - console.log(` Drafted: ${chalk.green(result.drafted)}`); - if (!dryRun) { - console.log(` Posted: ${chalk.green.bold(result.posted)}`); - console.log(` Failed: ${result.failed > 0 ? chalk.red(result.failed) : 0}`); - } - console.log(` LLM cost: ${chalk.yellow('$' + result.totalCost.toFixed(4))}`); - console.log(''); -} -// ─── stats ───────────────────────────────────────────────────────────────── -function statsCommand() { - const s = getStats('x'); - console.log(''); - console.log(chalk.bold(' franklin social stats — X')); - console.log(''); - console.log(` Total events: ${s.total}`); - console.log(` ✓ Posted: ${chalk.green(s.posted)} ${s.today > 0 ? chalk.dim(`(${s.today} today)`) : ''}`); - console.log(` ≡ Drafted: ${s.drafted}`); - console.log(` · Skipped (AI): ${chalk.dim(s.skipped)}`); - console.log(` ✗ Failed: ${s.failed > 0 ? chalk.red(s.failed) : 0}`); - console.log(` Total LLM cost: ${chalk.yellow('$' + s.totalCost.toFixed(4))}`); - if (Object.keys(s.byProduct).length > 0) { - console.log(''); - console.log(' By product:'); - for (const [name, count] of Object.entries(s.byProduct)) { - console.log(` ${name.padEnd(20)} ${count}`); - } - } - console.log(''); -} -// ─── config ──────────────────────────────────────────────────────────────── -function configCommand(subAction) { - if (!subAction || subAction === 'path') { - console.log(CONFIG_PATH); - return; - } - if (subAction === 'show' || subAction === 'print') { - if (!fs.existsSync(CONFIG_PATH)) { - console.log(chalk.yellow(` Config not found at ${CONFIG_PATH}`)); - console.log(chalk.dim(` Run: franklin social setup`)); - return; - } - console.log(fs.readFileSync(CONFIG_PATH, 'utf8')); - return; - } - if (subAction === 'edit' || subAction === 'open') { - if (!fs.existsSync(CONFIG_PATH)) { - loadSocialConfig(); // writes the default file - } - const editor = process.env.EDITOR || (process.platform === 'darwin' ? 'open' : 'vi'); - const args = editor === 'open' ? ['-t', CONFIG_PATH] : [CONFIG_PATH]; - const child = spawn(editor, args, { stdio: 'inherit' }); - child.on('close', () => { - console.log(chalk.dim(`\n Saved to ${CONFIG_PATH}`)); - }); - return; - } - console.log(chalk.red(` Unknown config subaction: ${subAction}`)); - console.log(chalk.dim(` Try: path, show, edit`)); -} -// ─── helpers ─────────────────────────────────────────────────────────────── -function runChild(cmd, args) { - return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { stdio: 'inherit' }); - child.on('close', (code) => { - if (code === 0) - resolve(); - else - reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`)); - }); - child.on('error', reject); - }); -} diff --git a/dist/commands/start.d.ts b/dist/commands/start.d.ts deleted file mode 100644 index 1b94297d..00000000 --- a/dist/commands/start.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -interface StartOptions { - model?: string; - debug?: boolean; - trust?: boolean; - version?: string; -} -export declare function startCommand(options: StartOptions): Promise; -export {}; diff --git a/dist/commands/start.js b/dist/commands/start.js deleted file mode 100644 index 32aaa42b..00000000 --- a/dist/commands/start.js +++ /dev/null @@ -1,344 +0,0 @@ -import chalk from 'chalk'; -import { getOrCreateWallet, getOrCreateSolanaWallet } from '@blockrun/llm'; -import { loadChain, API_URLS } from '../config.js'; -import { flushStats } from '../stats/tracker.js'; -import { loadConfig } from './config.js'; -import { printBanner } from '../banner.js'; -import { assembleInstructions } from '../agent/context.js'; -import { interactiveSession } from '../agent/loop.js'; -import { allCapabilities, createSubAgentCapability } from '../tools/index.js'; -import { validateToolDescriptions } from '../tools/validate.js'; -import { launchInkUI } from '../ui/app.js'; -import { pickModel, resolveModel } from '../ui/model-picker.js'; -import { loadMcpConfig } from '../mcp/config.js'; -import { connectMcpServers, disconnectMcpServers } from '../mcp/client.js'; -export async function startCommand(options) { - const version = options.version ?? '1.0.0'; - const chain = loadChain(); - const apiUrl = API_URLS[chain]; - const config = loadConfig(); - // Resolve model — default to GLM-5.1 promo if nothing specified - let model; - const configModel = config['default-model']; - if (options.model) { - model = resolveModel(options.model); - } - else if (configModel) { - model = configModel; - } - else { - // Default: GLM-5.1 promo if still active, otherwise Gemini Flash (cheap & reliable) - const promoExpiry = new Date('2026-04-15'); - model = Date.now() < promoExpiry.getTime() ? 'zai/glm-5.1' : 'google/gemini-2.5-flash'; - } - // Auto-create wallet if needed (no interruption — free models work without funding) - let walletAddress = ''; - if (chain === 'solana') { - const wallet = await getOrCreateSolanaWallet(); - walletAddress = wallet.address; - if (wallet.isNew) { - console.log(chalk.green(' Wallet created automatically.')); - console.log(chalk.dim(` Address: ${wallet.address}`)); - console.log(chalk.dim(' Free models work now. Fund with USDC for paid models.\n')); - } - } - else { - const wallet = getOrCreateWallet(); - walletAddress = wallet.address; - if (wallet.isNew) { - console.log(chalk.green(' Wallet created automatically.')); - console.log(chalk.dim(` Address: ${wallet.address}`)); - console.log(chalk.dim(' Free models work now. Fund with USDC for paid models.\n')); - } - } - // First-run: detect other AI tools and offer migration - if (process.stdin.isTTY) { - try { - const { checkAndSuggestMigration } = await import('./migrate.js'); - await checkAndSuggestMigration(); - } - catch { /* migration is optional */ } - } - printBanner(version); - const workDir = process.cwd(); - // Show session info immediately, fetch balance in background - // Model is shown in the live status bar — no static line needed. - console.log(chalk.dim(` Wallet: ${walletAddress || 'not set'}`)); - console.log(chalk.dim(` Dir: ${workDir}`)); - // First-run tip: show if no config file exists yet - if (!configModel && !options.model) { - console.log(chalk.dim(`\n Tip: /model to switch models · /compact to save tokens · /help for all commands`)); - } - // Welcome message — show things Hermes/OpenClaw can't do. - // Only on first run or when no model is configured (new user indicator). - // After the user's first session, the tip fades and they go straight to the prompt. - console.log(''); - console.log(chalk.dim(' Try something only Franklin can do:')); - console.log(chalk.dim(' ') + chalk.hex('#FFD700')('"what\'s BTC looking like today?"') + chalk.dim(' ← market signal')); - console.log(chalk.dim(' ') + chalk.hex('#10B981')('"find X posts about ai agent"') + chalk.dim(' ← social growth')); - console.log(chalk.dim(' ') + chalk.hex('#60A5FA')('"generate a hero image for my app"') + chalk.dim(' ← image gen')); - console.log(chalk.dim(' Or just code — 55+ models, no API keys.')); - console.log(''); - // Balance fetcher — used at startup and after each turn - const fetchBalance = async () => { - try { - let bal; - if (chain === 'solana') { - const { setupAgentSolanaWallet } = await import('@blockrun/llm'); - const client = await setupAgentSolanaWallet({ silent: true }); - bal = await client.getBalance(); - } - else { - const { setupAgentWallet } = await import('@blockrun/llm'); - const client = setupAgentWallet({ silent: true }); - bal = await client.getBalance(); - } - return `$${bal.toFixed(2)} USDC`; - } - catch { - return '$?.?? USDC'; - } - }; - // Fetch balance in background (don't block startup) - const walletInfo = { - address: walletAddress, - balance: 'checking...', - chain, - }; - // Balance fetch callback — will update Ink UI once resolved - let onBalanceFetched; - (async () => { - const balStr = await fetchBalance(); - walletInfo.balance = balStr; - onBalanceFetched?.(balStr); - })(); - // Assemble system instructions - const systemInstructions = assembleInstructions(workDir, model); - // Connect MCP servers (non-blocking — add tools if servers are available) - const mcpConfig = loadMcpConfig(workDir); - let mcpTools = []; - const mcpServerCount = Object.keys(mcpConfig.mcpServers).filter(k => !mcpConfig.mcpServers[k].disabled).length; - if (mcpServerCount > 0) { - try { - mcpTools = await connectMcpServers(mcpConfig, options.debug); - if (mcpTools.length > 0) { - console.log(chalk.dim(` MCP: ${mcpTools.length} tools from ${mcpServerCount} server(s)`)); - } - } - catch (err) { - if (options.debug) { - console.error(chalk.yellow(` MCP error: ${err.message}`)); - } - } - } - // Build capabilities (built-in + MCP + sub-agent) - const subAgent = createSubAgentCapability(apiUrl, chain, allCapabilities); - const capabilities = [...allCapabilities, ...mcpTools, subAgent]; - // Validate tool descriptions (self-evolution: detect SearchX-style description bugs) - if (options.debug) { - const issues = validateToolDescriptions(capabilities); - for (const issue of issues) { - console.error(`[validate] ${issue.severity}: ${issue.toolName} — ${issue.issue}`); - } - } - // Agent config - const agentConfig = { - model, - apiUrl, - chain, - systemInstructions, - capabilities, - maxTurns: 100, - workingDir: workDir, - // Non-TTY (piped) input = scripted mode → trust all tools automatically. - // Interactive TTY = default mode (prompts for Bash/Write/Edit). - permissionMode: (options.trust || !process.stdin.isTTY) ? 'trust' : 'default', - debug: options.debug, - }; - // Bootstrap learnings from Claude Code config on first run (async, non-blocking) - Promise.all([ - import('../learnings/extractor.js'), - import('../agent/llm.js'), - ]).then(([{ bootstrapFromClaudeConfig }, { ModelClient }]) => { - const client = new ModelClient({ apiUrl, chain }); - bootstrapFromClaudeConfig(client).catch(() => { }); - }).catch(() => { }); - // Use Ink UI if TTY, fallback to basic readline for piped input - if (process.stdin.isTTY) { - await runWithInkUI(agentConfig, model, workDir, version, walletInfo, (cb) => { - onBalanceFetched = cb; - }, fetchBalance); - } - else { - await runWithBasicUI(agentConfig, model, workDir); - } -} -// ─── Ink UI (interactive terminal) ───────────────────────────────────────── -async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, onBalanceReady, fetchBalance) { - const ui = launchInkUI({ - model, - workDir, - version, - walletAddress: walletInfo?.address, - walletBalance: walletInfo?.balance, - chain: walletInfo?.chain, - onModelChange: (newModel) => { - agentConfig.model = newModel; - }, - }); - // Wire permission prompts through Ink UI to avoid stdin/readline conflict. - // Ink owns stdin in raw mode; the old readline-based askQuestion() got EOF - // immediately and auto-denied every permission. Now y/n/a goes through useInput. - agentConfig.permissionPromptFn = (toolName, description) => ui.requestPermission(toolName, description); - agentConfig.onAskUser = (question, options) => ui.requestAskUser(question, options); - agentConfig.onModelChange = (model) => ui.updateModel(model); - // Wire up background balance fetch to UI - onBalanceReady?.((bal) => ui.updateBalance(bal)); - // Refresh balance after each completed turn so the display stays current - if (fetchBalance) { - ui.onTurnDone(() => { - fetchBalance().then(bal => ui.updateBalance(bal)).catch(() => { }); - }); - } - let sessionHistory; - try { - sessionHistory = await interactiveSession(agentConfig, async () => { - const input = await ui.waitForInput(); - if (input === null) - return null; - if (input === '') - return ''; - return input; - }, (event) => ui.handleEvent(event), (abortFn) => ui.onAbort(abortFn)); - } - catch (err) { - if (err.name !== 'AbortError') { - console.error(chalk.red(`\nError: ${err.message}`)); - } - } - ui.cleanup(); - flushStats(); - // Extract learnings from the session (async, 10s timeout, never blocks exit) - if (sessionHistory && sessionHistory.length >= 4) { - try { - const { extractLearnings } = await import('../learnings/extractor.js'); - const { extractBrainEntities } = await import('../brain/extract.js'); - const { ModelClient } = await import('../agent/llm.js'); - const client = new ModelClient({ apiUrl: agentConfig.apiUrl, chain: agentConfig.chain }); - const sid = `session-${new Date().toISOString()}`; - await Promise.race([ - Promise.all([ - extractLearnings(sessionHistory, sid, client), - extractBrainEntities(sessionHistory, sid, client), - ]), - new Promise(resolve => setTimeout(resolve, 15_000)), - ]); - } - catch { /* extraction is best-effort */ } - } - await disconnectMcpServers(); - console.log(chalk.dim('\nGoodbye.\n')); -} -// ─── Basic readline UI (piped input) ─────────────────────────────────────── -async function runWithBasicUI(agentConfig, model, workDir) { - const { TerminalUI } = await import('../ui/terminal.js'); - const ui = new TerminalUI(); - ui.printWelcome(model, workDir); - let lastTerminalPrompt = ''; - try { - await interactiveSession(agentConfig, async () => { - while (true) { - const input = await ui.promptUser(); - if (input === null) - return null; - if (input === '') - continue; - // Handle slash commands in terminal UI - if (input.startsWith('/') && ui.handleSlashCommand(input)) - continue; - // Handle model switch via /model shortcut - if (input === '/model' || input === '/models') { - console.error(chalk.dim(` Current model: ${agentConfig.model}`)); - console.error(chalk.dim(' Switch with: /model (e.g. /model sonnet, /model free)')); - continue; - } - if (input.startsWith('/model ')) { - const newModel = resolveModel(input.slice(7).trim()); - agentConfig.model = newModel; - console.error(chalk.green(` Model → ${newModel}`)); - continue; - } - // /retry — resend last prompt - if (input === '/retry') { - if (!lastTerminalPrompt) { - console.error(chalk.yellow(' No previous prompt to retry')); - continue; - } - return lastTerminalPrompt; - } - // /compact passes through to loop - if (input === '/compact') - return input; - lastTerminalPrompt = input; - return input; - } - }, (event) => ui.handleEvent(event)); - } - catch (err) { - if (err.name !== 'AbortError') { - console.error(chalk.red(`\nError: ${err.message}`)); - } - } - ui.printGoodbye(); - flushStats(); -} -async function handleSlashCommand(cmd, config, ui) { - const parts = cmd.trim().split(/\s+/); - const command = parts[0].toLowerCase(); - switch (command) { - case '/exit': - case '/quit': - return 'exit'; - case '/model': { - const newModel = parts[1]; - if (newModel) { - config.model = resolveModel(newModel); - console.error(chalk.green(` Model → ${config.model}`)); - return null; - } - const picked = await pickModel(config.model); - if (picked) { - config.model = picked; - console.error(chalk.green(` Model → ${config.model}`)); - } - return null; - } - case '/models': { - const picked = await pickModel(config.model); - if (picked) { - config.model = picked; - console.error(chalk.green(` Model → ${config.model}`)); - } - return null; - } - case '/cost': - case '/usage': { - const { getStatsSummary } = await import('../stats/tracker.js'); - const { stats, saved } = getStatsSummary(); - console.error(chalk.dim(`\n Requests: ${stats.totalRequests} | Cost: $${stats.totalCostUsd.toFixed(4)} | Saved: $${saved.toFixed(2)} vs Opus\n`)); - return null; - } - case '/help': - console.error(chalk.bold('\n Commands:')); - console.error(' /model [name] — switch model (picker if no name)'); - console.error(' /models — browse available models'); - console.error(' /cost — session cost and savings'); - console.error(' /exit — quit'); - console.error(' /help — this help\n'); - console.error(chalk.dim(' Shortcuts: sonnet, opus, gpt, gemini, deepseek, flash, free, r1, o4\n')); - return null; - default: - console.error(chalk.yellow(` Unknown command: ${command}. Try /help`)); - return null; - } -} diff --git a/dist/commands/stats.d.ts b/dist/commands/stats.d.ts deleted file mode 100644 index 3c3a5fc0..00000000 --- a/dist/commands/stats.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * runcode stats command - * Display usage statistics and cost savings - */ -interface StatsOptions { - clear?: boolean; - json?: boolean; -} -export declare function statsCommand(options: StatsOptions): void; -export {}; diff --git a/dist/commands/stats.js b/dist/commands/stats.js deleted file mode 100644 index 64d5f0af..00000000 --- a/dist/commands/stats.js +++ /dev/null @@ -1,94 +0,0 @@ -/** - * runcode stats command - * Display usage statistics and cost savings - */ -import chalk from 'chalk'; -import { clearStats, getStatsSummary } from '../stats/tracker.js'; -export function statsCommand(options) { - if (options.clear) { - clearStats(); - console.log(chalk.green('✓ Statistics cleared')); - return; - } - const { stats, opusCost, saved, savedPct, avgCostPerRequest, period } = getStatsSummary(); - // JSON output for programmatic access - if (options.json) { - console.log(JSON.stringify({ - ...stats, - computed: { - opusCost, - saved, - savedPct, - avgCostPerRequest, - period, - }, - }, null, 2)); - return; - } - // Pretty output - console.log(chalk.bold('\n📊 runcode Usage Statistics\n')); - console.log('─'.repeat(55)); - if (stats.totalRequests === 0) { - console.log(chalk.gray('\n No requests recorded yet. Start using runcode!\n')); - console.log('─'.repeat(55) + '\n'); - return; - } - // Overview - console.log(chalk.bold('\n Overview') + chalk.gray(` (${period})\n`)); - console.log(` Requests: ${chalk.cyan(stats.totalRequests.toLocaleString())}`); - console.log(` Total Cost: ${chalk.green('$' + stats.totalCostUsd.toFixed(4))}`); - console.log(` Avg per Request: ${chalk.gray('$' + avgCostPerRequest.toFixed(6))}`); - console.log(` Input Tokens: ${stats.totalInputTokens.toLocaleString()}`); - console.log(` Output Tokens: ${stats.totalOutputTokens.toLocaleString()}`); - if (stats.totalFallbacks > 0) { - const fallbackPct = ((stats.totalFallbacks / stats.totalRequests) * - 100).toFixed(1); - console.log(` Fallbacks: ${chalk.yellow(stats.totalFallbacks.toString())} (${fallbackPct}%)`); - } - // Per-model breakdown - const modelEntries = Object.entries(stats.byModel); - if (modelEntries.length > 0) { - console.log(chalk.bold('\n By Model\n')); - // Sort by cost (descending) - const sorted = modelEntries.sort((a, b) => b[1].costUsd - a[1].costUsd); - for (const [model, data] of sorted) { - const pct = stats.totalCostUsd > 0 - ? ((data.costUsd / stats.totalCostUsd) * 100).toFixed(1) - : '0'; - const avgLatency = Math.round(data.avgLatencyMs); - // Shorten model name if too long - const displayModel = model.length > 35 ? model.slice(0, 32) + '...' : model; - console.log(` ${chalk.cyan(displayModel)}`); - console.log(chalk.gray(` ${data.requests} req · $${data.costUsd.toFixed(4)} (${pct}%) · ${avgLatency}ms avg`)); - if (data.fallbackCount > 0) { - console.log(chalk.yellow(` ↳ ${data.fallbackCount} fallback recoveries`)); - } - } - } - // Savings comparison - console.log(chalk.bold('\n 💰 Savings vs Claude Opus\n')); - if (opusCost > 0) { - console.log(` Opus equivalent: ${chalk.gray('$' + opusCost.toFixed(2))}`); - console.log(` Your actual cost:${chalk.green(' $' + stats.totalCostUsd.toFixed(2))}`); - console.log(` ${chalk.green.bold(`Saved: $${saved.toFixed(2)} (${savedPct.toFixed(1)}%)`)}`); - } - else { - console.log(chalk.gray(' Not enough data to calculate savings')); - } - // Recent activity (last 5 requests) - if (stats.history.length > 0) { - console.log(chalk.bold('\n Recent Activity\n')); - const recent = stats.history.slice(-5).reverse(); - for (const record of recent) { - const time = new Date(record.timestamp).toLocaleTimeString(); - const model = record.model.split('/').pop() || record.model; - const cost = '$' + record.costUsd.toFixed(4); - const fallbackMark = record.fallback ? chalk.yellow(' ↺') : ''; - console.log(chalk.gray(` ${time}`) + - ` ${model}${fallbackMark} ` + - chalk.green(cost)); - } - } - console.log('\n' + '─'.repeat(55)); - console.log(chalk.gray(' Run `runcode stats --clear` to reset statistics\n')); -} diff --git a/dist/commands/uninit.d.ts b/dist/commands/uninit.d.ts deleted file mode 100644 index db991958..00000000 --- a/dist/commands/uninit.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function uninitCommand(): Promise; diff --git a/dist/commands/uninit.js b/dist/commands/uninit.js deleted file mode 100644 index 758309ac..00000000 --- a/dist/commands/uninit.js +++ /dev/null @@ -1,63 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import chalk from 'chalk'; -const CLAUDE_SETTINGS_FILE = path.join(os.homedir(), '.claude', 'settings.json'); -const LAUNCH_AGENT_PLIST = path.join(os.homedir(), 'Library', 'LaunchAgents', 'ai.blockrun.runcode.plist'); -export async function uninitCommand() { - let changed = false; - // ── 1. Remove env section from ~/.claude/settings.json ────────────────── - try { - if (fs.existsSync(CLAUDE_SETTINGS_FILE)) { - const settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_FILE, 'utf-8')); - const env = settings.env; - if (env) { - const proxyKeys = [ - 'ANTHROPIC_BASE_URL', - 'ANTHROPIC_AUTH_TOKEN', - 'ANTHROPIC_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', - ]; - let removed = false; - for (const k of proxyKeys) { - if (k in env) { - delete env[k]; - removed = true; - } - } - if (Object.keys(env).length === 0) - delete settings.env; - if (removed) { - fs.writeFileSync(CLAUDE_SETTINGS_FILE, JSON.stringify(settings, null, 2)); - console.log(chalk.green(`✓ Removed runcode env from ${CLAUDE_SETTINGS_FILE}`)); - changed = true; - } - } - } - } - catch (e) { - console.log(chalk.yellow(`Could not update settings.json: ${e.message}`)); - } - // ── 2. Unload and remove LaunchAgent ──────────────────────────────────── - if (process.platform === 'darwin' && fs.existsSync(LAUNCH_AGENT_PLIST)) { - try { - const { execSync } = await import('node:child_process'); - execSync(`launchctl unload -w "${LAUNCH_AGENT_PLIST}"`, { stdio: 'pipe' }); - } - catch { /* already unloaded */ } - fs.unlinkSync(LAUNCH_AGENT_PLIST); - console.log(chalk.green(`✓ Removed LaunchAgent`)); - changed = true; - } - if (!changed) { - console.log(chalk.dim('Nothing to uninit — runcode was not initialized.')); - } - else { - console.log(''); - console.log(chalk.bold('runcode uninitialized.')); - console.log(`Claude Code will use its default Anthropic API settings again.`); - console.log(`Run ${chalk.bold('runcode daemon stop')} to stop any running proxy.`); - } -} diff --git a/dist/config.d.ts b/dist/config.d.ts deleted file mode 100644 index 4a6648e0..00000000 --- a/dist/config.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -export declare const VERSION: string; -export declare const USER_AGENT: string; -export type Chain = 'base' | 'solana'; -export declare const BLOCKRUN_DIR: string; -export declare const CHAIN_FILE: string; -export declare const API_URLS: Record; -export declare const DEFAULT_PROXY_PORT = 8402; -export declare function saveChain(chain: Chain): void; -export declare function loadChain(): Chain; diff --git a/dist/config.js b/dist/config.js deleted file mode 100644 index a5214493..00000000 --- a/dist/config.js +++ /dev/null @@ -1,41 +0,0 @@ -import path from 'node:path'; -import os from 'node:os'; -import fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -let _version = '2.0.0'; -try { - const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8')); - _version = pkg.version || _version; -} -catch { /* use default */ } -export const VERSION = _version; -// Shared User-Agent string for all outbound HTTP requests -export const USER_AGENT = `runcode/${_version} (node/${process.versions.node}; ${process.platform}; ${process.arch})`; -export const BLOCKRUN_DIR = path.join(os.homedir(), '.blockrun'); -export const CHAIN_FILE = path.join(BLOCKRUN_DIR, 'payment-chain'); -export const API_URLS = { - base: 'https://blockrun.ai/api', - solana: 'https://sol.blockrun.ai/api', -}; -export const DEFAULT_PROXY_PORT = 8402; -export function saveChain(chain) { - fs.mkdirSync(BLOCKRUN_DIR, { recursive: true }); - fs.writeFileSync(CHAIN_FILE, chain + '\n', { mode: 0o600 }); -} -export function loadChain() { - const envChain = process.env.RUNCODE_CHAIN; - if (envChain === 'solana') - return 'solana'; - if (envChain === 'base') - return 'base'; - try { - const content = fs.readFileSync(CHAIN_FILE, 'utf-8').trim(); - if (content === 'solana') - return 'solana'; - return 'base'; - } - catch { - return 'base'; - } -} diff --git a/dist/events/bridge.d.ts b/dist/events/bridge.d.ts deleted file mode 100644 index 8ae4ef6e..00000000 --- a/dist/events/bridge.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function initBridge(): void; diff --git a/dist/events/bridge.js b/dist/events/bridge.js deleted file mode 100644 index 8244519f..00000000 --- a/dist/events/bridge.js +++ /dev/null @@ -1,24 +0,0 @@ -import { bus } from './bus.js'; -import { addSignal, addPost } from '../narrative/state.js'; -export function initBridge() { - bus.on('signal.detected', (event) => { - const e = event; - addSignal({ - asset: e.data.asset, - direction: e.data.direction, - confidence: e.data.confidence, - summary: e.data.summary, - ts: e.ts, - }); - }); - bus.on('post.published', (event) => { - const e = event; - addPost({ - platform: e.data.platform, - url: e.data.url, - text: e.data.text, - referencesAssets: e.data.referencesAssets, - ts: e.ts, - }); - }); -} diff --git a/dist/events/bus.d.ts b/dist/events/bus.d.ts deleted file mode 100644 index 647bd768..00000000 --- a/dist/events/bus.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { FranklinEvent } from './types.js'; -type Handler = (event: FranklinEvent) => void | Promise; -export declare class EventBus { - private handlers; - private logEnabled; - private logPath; - constructor(opts?: { - log?: boolean; - }); - on(type: FranklinEvent['type'], handler: Handler): void; - off(type: FranklinEvent['type'], handler: Handler): void; - emit(event: FranklinEvent): Promise; - clear(): void; - private appendLog; -} -export declare const bus: EventBus; -export {}; diff --git a/dist/events/bus.js b/dist/events/bus.js deleted file mode 100644 index dfdd2c78..00000000 --- a/dist/events/bus.js +++ /dev/null @@ -1,55 +0,0 @@ -import os from 'node:os'; -import path from 'node:path'; -import fs from 'node:fs'; -export class EventBus { - handlers = new Map(); - logEnabled; - logPath; - constructor(opts = {}) { - this.logEnabled = opts.log ?? false; - this.logPath = path.join(os.homedir(), '.blockrun', 'events.jsonl'); - } - on(type, handler) { - let set = this.handlers.get(type); - if (!set) { - set = new Set(); - this.handlers.set(type, set); - } - set.add(handler); - } - off(type, handler) { - this.handlers.get(type)?.delete(handler); - } - async emit(event) { - if (this.logEnabled) { - this.appendLog(event); - } - const set = this.handlers.get(event.type); - if (!set) - return; - const promises = []; - for (const handler of set) { - const result = handler(event); - if (result) - promises.push(result); - } - if (promises.length) - await Promise.all(promises); - } - clear() { - this.handlers.clear(); - } - appendLog(event) { - try { - const dir = path.dirname(this.logPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.appendFileSync(this.logPath, JSON.stringify(event) + '\n'); - } - catch { - // best-effort logging — don't crash the agent - } - } -} -export const bus = new EventBus(); diff --git a/dist/events/types.d.ts b/dist/events/types.d.ts deleted file mode 100644 index 91bec412..00000000 --- a/dist/events/types.d.ts +++ /dev/null @@ -1,49 +0,0 @@ -export interface BaseEvent { - id: string; - type: string; - ts: string; - source: 'trading' | 'social' | 'core'; - costUsd?: number; - correlationId?: string; -} -export interface SignalDetectedEvent extends BaseEvent { - type: 'signal.detected'; - data: { - asset: string; - direction: 'bullish' | 'bearish' | 'neutral'; - confidence: number; - indicators: Record; - summary: string; - }; -} -export interface PostPublishedEvent extends BaseEvent { - type: 'post.published'; - data: { - platform: 'x' | 'reddit' | (string & {}); - url: string; - text: string; - referencesAssets?: string[]; - }; -} -export interface MentionReceivedEvent extends BaseEvent { - type: 'mention.received'; - data: { - platform: string; - url: string; - text: string; - author: string; - sentiment?: 'positive' | 'negative' | 'neutral'; - mentionsAsset?: string; - }; -} -export interface BudgetExceededEvent extends BaseEvent { - type: 'budget.exceeded'; - data: { - category: 'llm' | 'data' | 'gas'; - spent: number; - cap: number; - blockedAction: string; - }; -} -export type FranklinEvent = SignalDetectedEvent | PostPublishedEvent | MentionReceivedEvent | BudgetExceededEvent; -export declare function makeEvent(props: Omit): T; diff --git a/dist/events/types.js b/dist/events/types.js deleted file mode 100644 index d0a76183..00000000 --- a/dist/events/types.js +++ /dev/null @@ -1,8 +0,0 @@ -import crypto from 'node:crypto'; -export function makeEvent(props) { - return { - id: crypto.randomUUID(), - ts: new Date().toISOString(), - ...props, - }; -} diff --git a/dist/index.d.ts b/dist/index.d.ts deleted file mode 100644 index b7988016..00000000 --- a/dist/index.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env node -export {}; diff --git a/dist/index.js b/dist/index.js deleted file mode 100755 index 3028c42e..00000000 --- a/dist/index.js +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env node -// Global error handlers — catch unhandled rejections/exceptions before they crash silently -process.on('unhandledRejection', (reason) => { - console.error(`\x1b[31mUnhandled error: ${reason instanceof Error ? reason.message : String(reason)}\x1b[0m`); - process.exit(1); -}); -process.on('uncaughtException', (err) => { - console.error(`\x1b[31mFatal error: ${err.message}\x1b[0m`); - process.exit(1); -}); -import { Command } from 'commander'; -import { flushStats } from './stats/tracker.js'; -// Ensure stats are flushed on any exit -process.on('exit', () => flushStats()); -import { setupCommand } from './commands/setup.js'; -import { startCommand } from './commands/start.js'; -import { balanceCommand } from './commands/balance.js'; -import { modelsCommand } from './commands/models.js'; -import { configCommand } from './commands/config.js'; -import { statsCommand } from './commands/stats.js'; -import { logsCommand } from './commands/logs.js'; -import { daemonCommand } from './commands/daemon.js'; -import { initCommand } from './commands/init.js'; -import { uninitCommand } from './commands/uninit.js'; -import { proxyCommand } from './commands/proxy.js'; -import { VERSION as version } from './config.js'; -const program = new Command(); -program - .name('franklin') - .description('Franklin — The AI agent with a wallet.\n\n' + - 'While others chat, Franklin spends — turning your USDC into real work.\n\n' + - 'Pay per action in USDC on Base or Solana. No subscriptions. No accounts.') - .version(version); -program - .command('setup [chain]') - .description('Create a new wallet for payments (base or solana)') - .action((chain) => setupCommand(chain)); -program - .command('start') - .description('Start the runcode agent') - .option('-m, --model ', 'Model to use (e.g. openai/gpt-5.4, anthropic/claude-sonnet-4.6). Default from config or claude-sonnet-4.6') - .option('--debug', 'Enable debug logging') - .option('--trust', 'Trust mode — skip permission prompts for all tools') - .action((options) => startCommand({ ...options, version })); -program - .command('proxy') - .description('Run payment proxy for Claude Code or other tools') - .option('-p, --port ', 'Proxy port', '8402') - .option('-m, --model ', 'Default model for proxied requests') - .option('--no-fallback', 'Disable automatic fallback to backup models') - .option('--debug', 'Enable debug logging') - .action((options) => proxyCommand({ ...options, version })); -program - .command('init') - .description('Configure runcode auto-start (writes ~/.claude/settings.json + installs LaunchAgent on macOS)') - .option('-p, --port ', 'Proxy port', '8402') - .action((options) => initCommand(options)); -program - .command('uninit') - .description('Remove runcode configuration and uninstall LaunchAgent') - .action(() => uninitCommand()); -program - .command('daemon ') - .description('Manage runcode background proxy (start|stop|status)') - .option('-p, --port ', 'Proxy port', '8402') - .action((action, options) => daemonCommand(action, options)); -program - .command('panel') - .description('Open the Franklin dashboard (localhost:3100)') - .option('-p, --port ', 'Dashboard port', '3100') - .action(async (options) => { - const { panelCommand } = await import('./commands/panel.js'); - await panelCommand(options); -}); -program - .command('models') - .description('List available models and pricing') - .action(modelsCommand); -program - .command('balance') - .description('Check wallet USDC balance') - .action(balanceCommand); -program - .command('config [key] [value]') - .description('Manage runcode config (set, get, unset, list)\n' + - 'Keys: default-model, sonnet-model, opus-model, haiku-model, smart-routing') - .action(configCommand); -program - .command('stats') - .description('Show usage statistics and cost savings') - .option('--clear', 'Clear all statistics') - .option('--json', 'Output in JSON format') - .action(statsCommand); -program - .command('logs') - .description('View debug logs (start with --debug to enable logging)') - .option('-f, --follow', 'Follow log output in real time') - .option('-n, --lines ', 'Number of lines to show (default: 50)') - .option('--clear', 'Delete log file') - .action(logsCommand); -program - .command('insights') - .description('Show rich usage insights — cost breakdown, trends, projections') - .option('-d, --days ', 'Window size in days (default: 30)', '30') - .action(async (opts) => { - const { generateInsights, formatInsights } = await import('./stats/insights.js'); - const days = parseInt(opts.days ?? '30', 10) || 30; - const report = generateInsights(days); - process.stdout.write(formatInsights(report, days)); -}); -program - .command('search ') - .description('Search past sessions by keyword (use quotes for phrases)') - .option('-l, --limit ', 'Max results to show (default: 10)', '10') - .option('-m, --model ', 'Filter by model name substring') - .action(async (query, opts) => { - const { searchSessions, formatSearchResults } = await import('./session/search.js'); - const limit = parseInt(opts.limit ?? '10', 10) || 10; - const matches = searchSessions(query, { limit, model: opts.model }); - process.stdout.write(formatSearchResults(matches, query)); -}); -// ─── franklin social (native X bot) ─────────────────────────────────────── -// First-class subcommand. Handles setup / login / run / stats / config -// subactions. No plugin SDK, no MCP — everything lives in src/social/. -program - .command('social [action] [arg]') - .description('Native X bot — franklin social setup | login x | run | stats | config') - .option('--dry-run', 'Generate drafts without posting (default for run)') - .option('--live', 'Actually post to X (overrides dry-run default)') - .option('-m, --model ', 'Override the model used for reply generation') - .option('--debug', 'Enable debug logging') - .action(async (action, arg, opts) => { - const { socialCommand } = await import('./commands/social.js'); - await socialCommand(action, arg, opts); -}); -// Plugin commands — dynamically registered from discovered plugins. -// Core stays plugin-agnostic: this loop adds a command for each installed plugin. -// Note: `social` is now a first-class native command above and NOT loaded as a -// plugin (the bundled social plugin was retired in v3.2.0 in favour of the -// src/social/ subsystem). -{ - const { loadAllPlugins, listWorkflowPlugins } = await import('./plugins/registry.js'); - await loadAllPlugins(); - for (const lp of listWorkflowPlugins()) { - const { manifest } = lp; - // Skip any plugin whose id collides with a built-in command (e.g. social) - if (manifest.id === 'social') - continue; - program - .command(`${manifest.id} [action]`) - .description(manifest.description) - .option('--dry', 'Dry run — preview without side effects') - .option('--debug', 'Enable debug logging') - .action(async (action, opts) => { - const { pluginCommand } = await import('./commands/plugin.js'); - await pluginCommand(manifest.id, action, { dryRun: opts.dry, debug: opts.debug }); - }); - } -} -program - .command('migrate') - .description('Import data from other AI tools (Claude Code, Cline, Cursor)') - .action(async () => { - const { migrateCommand } = await import('./commands/migrate.js'); - await migrateCommand(); -}); -program - .command('plugins') - .description('List installed plugins') - .action(async () => { - const { listAvailablePlugins } = await import('./commands/plugin.js'); - listAvailablePlugins(); -}); -// Default action: if no subcommand given, run 'start' -const args = process.argv.slice(2); -const firstArg = args[0]; -const HELP_FLAGS = new Set(['-h', '--help']); -const VERSION_FLAGS = new Set(['-V', '--version']); -const START_ONLY_FLAGS = new Set(['--trust', '--debug', '-m', '--model']); -function hasAnyFlag(argv, flags) { - return argv.some(arg => flags.has(arg)); -} -function hasStartOnlyFlag(argv) { - return argv.some(arg => START_ONLY_FLAGS.has(arg)); -} -// Handle chain shortcuts: `runcode solana` or `runcode base` -if (firstArg === 'solana' || firstArg === 'base') { - if (hasAnyFlag(args, HELP_FLAGS)) { - program.parse(['node', 'franklin', 'start', '--help']); - } - if (hasAnyFlag(args, VERSION_FLAGS)) { - console.log(version); - process.exit(0); - } - const { saveChain } = await import('./config.js'); - saveChain(firstArg); - const startOpts = { version }; - for (let i = 1; i < args.length; i++) { - if (args[i] === '--trust') - startOpts.trust = true; - else if (args[i] === '--debug') - startOpts.debug = true; - else if ((args[i] === '-m' || args[i] === '--model') && args[i + 1]) { - startOpts.model = args[++i]; - } - } - await startCommand(startOpts); - process.exit(0); -} -else if (!firstArg || firstArg.startsWith('-')) { - if (hasAnyFlag(args, HELP_FLAGS) && hasStartOnlyFlag(args)) { - program.parse(['node', 'franklin', 'start', '--help']); - } - if (hasAnyFlag(args, VERSION_FLAGS) && hasStartOnlyFlag(args)) { - console.log(version); - process.exit(0); - } - if (hasAnyFlag(args, HELP_FLAGS) || hasAnyFlag(args, VERSION_FLAGS)) { - program.parse(); - } - // No subcommand or only flags — treat as 'start' with flags - const startOpts = { version }; - for (let i = 0; i < args.length; i++) { - if (args[i] === '--trust') - startOpts.trust = true; - else if (args[i] === '--debug') - startOpts.debug = true; - else if ((args[i] === '-m' || args[i] === '--model') && args[i + 1]) { - startOpts.model = args[++i]; - } - } - await startCommand(startOpts); - process.exit(0); -} -else { - program.parse(); -} diff --git a/dist/learnings/extractor.d.ts b/dist/learnings/extractor.d.ts deleted file mode 100644 index 34e33e88..00000000 --- a/dist/learnings/extractor.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Extract user preferences from a completed session trace. - * Uses a cheap model to analyze the conversation and produce learnings. - */ -import { ModelClient } from '../agent/llm.js'; -import type { Dialogue } from '../agent/types.js'; -/** - * Scan for Claude Code configuration and bootstrap learnings from it. - * Only runs once — skips if learnings already exist. - */ -export declare function bootstrapFromClaudeConfig(client: ModelClient): Promise; -/** - * Extract learnings from a completed session. - * Runs asynchronously — caller should fire-and-forget. - */ -export declare function extractLearnings(history: Dialogue[], sessionId: string, client: ModelClient): Promise; diff --git a/dist/learnings/extractor.js b/dist/learnings/extractor.js deleted file mode 100644 index 25e3fbef..00000000 --- a/dist/learnings/extractor.js +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Extract user preferences from a completed session trace. - * Uses a cheap model to analyze the conversation and produce learnings. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { loadLearnings, mergeLearning, saveLearnings } from './store.js'; -// Cheapest models that reliably output structured JSON -const EXTRACTION_MODELS = [ - 'google/gemini-2.5-flash-lite', - 'google/gemini-2.5-flash', - 'nvidia/nemotron-super-49b', -]; -const VALID_CATEGORIES = new Set([ - 'language', 'model_preference', 'tool_pattern', 'coding_style', - 'communication', 'domain', 'correction', 'workflow', 'other', -]); -const EXTRACTION_PROMPT = `You are analyzing a conversation between a user and an AI coding agent. Extract user preferences and behavioral patterns that would help personalize future interactions. - -Analyze for: -1. Language — what language does the user write in? (English, Chinese, mixed?) -2. Model preferences — did they switch models or express a preference? -3. Coding style — did they correct the agent's code style? (naming, formatting, conventions) -4. Communication — are they terse or verbose? Do they want explanations or just code? -5. Domain — what tech stack, frameworks, or project type? -6. Corrections — did they repeatedly correct the same agent behavior? -7. Workflow — do they prefer short tasks or long planning sessions? - -Rules: -- ONLY extract signals clearly supported by evidence in the conversation. -- Do NOT speculate. If evidence is weak, set confidence below 0.5. -- If the conversation is too short or generic, return an empty array. -- Each learning should be one clear, actionable sentence. - -Respond with ONLY a JSON object (no markdown fences, no commentary): -{"learnings":[{"learning":"...","category":"language|model_preference|tool_pattern|coding_style|communication|domain|correction|workflow|other","confidence":0.5}]}`; -/** - * Condense session history into a compact text for extraction. - * Only includes user messages and assistant text — skips tool calls/results. - */ -function condenseHistory(history) { - const parts = []; - let chars = 0; - const CAP = 4000; - for (const msg of history) { - if (chars >= CAP) - break; - const role = msg.role === 'user' ? 'User' : 'Assistant'; - let text = ''; - if (typeof msg.content === 'string') { - text = msg.content; - } - else if (Array.isArray(msg.content)) { - text = msg.content - .filter(p => p.type === 'text') - .map(p => p.text) - .join('\n'); - } - if (!text.trim()) - continue; - // Truncate long messages - if (text.length > 500) - text = text.slice(0, 500) + '…'; - const line = `${role}: ${text}`; - parts.push(line); - chars += line.length; - } - return parts.join('\n\n'); -} -/** - * Parse JSON from LLM response, handling common quirks - * (markdown fences, trailing commas, commentary). - */ -function parseExtraction(raw) { - // Strip markdown fences - let cleaned = raw.replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim(); - // Find the JSON object - const start = cleaned.indexOf('{'); - const end = cleaned.lastIndexOf('}'); - if (start === -1 || end === -1) - return { learnings: [] }; - cleaned = cleaned.slice(start, end + 1); - const parsed = JSON.parse(cleaned); - if (!Array.isArray(parsed.learnings)) - return { learnings: [] }; - // Validate and sanitize each entry - return { - learnings: parsed.learnings - .filter((l) => typeof l.learning === 'string' && - typeof l.category === 'string' && - VALID_CATEGORIES.has(l.category) && - typeof l.confidence === 'number' && - l.confidence >= 0.1 && l.confidence <= 1.0 && - l.learning.length > 5) - .map((l) => ({ - learning: l.learning.slice(0, 200), - category: l.category, - confidence: Math.round(l.confidence * 100) / 100, - })), - }; -} -// ─── Onboarding: bootstrap from Claude Code config ─────────────────────── -const BOOTSTRAP_PROMPT = `You are analyzing a user's AI coding agent configuration file (CLAUDE.md). Extract user preferences that would help personalize a different AI agent's behavior. - -Analyze for: -1. Language — what language do they communicate in? -2. Coding style — naming conventions, formatting, lint rules, type annotations? -3. Communication — how do they want the agent to behave? (terse? formal? call them something?) -4. Domain — what tech stack, frameworks, languages do they work with? -5. Workflow — any specific git, commit, or deployment preferences? -6. Corrections — any explicit "do NOT" rules or anti-patterns? -7. Other — any other clear preferences? - -Rules: -- Extract EVERY explicit preference. These are user-written rules, so confidence is high (0.8-1.0). -- Each learning should be one clear, actionable sentence. -- Do NOT include project-specific paths or secrets. -- Do NOT include things that are tool-specific to Claude Code and wouldn't apply to another agent. - -Respond with ONLY a JSON object (no markdown fences, no commentary): -{"learnings":[{"learning":"...","category":"language|model_preference|tool_pattern|coding_style|communication|domain|correction|workflow|other","confidence":0.9}]}`; -/** - * Scan for Claude Code configuration and bootstrap learnings from it. - * Only runs once — skips if learnings already exist. - */ -export async function bootstrapFromClaudeConfig(client) { - // Only bootstrap if no learnings exist yet (first run) - const existing = loadLearnings(); - if (existing.length > 0) - return 0; - // Scan for Claude Code config files - const configPaths = [ - path.join(os.homedir(), '.claude', 'CLAUDE.md'), - path.join(process.cwd(), 'CLAUDE.md'), - path.join(process.cwd(), '.claude', 'CLAUDE.md'), - ]; - const contents = []; - for (const p of configPaths) { - try { - const text = fs.readFileSync(p, 'utf-8').trim(); - if (text && text.length > 20) { - contents.push(`--- ${p} ---\n${text}`); - } - } - catch { /* file doesn't exist */ } - } - if (contents.length === 0) - return 0; - // Cap total content - let combined = contents.join('\n\n'); - if (combined.length > 6000) - combined = combined.slice(0, 6000) + '\n…(truncated)'; - // Extract learnings - let result = null; - for (const model of EXTRACTION_MODELS) { - try { - const response = await client.complete({ - model, - messages: [{ role: 'user', content: combined }], - system: BOOTSTRAP_PROMPT, - max_tokens: 1500, - temperature: 0.2, - }); - const text = response.content - .filter((p) => p.type === 'text') - .map((p) => p.text) - .join(''); - result = parseExtraction(text); - break; - } - catch { - continue; - } - } - if (!result || result.learnings.length === 0) - return 0; - // Save all bootstrapped learnings - let learnings = loadLearnings(); - for (const entry of result.learnings) { - learnings = mergeLearning(learnings, { - ...entry, - source_session: 'bootstrap:claude-config', - }); - } - saveLearnings(learnings); - return result.learnings.length; -} -// ─── Session extraction ────────────────────────────────────────────────── -/** - * Extract learnings from a completed session. - * Runs asynchronously — caller should fire-and-forget. - */ -export async function extractLearnings(history, sessionId, client) { - // Skip very short sessions - if (history.length < 4) - return; - const condensed = condenseHistory(history); - if (condensed.length < 100) - return; // Too little content - // Try each model until one succeeds - let result = null; - for (const model of EXTRACTION_MODELS) { - try { - const response = await client.complete({ - model, - messages: [{ role: 'user', content: condensed }], - system: EXTRACTION_PROMPT, - max_tokens: 1000, - temperature: 0.3, - }); - const text = response.content - .filter((p) => p.type === 'text') - .map((p) => p.text) - .join(''); - result = parseExtraction(text); - break; - } - catch { - continue; // Try next model - } - } - if (!result || result.learnings.length === 0) - return; - // Merge with existing learnings - let existing = loadLearnings(); - for (const entry of result.learnings) { - existing = mergeLearning(existing, { - ...entry, - source_session: sessionId, - }); - } - saveLearnings(existing); -} diff --git a/dist/learnings/index.d.ts b/dist/learnings/index.d.ts deleted file mode 100644 index 9fdd76b8..00000000 --- a/dist/learnings/index.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type { Learning, LearningCategory, ExtractionResult } from './types.js'; -export { loadLearnings, saveLearnings, mergeLearning, decayLearnings, formatForPrompt } from './store.js'; -export { extractLearnings, bootstrapFromClaudeConfig } from './extractor.js'; diff --git a/dist/learnings/index.js b/dist/learnings/index.js deleted file mode 100644 index 79d6dda5..00000000 --- a/dist/learnings/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { loadLearnings, saveLearnings, mergeLearning, decayLearnings, formatForPrompt } from './store.js'; -export { extractLearnings, bootstrapFromClaudeConfig } from './extractor.js'; diff --git a/dist/learnings/store.d.ts b/dist/learnings/store.d.ts deleted file mode 100644 index a9cfc329..00000000 --- a/dist/learnings/store.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Persistence layer for per-user learnings. - * Stored as JSONL at ~/.blockrun/learnings.jsonl. - */ -import type { Learning, LearningCategory } from './types.js'; -export declare function loadLearnings(): Learning[]; -export declare function saveLearnings(learnings: Learning[]): void; -export declare function mergeLearning(existing: Learning[], newEntry: { - learning: string; - category: LearningCategory; - confidence: number; - source_session: string; -}): Learning[]; -export declare function decayLearnings(learnings: Learning[]): Learning[]; -export declare function formatForPrompt(learnings: Learning[]): string; diff --git a/dist/learnings/store.js b/dist/learnings/store.js deleted file mode 100644 index 260594c8..00000000 --- a/dist/learnings/store.js +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Persistence layer for per-user learnings. - * Stored as JSONL at ~/.blockrun/learnings.jsonl. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import crypto from 'node:crypto'; -import { BLOCKRUN_DIR } from '../config.js'; -const LEARNINGS_PATH = path.join(BLOCKRUN_DIR, 'learnings.jsonl'); -const MAX_LEARNINGS = 50; -const DECAY_AFTER_DAYS = 30; -const DECAY_AMOUNT = 0.15; -const PRUNE_THRESHOLD = 0.2; -const MERGE_SIMILARITY = 0.6; -// ─── Load / Save ────────────────────────────────────────────────────────── -export function loadLearnings() { - try { - const raw = fs.readFileSync(LEARNINGS_PATH, 'utf-8'); - const results = []; - for (const line of raw.split('\n')) { - if (!line.trim()) - continue; - try { - results.push(JSON.parse(line)); - } - catch { /* skip corrupted lines */ } - } - return results; - } - catch { - return []; - } -} -export function saveLearnings(learnings) { - fs.mkdirSync(BLOCKRUN_DIR, { recursive: true }); - const tmpPath = LEARNINGS_PATH + '.tmp'; - const content = learnings.map(l => JSON.stringify(l)).join('\n') + '\n'; - fs.writeFileSync(tmpPath, content); - fs.renameSync(tmpPath, LEARNINGS_PATH); -} -// ─── Merge / Dedup ──────────────────────────────────────────────────────── -function tokenize(text) { - return new Set(text.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 2)); -} -function jaccardSimilarity(a, b) { - if (a.size === 0 && b.size === 0) - return 1; - let intersection = 0; - for (const w of a) - if (b.has(w)) - intersection++; - return intersection / (a.size + b.size - intersection); -} -export function mergeLearning(existing, newEntry) { - const now = Date.now(); - const newTokens = tokenize(newEntry.learning); - // Find similar existing learning in same category - for (const entry of existing) { - if (entry.category !== newEntry.category) - continue; - const similarity = jaccardSimilarity(tokenize(entry.learning), newTokens); - if (similarity >= MERGE_SIMILARITY) { - // Merge: boost confidence, update timestamp - entry.times_confirmed++; - entry.last_confirmed = now; - entry.confidence = Math.min(entry.confidence + 0.1, 1.0); - // Prefer more specific wording - if (newEntry.learning.length > entry.learning.length) { - entry.learning = newEntry.learning; - } - return existing; - } - } - // No match — insert new - existing.push({ - id: crypto.randomBytes(8).toString('hex'), - learning: newEntry.learning, - category: newEntry.category, - confidence: newEntry.confidence, - source_session: newEntry.source_session, - created_at: now, - last_confirmed: now, - times_confirmed: 1, - }); - // Cap at MAX_LEARNINGS — drop lowest-scoring - if (existing.length > MAX_LEARNINGS) { - existing.sort((a, b) => score(b) - score(a)); - existing.length = MAX_LEARNINGS; - } - return existing; -} -function score(l) { - return l.confidence * Math.log2(l.times_confirmed + 1); -} -// ─── Decay ──────────────────────────────────────────────────────────────── -export function decayLearnings(learnings) { - const now = Date.now(); - const cutoff = DECAY_AFTER_DAYS * 24 * 60 * 60 * 1000; - return learnings.filter(l => { - if (l.times_confirmed >= 3) - return true; // Immune to time decay - if (now - l.last_confirmed > cutoff) { - l.confidence -= DECAY_AMOUNT; - return l.confidence >= PRUNE_THRESHOLD; - } - return true; - }); -} -// ─── Format for System Prompt ───────────────────────────────────────────── -const MAX_PROMPT_CHARS = 2000; // ~500 tokens -export function formatForPrompt(learnings) { - if (learnings.length === 0) - return ''; - const sorted = [...learnings].sort((a, b) => score(b) - score(a)); - const lines = []; - let chars = 0; - const header = '# Personal Context\nPreferences learned from previous sessions:\n'; - chars += header.length; - for (const l of sorted) { - const conf = l.confidence >= 0.8 ? '●' : l.confidence >= 0.5 ? '◐' : '○'; - const line = `- ${conf} ${l.learning}`; - if (chars + line.length + 1 > MAX_PROMPT_CHARS) - break; - lines.push(line); - chars += line.length + 1; - } - if (lines.length === 0) - return ''; - return header + lines.join('\n'); -} diff --git a/dist/learnings/types.d.ts b/dist/learnings/types.d.ts deleted file mode 100644 index ba62e320..00000000 --- a/dist/learnings/types.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Types for Franklin's per-user self-evolution system. - * - * Each user's Franklin learns preferences from session traces and - * injects them into the system prompt on next startup. - */ -export interface Learning { - id: string; - learning: string; - category: LearningCategory; - confidence: number; - source_session: string; - created_at: number; - last_confirmed: number; - times_confirmed: number; -} -export type LearningCategory = 'language' | 'model_preference' | 'tool_pattern' | 'coding_style' | 'communication' | 'domain' | 'correction' | 'workflow' | 'other'; -export interface ExtractionResult { - learnings: Array<{ - learning: string; - category: LearningCategory; - confidence: number; - }>; -} diff --git a/dist/learnings/types.js b/dist/learnings/types.js deleted file mode 100644 index 69ea13d7..00000000 --- a/dist/learnings/types.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Types for Franklin's per-user self-evolution system. - * - * Each user's Franklin learns preferences from session traces and - * injects them into the system prompt on next startup. - */ -export {}; diff --git a/dist/mcp/client.d.ts b/dist/mcp/client.d.ts deleted file mode 100644 index 779e4c7c..00000000 --- a/dist/mcp/client.d.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * MCP Client for runcode. - * Connects to MCP servers, discovers tools, and wraps them as CapabilityHandlers. - * Supports stdio and HTTP (SSE) transports. - */ -import type { CapabilityHandler } from '../agent/types.js'; -export interface McpServerConfig { - /** Transport type */ - transport: 'stdio' | 'http'; - /** For stdio: command to run */ - command?: string; - /** For stdio: arguments */ - args?: string[]; - /** For stdio: environment variables */ - env?: Record; - /** For http: server URL */ - url?: string; - /** For http: headers */ - headers?: Record; - /** Human-readable label */ - label?: string; - /** Disable this server */ - disabled?: boolean; -} -export interface McpConfig { - mcpServers: Record; -} -/** - * Connect to all configured MCP servers and return discovered tools. - * Each connection has a 5s timeout to avoid blocking startup. - */ -export declare function connectMcpServers(config: McpConfig, debug?: boolean): Promise; -/** - * Disconnect all MCP servers. - */ -export declare function disconnectMcpServers(): Promise; -/** - * List connected MCP servers and their tools. - */ -export declare function listMcpServers(): Array<{ - name: string; - toolCount: number; - tools: string[]; -}>; diff --git a/dist/mcp/client.js b/dist/mcp/client.js deleted file mode 100644 index 7c44064f..00000000 --- a/dist/mcp/client.js +++ /dev/null @@ -1,154 +0,0 @@ -/** - * MCP Client for runcode. - * Connects to MCP servers, discovers tools, and wraps them as CapabilityHandlers. - * Supports stdio and HTTP (SSE) transports. - */ -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -// ─── Connection Management ──────────────────────────────────────────────── -const connections = new Map(); -/** - * Connect to an MCP server via stdio transport. - * Discovers tools and returns them as CapabilityHandlers. - */ -async function connectStdio(name, config) { - if (!config.command) { - throw new Error(`MCP server "${name}" missing command`); - } - const transport = new StdioClientTransport({ - command: config.command, - args: config.args || [], - env: { ...process.env, ...(config.env || {}) }, - // 'ignore' discards subprocess stderr completely so a misconfigured MCP - // server (e.g. missing OAuth keys) can't dump multi-line stack traces - // into the user's terminal. 'pipe' didn't fully work because some SDK - // versions read piped stderr and re-emit it. - stderr: 'ignore', - }); - const client = new Client({ name: `runcode-mcp-${name}`, version: '1.0.0' }, { capabilities: {} }); - try { - await client.connect(transport); - } - catch (err) { - // Clean up transport if connect fails to prevent resource leak - try { - await transport.close(); - } - catch { /* ignore */ } - throw err; - } - // Discover tools - const { tools: mcpTools } = await client.listTools(); - const capabilities = []; - for (const tool of mcpTools) { - const toolName = `mcp__${name}__${tool.name}`; - const toolDescription = (tool.description || '').slice(0, 2048); - capabilities.push({ - spec: { - name: toolName, - description: toolDescription || `MCP tool from ${name}`, - input_schema: tool.inputSchema || { - type: 'object', - properties: {}, - }, - }, - execute: async (input, _ctx) => { - const MCP_TOOL_TIMEOUT = 30_000; - try { - // Timeout protection: if tool hangs, don't block the agent forever - const callPromise = client.callTool({ name: tool.name, arguments: input }); - const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`MCP tool timeout after ${MCP_TOOL_TIMEOUT / 1000}s`)), MCP_TOOL_TIMEOUT)); - const result = await Promise.race([callPromise, timeoutPromise]); - // Extract text content from MCP response - const output = result.content - ?.filter(c => c.type === 'text') - ?.map(c => c.text) - ?.join('\n') || JSON.stringify(result.content); - return { - output, - isError: result.isError === true, - }; - } - catch (err) { - return { - output: `MCP tool error (${name}/${tool.name}): ${err.message}`, - isError: true, - }; - } - }, - concurrent: true, // MCP tools are safe to run concurrently - }); - } - const connected = { name, client, transport, tools: capabilities }; - connections.set(name, connected); - return connected; -} -/** - * Connect to all configured MCP servers and return discovered tools. - */ -const MCP_CONNECT_TIMEOUT = 5_000; // 5s per server connection -/** - * Connect to all configured MCP servers and return discovered tools. - * Each connection has a 5s timeout to avoid blocking startup. - */ -export async function connectMcpServers(config, debug) { - const allTools = []; - for (const [name, serverConfig] of Object.entries(config.mcpServers)) { - if (serverConfig.disabled) - continue; - try { - if (debug) { - console.error(`[runcode] Connecting to MCP server: ${name}...`); - } - if (serverConfig.transport !== 'stdio') { - if (debug) { - console.error(`[runcode] MCP HTTP transport not yet supported for ${name}`); - } - continue; - } - // Timeout: don't let a slow server block startup - const connectPromise = connectStdio(name, serverConfig); - const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('connection timeout (5s)')), MCP_CONNECT_TIMEOUT)); - const connected = await Promise.race([connectPromise, timeoutPromise]); - allTools.push(...connected.tools); - if (debug) { - console.error(`[runcode] MCP ${name}: ${connected.tools.length} tools discovered`); - } - } - catch (err) { - // Graceful degradation — one-line warning, continue without this server. - // Always visible (not debug-only) so the user knows why tools are missing. - const shortMsg = err.message?.split('\n')[0]?.slice(0, 100) || 'unknown error'; - console.error(` ${name}: ${shortMsg} ${debug ? '' : '(--debug for details)'}`); - } - } - return allTools; -} -/** - * Disconnect all MCP servers. - */ -export async function disconnectMcpServers() { - for (const [name, conn] of connections) { - try { - await conn.client.close(); - } - catch { - // Ignore cleanup errors - } - connections.delete(name); - } -} -/** - * List connected MCP servers and their tools. - */ -export function listMcpServers() { - const result = []; - for (const [name, conn] of connections) { - result.push({ - name, - toolCount: conn.tools.length, - tools: conn.tools.map(t => t.spec.name), - }); - } - return result; -} diff --git a/dist/mcp/config.d.ts b/dist/mcp/config.d.ts deleted file mode 100644 index 225acf21..00000000 --- a/dist/mcp/config.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * MCP configuration management for runcode. - * Loads MCP server configs from: - * 1. Global: ~/.blockrun/mcp.json - * 2. Project: .mcp.json in working directory - */ -import type { McpConfig, McpServerConfig } from './client.js'; -export declare function loadMcpConfig(workDir: string): McpConfig; -/** - * Save a server config to the global MCP config. - */ -export declare function saveMcpServer(name: string, config: McpServerConfig): void; -/** - * Remove a server from the global MCP config. - */ -export declare function removeMcpServer(name: string): boolean; -/** - * Trust a project directory to load its .mcp.json. - */ -export declare function trustProjectDir(workDir: string): void; diff --git a/dist/mcp/config.js b/dist/mcp/config.js deleted file mode 100644 index 190c8847..00000000 --- a/dist/mcp/config.js +++ /dev/null @@ -1,166 +0,0 @@ -/** - * MCP configuration management for runcode. - * Loads MCP server configs from: - * 1. Global: ~/.blockrun/mcp.json - * 2. Project: .mcp.json in working directory - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { execSync } from 'node:child_process'; -import { BLOCKRUN_DIR } from '../config.js'; -const GLOBAL_MCP_FILE = path.join(BLOCKRUN_DIR, 'mcp.json'); -/** - * Load MCP server configurations from global + project files. - * Project config overrides global for same server name. - */ -// Built-in MCP server: @blockrun/mcp available when globally installed -// Uses `blockrun-mcp` binary instead of `npx` for fast startup -const BUILTIN_MCP_SERVERS = { - blockrun: { - transport: 'stdio', - command: 'blockrun-mcp', - args: [], - label: 'BlockRun (built-in)', - }, - unbrowse: { - transport: 'stdio', - command: 'unbrowse', - args: ['mcp'], - label: 'Unbrowse (built-in)', - }, -}; -function isCommandAvailable(cmd) { - try { - execSync(`which ${cmd}`, { stdio: 'pipe' }); - return true; - } - catch { - return false; - } -} -export function loadMcpConfig(workDir) { - // Start with built-in servers (only if their binary is available) - const servers = {}; - for (const [name, config] of Object.entries(BUILTIN_MCP_SERVERS)) { - if (config.command && isCommandAvailable(config.command)) { - servers[name] = config; - } - } - // 1. Global config - try { - if (fs.existsSync(GLOBAL_MCP_FILE)) { - const raw = JSON.parse(fs.readFileSync(GLOBAL_MCP_FILE, 'utf-8')); - if (raw.mcpServers && typeof raw.mcpServers === 'object') { - Object.assign(servers, raw.mcpServers); - } - } - } - catch { - // Ignore corrupt global config - } - // 2. Project config (.mcp.json in working directory) - // Security: project configs can execute arbitrary commands via stdio transport. - // Only load if a trust marker exists (user has explicitly opted in). - const projectMcpFile = path.join(workDir, '.mcp.json'); - const trustMarker = path.join(BLOCKRUN_DIR, 'trusted-projects.json'); - try { - if (fs.existsSync(projectMcpFile)) { - // Check if this project directory is trusted - let trusted = false; - try { - if (fs.existsSync(trustMarker)) { - const trustedDirs = JSON.parse(fs.readFileSync(trustMarker, 'utf-8')); - trusted = Array.isArray(trustedDirs) && trustedDirs.includes(workDir); - } - } - catch { /* not trusted */ } - if (trusted) { - const raw = JSON.parse(fs.readFileSync(projectMcpFile, 'utf-8')); - if (raw.mcpServers && typeof raw.mcpServers === 'object') { - Object.assign(servers, raw.mcpServers); - } - } - // If not trusted, silently skip project config (user must run /mcp trust) - } - } - catch { - // Ignore corrupt project config - } - // Filter out servers whose required credential files are missing. - // This prevents noisy startup errors from MCP servers that were imported - // (e.g. via `franklin migrate`) but don't have credentials configured yet. - // The server stays in the config file — it just gets auto-disabled until - // the user provides the credentials. - for (const [name, config] of Object.entries(servers)) { - if (config.disabled) - continue; - const env = (config.env || {}); - const args = (config.args || []); - const configStr = JSON.stringify(config).toLowerCase(); - // Check if any env var points to a file that doesn't exist - let missingFile = false; - for (const [, val] of Object.entries(env)) { - if (typeof val === 'string' && (val.endsWith('.json') || val.endsWith('.key') || val.endsWith('.pem'))) { - if (!fs.existsSync(val)) { - missingFile = true; - break; - } - } - } - // Check for common credential-dependent patterns in config - const needsAuth = missingFile || - (configStr.includes('oauth') && !args.some(a => fs.existsSync(a))); - if (needsAuth) { - servers[name].disabled = true; - } - } - return { mcpServers: servers }; -} -/** - * Save a server config to the global MCP config. - */ -export function saveMcpServer(name, config) { - const existing = loadGlobalMcpConfig(); - existing.mcpServers[name] = config; - fs.mkdirSync(BLOCKRUN_DIR, { recursive: true }); - fs.writeFileSync(GLOBAL_MCP_FILE, JSON.stringify(existing, null, 2) + '\n'); -} -/** - * Remove a server from the global MCP config. - */ -export function removeMcpServer(name) { - const existing = loadGlobalMcpConfig(); - if (!(name in existing.mcpServers)) - return false; - delete existing.mcpServers[name]; - fs.writeFileSync(GLOBAL_MCP_FILE, JSON.stringify(existing, null, 2) + '\n'); - return true; -} -/** - * Trust a project directory to load its .mcp.json. - */ -export function trustProjectDir(workDir) { - const trustMarker = path.join(BLOCKRUN_DIR, 'trusted-projects.json'); - let trusted = []; - try { - if (fs.existsSync(trustMarker)) { - trusted = JSON.parse(fs.readFileSync(trustMarker, 'utf-8')); - } - } - catch { /* fresh */ } - if (!trusted.includes(workDir)) { - trusted.push(workDir); - fs.mkdirSync(BLOCKRUN_DIR, { recursive: true }); - fs.writeFileSync(trustMarker, JSON.stringify(trusted, null, 2)); - } -} -function loadGlobalMcpConfig() { - try { - if (fs.existsSync(GLOBAL_MCP_FILE)) { - const raw = JSON.parse(fs.readFileSync(GLOBAL_MCP_FILE, 'utf-8')); - return { mcpServers: raw.mcpServers || {} }; - } - } - catch { /* fresh */ } - return { mcpServers: {} }; -} diff --git a/dist/narrative/state.d.ts b/dist/narrative/state.d.ts deleted file mode 100644 index d43035de..00000000 --- a/dist/narrative/state.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -export interface SignalRecord { - asset: string; - direction: 'bullish' | 'bearish' | 'neutral'; - confidence: number; - summary: string; - ts: string; -} -export interface PostRecord { - platform: string; - url: string; - text: string; - referencesAssets?: string[]; - ts: string; -} -export interface BudgetEnvelope { - dailyCapUsd: number; - spentTodayUsd: number; - date: string; -} -export interface NarrativeState { - watchlist: string[]; - recentSignals: SignalRecord[]; - recentPosts: PostRecord[]; - budget: BudgetEnvelope; -} -export declare function loadNarrative(): NarrativeState; -export declare function saveNarrative(s: NarrativeState): void; -export declare function updateNarrative(patch: Partial): NarrativeState; -export declare function addSignal(signal: SignalRecord): void; -export declare function addPost(post: PostRecord): void; diff --git a/dist/narrative/state.js b/dist/narrative/state.js deleted file mode 100644 index 7d4777af..00000000 --- a/dist/narrative/state.js +++ /dev/null @@ -1,69 +0,0 @@ -import path from 'node:path'; -import fs from 'node:fs'; -import os from 'node:os'; -const STORE_DIR = path.join(os.homedir(), '.blockrun'); -const STATE_PATH = path.join(STORE_DIR, 'narrative.json'); -const MAX_ENTRIES = 50; -let loaded = false; -let state; -function today() { - return new Date().toISOString().slice(0, 10); -} -function defaults() { - return { - watchlist: [], - recentSignals: [], - recentPosts: [], - budget: { dailyCapUsd: 10, spentTodayUsd: 0, date: today() }, - }; -} -export function loadNarrative() { - if (loaded) - return state; - fs.mkdirSync(STORE_DIR, { recursive: true }); - if (fs.existsSync(STATE_PATH)) { - try { - state = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); - } - catch { - state = defaults(); - } - } - else { - state = defaults(); - } - if (state.budget.date !== today()) { - state.budget.spentTodayUsd = 0; - state.budget.date = today(); - } - loaded = true; - return state; -} -export function saveNarrative(s) { - fs.mkdirSync(STORE_DIR, { recursive: true }); - fs.writeFileSync(STATE_PATH, JSON.stringify(s, null, 2) + '\n'); - state = s; - loaded = true; -} -export function updateNarrative(patch) { - const cur = loadNarrative(); - const merged = { ...cur, ...patch }; - if (patch.recentSignals) { - merged.recentSignals = [...patch.recentSignals, ...cur.recentSignals].slice(0, MAX_ENTRIES); - } - if (patch.recentPosts) { - merged.recentPosts = [...patch.recentPosts, ...cur.recentPosts].slice(0, MAX_ENTRIES); - } - saveNarrative(merged); - return merged; -} -export function addSignal(signal) { - const cur = loadNarrative(); - cur.recentSignals = [signal, ...cur.recentSignals].slice(0, MAX_ENTRIES); - saveNarrative(cur); -} -export function addPost(post) { - const cur = loadNarrative(); - cur.recentPosts = [post, ...cur.recentPosts].slice(0, MAX_ENTRIES); - saveNarrative(cur); -} diff --git a/dist/panel/html.d.ts b/dist/panel/html.d.ts deleted file mode 100644 index f41f8451..00000000 --- a/dist/panel/html.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Franklin Panel — embedded HTML dashboard. - * Single page, dark theme, zero dependencies. - * Design language adapted from Multica (oklch palette, sidebar nav). - * Currency-grade watermark + Inter font. - */ -export declare function getHTML(): string; diff --git a/dist/panel/html.js b/dist/panel/html.js deleted file mode 100644 index 15bb803a..00000000 --- a/dist/panel/html.js +++ /dev/null @@ -1,604 +0,0 @@ -/** - * Franklin Panel — embedded HTML dashboard. - * Single page, dark theme, zero dependencies. - * Design language adapted from Multica (oklch palette, sidebar nav). - * Currency-grade watermark + Inter font. - */ -export function getHTML() { - return ` - - - - -Franklin Panel - - - - - - - - - - - - - - - -
- -
-
-

Overview

-

Usage stats and cost breakdown

-
- - - -
-
-

Balance

-
-
-
-
-

Total Spent

-
-
-
-
-

Requests

-
-
-
-
-

Models Used

-
-
-
-
- -
-

Daily Spend (30 days)

-
-
-
-

Cost by Model

-
-
-
- - -
-
-

Sessions

-

Browse past conversations

-
- -
- -
- - -
-
-

Social

-

X/Twitter engagement stats

-
-
-
-

Recent Activity

-
No social activity yet
-
-
- - -
-
-

Learnings

-

Preferences Franklin has learned over time

-
-
-
-
- - - -`; -} diff --git a/dist/panel/server.d.ts b/dist/panel/server.d.ts deleted file mode 100644 index b6a0a733..00000000 --- a/dist/panel/server.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Franklin Panel — local HTTP server. - * Serves the dashboard HTML + JSON API endpoints + SSE for real-time updates. - * Zero external dependencies — uses node:http only. - */ -import http from 'node:http'; -export declare function createPanelServer(port: number): http.Server; diff --git a/dist/panel/server.js b/dist/panel/server.js deleted file mode 100644 index 77838832..00000000 --- a/dist/panel/server.js +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Franklin Panel — local HTTP server. - * Serves the dashboard HTML + JSON API endpoints + SSE for real-time updates. - * Zero external dependencies — uses node:http only. - */ -import http from 'node:http'; -import fs from 'node:fs'; -import path from 'node:path'; -import { BLOCKRUN_DIR, loadChain } from '../config.js'; -import { getStatsSummary } from '../stats/tracker.js'; -import { generateInsights } from '../stats/insights.js'; -import { listSessions, loadSessionHistory } from '../session/storage.js'; -import { searchSessions } from '../session/search.js'; -import { loadLearnings } from '../learnings/store.js'; -import { getStats as getSocialStats } from '../social/db.js'; -import { getHTML } from './html.js'; -const sseClients = new Set(); -function json(res, data, status = 200) { - res.writeHead(status, { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - }); - res.end(JSON.stringify(data)); -} -function broadcast(data) { - const msg = `data: ${JSON.stringify(data)}\n\n`; - for (const client of sseClients) { - try { - client.write(msg); - } - catch { - sseClients.delete(client); - } - } -} -export function createPanelServer(port) { - const html = getHTML(); - const server = http.createServer(async (req, res) => { - const url = new URL(req.url || '/', `http://localhost:${port}`); - const p = url.pathname; - // ─── HTML ── - if (p === '/') { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(html); - return; - } - // ─── Static assets ── - if (p.startsWith('/assets/') && p.endsWith('.jpg')) { - const filename = path.basename(p); - const assetsDir = path.join(path.dirname(path.dirname(new URL(import.meta.url).pathname)), '..', 'assets'); - const imgPath = path.join(assetsDir, filename); - try { - const img = fs.readFileSync(imgPath); - res.writeHead(200, { - 'Content-Type': 'image/jpeg', - 'Cache-Control': 'public, max-age=86400', - }); - res.end(img); - } - catch { - res.writeHead(404); - res.end('Not found'); - } - return; - } - // ─── SSE ── - if (p === '/api/events') { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': '*', - }); - res.write('data: {"type":"connected"}\n\n'); - sseClients.add(res); - req.on('close', () => sseClients.delete(res)); - return; - } - // ─── API ── - try { - if (p === '/api/stats') { - const summary = getStatsSummary(); - json(res, { - totalRequests: summary.stats.totalRequests, - totalCostUsd: summary.stats.totalCostUsd, - opusCost: summary.opusCost, - saved: summary.saved, - savedPct: summary.savedPct, - avgCostPerRequest: summary.avgCostPerRequest, - period: summary.period, - byModel: summary.stats.byModel, - }); - return; - } - if (p === '/api/insights') { - const days = parseInt(url.searchParams.get('days') || '30', 10); - const report = generateInsights(days); - json(res, report); - return; - } - if (p === '/api/sessions') { - const sessions = listSessions(); - json(res, sessions); - return; - } - if (p.startsWith('/api/sessions/search')) { - const q = url.searchParams.get('q') || ''; - const limit = parseInt(url.searchParams.get('limit') || '20', 10); - const results = searchSessions(q, { limit }); - json(res, results); - return; - } - if (p.startsWith('/api/sessions/')) { - const id = decodeURIComponent(p.slice('/api/sessions/'.length)); - const history = loadSessionHistory(id); - json(res, history); - return; - } - if (p === '/api/wallet') { - try { - const chain = loadChain(); - let address = '', balance = 0; - if (chain === 'solana') { - const { setupAgentSolanaWallet } = await import('@blockrun/llm'); - const client = await setupAgentSolanaWallet({ silent: true }); - address = await client.getWalletAddress(); - balance = await client.getBalance(); - } - else { - const { setupAgentWallet } = await import('@blockrun/llm'); - const client = setupAgentWallet({ silent: true }); - address = client.getWalletAddress(); - balance = await client.getBalance(); - } - json(res, { address, balance, chain }); - } - catch { - json(res, { address: 'not set', balance: 0, chain: loadChain() }); - } - return; - } - if (p === '/api/social') { - const stats = getSocialStats(); - json(res, stats); - return; - } - if (p === '/api/learnings') { - const learnings = loadLearnings(); - json(res, learnings); - return; - } - // 404 - res.writeHead(404); - res.end('Not found'); - } - catch (err) { - json(res, { error: err.message }, 500); - } - }); - // Watch stats file for changes → push to SSE clients - const statsFile = path.join(BLOCKRUN_DIR, 'runcode-stats.json'); - if (fs.existsSync(statsFile)) { - fs.watchFile(statsFile, { interval: 2000 }, () => { - try { - broadcast({ type: 'stats.updated' }); - } - catch { /* ignore */ } - }); - } - return server; -} diff --git a/dist/plugin-sdk/channel.d.ts b/dist/plugin-sdk/channel.d.ts deleted file mode 100644 index 8c009b39..00000000 --- a/dist/plugin-sdk/channel.d.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Channel contract — abstraction over messaging/social platforms. - * - * Channels are platforms where messages can be searched and posted: - * Reddit, X/Twitter, Telegram, Slack, Discord, HackerNews, etc. - * - * Workflows interact with channels through this contract — never with - * platform-specific code directly. - */ -/** A message to be posted on a channel */ -export interface ChannelMessage { - /** Plain text content */ - text: string; - /** Optional image URL or local path */ - image?: string; - /** Reply to a specific post (URL or platform-specific id) */ - inReplyTo?: string; - /** Platform-specific metadata (e.g. subreddit name for Reddit) */ - metadata?: Record; -} -/** A post discovered via channel search */ -export interface ChannelPost { - /** Post URL */ - url: string; - /** Post id (platform-specific) */ - id: string; - /** Post title (or first line for X) */ - title: string; - /** Post body */ - body: string; - /** Author username */ - author?: string; - /** Created timestamp (ISO) */ - createdAt?: string; - /** Engagement metrics */ - score?: number; - /** Reply/comment count */ - commentCount?: number; - /** Platform identifier */ - platform: string; - /** Platform-specific raw data */ - raw?: Record; -} -/** Channel search result wrapper */ -export interface ChannelSearchResult { - posts: ChannelPost[]; - /** Total found (may be larger than posts.length if paginated) */ - total: number; -} -/** Channel context provided by core */ -export interface ChannelContext { - /** Channel-specific auth/config from user settings */ - auth?: ChannelAuth; - /** Logger */ - log: (message: string) => void; - /** Dry-run mode — channels should not actually post */ - dryRun: boolean; -} -/** Auth blob — channel-specific shape */ -export interface ChannelAuth { - /** Auth method */ - method: 'browser' | 'api' | 'oauth' | 'none'; - /** Browser cookies (for browser auth) */ - cookies?: string; - /** API token (for api auth) */ - token?: string; - /** OAuth refresh token */ - refreshToken?: string; - /** Username on the platform */ - username?: string; - /** Platform-specific extra fields */ - extra?: Record; -} -/** - * Channel interface — implemented by channel plugins. - * Each channel knows how to search and post on its platform. - */ -export interface Channel { - /** Channel id (e.g. "reddit", "x", "telegram") */ - readonly id: string; - /** Display name */ - readonly name: string; - /** Search posts on this channel */ - search(query: string, ctx: ChannelContext, options?: { - maxResults?: number; - /** Platform-specific scope (e.g. subreddit for Reddit) */ - scope?: string[]; - }): Promise; - /** Post a message (or reply) on this channel */ - post(message: ChannelMessage, ctx: ChannelContext): Promise<{ - /** URL of the posted message */ - url: string; - /** Platform-specific id */ - id: string; - }>; - /** Check if the channel is properly authenticated */ - isAuthenticated(ctx: ChannelContext): Promise; - /** Optional: rate limiting hint (min seconds between posts) */ - readonly minDelaySeconds?: number; -} diff --git a/dist/plugin-sdk/channel.js b/dist/plugin-sdk/channel.js deleted file mode 100644 index f914df2b..00000000 --- a/dist/plugin-sdk/channel.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Channel contract — abstraction over messaging/social platforms. - * - * Channels are platforms where messages can be searched and posted: - * Reddit, X/Twitter, Telegram, Slack, Discord, HackerNews, etc. - * - * Workflows interact with channels through this contract — never with - * platform-specific code directly. - */ -export {}; diff --git a/dist/plugin-sdk/index.d.ts b/dist/plugin-sdk/index.d.ts deleted file mode 100644 index 92030b02..00000000 --- a/dist/plugin-sdk/index.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * RunCode Plugin SDK — public surface for plugins. - * - * Plugins import ONLY from '@blockrun/runcode/plugin-sdk' (or this barrel). - * They MUST NOT import from src/** of core or other plugins. - * - * Core stays plugin-agnostic: adding a plugin should never require editing core. - */ -export type { Plugin, PluginManifest, PluginContext, PluginCommand, PluginCommandHandler, } from './plugin.js'; -export type { Workflow, WorkflowStep, WorkflowStepContext, WorkflowStepResult, WorkflowResult, WorkflowConfig, ModelTier, ModelTierConfig, WorkflowStepStatus, OnboardingQuestion, } from './workflow.js'; -export { DEFAULT_MODEL_TIERS } from './workflow.js'; -export type { Channel, ChannelContext, ChannelMessage, ChannelPost, ChannelSearchResult, ChannelAuth, } from './channel.js'; -export type { WorkflowTracker, TrackedAction, WorkflowStats, } from './tracker.js'; -export type { SearchResult } from './search.js'; diff --git a/dist/plugin-sdk/index.js b/dist/plugin-sdk/index.js deleted file mode 100644 index d5a387b1..00000000 --- a/dist/plugin-sdk/index.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * RunCode Plugin SDK — public surface for plugins. - * - * Plugins import ONLY from '@blockrun/runcode/plugin-sdk' (or this barrel). - * They MUST NOT import from src/** of core or other plugins. - * - * Core stays plugin-agnostic: adding a plugin should never require editing core. - */ -export { DEFAULT_MODEL_TIERS } from './workflow.js'; diff --git a/dist/plugin-sdk/plugin.d.ts b/dist/plugin-sdk/plugin.d.ts deleted file mode 100644 index cba60829..00000000 --- a/dist/plugin-sdk/plugin.d.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Plugin contract — what every plugin must export. - * - * Plugins are discovered by their manifest file (plugin.json) and loaded - * dynamically. Core code never references plugins by name. - */ -import type { Workflow } from './workflow.js'; -import type { Channel } from './channel.js'; -/** Plugin manifest (plugin.json) */ -export interface PluginManifest { - /** Unique plugin id (e.g. "social", "trading") */ - id: string; - /** Display name */ - name: string; - /** Short description */ - description: string; - /** Semantic version */ - version: string; - /** Plugin type — what surfaces it provides */ - provides: PluginProvides; - /** Entry point (relative to manifest) */ - entry: string; - /** Author info */ - author?: string; - /** Homepage URL */ - homepage?: string; - /** License */ - license?: string; - /** Required runcode version (semver range) */ - runcodeVersion?: string; -} -export interface PluginProvides { - /** This plugin contributes one or more workflows (e.g. "social", "trading") */ - workflows?: string[]; - /** This plugin contributes channels (e.g. "reddit", "x", "telegram") */ - channels?: string[]; - /** This plugin contributes CLI commands */ - commands?: string[]; -} -/** Plugin entry point — exported as default from plugin's entry file */ -export interface Plugin { - /** Manifest (loaded from plugin.json — plugin doesn't need to repeat it) */ - manifest: PluginManifest; - /** Workflows this plugin provides (mapped by workflow id) */ - workflows?: Record Workflow>; - /** Channels this plugin provides (mapped by channel id) */ - channels?: Record Channel>; - /** Custom CLI commands */ - commands?: PluginCommand[]; - /** Called once when plugin is loaded (optional) */ - onLoad?: (ctx: PluginContext) => void | Promise; - /** Called when plugin is unloaded (optional) */ - onUnload?: () => void | Promise; -} -/** Context passed to plugin lifecycle hooks */ -export interface PluginContext { - /** RunCode version */ - runcodeVersion: string; - /** Plugin's own data directory (~/.blockrun/plugins//) */ - dataDir: string; - /** Path to plugin's installation directory */ - pluginDir: string; - /** Logger */ - log: (message: string) => void; -} -/** A CLI command contributed by a plugin */ -export interface PluginCommand { - /** Command name (e.g. "init", "run", "stats") */ - name: string; - /** Description shown in help */ - description: string; - /** Optional flags */ - options?: Array<{ - flag: string; - description: string; - }>; - /** Handler — receives parsed args and plugin context */ - handler: PluginCommandHandler; -} -export type PluginCommandHandler = (args: { - /** Positional arguments */ - positional: string[]; - /** Parsed flags */ - flags: Record; - /** Plugin context */ - ctx: PluginContext; -}) => Promise | void; diff --git a/dist/plugin-sdk/plugin.js b/dist/plugin-sdk/plugin.js deleted file mode 100644 index a58b9176..00000000 --- a/dist/plugin-sdk/plugin.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Plugin contract — what every plugin must export. - * - * Plugins are discovered by their manifest file (plugin.json) and loaded - * dynamically. Core code never references plugins by name. - */ -export {}; diff --git a/dist/plugin-sdk/search.d.ts b/dist/plugin-sdk/search.d.ts deleted file mode 100644 index 48f92674..00000000 --- a/dist/plugin-sdk/search.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Search result type — used by both web search (Exa/WebSearch) and channels. - */ -export interface SearchResult { - title: string; - url: string; - snippet: string; - source: string; - author?: string; - timestamp?: string; - score?: number; - commentCount?: number; -} diff --git a/dist/plugin-sdk/search.js b/dist/plugin-sdk/search.js deleted file mode 100644 index 3adf08ef..00000000 --- a/dist/plugin-sdk/search.js +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Search result type — used by both web search (Exa/WebSearch) and channels. - */ -export {}; diff --git a/dist/plugin-sdk/tracker.d.ts b/dist/plugin-sdk/tracker.d.ts deleted file mode 100644 index e09a1e37..00000000 --- a/dist/plugin-sdk/tracker.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Tracker contract — workflows record actions for dedup, stats, and history. - * Core provides a default implementation; plugins use it via WorkflowStepContext. - */ -export interface TrackedAction { - workflow: string; - action: string; - key: string; - metadata: Record; - costUsd: number; - createdAt: string; -} -export interface WorkflowStats { - totalRuns: number; - totalActions: number; - totalCostUsd: number; - todayActions: number; - todayCostUsd: number; - lastRun?: string; - byAction: Record; -} -export interface WorkflowTracker { - trackAction(action: string, key: string, metadata: Record, costUsd?: number): void; - isDuplicate(key: string): boolean; - getStats(): WorkflowStats; - getByAction(action: string): TrackedAction[]; -} diff --git a/dist/plugin-sdk/tracker.js b/dist/plugin-sdk/tracker.js deleted file mode 100644 index 8fd0f604..00000000 --- a/dist/plugin-sdk/tracker.js +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Tracker contract — workflows record actions for dedup, stats, and history. - * Core provides a default implementation; plugins use it via WorkflowStepContext. - */ -export {}; diff --git a/dist/plugin-sdk/workflow.d.ts b/dist/plugin-sdk/workflow.d.ts deleted file mode 100644 index ba81b29d..00000000 --- a/dist/plugin-sdk/workflow.d.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Workflow contract — public surface for plugins implementing workflows. - * - * A workflow is a multi-step AI process: search → filter → generate → execute → track. - * Plugins implement Workflow; core orchestrates execution and provides infrastructure. - */ -import type { SearchResult } from './search.js'; -import type { ChannelMessage } from './channel.js'; -/** Model selection tier — workflows pick tier per step, core resolves to actual model */ -export type ModelTier = 'free' | 'cheap' | 'premium' | 'none'; -/** Maps tier names to actual model identifiers */ -export interface ModelTierConfig { - free: string; - cheap: string; - premium: string; -} -export declare const DEFAULT_MODEL_TIERS: ModelTierConfig; -/** Context provided to each workflow step by core */ -export interface WorkflowStepContext { - /** Accumulated data from previous steps (mutable across steps) */ - data: Record; - /** Call an LLM at the specified tier */ - callModel: (tier: ModelTier, prompt: string, system?: string) => Promise; - /** Generate an image (DALL-E / Flux) */ - generateImage?: (prompt: string) => Promise; - /** Search the web (Exa neural / WebSearch fallback) */ - search: (query: string, options?: { - sources?: string[]; - maxResults?: number; - }) => Promise; - /** Send a message via a channel (e.g. reddit, x, telegram) */ - sendMessage?: (channelId: string, message: ChannelMessage) => Promise; - /** Log progress (visible to user) */ - log: (message: string) => void; - /** Track an action in the workflow's database */ - track: (action: string, metadata: Record) => Promise; - /** Check if a key was already processed (dedup) */ - isDuplicate: (key: string) => Promise; - /** Dry-run mode — skip side effects */ - dryRun: boolean; - /** Workflow config (typed by the workflow) */ - config: WorkflowConfig; -} -/** Result of executing one step */ -export interface WorkflowStepResult { - /** Data to merge into shared context for subsequent steps */ - data?: Record; - /** Human-readable summary of what this step did */ - summary?: string; - /** If true, abort the workflow */ - abort?: boolean; - /** Cost of this step in USD */ - cost?: number; -} -/** A single step in a workflow */ -export interface WorkflowStep { - /** Step name (used for tracking and display) */ - name: string; - /** Which model tier this step uses (or 'none', or 'dynamic' for runtime decision) */ - modelTier: ModelTier | 'dynamic'; - /** Execute this step */ - execute: (ctx: WorkflowStepContext) => Promise; - /** Skip this step in dry-run mode (e.g. posting) */ - skipInDryRun?: boolean; -} -/** Base workflow config — workflows extend this with their own fields */ -export interface WorkflowConfig { - /** Workflow id (matches plugin id) */ - name: string; - /** Model tier mapping */ - models: ModelTierConfig; - /** Optional schedule */ - schedule?: { - cron?: string; - dailyTime?: string; - budgetCapUsd?: number; - }; - /** Allow workflow-specific fields */ - [key: string]: unknown; -} -export interface WorkflowResult { - steps: Array<{ - name: string; - summary: string; - cost: number; - status?: WorkflowStepStatus; - }>; - totalCost: number; - itemsProcessed: number; - durationMs: number; - dryRun: boolean; -} -/** Normalized status for step rendering/reporting */ -export type WorkflowStepStatus = 'ok' | 'error' | 'aborted' | 'skipped'; -/** - * The Workflow interface plugins implement. - * Core's WorkflowRunner orchestrates execution of any Workflow. - */ -export interface Workflow { - /** Workflow id (e.g. "social", "trading") */ - readonly id: string; - /** Display name */ - readonly name: string; - /** Short description */ - readonly description: string; - /** Steps in execution order */ - readonly steps: WorkflowStep[]; - /** Default config (used when not yet configured) */ - defaultConfig(): WorkflowConfig; - /** Onboarding questions for first-time setup */ - readonly onboardingQuestions: OnboardingQuestion[]; - /** Build config from onboarding answers (may call cheap LLM to auto-generate) */ - buildConfigFromAnswers(answers: Record, llm: (prompt: string) => Promise): Promise; - /** Optional: lifecycle hook before workflow runs (e.g. to load state) */ - beforeRun?(config: WorkflowConfig): Promise; - /** Optional: lifecycle hook after workflow runs */ - afterRun?(result: WorkflowResult): Promise; -} -/** Question for interactive onboarding */ -export interface OnboardingQuestion { - id: string; - prompt: string; - type: 'text' | 'select' | 'multi-select'; - options?: string[]; - default?: string; -} diff --git a/dist/plugin-sdk/workflow.js b/dist/plugin-sdk/workflow.js deleted file mode 100644 index 4f90882b..00000000 --- a/dist/plugin-sdk/workflow.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Workflow contract — public surface for plugins implementing workflows. - * - * A workflow is a multi-step AI process: search → filter → generate → execute → track. - * Plugins implement Workflow; core orchestrates execution and provides infrastructure. - */ -export const DEFAULT_MODEL_TIERS = { - free: 'nvidia/nemotron-ultra-253b', - cheap: 'zai/glm-5.1', - premium: 'anthropic/claude-sonnet-4.6', -}; diff --git a/dist/plugins-bundled/social/index.d.ts b/dist/plugins-bundled/social/index.d.ts deleted file mode 100644 index 736b8b70..00000000 --- a/dist/plugins-bundled/social/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Social Workflow Plugin. - * - * IMPORTANT: This file ONLY imports from `../../plugin-sdk/`. - * It does NOT import from `src/agent/`, `src/commands/`, `src/social/`, etc. - * This is the boundary that keeps plugins decoupled from core internals. - */ -import type { Plugin } from '../../plugin-sdk/index.js'; -declare const plugin: Plugin; -export default plugin; diff --git a/dist/plugins-bundled/social/index.js b/dist/plugins-bundled/social/index.js deleted file mode 100644 index e838aa53..00000000 --- a/dist/plugins-bundled/social/index.js +++ /dev/null @@ -1,363 +0,0 @@ -/** - * Social Workflow Plugin. - * - * IMPORTANT: This file ONLY imports from `../../plugin-sdk/`. - * It does NOT import from `src/agent/`, `src/commands/`, `src/social/`, etc. - * This is the boundary that keeps plugins decoupled from core internals. - */ -import { DEFAULT_MODEL_TIERS } from '../../plugin-sdk/index.js'; -import { DEFAULT_REPLY_STYLE } from './types.js'; -import { FILTER_SYSTEM, LEAD_SCORE_SYSTEM, buildReplyPrompt, buildKeywordPrompt, buildSubredditPrompt, } from './prompts.js'; -// ─── Workflow Implementation ────────────────────────────────────────────── -const socialWorkflow = { - id: 'social', - name: 'Social Growth', - description: 'AI-powered social engagement on Reddit/X', - defaultConfig() { - return { - name: 'social', - models: { ...DEFAULT_MODEL_TIERS }, - products: [], - platforms: {}, - replyStyle: { ...DEFAULT_REPLY_STYLE }, - targetUsers: '', - }; - }, - onboardingQuestions: [ - { - id: 'product', - prompt: "What's your product? (name + one-line description)", - type: 'text', - }, - { - id: 'targetUsers', - prompt: 'Who are your target users? (be specific)', - type: 'text', - }, - { - id: 'platform', - prompt: 'Which platforms?', - type: 'select', - options: ['X/Twitter', 'Reddit', 'Both'], - default: 'Both', - }, - { - id: 'handle', - prompt: "What's your social media handle/username?", - type: 'text', - }, - ], - async buildConfigFromAnswers(answers, llm) { - const [productName, ...descParts] = (answers.product || '').split('—').map(s => s.trim()); - const productDesc = descParts.join(' — ') || productName; - const targetUsers = answers.targetUsers || ''; - const platform = answers.platform || 'Both'; - const handle = answers.handle || ''; - // Auto-generate keywords using LLM - let keywords = []; - let subreddits = []; - try { - const kwResponse = await llm(buildKeywordPrompt(productName, productDesc, targetUsers)); - const parsed = JSON.parse(kwResponse.replace(/```json?\n?/g, '').replace(/```/g, '').trim()); - if (Array.isArray(parsed)) - keywords = parsed; - } - catch { /* use empty */ } - if (platform === 'Reddit' || platform === 'Both') { - try { - const srResponse = await llm(buildSubredditPrompt(productName, productDesc, targetUsers)); - const parsed = JSON.parse(srResponse.replace(/```json?\n?/g, '').replace(/```/g, '').trim()); - if (Array.isArray(parsed)) - subreddits = parsed; - } - catch { /* use empty */ } - } - const config = { - name: 'social', - models: { ...DEFAULT_MODEL_TIERS }, - products: [{ - name: productName, - description: productDesc, - keywords: keywords.slice(0, 10), - }], - platforms: {}, - replyStyle: { ...DEFAULT_REPLY_STYLE }, - targetUsers, - }; - if (platform === 'X/Twitter' || platform === 'Both') { - config.platforms.x = { - username: handle.startsWith('@') ? handle : `@${handle}`, - dailyTarget: 20, - minDelaySeconds: 300, - searchQueries: keywords.slice(0, 10), - }; - } - if (platform === 'Reddit' || platform === 'Both') { - config.platforms.reddit = { - username: handle.replace('@', ''), - dailyTarget: 10, - minDelaySeconds: 600, - subreddits: subreddits.slice(0, 8), - }; - } - return config; - }, - steps: [ - { - name: 'search', - modelTier: 'none', - execute: searchStep, - }, - { - name: 'filter', - modelTier: 'cheap', - execute: filterStep, - }, - { - name: 'score', - modelTier: 'cheap', - execute: scoreStep, - }, - { - name: 'draft', - modelTier: 'dynamic', - execute: draftStep, - }, - { - name: 'preview', - modelTier: 'none', - execute: previewStep, - }, - { - name: 'post', - modelTier: 'none', - execute: postStep, - skipInDryRun: true, - }, - { - name: 'track', - modelTier: 'none', - execute: trackStep, - }, - ], -}; -// ─── Step Implementations ───────────────────────────────────────────────── -async function searchStep(ctx) { - const sc = ctx.config; - const allResults = []; - // Search using configured queries - const queries = sc.platforms?.x?.searchQueries ?? sc.products?.[0]?.keywords ?? []; - for (const query of queries.slice(0, 5)) { - const results = await ctx.search(query, { - maxResults: 5, - sources: ['reddit', 'x', 'web'], - }); - allResults.push(...results); - } - if (allResults.length === 0) { - return { summary: 'No posts found (search returned empty — channel plugins may not be installed)', abort: true }; - } - // Dedup by URL - const seen = new Set(); - const unique = allResults.filter(r => { - if (seen.has(r.url)) - return false; - seen.add(r.url); - return true; - }); - return { - data: { searchResults: unique, itemCount: unique.length }, - summary: `Found ${unique.length} posts`, - }; -} -async function filterStep(ctx) { - const results = (ctx.data.searchResults ?? []); - const sc = ctx.config; - const product = sc.products?.[0]; - if (!product) - return { summary: 'No product configured', abort: true }; - const relevant = []; - for (const post of results) { - if (await ctx.isDuplicate(post.url)) - continue; - const prompt = `Product: ${product.name} — ${product.description}\n\nPost:\nTitle: ${post.title}\nBody: ${post.snippet}\n\nIs this post relevant?`; - try { - const response = await ctx.callModel('cheap', prompt, FILTER_SYSTEM); - const parsed = JSON.parse(response.replace(/```json?\n?/g, '').replace(/```/g, '').trim()); - if (parsed.relevant && parsed.score >= 5) { - relevant.push({ ...post, relevanceScore: parsed.score }); - } - } - catch { /* skip parse failures */ } - } - if (relevant.length === 0) { - return { summary: 'No relevant posts after filtering', abort: true }; - } - relevant.sort((a, b) => b.relevanceScore - a.relevanceScore); - return { - data: { filteredPosts: relevant, itemCount: relevant.length }, - summary: `${relevant.length}/${results.length} posts are relevant`, - }; -} -async function scoreStep(ctx) { - const posts = (ctx.data.filteredPosts ?? []); - const sc = ctx.config; - const product = sc.products[0]; - const scored = []; - for (const post of posts) { - const prompt = `Product: ${product.name} — ${product.description}\n\nPost:\nTitle: ${post.title}\nBody: ${post.snippet}\nAuthor: ${post.author ?? 'unknown'}`; - try { - const response = await ctx.callModel('cheap', prompt, LEAD_SCORE_SYSTEM); - const parsed = JSON.parse(response.replace(/```json?\n?/g, '').replace(/```/g, '').trim()); - scored.push({ - title: post.title, - url: post.url, - snippet: post.snippet, - platform: post.source.includes('reddit') ? 'reddit' : 'x', - author: post.author, - timestamp: post.timestamp, - commentCount: post.commentCount, - relevanceScore: post.relevanceScore, - leadScore: parsed.leadScore ?? 5, - urgency: parsed.urgency ?? 'medium', - painPoints: parsed.painPoints ?? [], - }); - } - catch { - scored.push({ - title: post.title, - url: post.url, - snippet: post.snippet, - platform: post.source.includes('reddit') ? 'reddit' : 'x', - relevanceScore: post.relevanceScore, - leadScore: 5, - urgency: 'medium', - painPoints: [], - }); - } - } - // Track high-score leads - for (const s of scored.filter(s => s.leadScore >= 7)) { - await ctx.track('lead', { - url: s.url, - title: s.title, - leadScore: s.leadScore, - urgency: s.urgency, - painPoints: s.painPoints, - platform: s.platform, - }); - } - return { - data: { scoredPosts: scored }, - summary: `${scored.filter(s => s.leadScore >= 7).length} high-value leads, ${scored.length} total`, - }; -} -async function draftStep(ctx) { - const posts = (ctx.data.scoredPosts ?? []); - const sc = ctx.config; - const product = sc.products[0]; - const drafts = []; - for (const post of posts) { - const tier = post.leadScore >= 7 ? 'premium' : 'cheap'; - const maxLength = post.platform === 'reddit' ? sc.replyStyle.maxLengthReddit : sc.replyStyle.maxLengthX; - const prompt = buildReplyPrompt({ title: post.title, body: post.snippet, platform: post.platform }, { name: product.name, description: product.description }, { tone: sc.replyStyle.tone, maxLength, rules: sc.replyStyle.rules }); - try { - const text = await ctx.callModel(tier, prompt); - drafts.push({ - post, - text: text.trim(), - model: tier, - tier, - estimatedCost: 0, // Cost tracked at runner level - }); - } - catch (err) { - ctx.log(`Failed to draft reply for ${post.url}: ${err.message}`); - } - } - return { - data: { drafts, itemCount: drafts.length }, - summary: `${drafts.length} draft replies generated`, - }; -} -async function previewStep(ctx) { - const drafts = (ctx.data.drafts ?? []); - if (drafts.length === 0) - return { summary: 'No drafts to preview' }; - const high = drafts.filter(d => d.post.leadScore >= 7); - const medium = drafts.filter(d => d.post.leadScore < 7); - ctx.log('\n' + '═'.repeat(50)); - ctx.log('DRAFT REPLIES'); - ctx.log('═'.repeat(50)); - if (high.length > 0) { - ctx.log(`\n🎯 HIGH VALUE (${high.length} posts)`); - for (const d of high) { - ctx.log(`\n ${d.post.platform}: "${d.post.title.slice(0, 60)}"`); - ctx.log(` ⭐ Lead: ${d.post.leadScore}/10 | Tier: ${d.tier}`); - ctx.log(` Reply: "${d.text.slice(0, 120)}..."`); - } - } - if (medium.length > 0) { - ctx.log(`\n📋 MEDIUM (${medium.length} posts)`); - for (const d of medium) { - ctx.log(` ${d.post.platform}: "${d.post.title.slice(0, 50)}" | Lead: ${d.post.leadScore}/10`); - } - } - ctx.log('═'.repeat(50)); - return { summary: `${high.length} high + ${medium.length} medium drafts` }; -} -async function postStep(ctx) { - const drafts = (ctx.data.drafts ?? []); - let posted = 0; - for (const draft of drafts) { - await ctx.track('reply', { - url: draft.post.url, - platform: draft.post.platform, - tier: draft.tier, - leadScore: draft.post.leadScore, - replyLength: draft.text.length, - }); - // Post via channel if available - if (ctx.sendMessage) { - try { - await ctx.sendMessage(draft.post.platform, { - text: draft.text, - inReplyTo: draft.post.url, - }); - posted++; - } - catch (err) { - ctx.log(`Failed to post to ${draft.post.platform}: ${err.message}`); - } - } - else { - ctx.log(`✓ Would post to ${draft.post.platform}: ${draft.post.url}`); - posted++; - } - } - return { - data: { postedCount: posted }, - summary: `${posted} replies ${ctx.dryRun ? 'drafted' : 'posted'}`, - }; -} -async function trackStep(ctx) { - const drafts = (ctx.data.drafts ?? []); - return { - summary: `${drafts.length} replies tracked`, - }; -} -// ─── Plugin Export ──────────────────────────────────────────────────────── -const plugin = { - manifest: { - id: 'social', - name: 'Social Growth', - description: 'AI-powered social engagement on Reddit/X', - version: '1.0.0', - provides: { workflows: ['social'] }, - entry: 'index.js', - }, - workflows: { - social: () => socialWorkflow, - }, -}; -export default plugin; diff --git a/dist/plugins-bundled/social/plugin.json b/dist/plugins-bundled/social/plugin.json deleted file mode 100644 index 9f8a8c49..00000000 --- a/dist/plugins-bundled/social/plugin.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "id": "social", - "name": "Social Growth", - "description": "AI-powered social engagement — find relevant posts on Reddit/X, generate quality replies with multi-model routing", - "version": "1.0.0", - "provides": { - "workflows": ["social"] - }, - "entry": "index.js", - "author": "BlockRun", - "homepage": "https://github.com/BlockRunAI/runcode", - "license": "Apache-2.0", - "runcodeVersion": ">=2.6.0" -} diff --git a/dist/plugins-bundled/social/prompts.d.ts b/dist/plugins-bundled/social/prompts.d.ts deleted file mode 100644 index 21f543a6..00000000 --- a/dist/plugins-bundled/social/prompts.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Social workflow prompts. - */ -export declare const FILTER_SYSTEM = "You are a social media relevance filter. Given a post and a product description, determine if the post is relevant for engagement.\n\nRespond with a JSON object:\n{\"relevant\": true/false, \"score\": 1-10, \"reason\": \"one line explanation\"}\n\nScore guide:\n- 9-10: Directly asking for what the product does, or complaining about the exact problem it solves\n- 7-8: Discussing the product's domain, comparing alternatives\n- 5-6: Tangentially related, could be relevant with a creative angle\n- 1-4: Not relevant enough to engage\n\nOnly mark as relevant if score >= 5."; -export declare const LEAD_SCORE_SYSTEM = "You are a lead qualification analyst. Given a social media post and product info, score the poster as a potential customer.\n\nRespond with a JSON object:\n{\"leadScore\": 1-10, \"urgency\": \"high\"|\"medium\"|\"low\", \"painPoints\": [\"point1\", \"point2\"], \"businessType\": \"description\"}\n\nLead score guide:\n- 9-10: Actively looking for a solution, has budget, decision maker\n- 7-8: Has the problem, open to solutions\n- 5-6: In the right space but not actively looking\n- 1-4: Low intent or wrong audience"; -export declare function buildReplyPrompt(post: { - title: string; - body: string; - platform: string; -}, product: { - name: string; - description: string; -}, style: { - tone: string; - maxLength: number; - rules: string[]; -}): string; -export declare function buildKeywordPrompt(productName: string, productDesc: string, targetUsers: string): string; -export declare function buildSubredditPrompt(productName: string, productDesc: string, targetUsers: string): string; diff --git a/dist/plugins-bundled/social/prompts.js b/dist/plugins-bundled/social/prompts.js deleted file mode 100644 index 513d60cf..00000000 --- a/dist/plugins-bundled/social/prompts.js +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Social workflow prompts. - */ -export const FILTER_SYSTEM = `You are a social media relevance filter. Given a post and a product description, determine if the post is relevant for engagement. - -Respond with a JSON object: -{"relevant": true/false, "score": 1-10, "reason": "one line explanation"} - -Score guide: -- 9-10: Directly asking for what the product does, or complaining about the exact problem it solves -- 7-8: Discussing the product's domain, comparing alternatives -- 5-6: Tangentially related, could be relevant with a creative angle -- 1-4: Not relevant enough to engage - -Only mark as relevant if score >= 5.`; -export const LEAD_SCORE_SYSTEM = `You are a lead qualification analyst. Given a social media post and product info, score the poster as a potential customer. - -Respond with a JSON object: -{"leadScore": 1-10, "urgency": "high"|"medium"|"low", "painPoints": ["point1", "point2"], "businessType": "description"} - -Lead score guide: -- 9-10: Actively looking for a solution, has budget, decision maker -- 7-8: Has the problem, open to solutions -- 5-6: In the right space but not actively looking -- 1-4: Low intent or wrong audience`; -export function buildReplyPrompt(post, product, style) { - return `Write a reply to this ${post.platform} post. You are NOT writing an ad. You are a real user sharing your experience. - -POST: -Title: ${post.title} -Body: ${post.body} - -YOUR PRODUCT (mention naturally, not as an ad): -${product.name}: ${product.description} - -REPLY STYLE: -- Tone: ${style.tone} -- Max length: ${style.maxLength} characters -- Rules: -${style.rules.map(r => ` - ${r}`).join('\n')} - -Write ONLY the reply text. No quotation marks, no meta-commentary, no "Here's my reply:".`; -} -export function buildKeywordPrompt(productName, productDesc, targetUsers) { - return `Given this product and target audience, generate 10 search queries that would find relevant social media posts to engage with. - -Product: ${productName} -Description: ${productDesc} -Target users: ${targetUsers} - -Return a JSON array of 10 search queries. Mix specific and broad queries. -Example: ["claude code rate limit alternative", "ai coding agent comparison 2026", ...] - -Return ONLY the JSON array.`; -} -export function buildSubredditPrompt(productName, productDesc, targetUsers) { - return `Given this product and target audience, suggest 5-8 subreddits where the target users hang out. - -Product: ${productName} -Description: ${productDesc} -Target users: ${targetUsers} - -Return a JSON array of subreddit names (without r/ prefix). -Example: ["programming", "MachineLearning", "LocalLLaMA", ...] - -Return ONLY the JSON array.`; -} diff --git a/dist/plugins-bundled/social/types.d.ts b/dist/plugins-bundled/social/types.d.ts deleted file mode 100644 index 3252bf08..00000000 --- a/dist/plugins-bundled/social/types.d.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Social plugin types — extends WorkflowConfig with social-specific fields. - */ -import type { WorkflowConfig } from '../../plugin-sdk/index.js'; -export interface SocialProduct { - name: string; - description: string; - keywords: string[]; - url?: string; -} -export interface SocialPlatformConfig { - username: string; - dailyTarget: number; - minDelaySeconds: number; -} -export interface SocialReplyStyle { - tone: string; - maxLengthReddit: number; - maxLengthX: number; - rules: string[]; - imageForHighValue: boolean; -} -export interface SocialConfig extends WorkflowConfig { - name: 'social'; - products: SocialProduct[]; - platforms: { - reddit?: SocialPlatformConfig & { - subreddits: string[]; - }; - x?: SocialPlatformConfig & { - searchQueries: string[]; - }; - }; - replyStyle: SocialReplyStyle; - targetUsers: string; -} -export interface ScoredPost { - title: string; - url: string; - snippet: string; - platform: 'reddit' | 'x'; - author?: string; - timestamp?: string; - commentCount?: number; - relevanceScore: number; - leadScore: number; - urgency: 'high' | 'medium' | 'low'; - painPoints: string[]; -} -export interface DraftReply { - post: ScoredPost; - text: string; - model: string; - tier: 'cheap' | 'premium'; - estimatedCost: number; - imageUrl?: string; -} -export declare const DEFAULT_REPLY_STYLE: SocialReplyStyle; diff --git a/dist/plugins-bundled/social/types.js b/dist/plugins-bundled/social/types.js deleted file mode 100644 index 62a803fd..00000000 --- a/dist/plugins-bundled/social/types.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Social plugin types — extends WorkflowConfig with social-specific fields. - */ -export const DEFAULT_REPLY_STYLE = { - tone: 'knowledgeable developer sharing experience', - maxLengthReddit: 400, - maxLengthX: 260, - rules: [ - 'Lead with a genuine insight or question about the post', - 'Mention product naturally as "what I use/built" — not as an ad', - 'Never start with "Great post!" or "I agree!"', - 'Sound like a real developer who has faced this problem', - 'If post is not directly relevant, skip — do not force a mention', - ], - imageForHighValue: true, -}; diff --git a/dist/plugins/registry.d.ts b/dist/plugins/registry.d.ts deleted file mode 100644 index 49fec5c5..00000000 --- a/dist/plugins/registry.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Plugin Registry — discovers, loads, and manages plugins. - * - * Core stays plugin-agnostic: it knows about the *interface*, not specific plugins. - * Plugins are discovered from: - * 1. Bundled: /plugins-bundled/* (ships with runcode) - * 2. User: ~/.blockrun/plugins/* (installed via `runcode plugin install`) - * 3. Local dev: $RUNCODE_PLUGINS_DIR/* (env var for development) - */ -import type { Plugin, PluginManifest } from '../plugin-sdk/plugin.js'; -export declare function getBundledPluginsDir(): string; -export declare function getUserPluginsDir(): string; -interface LoadedPlugin { - manifest: PluginManifest; - pluginDir: string; - plugin: Plugin; -} -/** Find all plugin manifests across discovery paths */ -export declare function discoverPluginManifests(): Array<{ - manifest: PluginManifest; - dir: string; -}>; -/** Load a single plugin from its directory */ -export declare function loadPlugin(manifest: PluginManifest, pluginDir: string): Promise; -/** Discover and load all plugins. Returns the loaded registry. */ -export declare function loadAllPlugins(): Promise>; -export declare function getPlugin(id: string): LoadedPlugin | undefined; -export declare function listPlugins(): LoadedPlugin[]; -/** Get all plugins that provide workflows */ -export declare function listWorkflowPlugins(): LoadedPlugin[]; -/** Get all plugins that provide channels */ -export declare function listChannelPlugins(): LoadedPlugin[]; -export {}; diff --git a/dist/plugins/registry.js b/dist/plugins/registry.js deleted file mode 100644 index 4af8d478..00000000 --- a/dist/plugins/registry.js +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Plugin Registry — discovers, loads, and manages plugins. - * - * Core stays plugin-agnostic: it knows about the *interface*, not specific plugins. - * Plugins are discovered from: - * 1. Bundled: /plugins-bundled/* (ships with runcode) - * 2. User: ~/.blockrun/plugins/* (installed via `runcode plugin install`) - * 3. Local dev: $RUNCODE_PLUGINS_DIR/* (env var for development) - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import os from 'node:os'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// ─── Plugin Discovery Paths ─────────────────────────────────────────────── -export function getBundledPluginsDir() { - // From dist/plugins/registry.js, plugins-bundled is at ../plugins-bundled - // (built from src/plugins-bundled by tsc + copy-plugin-assets) - return path.resolve(__dirname, '..', 'plugins-bundled'); -} -export function getUserPluginsDir() { - return path.join(os.homedir(), '.blockrun', 'plugins'); -} -function getDevPluginsDir() { - return process.env.RUNCODE_PLUGINS_DIR || null; -} -const loaded = new Map(); -// ─── Discovery ──────────────────────────────────────────────────────────── -/** Find all plugin manifests across discovery paths */ -export function discoverPluginManifests() { - const found = []; - const seen = new Set(); - const searchPaths = []; - const dev = getDevPluginsDir(); - if (dev && fs.existsSync(dev)) - searchPaths.push(dev); - const user = getUserPluginsDir(); - if (fs.existsSync(user)) - searchPaths.push(user); - const bundled = getBundledPluginsDir(); - if (fs.existsSync(bundled)) - searchPaths.push(bundled); - for (const base of searchPaths) { - let entries = []; - try { - entries = fs.readdirSync(base); - } - catch { - continue; - } - for (const entry of entries) { - const pluginDir = path.join(base, entry); - const manifestPath = path.join(pluginDir, 'plugin.json'); - if (!fs.existsSync(manifestPath)) - continue; - try { - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); - if (!manifest.id || seen.has(manifest.id)) - continue; - seen.add(manifest.id); - found.push({ manifest, dir: pluginDir }); - } - catch { - // Invalid manifest — skip - } - } - } - return found; -} -// ─── Loading ────────────────────────────────────────────────────────────── -/** Load a single plugin from its directory */ -export async function loadPlugin(manifest, pluginDir) { - // Resolve entry path. Plugin's entry should point to a built JS file. - // For bundled plugins, entry might be "dist/index.js" but we ship from src/. - // Check both — prefer dist if present. - let entryPath = path.join(pluginDir, manifest.entry); - if (!fs.existsSync(entryPath)) { - // Try .js extension swap (TS source vs built) - const jsEntry = entryPath.replace(/\.ts$/, '.js'); - if (fs.existsSync(jsEntry)) - entryPath = jsEntry; - } - if (!fs.existsSync(entryPath)) { - process.stderr.write(`[plugin:${manifest.id}] entry not found: ${manifest.entry}\n`); - return null; - } - try { - // Dynamic import — works for both ESM and CJS - const mod = await import(entryPath); - const plugin = mod.default ?? mod.plugin ?? mod; - if (!plugin || typeof plugin !== 'object') { - process.stderr.write(`[plugin:${manifest.id}] invalid plugin export\n`); - return null; - } - // Inject manifest if plugin didn't include it - plugin.manifest = manifest; - return plugin; - } - catch (err) { - process.stderr.write(`[plugin:${manifest.id}] load failed: ${err.message}\n`); - return null; - } -} -/** Discover and load all plugins. Returns the loaded registry. */ -export async function loadAllPlugins() { - if (loaded.size > 0) - return loaded; - const manifests = discoverPluginManifests(); - for (const { manifest, dir } of manifests) { - const plugin = await loadPlugin(manifest, dir); - if (plugin) { - loaded.set(manifest.id, { manifest, pluginDir: dir, plugin }); - // Lifecycle hook - if (plugin.onLoad) { - try { - await plugin.onLoad({ - runcodeVersion: getRuncodeVersion(), - dataDir: path.join(os.homedir(), '.blockrun', 'plugins', manifest.id), - pluginDir: dir, - log: (msg) => process.stderr.write(`[${manifest.id}] ${msg}\n`), - }); - } - catch (err) { - process.stderr.write(`[plugin:${manifest.id}] onLoad failed: ${err.message}\n`); - } - } - } - } - return loaded; -} -// ─── Query API ──────────────────────────────────────────────────────────── -export function getPlugin(id) { - return loaded.get(id); -} -export function listPlugins() { - return Array.from(loaded.values()); -} -/** Get all plugins that provide workflows */ -export function listWorkflowPlugins() { - return listPlugins().filter(p => p.plugin.workflows && Object.keys(p.plugin.workflows).length > 0); -} -/** Get all plugins that provide channels */ -export function listChannelPlugins() { - return listPlugins().filter(p => p.plugin.channels && Object.keys(p.plugin.channels).length > 0); -} -// ─── Helpers ────────────────────────────────────────────────────────────── -function getRuncodeVersion() { - try { - const pkgPath = path.resolve(__dirname, '..', '..', 'package.json'); - return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version || '0.0.0'; - } - catch { - return '0.0.0'; - } -} diff --git a/dist/plugins/runner.d.ts b/dist/plugins/runner.d.ts deleted file mode 100644 index 96650b68..00000000 --- a/dist/plugins/runner.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Workflow Runner — orchestrates execution of any Workflow. - * - * Plugin-agnostic: takes a Workflow + config, runs steps, handles - * model dispatch, dedup, tracking, dry-run. - */ -import { ModelClient } from '../agent/llm.js'; -import type { Workflow, WorkflowConfig, WorkflowResult } from '../plugin-sdk/workflow.js'; -import type { WorkflowStats, TrackedAction } from '../plugin-sdk/tracker.js'; -export declare function loadWorkflowConfig(workflowId: string): WorkflowConfig | null; -export declare function saveWorkflowConfig(workflowId: string, config: WorkflowConfig): void; -interface TrackerEntry extends TrackedAction { -} -export declare function getStats(workflow: string): WorkflowStats; -export declare function getByAction(workflow: string, action: string): TrackerEntry[]; -export declare function runWorkflow(workflow: Workflow, config: WorkflowConfig, client: ModelClient, options?: { - dryRun?: boolean; -}): Promise; -export declare function formatWorkflowResult(workflow: Workflow, result: WorkflowResult): string; -export declare function formatWorkflowStats(workflow: Workflow, stats: WorkflowStats): string; -export {}; diff --git a/dist/plugins/runner.js b/dist/plugins/runner.js deleted file mode 100644 index 1cbbff29..00000000 --- a/dist/plugins/runner.js +++ /dev/null @@ -1,470 +0,0 @@ -/** - * Workflow Runner — orchestrates execution of any Workflow. - * - * Plugin-agnostic: takes a Workflow + config, runs steps, handles - * model dispatch, dedup, tracking, dry-run. - */ -import path from 'node:path'; -import os from 'node:os'; -import fs from 'node:fs'; -import { estimateCost } from '../pricing.js'; -import { USER_AGENT } from '../config.js'; -import { DEFAULT_MODEL_TIERS } from '../plugin-sdk/workflow.js'; -// ─── Storage ────────────────────────────────────────────────────────────── -const WORKFLOW_DIR = path.join(os.homedir(), '.blockrun', 'workflows'); -function ensureDir() { - fs.mkdirSync(WORKFLOW_DIR, { recursive: true }); -} -function getDbPath(workflow) { - return path.join(WORKFLOW_DIR, `${workflow}.jsonl`); -} -function getConfigPath(workflow) { - return path.join(WORKFLOW_DIR, `${workflow}.config.json`); -} -// ─── Config Persistence ─────────────────────────────────────────────────── -export function loadWorkflowConfig(workflowId) { - try { - const p = getConfigPath(workflowId); - if (fs.existsSync(p)) { - const raw = JSON.parse(fs.readFileSync(p, 'utf-8')); - raw.models = { ...DEFAULT_MODEL_TIERS, ...raw.models }; - raw.name = workflowId; - return raw; - } - } - catch { /* corrupt */ } - return null; -} -export function saveWorkflowConfig(workflowId, config) { - ensureDir(); - fs.writeFileSync(getConfigPath(workflowId), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }); -} -function trackAction(workflow, action, key, metadata = {}, costUsd = 0) { - ensureDir(); - const entry = { - workflow, - action, - key, - metadata, - costUsd, - createdAt: new Date().toISOString(), - }; - fs.appendFileSync(getDbPath(workflow), JSON.stringify(entry) + '\n'); -} -function isDuplicate(workflow, key) { - const dbPath = getDbPath(workflow); - if (!fs.existsSync(dbPath)) - return false; - try { - const lines = fs.readFileSync(dbPath, 'utf-8').split('\n').filter(Boolean); - for (const line of lines) { - try { - const entry = JSON.parse(line); - if (entry.key === key) - return true; - } - catch { /* skip */ } - } - } - catch { /* no db */ } - return false; -} -export function getStats(workflow) { - const stats = { - totalRuns: 0, - totalActions: 0, - totalCostUsd: 0, - todayActions: 0, - todayCostUsd: 0, - byAction: {}, - }; - const dbPath = getDbPath(workflow); - if (!fs.existsSync(dbPath)) - return stats; - const today = new Date().toISOString().slice(0, 10); - try { - const lines = fs.readFileSync(dbPath, 'utf-8').split('\n').filter(Boolean); - for (const line of lines) { - try { - const entry = JSON.parse(line); - stats.totalActions++; - stats.totalCostUsd += entry.costUsd; - stats.byAction[entry.action] = (stats.byAction[entry.action] || 0) + 1; - if (entry.action === 'run_start') - stats.totalRuns++; - if (entry.createdAt.startsWith(today)) { - stats.todayActions++; - stats.todayCostUsd += entry.costUsd; - } - stats.lastRun = entry.createdAt; - } - catch { /* skip */ } - } - } - catch { /* no db */ } - return stats; -} -export function getByAction(workflow, action) { - const dbPath = getDbPath(workflow); - if (!fs.existsSync(dbPath)) - return []; - try { - const lines = fs.readFileSync(dbPath, 'utf-8').split('\n').filter(Boolean); - return lines.map(l => { try { - return JSON.parse(l); - } - catch { - return null; - } }) - .filter((e) => e !== null && e.action === action); - } - catch { - return []; - } -} -// ─── Model Tier Resolution ──────────────────────────────────────────────── -function resolveModel(tier, tiers) { - switch (tier) { - case 'free': return tiers.free; - case 'cheap': return tiers.cheap; - case 'premium': return tiers.premium; - case 'none': return null; - } -} -// ─── Channel Search Adapter ─────────────────────────────────────────────── -import { listChannelPlugins } from './registry.js'; -/** Default web search fallback using DuckDuckGo HTML */ -async function defaultWebSearch(query, options) { - const maxResults = Math.min(Math.max(options?.maxResults ?? 8, 1), 20); - const domainHints = (options?.sources ?? []) - .map(sourceToDomainHint) - .filter((domain) => Boolean(domain)); - const scopedQueries = Array.from(new Set([ - ...domainHints.map((domain) => `${query} site:${domain}`), - query, - ])).slice(0, 3); - const merged = []; - const seenUrls = new Set(); - for (const scoped of scopedQueries) { - const results = await searchDuckDuckGo(scoped, maxResults); - for (const result of results) { - if (seenUrls.has(result.url)) - continue; - seenUrls.add(result.url); - merged.push(result); - if (merged.length >= maxResults) - return merged; - } - } - if (merged.length === 0 && scopedQueries[scopedQueries.length - 1] !== query) { - return searchDuckDuckGo(query, maxResults); - } - return merged; -} -function sourceToDomainHint(source) { - const normalized = source.toLowerCase(); - if (normalized === 'reddit') - return 'reddit.com'; - if (normalized === 'x' || normalized === 'twitter') - return 'x.com'; - if (normalized === 'web') - return null; - return null; -} -async function searchDuckDuckGo(query, maxResults) { - try { - const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 15_000); - const response = await fetch(url, { - signal: controller.signal, - headers: { 'User-Agent': USER_AGENT }, - }); - clearTimeout(timer); - if (!response.ok) - return []; - const html = await response.text(); - return parseDuckDuckGoResults(html, maxResults); - } - catch { - return []; - } -} -function parseDuckDuckGoResults(html, maxResults) { - const results = []; - const linkRegex = /]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi; - const snippetRegex = /]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi; - let links = [...html.matchAll(linkRegex)]; - const snippets = [...html.matchAll(snippetRegex)]; - if (links.length === 0) { - const fallbackLink = /]*class="[^"]*result[^"]*"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi; - links = [...html.matchAll(fallbackLink)]; - } - for (let i = 0; i < Math.min(links.length, maxResults); i++) { - const link = links[i]; - const snippet = snippets[i]; - const decodedUrl = decodeDuckDuckGoUrl(link[1] ?? ''); - if (!decodedUrl || decodedUrl.startsWith('/') || decodedUrl.includes('duckduckgo.com')) - continue; - results.push({ - title: stripHtml(link[2] ?? '').trim(), - url: decodedUrl, - snippet: stripHtml(snippet?.[1] ?? '').trim(), - source: inferSource(decodedUrl), - }); - } - return results; -} -function decodeDuckDuckGoUrl(url) { - const uddg = url.match(/[?&]uddg=([^&]+)/); - if (uddg?.[1]) { - try { - return decodeURIComponent(uddg[1]); - } - catch { - return url; - } - } - return url; -} -function inferSource(url) { - const lower = url.toLowerCase(); - if (lower.includes('reddit.com')) - return 'reddit'; - if (lower.includes('x.com') || lower.includes('twitter.com')) - return 'x'; - return 'web'; -} -function stripHtml(input) { - return input - .replace(/<[^>]+>/g, '') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, '\'') - .replace(/ /g, ' ') - .replace(/\s+/g, ' '); -} -/** Resolve channel by id and call its search method */ -async function searchViaChannel(channelId, query, options) { - const channelPlugins = listChannelPlugins(); - for (const cp of channelPlugins) { - if (cp.plugin.channels?.[channelId]) { - const channel = cp.plugin.channels[channelId](); - try { - const result = await channel.search(query, { - log: (msg) => process.stderr.write(`[${channelId}] ${msg}\n`), - dryRun: false, - }, { maxResults: options?.maxResults }); - return result.posts.map(p => ({ - title: p.title, - url: p.url, - snippet: p.body, - source: p.platform, - author: p.author, - timestamp: p.createdAt, - score: p.score, - commentCount: p.commentCount, - })); - } - catch (err) { - process.stderr.write(`[${channelId}] search failed: ${err.message}\n`); - } - } - } - return []; -} -// ─── Workflow Runner ────────────────────────────────────────────────────── -export async function runWorkflow(workflow, config, client, options = {}) { - const dryRun = options.dryRun ?? false; - const start = Date.now(); - const stepResults = []; - let totalCost = 0; - let itemsProcessed = 0; - const data = {}; - const tiers = config.models ?? DEFAULT_MODEL_TIERS; - trackAction(workflow.id, 'run_start', `run-${Date.now()}`, { dryRun }); - // Lifecycle hook - if (workflow.beforeRun) { - try { - await workflow.beforeRun(config); - } - catch (err) { - process.stderr.write(`[${workflow.id}] beforeRun failed: ${err.message}\n`); - } - } - const ctx = { - data, - config, - dryRun, - callModel: async (tier, prompt, system) => { - if (tier === 'none') - throw new Error('Cannot call model with tier "none"'); - const model = resolveModel(tier, tiers); - if (!model) - throw new Error(`No model resolved for tier ${tier}`); - const result = await client.complete({ - model, - messages: [{ role: 'user', content: prompt }], - system, - max_tokens: 4096, - stream: true, - }); - let text = ''; - for (const part of result.content) { - if (part.type === 'text') - text += part.text; - } - const cost = estimateCost(model, result.usage.inputTokens, result.usage.outputTokens, 1); - totalCost += cost; - return text; - }, - search: async (query, opts) => { - // Try channel search first if scope hints at a channel - if (opts?.sources && opts.sources.length > 0) { - for (const source of opts.sources) { - const results = await searchViaChannel(source, query, opts); - if (results.length > 0) - return results; - } - } - return defaultWebSearch(query, opts); - }, - sendMessage: async (channelId, message) => { - if (dryRun) { - process.stderr.write(`[${workflow.id}] [dry-run] would send to ${channelId}\n`); - return; - } - const channelPlugins = listChannelPlugins(); - for (const cp of channelPlugins) { - if (cp.plugin.channels?.[channelId]) { - const channel = cp.plugin.channels[channelId](); - await channel.post(message, { - log: (msg) => process.stderr.write(`[${channelId}] ${msg}\n`), - dryRun, - }); - return; - } - } - throw new Error(`Channel "${channelId}" not found`); - }, - log: (msg) => process.stderr.write(`[${workflow.id}] ${msg}\n`), - track: async (action, metadata) => { - trackAction(workflow.id, action, `${action}-${Date.now()}`, metadata, 0); - }, - isDuplicate: async (key) => isDuplicate(workflow.id, key), - }; - for (const step of workflow.steps) { - if (dryRun && step.skipInDryRun) { - stepResults.push({ name: step.name, summary: '[dry-run] skipped', cost: 0, status: 'skipped' }); - continue; - } - process.stderr.write(`[${workflow.id}] → ${step.name}...\n`); - try { - const result = await step.execute(ctx); - if (result.data) - Object.assign(data, result.data); - const stepCost = result.cost ?? 0; - totalCost += stepCost; - if (result.data?.itemCount) - itemsProcessed += result.data.itemCount; - stepResults.push({ - name: step.name, - summary: result.summary ?? 'done', - cost: stepCost, - status: result.abort ? 'aborted' : 'ok', - }); - if (result.abort) { - process.stderr.write(`[${workflow.id}] ⚠ ${step.name}: ${result.summary ?? 'aborted'}\n`); - break; - } - } - catch (err) { - const errMsg = err.message; - process.stderr.write(`[${workflow.id}] ✗ ${step.name}: ${errMsg}\n`); - stepResults.push({ name: step.name, summary: `error: ${errMsg}`, cost: 0, status: 'error' }); - break; - } - } - const result = { - steps: stepResults, - totalCost, - itemsProcessed, - durationMs: Date.now() - start, - dryRun, - }; - trackAction(workflow.id, 'run_complete', `run-${Date.now()}`, { - dryRun, totalCost, itemsProcessed, durationMs: result.durationMs, - }, totalCost); - if (workflow.afterRun) { - try { - await workflow.afterRun(result); - } - catch (err) { - process.stderr.write(`[${workflow.id}] afterRun failed: ${err.message}\n`); - } - } - return result; -} -// ─── Display ────────────────────────────────────────────────────────────── -export function formatWorkflowResult(workflow, result) { - const lines = []; - const sep = '─'.repeat(50); - lines.push(`\n${sep}`); - lines.push(`${workflow.name.toUpperCase()} ${result.dryRun ? '[DRY RUN]' : 'COMPLETE'}`); - lines.push(sep); - for (const step of result.steps) { - const costStr = step.cost > 0 ? ` ($${step.cost.toFixed(4)})` : ''; - const status = inferStepStatus(step); - const icon = status === 'error' - ? '✗' - : status === 'aborted' - ? '⚠' - : status === 'skipped' - ? '○' - : '✓'; - lines.push(` ${icon} ${step.name}: ${step.summary}${costStr}`); - } - lines.push(sep); - lines.push(` Items: ${result.itemsProcessed} Cost: $${result.totalCost.toFixed(4)} Time: ${(result.durationMs / 1000).toFixed(1)}s`); - lines.push(`${sep}\n`); - return lines.join('\n'); -} -function inferStepStatus(step) { - if (step.status) - return step.status; - const summary = step.summary.toLowerCase(); - if (summary.startsWith('error')) - return 'error'; - if (summary.includes('abort')) - return 'aborted'; - if (summary.includes('no posts found')) - return 'aborted'; - if (summary.includes('[dry-run] skipped')) - return 'skipped'; - if (summary.includes(' skipped')) - return 'skipped'; - return 'ok'; -} -export function formatWorkflowStats(workflow, stats) { - const lines = []; - const sep = '─'.repeat(40); - lines.push(`\n${sep}\n${workflow.name.toUpperCase()} STATS\n${sep}`); - lines.push(` Total runs: ${stats.totalRuns}`); - lines.push(` Total actions: ${stats.totalActions}`); - lines.push(` Total cost: $${stats.totalCostUsd.toFixed(4)}`); - lines.push(` Today: ${stats.todayActions} actions, $${stats.todayCostUsd.toFixed(4)}`); - if (stats.lastRun) - lines.push(` Last run: ${stats.lastRun}`); - if (Object.keys(stats.byAction).length > 0) { - lines.push(` By action:`); - for (const [action, count] of Object.entries(stats.byAction)) { - if (action !== 'run_start' && action !== 'run_complete') { - lines.push(` ${action}: ${count}`); - } - } - } - lines.push(`${sep}\n`); - return lines.join('\n'); -} diff --git a/dist/pricing.d.ts b/dist/pricing.d.ts deleted file mode 100644 index 55c5bf27..00000000 --- a/dist/pricing.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Single source of truth for model pricing (per 1M tokens). - * Used by agent loop, proxy server, stats tracker, and router. - */ -export declare const MODEL_PRICING: Record; -/** Opus pricing for savings calculations */ -export declare const OPUS_PRICING: { - input: number; - output: number; - perCall?: number; -}; -/** - * Estimate cost in USD for a request. - * Falls back to $2/$10 per 1M for unknown models. - * For per-call models (perCall > 0), uses flat per-call pricing instead of per-token. - */ -export declare function estimateCost(model: string, inputTokens: number, outputTokens: number, calls?: number): number; diff --git a/dist/pricing.js b/dist/pricing.js deleted file mode 100644 index d750a68f..00000000 --- a/dist/pricing.js +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Single source of truth for model pricing (per 1M tokens). - * Used by agent loop, proxy server, stats tracker, and router. - */ -export const MODEL_PRICING = { - // Routing profiles (blended averages) - 'blockrun/auto': { input: 0.8, output: 4.0 }, - 'blockrun/eco': { input: 0.2, output: 1.0 }, - 'blockrun/premium': { input: 3.0, output: 15.0 }, - 'blockrun/free': { input: 0, output: 0 }, - // FREE - NVIDIA models - 'nvidia/gpt-oss-120b': { input: 0, output: 0 }, - 'nvidia/gpt-oss-20b': { input: 0, output: 0 }, - 'nvidia/nemotron-ultra-253b': { input: 0, output: 0 }, - 'nvidia/nemotron-3-super-120b': { input: 0, output: 0 }, - 'nvidia/nemotron-super-49b': { input: 0, output: 0 }, - 'nvidia/deepseek-v3.2': { input: 0, output: 0 }, - 'nvidia/mistral-large-3-675b': { input: 0, output: 0 }, - 'nvidia/qwen3-coder-480b': { input: 0, output: 0 }, - 'nvidia/devstral-2-123b': { input: 0, output: 0 }, - 'nvidia/glm-4.7': { input: 0, output: 0 }, - 'nvidia/llama-4-maverick': { input: 0, output: 0 }, - // Anthropic - 'anthropic/claude-sonnet-4.6': { input: 3.0, output: 15.0 }, - 'anthropic/claude-opus-4.6': { input: 5.0, output: 25.0 }, - 'anthropic/claude-haiku-4.5': { input: 1.0, output: 5.0 }, - 'anthropic/claude-haiku-4.5-20251001': { input: 1.0, output: 5.0 }, - // OpenAI - 'openai/gpt-5-nano': { input: 0.05, output: 0.4 }, - 'openai/gpt-4.1-nano': { input: 0.1, output: 0.4 }, - 'openai/gpt-4o-mini': { input: 0.15, output: 0.6 }, - 'openai/gpt-5-mini': { input: 0.25, output: 2.0 }, - 'openai/gpt-4.1-mini': { input: 0.4, output: 1.6 }, - 'openai/gpt-5.2': { input: 1.75, output: 14.0 }, - 'openai/gpt-5.3': { input: 1.75, output: 14.0 }, - 'openai/gpt-5.3-codex': { input: 1.75, output: 14.0 }, - 'openai/gpt-4.1': { input: 2.0, output: 8.0 }, - 'openai/o3': { input: 2.0, output: 8.0 }, - 'openai/gpt-4o': { input: 2.5, output: 10.0 }, - 'openai/gpt-5.4': { input: 2.5, output: 15.0 }, - 'openai/o1-mini': { input: 1.1, output: 4.4 }, - 'openai/o3-mini': { input: 1.1, output: 4.4 }, - 'openai/o4-mini': { input: 1.1, output: 4.4 }, - 'openai/o1': { input: 15.0, output: 60.0 }, - 'openai/gpt-5.2-pro': { input: 21.0, output: 168.0 }, - 'openai/gpt-5.4-pro': { input: 30.0, output: 180.0 }, - // Google - 'google/gemini-2.5-flash-lite': { input: 0.1, output: 0.4 }, - 'google/gemini-2.5-flash': { input: 0.3, output: 2.5 }, - 'google/gemini-3-flash-preview': { input: 0.5, output: 3.0 }, - 'google/gemini-2.5-pro': { input: 1.25, output: 10.0 }, - 'google/gemini-3-pro-preview': { input: 2.0, output: 12.0 }, - 'google/gemini-3.1-pro': { input: 2.0, output: 12.0 }, - // xAI - 'xai/grok-4-fast': { input: 0.2, output: 0.5 }, - 'xai/grok-4-fast-reasoning': { input: 0.2, output: 0.5 }, - 'xai/grok-4-1-fast': { input: 0.2, output: 0.5 }, - 'xai/grok-4-1-fast-reasoning': { input: 0.2, output: 0.5 }, - 'xai/grok-4-0709': { input: 0.2, output: 1.5 }, - 'xai/grok-3-mini': { input: 0.3, output: 0.5 }, - 'xai/grok-2-vision': { input: 2.0, output: 10.0 }, - 'xai/grok-3': { input: 3.0, output: 15.0 }, - // DeepSeek - 'deepseek/deepseek-chat': { input: 0.28, output: 0.42 }, - 'deepseek/deepseek-reasoner': { input: 0.28, output: 0.42 }, - // Minimax - 'minimax/minimax-m2.7': { input: 0.3, output: 1.2 }, - 'minimax/minimax-m2.5': { input: 0.3, output: 1.2 }, - // Others - 'moonshot/kimi-k2.5': { input: 0.6, output: 3.0 }, - 'nvidia/kimi-k2.5': { input: 0.55, output: 2.5 }, - // PROMOTION (active ~2026-04): flat $0.001/call for all GLM models - 'zai/glm-5': { input: 0, output: 0, perCall: 0.001 }, - 'zai/glm-5.1': { input: 0, output: 0, perCall: 0.001 }, - 'zai/glm-5-turbo': { input: 0, output: 0, perCall: 0.001 }, - 'zai/glm-5.1-turbo': { input: 0, output: 0, perCall: 0.001 }, -}; -/** Opus pricing for savings calculations */ -export const OPUS_PRICING = MODEL_PRICING['anthropic/claude-opus-4.6']; -/** - * Estimate cost in USD for a request. - * Falls back to $2/$10 per 1M for unknown models. - * For per-call models (perCall > 0), uses flat per-call pricing instead of per-token. - */ -export function estimateCost(model, inputTokens, outputTokens, calls = 1) { - const pricing = MODEL_PRICING[model] || { input: 2.0, output: 10.0 }; - if (pricing.perCall) { - return pricing.perCall * calls; - } - return ((inputTokens / 1_000_000) * pricing.input + - (outputTokens / 1_000_000) * pricing.output); -} diff --git a/dist/proxy/fallback.d.ts b/dist/proxy/fallback.d.ts deleted file mode 100644 index 4084fbe3..00000000 --- a/dist/proxy/fallback.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Fallback chain for runcode - * Automatically switches to backup models when primary fails (429, 5xx, etc.) - */ -export interface FallbackConfig { - /** Models to try in order of priority */ - chain: string[]; - /** HTTP status codes that trigger fallback */ - retryOn: number[]; - /** Maximum retries across all models */ - maxRetries: number; - /** Delay between retries in ms */ - retryDelayMs: number; -} -export declare const DEFAULT_FALLBACK_CONFIG: FallbackConfig; -export interface FallbackResult { - response: Response; - modelUsed: string; - /** The request body with the successful model substituted in */ - bodyUsed: string; - fallbackUsed: boolean; - attemptsCount: number; - failedModels: string[]; -} -/** - * Fetch with automatic fallback to backup models - */ -export declare function fetchWithFallback(url: string, init: RequestInit, originalBody: string, config?: FallbackConfig, onFallback?: (model: string, statusCode: number, nextModel: string) => void): Promise; -/** - * Get the current model from fallback chain based on parsed request - */ -export declare function getCurrentModelFromChain(requestedModel: string | undefined, config?: FallbackConfig): string; -/** - * Build fallback chain starting from a specific model. - * Filters out routing profiles (blockrun/auto etc.) since the backend - * doesn't recognize them — they must be resolved by the smart router first. - */ -export declare function buildFallbackChain(startModel: string, config?: FallbackConfig): string[]; diff --git a/dist/proxy/fallback.js b/dist/proxy/fallback.js deleted file mode 100644 index 60d2b65d..00000000 --- a/dist/proxy/fallback.js +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Fallback chain for runcode - * Automatically switches to backup models when primary fails (429, 5xx, etc.) - */ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -const LOG_FILE = path.join(os.homedir(), '.blockrun', 'runcode-debug.log'); -// eslint-disable-next-line no-control-regex -const ANSI_RE = /\x1B\[[0-9;]*[A-Za-z]|\x1B\][^\x07]*\x07|\x1B[()][A-B]|\r/g; -function appendLog(msg) { - try { - fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true }); - fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg.replace(ANSI_RE, '')}\n`); - } - catch { /* ignore */ } -} -export const DEFAULT_FALLBACK_CONFIG = { - chain: [ - 'deepseek/deepseek-chat', // Direct fallback — cheap & reliable - 'google/gemini-2.5-flash', // Fast & capable - 'nvidia/nemotron-ultra-253b', // Free model as ultimate fallback - ], - retryOn: [429, 500, 502, 503, 504, 529], - maxRetries: 5, - retryDelayMs: 1000, -}; -/** - * Sleep helper - */ -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} -/** - * Replace model in request body - */ -function replaceModelInBody(body, newModel) { - try { - const parsed = JSON.parse(body); - parsed.model = newModel; - return JSON.stringify(parsed); - } - catch { - return body; - } -} -/** - * Fetch with automatic fallback to backup models - */ -export async function fetchWithFallback(url, init, originalBody, config = DEFAULT_FALLBACK_CONFIG, onFallback) { - const failedModels = []; - let attempts = 0; - const FALLBACK_TIMEOUT_MS = 60_000; // 60s per attempt - for (let i = 0; i < config.chain.length && attempts < config.maxRetries; i++) { - const model = config.chain[i]; - const body = replaceModelInBody(originalBody, model); - try { - attempts++; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), FALLBACK_TIMEOUT_MS); - const response = await fetch(url, { - ...init, - body, - signal: controller.signal, - }); - clearTimeout(timeout); - // Success or non-retryable error - if (!config.retryOn.includes(response.status)) { - return { - response, - modelUsed: model, - bodyUsed: body, - fallbackUsed: i > 0, - attemptsCount: attempts, - failedModels, - }; - } - // Retryable error - log and try next - failedModels.push(model); - const nextModel = config.chain[i + 1]; - if (nextModel && onFallback) { - onFallback(model, response.status, nextModel); - } - // Wait before trying next model (with exponential backoff for same model retries) - if (i < config.chain.length - 1) { - await sleep(config.retryDelayMs); - } - } - catch (err) { - // Network error - try next model - failedModels.push(model); - const nextModel = config.chain[i + 1]; - if (nextModel && onFallback) { - const errMsg = err instanceof Error ? err.message : 'Network error'; - onFallback(model, 0, nextModel); - appendLog(`[runcode] [fallback] ${model} network error: ${errMsg}`); - } - if (i < config.chain.length - 1) { - await sleep(config.retryDelayMs); - } - } - } - // All models failed - throw error - throw new Error(`All models in fallback chain failed: ${failedModels.join(', ')}`); -} -/** - * Get the current model from fallback chain based on parsed request - */ -export function getCurrentModelFromChain(requestedModel, config = DEFAULT_FALLBACK_CONFIG) { - // If model is explicitly set and in chain, start from there - if (requestedModel) { - const index = config.chain.indexOf(requestedModel); - if (index >= 0) { - return requestedModel; - } - // Model not in chain, use as-is (user specified custom model) - return requestedModel; - } - // Default to first model in chain - return config.chain[0]; -} -/** Routing profiles that must never be sent to the backend directly */ -const ROUTING_PROFILES = new Set([ - 'blockrun/auto', 'blockrun/eco', 'blockrun/premium', 'blockrun/free', -]); -/** - * Build fallback chain starting from a specific model. - * Filters out routing profiles (blockrun/auto etc.) since the backend - * doesn't recognize them — they must be resolved by the smart router first. - */ -export function buildFallbackChain(startModel, config = DEFAULT_FALLBACK_CONFIG) { - // Never include routing profiles in the chain — they'd cause 400s - const safeChain = config.chain.filter(m => !ROUTING_PROFILES.has(m)); - const index = safeChain.indexOf(startModel); - if (index >= 0) { - return safeChain.slice(index); - } - // If startModel is a routing profile, skip it and just use the safe chain - if (ROUTING_PROFILES.has(startModel)) { - return safeChain; - } - // Model not in default chain - prepend it - return [startModel, ...safeChain]; -} diff --git a/dist/proxy/server.d.ts b/dist/proxy/server.d.ts deleted file mode 100644 index 05985cbd..00000000 --- a/dist/proxy/server.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -import http from 'node:http'; -import type { Chain } from '../config.js'; -export interface ProxyOptions { - port: number; - apiUrl: string; - chain?: Chain; - modelOverride?: string; - debug?: boolean; - fallbackEnabled?: boolean; -} -export declare function createProxy(options: ProxyOptions): http.Server; -type RequestCategory = 'simple' | 'code' | 'default'; -interface ClassifiedRequest { - category: RequestCategory; - suggestedModel?: string; -} -export declare function classifyRequest(body: string): ClassifiedRequest; -export {}; diff --git a/dist/proxy/server.js b/dist/proxy/server.js deleted file mode 100644 index e0f76495..00000000 --- a/dist/proxy/server.js +++ /dev/null @@ -1,576 +0,0 @@ -import http from 'node:http'; -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm'; -import { recordUsage } from '../stats/tracker.js'; -import { fetchWithFallback, buildFallbackChain, DEFAULT_FALLBACK_CONFIG, } from './fallback.js'; -import { routeRequest, parseRoutingProfile, } from '../router/index.js'; -import { estimateCost } from '../pricing.js'; -import { VERSION } from '../config.js'; -// User-Agent for backend requests -const USER_AGENT = `runcode/${VERSION}`; -const X_RUNCODE_VERSION = VERSION; -const LOG_FILE = path.join(os.homedir(), '.blockrun', 'runcode-debug.log'); -// Strip ANSI escape codes so log file doesn't distort terminal on replay -function stripAnsi(str) { - // eslint-disable-next-line no-control-regex - return str.replace(/\x1B\[[0-9;]*[A-Za-z]|\x1B\][^\x07]*\x07|\x1B[()][A-B]|\r/g, ''); -} -function debug(options, ...args) { - if (!options.debug) - return; - const msg = `[${new Date().toISOString()}] ${stripAnsi(args.map(String).join(' '))}\n`; - try { - fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true }); - fs.appendFileSync(LOG_FILE, msg); - } - catch { - /* ignore */ - } -} -function log(...args) { - const msg = `[runcode] ${args.map(String).join(' ')}`; - // Do NOT print to stdout — Claude Code owns the terminal (stdio: inherit). - // Use `runcode logs` to read runtime messages. - try { - fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true }); - fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${stripAnsi(msg)}\n`); - } - catch { /* ignore */ } -} -const DEFAULT_MAX_TOKENS = 4096; -// Per-model last output tokens for adaptive max_tokens (avoids cross-request pollution) -const MAX_TRACKED_MODELS = 50; -const lastOutputByModel = new Map(); -function trackOutputTokens(model, tokens) { - if (lastOutputByModel.size >= MAX_TRACKED_MODELS) { - const firstKey = lastOutputByModel.keys().next().value; - if (firstKey) - lastOutputByModel.delete(firstKey); - } - lastOutputByModel.set(model, tokens); -} -// Model shortcuts for quick switching -const MODEL_SHORTCUTS = { - // Routing profiles - auto: 'blockrun/auto', - smart: 'blockrun/auto', - eco: 'blockrun/eco', - premium: 'blockrun/premium', - // Anthropic - sonnet: 'anthropic/claude-sonnet-4.6', - claude: 'anthropic/claude-sonnet-4.6', - opus: 'anthropic/claude-opus-4.6', - haiku: 'anthropic/claude-haiku-4.5', - // OpenAI - gpt: 'openai/gpt-5.4', - gpt5: 'openai/gpt-5.4', - 'gpt-5': 'openai/gpt-5.4', - 'gpt-5.4': 'openai/gpt-5.4', - 'gpt-5.4-pro': 'openai/gpt-5.4-pro', - 'gpt-5.3': 'openai/gpt-5.3', - 'gpt-5.2': 'openai/gpt-5.2', - 'gpt-5.2-pro': 'openai/gpt-5.2-pro', - 'gpt-4.1': 'openai/gpt-4.1', - codex: 'openai/gpt-5.3-codex', - nano: 'openai/gpt-5-nano', - mini: 'openai/gpt-5-mini', - o3: 'openai/o3', - o4: 'openai/o4-mini', - 'o4-mini': 'openai/o4-mini', - o1: 'openai/o1', - // Google - gemini: 'google/gemini-2.5-pro', - flash: 'google/gemini-2.5-flash', - 'gemini-3': 'google/gemini-3.1-pro', - // xAI - grok: 'xai/grok-3', - 'grok-4': 'xai/grok-4-0709', - 'grok-fast': 'xai/grok-4-1-fast-reasoning', - // DeepSeek - deepseek: 'deepseek/deepseek-chat', - r1: 'deepseek/deepseek-reasoner', - // Free models - free: 'nvidia/nemotron-ultra-253b', - nemotron: 'nvidia/nemotron-ultra-253b', - 'deepseek-free': 'nvidia/deepseek-v3.2', - devstral: 'nvidia/devstral-2-123b', - 'qwen-coder': 'nvidia/qwen3-coder-480b', - maverick: 'nvidia/llama-4-maverick', - // Minimax - minimax: 'minimax/minimax-m2.7', - // Others - glm: 'zai/glm-5.1', - kimi: 'moonshot/kimi-k2.5', -}; -// Model pricing now uses shared source from src/pricing.ts -function detectModelSwitch(parsed) { - if (!parsed.messages || parsed.messages.length === 0) - return null; - const last = parsed.messages[parsed.messages.length - 1]; - if (last.role !== 'user') - return null; - let content = ''; - if (typeof last.content === 'string') { - content = last.content; - } - else if (Array.isArray(last.content)) { - const textBlock = last.content.find((b) => b.type === 'text' && b.text); - if (textBlock && textBlock.text) - content = textBlock.text; - } - if (!content) - return null; - content = content.trim().toLowerCase(); - const match = content.match(/^use\s+(.+)$/); - if (!match) - return null; - const modelInput = match[1].trim(); - // Check shortcuts first - if (MODEL_SHORTCUTS[modelInput]) - return MODEL_SHORTCUTS[modelInput]; - // If it contains a slash, treat as full model ID - if (modelInput.includes('/')) - return modelInput; - return null; -} -// Default model - smart routing built-in -const DEFAULT_MODEL = 'blockrun/auto'; -export function createProxy(options) { - const chain = options.chain || 'base'; - let currentModel = options.modelOverride || DEFAULT_MODEL; - const fallbackEnabled = options.fallbackEnabled !== false; // Default true - let baseWallet = null; - let solanaWallet = null; - if (chain === 'base') { - const w = getOrCreateWallet(); - baseWallet = { privateKey: w.privateKey, address: w.address }; - } - let solanaInitPromise = null; - const initSolana = () => { - if (chain !== 'solana' || solanaWallet) - return Promise.resolve(); - if (!solanaInitPromise) { - solanaInitPromise = getOrCreateSolanaWallet().then((w) => { - solanaWallet = { privateKey: w.privateKey, address: w.address }; - }).catch((err) => { - solanaInitPromise = null; // Allow retry on failure - throw err; - }); - } - return solanaInitPromise; - }; - const server = http.createServer(async (req, res) => { - if (req.method === 'OPTIONS') { - res.writeHead(200); - res.end(); - return; - } - await initSolana(); - const requestPath = req.url?.replace(/^\/api/, '') || ''; - const targetUrl = `${options.apiUrl}${requestPath}`; - let body = ''; - const requestStartTime = Date.now(); - req.on('data', (chunk) => { - body += chunk; - }); - req.on('end', async () => { - let requestModel = currentModel || options.modelOverride || 'unknown'; - let usedFallback = false; - try { - debug(options, `request: ${req.method} ${req.url} currentModel=${currentModel || 'none'}`); - if (body) { - try { - const parsed = JSON.parse(body); - // Intercept "use " commands for in-session model switching - if (parsed.messages) { - const last = parsed.messages[parsed.messages.length - 1]; - debug(options, `last msg role=${last?.role} content-type=${typeof last?.content} content=${JSON.stringify(last?.content).slice(0, 200)}`); - } - const switchCmd = detectModelSwitch(parsed); - if (switchCmd) { - currentModel = switchCmd; - debug(options, `model switched to: ${currentModel}`); - const fakeResponse = { - id: `msg_runcode_${Date.now()}`, - type: 'message', - role: 'assistant', - model: currentModel, - content: [ - { - type: 'text', - text: `Switched to **${currentModel}**. All subsequent requests will use this model.`, - }, - ], - stop_reason: 'end_turn', - stop_sequence: null, - usage: { input_tokens: 0, output_tokens: 10 }, - }; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(fakeResponse)); - return; - } - // Model override logic: - // - Claude Code sends native Anthropic IDs (e.g. "claude-sonnet-4-6-20250514") - // which don't contain "/" — these MUST be replaced with currentModel. - // - BlockRun model IDs always contain "/" (e.g. "blockrun/auto", "nvidia/nemotron-ultra-253b") - // — these should be passed through as-is. - // - If --model CLI flag is set, always override regardless. - if (options.modelOverride) { - parsed.model = currentModel; - } - else if (!parsed.model || !parsed.model.includes('/')) { - parsed.model = currentModel || DEFAULT_MODEL; - } - requestModel = parsed.model || DEFAULT_MODEL; - // Smart routing: if model is a routing profile, classify and route - const routingProfile = parseRoutingProfile(requestModel); - if (routingProfile) { - // Extract user prompt for classification - const userMessages = parsed.messages?.filter((m) => m.role === 'user') || []; - const lastUserMsg = userMessages[userMessages.length - 1]; - let promptText = ''; - if (lastUserMsg) { - if (typeof lastUserMsg.content === 'string') { - promptText = lastUserMsg.content; - } - else if (Array.isArray(lastUserMsg.content)) { - promptText = lastUserMsg.content - .filter((b) => b.type === 'text') - .map((b) => b.text) - .join('\n'); - } - } - // Route the request - const routing = routeRequest(promptText, routingProfile); - parsed.model = routing.model; - requestModel = routing.model; - log(`🧠 Smart routing: ${routingProfile} → ${routing.tier} → ${routing.model} ` + - `(${(routing.savings * 100).toFixed(0)}% savings) [${routing.signals.join(', ')}]`); - } - { - const original = parsed.max_tokens; - const model = (parsed.model || '').toLowerCase(); - const modelCap = model.includes('deepseek') || - model.includes('haiku') || - model.includes('gpt-oss') - ? 8192 - : 16384; - // Use max of (last output × 2, default 4096) capped by model limit - // This ensures short replies don't starve the next request - const lastOut = lastOutputByModel.get(requestModel) ?? 0; - const adaptive = lastOut > 0 - ? Math.max(lastOut * 2, DEFAULT_MAX_TOKENS) - : DEFAULT_MAX_TOKENS; - parsed.max_tokens = Math.min(adaptive, modelCap); - if (original !== parsed.max_tokens) { - debug(options, `max_tokens: ${original || 'unset'} → ${parsed.max_tokens} (last output: ${lastOut || 'none'})`); - } - } - body = JSON.stringify(parsed); - } - catch { - /* not JSON, pass through */ - } - } - const headers = { - 'Content-Type': 'application/json', - 'User-Agent': USER_AGENT, - 'X-runcode-Version': X_RUNCODE_VERSION, - }; - for (const [key, value] of Object.entries(req.headers)) { - if (key.toLowerCase() !== 'host' && - key.toLowerCase() !== 'content-length' && - key.toLowerCase() !== 'user-agent' && // Don't forward client's user-agent - value) { - headers[key] = Array.isArray(value) ? value[0] : value; - } - } - // Build request init - const requestInit = { - method: req.method || 'POST', - headers, - body: body || undefined, - }; - let response; - let finalModel = requestModel; - // Use fallback chain if enabled - if (fallbackEnabled && body && requestPath.includes('messages')) { - const fallbackConfig = { - ...DEFAULT_FALLBACK_CONFIG, - chain: buildFallbackChain(requestModel), - }; - const result = await fetchWithFallback(targetUrl, requestInit, body, fallbackConfig, (failedModel, status, nextModel) => { - log(`⚠️ ${failedModel} returned ${status}, falling back to ${nextModel}`); - }); - response = result.response; - finalModel = result.modelUsed; - // Use the body with the correct fallback model for payment - body = result.bodyUsed; - usedFallback = result.fallbackUsed; - if (usedFallback) { - log(`↺ Fallback successful: using ${finalModel}`); - } - } - else { - // Direct fetch without fallback (with timeout) - const directCtrl = new AbortController(); - const directTimeout = setTimeout(() => directCtrl.abort(), 120_000); // 2min - response = await fetch(targetUrl, { ...requestInit, signal: directCtrl.signal }); - clearTimeout(directTimeout); - } - // Handle 402 payment — body now has the correct model after fallback - if (response.status === 402) { - if (chain === 'solana' && solanaWallet) { - response = await handleSolanaPayment(response, targetUrl, req.method || 'POST', headers, body, solanaWallet.privateKey, solanaWallet.address); - } - else if (baseWallet) { - response = await handleBasePayment(response, targetUrl, req.method || 'POST', headers, body, baseWallet.privateKey, baseWallet.address); - } - } - const responseHeaders = {}; - response.headers.forEach((v, k) => { - responseHeaders[k] = v; - }); - // Intercept error responses and ensure Anthropic-format errors - // so Claude Code doesn't fall back to showing a login page - if (response.status >= 400 && !responseHeaders['content-type']?.includes('text/event-stream')) { - let errorBody; - try { - const rawText = await response.text(); - const parsed = JSON.parse(rawText); - // Already has Anthropic error shape? Pass through - if (parsed.type === 'error' && parsed.error) { - errorBody = rawText; - } - else { - // Wrap in Anthropic error format - const errorMsg = parsed.error?.message || parsed.message || rawText.slice(0, 500); - errorBody = JSON.stringify({ - type: 'error', - error: { - type: response.status === 401 ? 'authentication_error' - : response.status === 402 ? 'invalid_request_error' - : response.status === 429 ? 'rate_limit_error' - : response.status === 400 ? 'invalid_request_error' - : 'api_error', - message: `[${finalModel}] ${errorMsg}`, - }, - }); - } - } - catch { - errorBody = JSON.stringify({ - type: 'error', - error: { type: 'api_error', message: `Backend returned ${response.status}` }, - }); - } - res.writeHead(response.status, { 'Content-Type': 'application/json' }); - res.end(errorBody); - log(`⚠️ ${response.status} from backend for ${finalModel}`); - return; - } - res.writeHead(response.status, responseHeaders); - const isStreaming = responseHeaders['content-type']?.includes('text/event-stream'); - if (response.body) { - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let fullResponse = ''; - const STREAM_CAP = 5_000_000; // 5MB cap on accumulated stream - const STREAM_TIMEOUT_MS = 5 * 60 * 1000; // 5 min timeout for entire stream - const streamDeadline = Date.now() + STREAM_TIMEOUT_MS; - const pump = async () => { - while (true) { - if (Date.now() > streamDeadline) { - log('⚠️ Stream timeout after 5 minutes'); - try { - reader.cancel(); - } - catch { /* ignore */ } - break; - } - const { done, value } = await reader.read(); - if (done) { - // Record stats from streaming response - if (isStreaming && fullResponse) { - // Extract token usage from SSE stream by parsing message_delta events - let outputTokens = 0; - let inputTokens = 0; - // Find all data: lines and parse JSON to extract usage - for (const line of fullResponse.split('\n')) { - if (!line.startsWith('data: ')) - continue; - const json = line.slice(6).trim(); - if (json === '[DONE]') - continue; - try { - const parsed = JSON.parse(json); - if (parsed.usage?.output_tokens) - outputTokens = parsed.usage.output_tokens; - if (parsed.usage?.input_tokens) - inputTokens = parsed.usage.input_tokens; - } - catch { /* skip malformed */ } - } - if (outputTokens > 0) { - trackOutputTokens(finalModel, outputTokens); - const latencyMs = Date.now() - requestStartTime; - const cost = estimateCost(finalModel, inputTokens, outputTokens); - recordUsage(finalModel, inputTokens, outputTokens, cost, latencyMs, usedFallback); - debug(options, `recorded: model=${finalModel} in=${inputTokens} out=${outputTokens} cost=$${cost.toFixed(4)} fallback=${usedFallback}`); - } - } - res.end(); - break; - } - if (isStreaming && fullResponse.length < STREAM_CAP) { - const chunk = decoder.decode(value, { stream: true }); - fullResponse += chunk; - } - res.write(value); - } - }; - pump().catch((err) => { - log(`❌ Stream error: ${err instanceof Error ? err.message : String(err)}`); - res.end(); - }); - } - else { - const text = await response.text(); - try { - const parsed = JSON.parse(text); - if (parsed.usage?.output_tokens) { - const outputTokens = parsed.usage.output_tokens; - trackOutputTokens(finalModel, outputTokens); - const inputTokens = parsed.usage?.input_tokens || 0; - const latencyMs = Date.now() - requestStartTime; - const cost = estimateCost(finalModel, inputTokens, outputTokens); - recordUsage(finalModel, inputTokens, outputTokens, cost, latencyMs, usedFallback); - debug(options, `recorded: model=${finalModel} in=${inputTokens} out=${outputTokens} cost=$${cost.toFixed(4)} fallback=${usedFallback}`); - } - } - catch { - /* not JSON */ - } - res.end(text); - } - } - catch (error) { - const msg = error instanceof Error ? error.message : 'Proxy error'; - log(`❌ Error: ${msg}`); - res.writeHead(502, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - type: 'error', - error: { type: 'api_error', message: msg }, - })); - } - }); - }); - return server; -} -// ====================================================================== -// Base (EIP-712) payment handler -// ====================================================================== -async function handleBasePayment(response, url, method, headers, body, privateKey, fromAddress) { - const paymentHeader = await extractPaymentHeader(response); - if (!paymentHeader) { - throw new Error('402 Payment Required — wallet may need funding. Run: runcode balance'); - } - const paymentRequired = parsePaymentRequired(paymentHeader); - const details = extractPaymentDetails(paymentRequired); - const paymentPayload = await createPaymentPayload(privateKey, fromAddress, details.recipient, details.amount, details.network || 'eip155:8453', { - resourceUrl: details.resource?.url || url, - resourceDescription: details.resource?.description || 'BlockRun AI API call', - maxTimeoutSeconds: details.maxTimeoutSeconds || 300, - extra: details.extra, - }); - return fetch(url, { - method, - headers: { - ...headers, - 'PAYMENT-SIGNATURE': paymentPayload, - }, - body: body || undefined, - }); -} -// ====================================================================== -// Solana payment handler -// ====================================================================== -async function handleSolanaPayment(response, url, method, headers, body, privateKey, fromAddress) { - const paymentHeader = await extractPaymentHeader(response); - if (!paymentHeader) { - throw new Error('402 Payment Required — wallet may need funding. Run: runcode balance'); - } - const paymentRequired = parsePaymentRequired(paymentHeader); - const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK); - const secretKey = await solanaKeyToBytes(privateKey); - const feePayer = details.extra?.feePayer || details.recipient; - const paymentPayload = await createSolanaPaymentPayload(secretKey, fromAddress, details.recipient, details.amount, feePayer, { - resourceUrl: details.resource?.url || url, - resourceDescription: details.resource?.description || 'BlockRun AI API call', - maxTimeoutSeconds: details.maxTimeoutSeconds || 300, - extra: details.extra, - }); - return fetch(url, { - method, - headers: { - ...headers, - 'PAYMENT-SIGNATURE': paymentPayload, - }, - body: body || undefined, - }); -} -export function classifyRequest(body) { - try { - const parsed = JSON.parse(body); - const messages = parsed.messages; - if (!Array.isArray(messages) || messages.length === 0) { - return { category: 'default' }; - } - const lastMessage = messages[messages.length - 1]; - let content = ''; - if (typeof lastMessage.content === 'string') { - content = lastMessage.content; - } - else if (Array.isArray(lastMessage.content)) { - content = lastMessage.content - .filter((b) => b.type === 'text' && b.text) - .map((b) => b.text) - .join('\n'); - } - if (content.includes('```') || - content.includes('function ') || - content.includes('class ') || - content.includes('import ') || - content.includes('def ') || - content.includes('const ')) { - return { category: 'code' }; - } - if (content.length < 100) { - return { category: 'simple' }; - } - return { category: 'default' }; - } - catch { - return { category: 'default' }; - } -} -// ====================================================================== -// Shared helpers -// ====================================================================== -async function extractPaymentHeader(response) { - let paymentHeader = response.headers.get('payment-required'); - if (!paymentHeader) { - try { - const respBody = (await response.json()); - if (respBody.x402 || respBody.accepts) { - paymentHeader = btoa(JSON.stringify(respBody)); - } - } - catch { - // ignore parse errors - } - } - return paymentHeader; -} diff --git a/dist/proxy/sse-translator.d.ts b/dist/proxy/sse-translator.d.ts deleted file mode 100644 index 6e47d551..00000000 --- a/dist/proxy/sse-translator.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * SSE Event Translator: OpenAI → Anthropic Messages API format - * - * Handles three critical gaps in the streaming pipeline: - * 1. Tool calls: choice.delta.tool_calls → content_block_start/content_block_delta (tool_use) - * 2. Reasoning: reasoning_content → content_block_start/content_block_delta (thinking) - * 3. Ensures proper content_block_stop and message_stop events - */ -export declare class SSETranslator { - private state; - private buffer; - constructor(model?: string); - /** - * Detect whether an SSE chunk is in OpenAI format. - * Returns true if it contains OpenAI-style `choices[].delta` structure. - */ - static isOpenAIFormat(chunk: string): boolean; - /** - * Process a raw SSE text chunk and return translated Anthropic-format SSE events. - * Returns null if no translation needed (already Anthropic format or not parseable). - */ - processChunk(rawChunk: string): string | null; - private parseSSEEvents; - private formatSSE; - private closeThinkingBlock; - private closeTextBlock; - private closeToolCalls; - private closeActiveBlocks; -} diff --git a/dist/proxy/sse-translator.js b/dist/proxy/sse-translator.js deleted file mode 100644 index 2c9f1afc..00000000 --- a/dist/proxy/sse-translator.js +++ /dev/null @@ -1,270 +0,0 @@ -/** - * SSE Event Translator: OpenAI → Anthropic Messages API format - * - * Handles three critical gaps in the streaming pipeline: - * 1. Tool calls: choice.delta.tool_calls → content_block_start/content_block_delta (tool_use) - * 2. Reasoning: reasoning_content → content_block_start/content_block_delta (thinking) - * 3. Ensures proper content_block_stop and message_stop events - */ -// ─── SSE Translator ───────────────────────────────────────────────────────── -export class SSETranslator { - state; - buffer = ''; - constructor(model = 'unknown') { - this.state = { - messageId: `msg_runcode_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, - model, - blockIndex: 0, - activeToolCalls: new Map(), - thinkingBlockActive: false, - textBlockActive: false, - messageStarted: false, - inputTokens: 0, - outputTokens: 0, - }; - } - /** - * Detect whether an SSE chunk is in OpenAI format. - * Returns true if it contains OpenAI-style `choices[].delta` structure. - */ - static isOpenAIFormat(chunk) { - return (chunk.includes('"choices"') && - chunk.includes('"delta"') && - !chunk.includes('"content_block_')); - } - /** - * Process a raw SSE text chunk and return translated Anthropic-format SSE events. - * Returns null if no translation needed (already Anthropic format or not parseable). - */ - processChunk(rawChunk) { - this.buffer += rawChunk; - const events = this.parseSSEEvents(); - if (events.length === 0) - return null; - const translated = []; - for (const event of events) { - if (event.data === '[DONE]') { - translated.push(...this.closeActiveBlocks()); - translated.push(this.formatSSE('message_delta', { - type: 'message_delta', - delta: { stop_reason: 'end_turn', stop_sequence: null }, - usage: { output_tokens: this.state.outputTokens }, - })); - translated.push(this.formatSSE('message_stop', { type: 'message_stop' })); - continue; - } - let parsed; - try { - parsed = JSON.parse(event.data); - } - catch { - continue; - } - // Skip if not OpenAI format - const choices = parsed.choices; - if (!choices || choices.length === 0) { - // Could be a usage-only event - const usage = parsed.usage; - if (usage) { - this.state.inputTokens = usage.prompt_tokens ?? 0; - this.state.outputTokens = usage.completion_tokens ?? 0; - } - continue; - } - // Emit message_start on first chunk - if (!this.state.messageStarted) { - this.state.messageStarted = true; - if (parsed.model) - this.state.model = parsed.model; - translated.push(this.formatSSE('message_start', { - type: 'message_start', - message: { - id: this.state.messageId, - type: 'message', - role: 'assistant', - model: this.state.model, - content: [], - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: this.state.inputTokens, output_tokens: 0 }, - }, - })); - translated.push(this.formatSSE('ping', { type: 'ping' })); - } - const choice = choices[0]; - const delta = choice.delta; - // ── Reasoning content → thinking block ── - if (delta.reasoning_content) { - if (!this.state.thinkingBlockActive) { - if (this.state.textBlockActive) - translated.push(...this.closeTextBlock()); - this.state.thinkingBlockActive = true; - translated.push(this.formatSSE('content_block_start', { - type: 'content_block_start', - index: this.state.blockIndex, - content_block: { type: 'thinking', thinking: '' }, - })); - } - translated.push(this.formatSSE('content_block_delta', { - type: 'content_block_delta', - index: this.state.blockIndex, - delta: { type: 'thinking_delta', thinking: delta.reasoning_content }, - })); - this.state.outputTokens++; - } - // ── Text content → text block ── - if (delta.content) { - if (this.state.thinkingBlockActive) - translated.push(...this.closeThinkingBlock()); - if (!this.state.textBlockActive) { - translated.push(...this.closeToolCalls()); - this.state.textBlockActive = true; - translated.push(this.formatSSE('content_block_start', { - type: 'content_block_start', - index: this.state.blockIndex, - content_block: { type: 'text', text: '' }, - })); - } - translated.push(this.formatSSE('content_block_delta', { - type: 'content_block_delta', - index: this.state.blockIndex, - delta: { type: 'text_delta', text: delta.content }, - })); - this.state.outputTokens++; - } - // ── Tool calls → tool_use blocks ── - const toolCalls = delta.tool_calls; - if (toolCalls && toolCalls.length > 0) { - if (this.state.thinkingBlockActive) - translated.push(...this.closeThinkingBlock()); - if (this.state.textBlockActive) - translated.push(...this.closeTextBlock()); - for (const tc of toolCalls) { - const tcIndex = tc.index; - const fn = tc.function; - if (tc.id && fn?.name) { - if (this.state.activeToolCalls.has(tcIndex)) { - translated.push(this.formatSSE('content_block_stop', { - type: 'content_block_stop', - index: this.state.blockIndex, - })); - this.state.blockIndex++; - } - this.state.activeToolCalls.set(tcIndex, { id: tc.id, name: fn.name }); - translated.push(this.formatSSE('content_block_start', { - type: 'content_block_start', - index: this.state.blockIndex, - content_block: { type: 'tool_use', id: tc.id, name: fn.name, input: {} }, - })); - if (fn.arguments) { - translated.push(this.formatSSE('content_block_delta', { - type: 'content_block_delta', - index: this.state.blockIndex, - delta: { type: 'input_json_delta', partial_json: fn.arguments }, - })); - } - } - else if (fn?.arguments) { - translated.push(this.formatSSE('content_block_delta', { - type: 'content_block_delta', - index: this.state.blockIndex, - delta: { type: 'input_json_delta', partial_json: fn.arguments }, - })); - } - } - this.state.outputTokens++; - } - // ── Handle finish_reason ── - if (choice.finish_reason) { - translated.push(...this.closeActiveBlocks()); - const stopReason = choice.finish_reason === 'tool_calls' - ? 'tool_use' - : choice.finish_reason === 'stop' - ? 'end_turn' - : choice.finish_reason; - translated.push(this.formatSSE('message_delta', { - type: 'message_delta', - delta: { stop_reason: stopReason, stop_sequence: null }, - usage: { output_tokens: this.state.outputTokens }, - })); - } - } - return translated.length > 0 ? translated.join('') : null; - } - // ── Helpers ───────────────────────────────────────────────────────────── - parseSSEEvents() { - const events = []; - const lines = this.buffer.split('\n'); - let currentEvent; - let dataLines = []; - let consumed = 0; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (line.startsWith('event: ')) { - currentEvent = line.slice(7).trim(); - } - else if (line.startsWith('data: ')) { - dataLines.push(line.slice(6)); - } - else if (line === '' && dataLines.length > 0) { - events.push({ event: currentEvent, data: dataLines.join('\n') }); - currentEvent = undefined; - dataLines = []; - consumed = lines.slice(0, i + 1).join('\n').length + 1; - } - } - if (consumed > 0) - this.buffer = this.buffer.slice(consumed); - return events; - } - formatSSE(event, data) { - return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; - } - closeThinkingBlock() { - if (!this.state.thinkingBlockActive) - return []; - this.state.thinkingBlockActive = false; - const events = [ - this.formatSSE('content_block_stop', { - type: 'content_block_stop', - index: this.state.blockIndex, - }), - ]; - this.state.blockIndex++; - return events; - } - closeTextBlock() { - if (!this.state.textBlockActive) - return []; - this.state.textBlockActive = false; - const events = [ - this.formatSSE('content_block_stop', { - type: 'content_block_stop', - index: this.state.blockIndex, - }), - ]; - this.state.blockIndex++; - return events; - } - closeToolCalls() { - if (this.state.activeToolCalls.size === 0) - return []; - const events = []; - for (const [_index] of this.state.activeToolCalls) { - events.push(this.formatSSE('content_block_stop', { - type: 'content_block_stop', - index: this.state.blockIndex, - })); - this.state.blockIndex++; - } - this.state.activeToolCalls.clear(); - return events; - } - closeActiveBlocks() { - return [ - ...this.closeThinkingBlock(), - ...this.closeTextBlock(), - ...this.closeToolCalls(), - ]; - } -} diff --git a/dist/router/categories.d.ts b/dist/router/categories.d.ts deleted file mode 100644 index 4758777e..00000000 --- a/dist/router/categories.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Request category detection for the learned router. - * Classifies requests into categories (coding, trading, reasoning, etc.) - * using keyword matching from router weights or built-in defaults. - */ -export type Category = 'coding' | 'trading' | 'reasoning' | 'chat' | 'creative' | 'research'; -interface CategoryResult { - category: Category; - confidence: number; - scores: Partial>; -} -/** - * Detect the primary category of a request. - * Uses provided keywords (from learned weights) or built-in defaults. - */ -export declare function detectCategory(prompt: string, categoryKeywords?: Record): CategoryResult; -/** - * Map a learned category to the legacy tier system (backward compat). - */ -export declare function mapCategoryToTier(category: Category): 'SIMPLE' | 'MEDIUM' | 'COMPLEX' | 'REASONING'; -export {}; diff --git a/dist/router/categories.js b/dist/router/categories.js deleted file mode 100644 index fa85c21e..00000000 --- a/dist/router/categories.js +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Request category detection for the learned router. - * Classifies requests into categories (coding, trading, reasoning, etc.) - * using keyword matching from router weights or built-in defaults. - */ -// Built-in category keywords (used when no learned weights available) -const DEFAULT_CATEGORY_KEYWORDS = { - coding: [ - 'function', 'class', 'import', 'def', 'SELECT', 'async', 'await', - 'const', 'let', 'var', 'return', '```', 'bug', 'error', 'fix', - 'refactor', 'implement', 'test', 'npm', 'pip', 'git', 'deploy', - 'API', 'endpoint', 'database', 'query', 'migration', 'lint', - '函数', '类', '导入', '修复', '调试', '部署', - ], - trading: [ - 'BTC', 'ETH', 'SOL', 'bitcoin', 'ethereum', 'solana', 'crypto', - 'price', 'market', 'signal', 'trade', 'buy', 'sell', 'RSI', - 'MACD', 'volume', 'bullish', 'bearish', 'support', 'resistance', - 'portfolio', 'risk', 'leverage', 'DeFi', 'token', 'swap', - '比特币', '以太坊', '价格', '市场', '交易', '信号', - ], - reasoning: [ - 'prove', 'theorem', 'derive', 'step by step', 'chain of thought', - 'formally', 'mathematical', 'proof', 'logically', 'analyze', - 'compare', 'evaluate', 'trade-off', 'pros and cons', 'why', - 'explain why', 'reasoning', 'logic', 'deduce', 'infer', - '证明', '定理', '推导', '分析', '比较', - ], - creative: [ - 'write a story', 'poem', 'creative', 'brainstorm', 'imagine', - 'generate an image', 'design', 'logo', 'illustration', 'art', - 'narrative', 'fiction', 'song', 'lyrics', 'slogan', 'tagline', - '写一个故事', '诗', '创意', '设计', '头脑风暴', - ], - research: [ - 'search', 'find', 'look up', 'what is', 'who is', 'when was', - 'summarize', 'report', 'overview', 'comparison', 'review', - 'article', 'paper', 'study', 'data', 'statistics', 'trend', - '搜索', '查找', '什么是', '总结', '报告', - ], - chat: [ - 'hello', 'hi', 'thanks', 'thank you', 'how are you', 'help', - 'translate', 'yes', 'no', 'ok', 'sure', 'good', - '你好', '谢谢', '帮我', '翻译', - ], -}; -/** - * Detect the primary category of a request. - * Uses provided keywords (from learned weights) or built-in defaults. - */ -export function detectCategory(prompt, categoryKeywords) { - const keywords = (categoryKeywords ?? DEFAULT_CATEGORY_KEYWORDS); - const lower = prompt.toLowerCase(); - const scores = {}; - let maxScore = 0; - let maxCategory = 'chat'; // default fallback - for (const [cat, kws] of Object.entries(keywords)) { - let score = 0; - for (const kw of kws) { - if (lower.includes(kw.toLowerCase())) - score++; - } - // Bonus for code blocks (strong coding signal) - if (cat === 'coding') { - const codeBlocks = (prompt.match(/```/g) || []).length / 2; - score += codeBlocks * 3; - } - if (score > 0) - scores[cat] = score; - if (score > maxScore) { - maxScore = score; - maxCategory = cat; - } - } - // Confidence: how much the winner leads the runner-up - const sortedScores = Object.values(scores).sort((a, b) => b - a); - const gap = sortedScores.length >= 2 - ? (sortedScores[0] - sortedScores[1]) / Math.max(sortedScores[0], 1) - : sortedScores.length === 1 ? 0.8 : 0; - const confidence = Math.min(0.95, 0.5 + gap * 0.5); - return { category: maxCategory, confidence, scores }; -} -/** - * Map a learned category to the legacy tier system (backward compat). - */ -export function mapCategoryToTier(category) { - switch (category) { - case 'chat': return 'SIMPLE'; - case 'research': return 'MEDIUM'; - case 'creative': return 'MEDIUM'; - case 'coding': return 'COMPLEX'; - case 'trading': return 'COMPLEX'; - case 'reasoning': return 'REASONING'; - default: return 'MEDIUM'; - } -} diff --git a/dist/router/index.d.ts b/dist/router/index.d.ts deleted file mode 100644 index f086e3b7..00000000 --- a/dist/router/index.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Smart Router for Franklin - * - * Two routing modes: - * 1. Learned — uses Elo scores from 2M+ gateway requests (router-weights.json) - * 2. Classic — 15-dimension keyword scoring (fallback when no weights) - * - * The learned router detects request category (coding, trading, reasoning, etc.) - * and picks the model with the best quality-to-cost ratio for that category. - * Local Elo adjustments personalize routing per user over time. - */ -export type Tier = 'SIMPLE' | 'MEDIUM' | 'COMPLEX' | 'REASONING'; -export type RoutingProfile = 'auto' | 'eco' | 'premium' | 'free'; -export interface RoutingResult { - model: string; - tier: Tier; - confidence: number; - signals: string[]; - savings: number; -} -export declare function routeRequest(prompt: string, profile?: RoutingProfile): RoutingResult; -/** - * Get fallback models for a tier - */ -export declare function getFallbackChain(tier: Tier, profile?: RoutingProfile): string[]; -/** - * Parse routing profile from model string - */ -export declare function parseRoutingProfile(model: string): RoutingProfile | null; diff --git a/dist/router/index.js b/dist/router/index.js deleted file mode 100644 index d446e2d8..00000000 --- a/dist/router/index.js +++ /dev/null @@ -1,333 +0,0 @@ -/** - * Smart Router for Franklin - * - * Two routing modes: - * 1. Learned — uses Elo scores from 2M+ gateway requests (router-weights.json) - * 2. Classic — 15-dimension keyword scoring (fallback when no weights) - * - * The learned router detects request category (coding, trading, reasoning, etc.) - * and picks the model with the best quality-to-cost ratio for that category. - * Local Elo adjustments personalize routing per user over time. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { MODEL_PRICING, OPUS_PRICING } from '../pricing.js'; -import { BLOCKRUN_DIR } from '../config.js'; -import { detectCategory, mapCategoryToTier } from './categories.js'; -import { selectModel } from './selector.js'; -import { computeLocalElo, blendElo } from './local-elo.js'; -// ─── Learned Weights Loading ─── -const WEIGHTS_FILE = path.join(BLOCKRUN_DIR, 'router-weights.json'); -let cachedWeights; // undefined = not loaded yet -function loadLearnedWeights() { - if (cachedWeights !== undefined) - return cachedWeights; - try { - if (fs.existsSync(WEIGHTS_FILE)) { - cachedWeights = JSON.parse(fs.readFileSync(WEIGHTS_FILE, 'utf-8')); - return cachedWeights; - } - } - catch { /* fall through */ } - cachedWeights = null; - return null; -} -// ─── Tier Model Configs ─── -const AUTO_TIERS = { - SIMPLE: { - primary: 'google/gemini-2.5-flash', - fallback: ['deepseek/deepseek-chat', 'nvidia/nemotron-ultra-253b'], - }, - MEDIUM: { - primary: 'moonshot/kimi-k2.5', - fallback: ['google/gemini-2.5-flash', 'minimax/minimax-m2.7'], - }, - COMPLEX: { - primary: 'google/gemini-3.1-pro', - fallback: ['anthropic/claude-sonnet-4.6', 'google/gemini-2.5-pro'], - }, - REASONING: { - primary: 'xai/grok-4-1-fast-reasoning', - fallback: ['deepseek/deepseek-reasoner', 'openai/o4-mini'], - }, -}; -const ECO_TIERS = { - SIMPLE: { - primary: 'nvidia/nemotron-ultra-253b', - fallback: ['nvidia/gpt-oss-120b', 'nvidia/deepseek-v3.2'], - }, - MEDIUM: { - primary: 'google/gemini-2.5-flash-lite', - fallback: ['nvidia/nemotron-ultra-253b', 'nvidia/qwen3-coder-480b'], - }, - COMPLEX: { - primary: 'google/gemini-2.5-flash-lite', - fallback: ['deepseek/deepseek-chat', 'nvidia/mistral-large-3-675b'], - }, - REASONING: { - primary: 'xai/grok-4-1-fast-reasoning', - fallback: ['deepseek/deepseek-reasoner', 'nvidia/nemotron-ultra-253b'], - }, -}; -const PREMIUM_TIERS = { - SIMPLE: { - primary: 'moonshot/kimi-k2.5', - fallback: ['anthropic/claude-haiku-4.5'], - }, - MEDIUM: { - primary: 'openai/gpt-5.3-codex', - fallback: ['anthropic/claude-sonnet-4.6'], - }, - COMPLEX: { - primary: 'anthropic/claude-opus-4.6', - fallback: ['openai/gpt-5.4', 'anthropic/claude-sonnet-4.6'], - }, - REASONING: { - primary: 'anthropic/claude-sonnet-4.6', - fallback: ['anthropic/claude-opus-4.6', 'openai/o3'], - }, -}; -// ─── Keywords for Classification ─── -const CODE_KEYWORDS = [ - 'function', 'class', 'import', 'def', 'SELECT', 'async', 'await', - 'const', 'let', 'var', 'return', '```', '函数', '类', '导入', -]; -const REASONING_KEYWORDS = [ - 'prove', 'theorem', 'derive', 'step by step', 'chain of thought', - 'formally', 'mathematical', 'proof', 'logically', '证明', '定理', '推导', -]; -const SIMPLE_KEYWORDS = [ - 'what is', 'define', 'translate', 'hello', 'yes or no', 'capital of', - 'how old', 'who is', 'when was', '什么是', '翻译', '你好', -]; -const TECHNICAL_KEYWORDS = [ - 'algorithm', 'optimize', 'architecture', 'distributed', 'kubernetes', - 'microservice', 'database', 'infrastructure', '算法', '架构', '优化', -]; -const AGENTIC_KEYWORDS = [ - 'read file', 'edit', 'modify', 'update', 'create file', 'execute', - 'deploy', 'install', 'npm', 'pip', 'fix', 'debug', 'verify', - '编辑', '修改', '部署', '安装', '修复', '调试', -]; -function countMatches(text, keywords) { - const lower = text.toLowerCase(); - return keywords.filter(kw => lower.includes(kw.toLowerCase())).length; -} -function classifyRequest(prompt, tokenCount) { - const signals = []; - let score = 0; - // Token count scoring (reduced weight - don't penalize short prompts too much) - if (tokenCount < 30) { - score -= 0.15; - signals.push('short'); - } - else if (tokenCount > 500) { - score += 0.2; - signals.push('long'); - } - // Code detection (weight: 0.20) - increased weight - const codeMatches = countMatches(prompt, CODE_KEYWORDS); - // Extra weight for code blocks (triple backticks) - const codeBlockCount = (prompt.match(/```/g) || []).length / 2; // pairs - if (codeBlockCount >= 1 || codeMatches >= 2) { - score += 0.5; - signals.push(codeBlockCount >= 1 ? 'code-block' : 'code'); - } - else if (codeMatches >= 1) { - score += 0.25; - signals.push('code-light'); - } - // Reasoning detection (weight: 0.18) - const reasoningMatches = countMatches(prompt, REASONING_KEYWORDS); - if (reasoningMatches >= 2) { - // Direct reasoning override - return { tier: 'REASONING', confidence: 0.9, signals: [...signals, 'reasoning'] }; - } - else if (reasoningMatches >= 1) { - score += 0.4; - signals.push('reasoning-light'); - } - // Simple detection (weight: -0.12) - only trigger on strong simple signals - const simpleMatches = countMatches(prompt, SIMPLE_KEYWORDS); - if (simpleMatches >= 2) { - score -= 0.4; - signals.push('simple'); - } - else if (simpleMatches >= 1 && codeMatches === 0 && tokenCount < 50) { - // Only mark as simple if no code and very short - score -= 0.25; - signals.push('simple'); - } - // Technical complexity (weight: 0.15) - increased - const techMatches = countMatches(prompt, TECHNICAL_KEYWORDS); - if (techMatches >= 2) { - score += 0.4; - signals.push('technical'); - } - else if (techMatches >= 1) { - score += 0.2; - signals.push('technical-light'); - } - // Agentic detection (weight: 0.10) - increased - const agenticMatches = countMatches(prompt, AGENTIC_KEYWORDS); - if (agenticMatches >= 3) { - score += 0.35; - signals.push('agentic'); - } - else if (agenticMatches >= 2) { - score += 0.2; - signals.push('agentic-light'); - } - // Multi-step patterns - if (/first.*then|step \d|\d\.\s/i.test(prompt)) { - score += 0.2; - signals.push('multi-step'); - } - // Question complexity - const questionCount = (prompt.match(/\?/g) || []).length; - if (questionCount > 3) { - score += 0.15; - signals.push(`${questionCount} questions`); - } - // Imperative verbs (build, create, implement, etc.) - const imperativeMatches = countMatches(prompt, [ - 'build', 'create', 'implement', 'design', 'develop', 'write', 'make', - 'generate', 'construct', '构建', '创建', '实现', '设计', '开发' - ]); - if (imperativeMatches >= 1) { - score += 0.15; - signals.push('imperative'); - } - // Map score to tier (adjusted boundaries) - let tier; - if (score < -0.1) { - tier = 'SIMPLE'; - } - else if (score < 0.25) { - tier = 'MEDIUM'; - } - else if (score < 0.45) { - tier = 'COMPLEX'; - } - else { - tier = 'REASONING'; - } - // Calculate confidence based on distance from boundary - const confidence = Math.min(0.95, 0.7 + Math.abs(score) * 0.3); - return { tier, confidence, signals }; -} -// ─── Classic Router (keyword-based fallback) ─── -function classicRouteRequest(prompt, profile) { - // Estimate token count (use byte length / 4 for better accuracy with non-ASCII) - const byteLen = Buffer.byteLength(prompt, 'utf-8'); - const tokenCount = Math.ceil(byteLen / 4); - // Classify the request - const { tier, confidence, signals } = classifyRequest(prompt, tokenCount); - // Select tier config based on profile - let tierConfigs; - switch (profile) { - case 'eco': - tierConfigs = ECO_TIERS; - break; - case 'premium': - tierConfigs = PREMIUM_TIERS; - break; - default: - tierConfigs = AUTO_TIERS; - } - const model = tierConfigs[tier].primary; - const savings = computeSavings(model); - return { model, tier, confidence, signals, savings }; -} -// ─── Main Router ─── -export function routeRequest(prompt, profile = 'auto') { - // Free profile — always use free model - if (profile === 'free') { - return { - model: 'nvidia/nemotron-ultra-253b', - tier: 'SIMPLE', - confidence: 1.0, - signals: ['free-profile'], - savings: 1.0, - }; - } - // ── Learned routing (if weights available) ── - const weights = loadLearnedWeights(); - if (weights) { - const { category, confidence } = detectCategory(prompt, weights.category_keywords); - // Apply local Elo adjustments - const localElo = computeLocalElo(); - const localCatMap = localElo.get(category); - // Create adjusted weights with blended Elo scores - const adjustedWeights = localCatMap - ? { - ...weights, - model_scores: { - ...weights.model_scores, - [category]: (weights.model_scores[category] || []).map(s => ({ - ...s, - elo: blendElo(s.elo, localCatMap.get(s.model) ?? 0), - })), - }, - } - : weights; - const selected = selectModel(category, profile, adjustedWeights); - if (selected) { - const tier = mapCategoryToTier(category); - const savings = computeSavings(selected.model); - return { - model: selected.model, - tier, - confidence, - signals: [category], - savings, - }; - } - // Fall through to classic if selectModel returns null (no candidates for category) - } - // ── Classic routing (keyword-based fallback) ── - return classicRouteRequest(prompt, profile); -} -function computeSavings(model) { - const opusCostPer1K = (OPUS_PRICING.input + OPUS_PRICING.output) / 2 / 1000; - const modelPricing = MODEL_PRICING[model]; - const modelCostPer1K = modelPricing - ? (modelPricing.input + modelPricing.output) / 2 / 1000 - : 0.005; - return Math.max(0, (opusCostPer1K - modelCostPer1K) / opusCostPer1K); -} -/** - * Get fallback models for a tier - */ -export function getFallbackChain(tier, profile = 'auto') { - let tierConfigs; - switch (profile) { - case 'eco': - tierConfigs = ECO_TIERS; - break; - case 'premium': - tierConfigs = PREMIUM_TIERS; - break; - case 'free': - return ['nvidia/nemotron-ultra-253b']; - default: - tierConfigs = AUTO_TIERS; - } - const config = tierConfigs[tier]; - return [config.primary, ...config.fallback]; -} -/** - * Parse routing profile from model string - */ -export function parseRoutingProfile(model) { - const lower = model.toLowerCase(); - if (lower === 'blockrun/auto' || lower === 'auto') - return 'auto'; - if (lower === 'blockrun/eco' || lower === 'eco') - return 'eco'; - if (lower === 'blockrun/premium' || lower === 'premium') - return 'premium'; - if (lower === 'blockrun/free' || lower === 'free') - return 'free'; - return null; -} diff --git a/dist/router/local-elo.d.ts b/dist/router/local-elo.d.ts deleted file mode 100644 index 5907d8d3..00000000 --- a/dist/router/local-elo.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Local Elo learning — adapts routing to the user's own usage patterns. - * Tracks model outcomes per category and adjusts Elo ratings locally. - * - * Storage: ~/.blockrun/router-history.jsonl (append-only, capped 2000 records) - * Never uploaded — purely local personalization. - */ -export type Outcome = 'continued' | 'switched' | 'retried' | 'error' | 'max_turns'; -/** - * Record a model outcome for local learning. - */ -export declare function recordOutcome(category: string, model: string, outcome: Outcome, toolCalls?: number): void; -/** - * Compute local Elo adjustments from history. - * Returns a map of (category → model → elo_delta). - * - * Outcomes map to win/loss: - * continued → win (+K * 0.6) - * switched → loss (-K * 1.0) - * retried → loss (-K * 0.8) - * error → loss (-K * 0.5) - * max_turns → loss (-K * 0.3) - */ -export declare function computeLocalElo(): Map>; -/** - * Get the effective Elo for a model in a category, - * blending global (server-trained) and local (user-specific) scores. - * - * effective = 0.7 * global + 0.3 * (1200 + local_delta) - */ -export declare function blendElo(globalElo: number, localDelta: number): number; diff --git a/dist/router/local-elo.js b/dist/router/local-elo.js deleted file mode 100644 index 05992e14..00000000 --- a/dist/router/local-elo.js +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Local Elo learning — adapts routing to the user's own usage patterns. - * Tracks model outcomes per category and adjusts Elo ratings locally. - * - * Storage: ~/.blockrun/router-history.jsonl (append-only, capped 2000 records) - * Never uploaded — purely local personalization. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { BLOCKRUN_DIR } from '../config.js'; -const HISTORY_FILE = path.join(BLOCKRUN_DIR, 'router-history.jsonl'); -const MAX_RECORDS = 2000; -const K_FACTOR = 32; // Elo K-factor — how much each outcome shifts the rating -/** - * Record a model outcome for local learning. - */ -export function recordOutcome(category, model, outcome, toolCalls) { - try { - fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true }); - const record = { ts: Date.now(), category, model, outcome, toolCalls }; - fs.appendFileSync(HISTORY_FILE, JSON.stringify(record) + '\n'); - // Trim periodically (10% chance) - if (Math.random() < 0.1) { - trimHistory(); - } - } - catch { - // Fire-and-forget - } -} -function trimHistory() { - try { - if (!fs.existsSync(HISTORY_FILE)) - return; - const lines = fs.readFileSync(HISTORY_FILE, 'utf-8').trim().split('\n'); - if (lines.length > MAX_RECORDS) { - fs.writeFileSync(HISTORY_FILE, lines.slice(-MAX_RECORDS).join('\n') + '\n'); - } - } - catch { /* ignore */ } -} -/** - * Compute local Elo adjustments from history. - * Returns a map of (category → model → elo_delta). - * - * Outcomes map to win/loss: - * continued → win (+K * 0.6) - * switched → loss (-K * 1.0) - * retried → loss (-K * 0.8) - * error → loss (-K * 0.5) - * max_turns → loss (-K * 0.3) - */ -export function computeLocalElo() { - const adjustments = new Map(); - try { - if (!fs.existsSync(HISTORY_FILE)) - return adjustments; - const lines = fs.readFileSync(HISTORY_FILE, 'utf-8').trim().split('\n').filter(Boolean); - for (const line of lines) { - try { - const record = JSON.parse(line); - if (!adjustments.has(record.category)) { - adjustments.set(record.category, new Map()); - } - const catMap = adjustments.get(record.category); - const current = catMap.get(record.model) ?? 0; - let delta; - switch (record.outcome) { - case 'continued': - delta = K_FACTOR * 0.6; - break; - case 'switched': - delta = -K_FACTOR * 1.0; - break; - case 'retried': - delta = -K_FACTOR * 0.8; - break; - case 'error': - delta = -K_FACTOR * 0.5; - break; - case 'max_turns': - delta = -K_FACTOR * 0.3; - break; - default: delta = 0; - } - catMap.set(record.model, current + delta); - } - catch { /* skip malformed lines */ } - } - } - catch { /* ignore read errors */ } - return adjustments; -} -/** - * Get the effective Elo for a model in a category, - * blending global (server-trained) and local (user-specific) scores. - * - * effective = 0.7 * global + 0.3 * (1200 + local_delta) - */ -export function blendElo(globalElo, localDelta) { - const localElo = 1200 + localDelta; - return 0.7 * globalElo + 0.3 * localElo; -} diff --git a/dist/router/selector.d.ts b/dist/router/selector.d.ts deleted file mode 100644 index 329358ad..00000000 --- a/dist/router/selector.d.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Model selector for the learned router. - * - * Scoring formula (4 factors): - * score = w_quality * norm_quality - * + w_cost * (1 - norm_cost) - * + w_latency * (1 - norm_latency) - * + w_efficiency * norm_efficiency - * - * Efficiency = how few tool calls a model needs to complete a task. - * A model that does it in 5 calls is better than one that loops 85 times. - * Measured as 1/avg_tool_calls_per_turn (higher = more efficient). - * - * Profile weights: - * auto — balanced: quality 0.2, cost 0.3, latency 0.25, efficiency 0.25 - * eco — cost-first: quality 0.1, cost 0.6, latency 0.15, efficiency 0.15 - * premium — quality-first: quality 0.4, cost 0.1, latency 0.25, efficiency 0.25 - * free — best efficiency among free models - */ -import type { Category } from './categories.js'; -import type { RoutingProfile } from './index.js'; -export interface ModelScore { - model: string; - elo: number; - avg_cost_per_1k?: number; - avg_latency_ms?: number; - avg_tool_calls_per_turn?: number; - requests?: number; - unique_users?: number; -} -export interface LearnedWeights { - version: number; - trained_on: number; - trained_at: string; - categories: string[]; - category_keywords?: Record; - model_scores: Record; -} -export interface SelectionResult { - model: string; - score: number; - expectedCost: number; - expectedLatency: number; - category: Category; -} -export declare function selectModel(category: Category, profile: RoutingProfile, weights: LearnedWeights): SelectionResult | null; diff --git a/dist/router/selector.js b/dist/router/selector.js deleted file mode 100644 index 9034b457..00000000 --- a/dist/router/selector.js +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Model selector for the learned router. - * - * Scoring formula (4 factors): - * score = w_quality * norm_quality - * + w_cost * (1 - norm_cost) - * + w_latency * (1 - norm_latency) - * + w_efficiency * norm_efficiency - * - * Efficiency = how few tool calls a model needs to complete a task. - * A model that does it in 5 calls is better than one that loops 85 times. - * Measured as 1/avg_tool_calls_per_turn (higher = more efficient). - * - * Profile weights: - * auto — balanced: quality 0.2, cost 0.3, latency 0.25, efficiency 0.25 - * eco — cost-first: quality 0.1, cost 0.6, latency 0.15, efficiency 0.15 - * premium — quality-first: quality 0.4, cost 0.1, latency 0.25, efficiency 0.25 - * free — best efficiency among free models - */ -import { MODEL_PRICING } from '../pricing.js'; -const PROFILE_WEIGHTS = { - auto: { quality: 0.20, cost: 0.30, latency: 0.25, efficiency: 0.25 }, - eco: { quality: 0.10, cost: 0.60, latency: 0.15, efficiency: 0.15 }, - premium: { quality: 0.40, cost: 0.10, latency: 0.25, efficiency: 0.25 }, -}; -export function selectModel(category, profile, weights) { - const candidates = weights.model_scores[category]; - if (!candidates || candidates.length === 0) - return null; - // Enrich with pricing data and defaults - const enriched = candidates.map(c => { - const pricing = MODEL_PRICING[c.model]; - const costPer1K = pricing - ? (pricing.input + pricing.output) / 2 / 1000 - : c.avg_cost_per_1k ?? 0.005; - const latencyMs = c.avg_latency_ms ?? 2000; - // Efficiency: 1/avg_tool_calls (higher = better). Default 10 calls/turn if unknown. - const toolCallsPerTurn = c.avg_tool_calls_per_turn ?? 10; - const efficiency = 1 / Math.max(1, toolCallsPerTurn); - return { ...c, costPer1K, latencyMs, efficiency }; - }); - // Filter to models we can actually route to - const available = enriched.filter(c => MODEL_PRICING[c.model]); - if (available.length === 0) - return null; - // ── Free profile: best efficiency + latency among free models ── - if (profile === 'free') { - const free = available.filter(c => c.costPer1K === 0); - if (free.length === 0) - return null; - // Score free models by efficiency (60%) + latency (40%) - const maxLat = Math.max(...free.map(c => c.latencyMs)) || 1; - const maxEff = Math.max(...free.map(c => c.efficiency)) || 1; - const selected = free.reduce((best, c) => { - const s = 0.6 * (c.efficiency / maxEff) + 0.4 * (1 - c.latencyMs / maxLat); - const bestS = 0.6 * (best.efficiency / maxEff) + 0.4 * (1 - best.latencyMs / maxLat); - return s > bestS ? c : best; - }); - return { - model: selected.model, - score: selected.efficiency, - expectedCost: 0, - expectedLatency: selected.latencyMs, - category, - }; - } - // ── Scored profiles: auto / eco / premium ── - const w = PROFILE_WEIGHTS[profile] ?? PROFILE_WEIGHTS.auto; - // Compute normalization bounds - const elos = available.map(c => c.elo); - const costs = available.map(c => c.costPer1K); - const latencies = available.map(c => c.latencyMs); - const efficiencies = available.map(c => c.efficiency); - const minElo = Math.min(...elos); - const maxElo = Math.max(...elos); - const maxCost = Math.max(...costs); - const maxLatency = Math.max(...latencies); - const maxEfficiency = Math.max(...efficiencies); - const eloRange = maxElo - minElo || 1; - const costRange = maxCost || 1; - const latencyRange = maxLatency || 1; - const efficiencyRange = maxEfficiency || 1; - let bestScore = -Infinity; - let selected = available[0]; - for (const c of available) { - const normQuality = (c.elo - minElo) / eloRange; - const normCost = c.costPer1K / costRange; - const normLatency = c.latencyMs / latencyRange; - const normEfficiency = c.efficiency / efficiencyRange; - const score = w.quality * normQuality + - w.cost * (1 - normCost) + - w.latency * (1 - normLatency) + - w.efficiency * normEfficiency; - if (score > bestScore) { - bestScore = score; - selected = c; - } - } - return { - model: selected.model, - score: bestScore, - expectedCost: selected.costPer1K, - expectedLatency: selected.latencyMs, - category, - }; -} diff --git a/dist/session/search.d.ts b/dist/session/search.d.ts deleted file mode 100644 index ec696cff..00000000 --- a/dist/session/search.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Session search — find past conversations by keyword. - * - * Inspired by Hermes Agent's FTS5 search (`hermes_state.py`). For RunCode's - * scale (last 20 sessions) we use a lightweight in-memory tokenized search - * instead of SQLite FTS5 — zero install cost, same user experience. - */ -import type { SessionMeta } from './storage.js'; -export interface SearchMatch { - session: SessionMeta; - /** Relevance score (higher = better) */ - score: number; - /** Number of times all query terms appear in this session */ - hitCount: number; - /** Best snippet (~200 chars) around the first match */ - snippet: string; - /** Which message role contained the match */ - matchedRole: 'user' | 'assistant'; -} -export interface SearchOptions { - /** Maximum number of results */ - limit?: number; - /** Filter by model substring (e.g. "sonnet") */ - model?: string; - /** Only sessions newer than this timestamp (ms) */ - since?: number; -} -/** - * Search sessions for a query string. - * Returns results ranked by relevance (term frequency + recency). - */ -export declare function searchSessions(query: string, options?: SearchOptions): SearchMatch[]; -export declare function formatSearchResults(matches: SearchMatch[], query: string): string; diff --git a/dist/session/search.js b/dist/session/search.js deleted file mode 100644 index 7e5b7617..00000000 --- a/dist/session/search.js +++ /dev/null @@ -1,229 +0,0 @@ -/** - * Session search — find past conversations by keyword. - * - * Inspired by Hermes Agent's FTS5 search (`hermes_state.py`). For RunCode's - * scale (last 20 sessions) we use a lightweight in-memory tokenized search - * instead of SQLite FTS5 — zero install cost, same user experience. - */ -import fs from 'node:fs'; -import { listSessions, getSessionFilePath } from './storage.js'; -// ─── Tokenization ───────────────────────────────────────────────────────── -function tokenize(text) { - return text - .toLowerCase() - .replace(/[^\w\s]/g, ' ') - .split(/\s+/) - .filter(t => t.length > 1); -} -function parseQuery(query) { - const phrases = []; - // Extract quoted phrases first - const cleaned = query.replace(/"([^"]+)"/g, (_, phrase) => { - phrases.push(phrase.toLowerCase()); - return ' '; - }); - const terms = tokenize(cleaned); - return { terms, phrases }; -} -// ─── Snippet Extraction ─────────────────────────────────────────────────── -function extractSnippet(content, query, maxLen = 200) { - const lower = content.toLowerCase(); - const q = query.toLowerCase(); - const idx = lower.indexOf(q); - if (idx === -1) { - // Fall back to first token match - const firstToken = tokenize(query)[0]; - if (firstToken) { - const tIdx = lower.indexOf(firstToken); - if (tIdx !== -1) - return centerSnippet(content, tIdx, firstToken.length, maxLen); - } - return content.slice(0, maxLen); - } - return centerSnippet(content, idx, q.length, maxLen); -} -function centerSnippet(content, matchStart, matchLen, maxLen) { - const padding = Math.floor((maxLen - matchLen) / 2); - const start = Math.max(0, matchStart - padding); - const end = Math.min(content.length, matchStart + matchLen + padding); - const prefix = start > 0 ? '...' : ''; - const suffix = end < content.length ? '...' : ''; - return (prefix + content.slice(start, end) + suffix).replace(/\s+/g, ' ').trim(); -} -function extractMessageText(msg) { - if (typeof msg.content === 'string') - return msg.content; - if (Array.isArray(msg.content)) { - return msg.content - .map((part) => { - if (typeof part === 'string') - return part; - if (!part || typeof part !== 'object') - return ''; - const p = part; - if (p.type === 'text' && typeof p.text === 'string') - return p.text; - if (p.type === 'tool_use' && typeof p.name === 'string') - return `[tool:${p.name}]`; - if (p.type === 'tool_result') { - const c = p.content; - if (typeof c === 'string') - return c; - if (Array.isArray(c)) { - return c.map((cp) => { - if (typeof cp === 'string') - return cp; - if (cp && typeof cp === 'object' && 'text' in cp) - return String(cp.text); - return ''; - }).join(' '); - } - } - return ''; - }) - .join(' '); - } - return ''; -} -// ─── Core Search ────────────────────────────────────────────────────────── -/** - * Search sessions for a query string. - * Returns results ranked by relevance (term frequency + recency). - */ -export function searchSessions(query, options = {}) { - const { limit = 10, model, since } = options; - const { terms, phrases } = parseQuery(query); - if (terms.length === 0 && phrases.length === 0) - return []; - const sessions = listSessions(); - const results = []; - for (const session of sessions) { - if (model && !session.model.toLowerCase().includes(model.toLowerCase())) - continue; - if (since && session.updatedAt < since) - continue; - const match = scoreSession(session, terms, phrases, query); - if (match) - results.push(match); - } - // Sort by score desc, then recency desc - results.sort((a, b) => { - if (b.score !== a.score) - return b.score - a.score; - return b.session.updatedAt - a.session.updatedAt; - }); - return results.slice(0, limit); -} -function scoreSession(session, terms, phrases, originalQuery) { - // Locate the session's JSONL file (search in sessions dir) - const sessionFile = findSessionFile(session.id); - if (!sessionFile) - return null; - let rawContent; - try { - rawContent = fs.readFileSync(sessionFile, 'utf-8'); - } - catch { - return null; - } - // Parse messages - const messages = []; - for (const line of rawContent.split('\n')) { - if (!line.trim()) - continue; - try { - const msg = JSON.parse(line); - if (msg && typeof msg === 'object' && 'role' in msg) { - messages.push(msg); - } - } - catch { /* skip malformed */ } - } - if (messages.length === 0) - return null; - // Score each message - let totalScore = 0; - let hitCount = 0; - let bestSnippet = ''; - let bestMatchedRole = 'user'; - let bestMessageScore = 0; - for (const msg of messages) { - const text = extractMessageText(msg); - if (!text) - continue; - const lowerText = text.toLowerCase(); - // Score: sum of term frequencies + phrase bonuses - let msgScore = 0; - for (const term of terms) { - const count = countOccurrences(lowerText, term); - if (count > 0) { - msgScore += count; - hitCount += count; - } - } - for (const phrase of phrases) { - const count = countOccurrences(lowerText, phrase); - if (count > 0) { - // Phrase matches are worth 3x term matches - msgScore += count * 3; - hitCount += count; - } - } - // Assistant matches slightly preferred (usually more substantive) - if (msg.role === 'assistant') - msgScore *= 1.1; - if (msgScore > bestMessageScore) { - bestMessageScore = msgScore; - bestSnippet = extractSnippet(text, originalQuery); - bestMatchedRole = msg.role === 'assistant' ? 'assistant' : 'user'; - } - totalScore += msgScore; - } - if (totalScore === 0) - return null; - // Recency bonus: newer sessions get a small boost - const ageDays = (Date.now() - session.updatedAt) / (1000 * 60 * 60 * 24); - const recencyBonus = Math.max(0, 5 - ageDays * 0.1); - const finalScore = totalScore + recencyBonus; - return { - session, - score: finalScore, - hitCount, - snippet: bestSnippet, - matchedRole: bestMatchedRole, - }; -} -function countOccurrences(text, needle) { - if (!needle) - return 0; - let count = 0; - let idx = 0; - while ((idx = text.indexOf(needle, idx)) !== -1) { - count++; - idx += needle.length; - } - return count; -} -function findSessionFile(sessionId) { - const p = getSessionFilePath(sessionId); - return fs.existsSync(p) ? p : null; -} -// ─── Display ────────────────────────────────────────────────────────────── -export function formatSearchResults(matches, query) { - if (matches.length === 0) { - return `\nNo sessions found matching "${query}".\n`; - } - const lines = []; - lines.push(`\n Found ${matches.length} session${matches.length === 1 ? '' : 's'} matching "${query}":\n`); - for (let i = 0; i < matches.length; i++) { - const m = matches[i]; - const date = new Date(m.session.updatedAt).toISOString().slice(0, 16).replace('T', ' '); - const hitLabel = m.hitCount === 1 ? 'hit' : 'hits'; - lines.push(` ${i + 1}. ${m.session.id}`); - lines.push(` ${date} | ${m.session.model} | ${m.hitCount} ${hitLabel} | score ${m.score.toFixed(1)}`); - lines.push(` [${m.matchedRole}] ${m.snippet}`); - lines.push(''); - } - lines.push(` Resume: runcode (then /resume )\n`); - return lines.join('\n'); -} diff --git a/dist/session/storage.d.ts b/dist/session/storage.d.ts deleted file mode 100644 index b1687b82..00000000 --- a/dist/session/storage.d.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Session persistence for runcode. - * Saves conversation history as JSONL for resume capability. - */ -import type { Dialogue } from '../agent/types.js'; -export interface SessionMeta { - id: string; - model: string; - workDir: string; - createdAt: number; - updatedAt: number; - turnCount: number; - messageCount: number; - inputTokens?: number; - outputTokens?: number; - costUsd?: number; - savedVsOpusUsd?: number; -} -/** Get the absolute path to a session's JSONL file (for external readers like search). */ -export declare function getSessionFilePath(id: string): string; -/** - * Create a new session ID based on timestamp. - */ -export declare function createSessionId(): string; -/** - * Save a message to the session transcript (append-only JSONL). - */ -export declare function appendToSession(sessionId: string, message: Dialogue): void; -/** - * Update session metadata. - */ -export declare function updateSessionMeta(sessionId: string, meta: Partial): void; -/** - * Load session metadata. - */ -export declare function loadSessionMeta(sessionId: string): SessionMeta | null; -/** - * Load full session history from JSONL. - */ -export declare function loadSessionHistory(sessionId: string): Dialogue[]; -/** - * List all saved sessions, newest first. - */ -export declare function listSessions(): SessionMeta[]; -/** - * Prune old sessions beyond MAX_SESSIONS. - */ -/** - * Prune old sessions beyond MAX_SESSIONS. - * Accepts optional activeSessionId to protect from deletion. - */ -export declare function pruneOldSessions(activeSessionId?: string): void; diff --git a/dist/session/storage.js b/dist/session/storage.js deleted file mode 100644 index 94a702b7..00000000 --- a/dist/session/storage.js +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Session persistence for runcode. - * Saves conversation history as JSONL for resume capability. - */ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { randomUUID } from 'node:crypto'; -import { BLOCKRUN_DIR } from '../config.js'; -const MAX_SESSIONS = 20; // Keep last 20 sessions -let resolvedSessionsDir = null; -function getSessionsDir() { - if (resolvedSessionsDir) - return resolvedSessionsDir; - const preferred = path.join(BLOCKRUN_DIR, 'sessions'); - const fallback = path.join(os.tmpdir(), 'runcode', 'sessions'); - for (const dir of [preferred, fallback]) { - try { - fs.mkdirSync(dir, { recursive: true }); - resolvedSessionsDir = dir; - return dir; - } - catch { - // Try the next candidate. - } - } - // If both locations fail, keep the preferred path so the original error - // surfaces from the caller rather than hiding the failure. - resolvedSessionsDir = preferred; - return resolvedSessionsDir; -} -function sessionPath(id) { - return path.join(getSessionsDir(), `${id}.jsonl`); -} -/** Get the absolute path to a session's JSONL file (for external readers like search). */ -export function getSessionFilePath(id) { - return sessionPath(id); -} -function metaPath(id) { - return path.join(getSessionsDir(), `${id}.meta.json`); -} -function withWritableSessionDir(action) { - const preferred = path.join(BLOCKRUN_DIR, 'sessions'); - const fallback = path.join(os.tmpdir(), 'runcode', 'sessions'); - try { - action(); - } - catch (err) { - const code = err.code; - const shouldFallback = (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') && - resolvedSessionsDir === preferred; - if (!shouldFallback) - throw err; - fs.mkdirSync(fallback, { recursive: true }); - resolvedSessionsDir = fallback; - action(); - } -} -/** - * Create a new session ID based on timestamp. - */ -export function createSessionId() { - const now = new Date(); - const ts = now.toISOString().replace(/[:.]/g, '-'); - const suffix = randomUUID().slice(0, 8); - return `session-${ts}-${suffix}`; -} -/** - * Save a message to the session transcript (append-only JSONL). - */ -export function appendToSession(sessionId, message) { - const line = JSON.stringify(message) + '\n'; - withWritableSessionDir(() => { - fs.appendFileSync(sessionPath(sessionId), line); - }); -} -/** - * Update session metadata. - */ -export function updateSessionMeta(sessionId, meta) { - withWritableSessionDir(() => { - const existing = loadSessionMeta(sessionId); - const updated = { - id: sessionId, - model: meta.model || existing?.model || 'unknown', - workDir: meta.workDir || existing?.workDir || '', - createdAt: existing?.createdAt || Date.now(), - updatedAt: Date.now(), - turnCount: meta.turnCount ?? existing?.turnCount ?? 0, - messageCount: meta.messageCount ?? existing?.messageCount ?? 0, - inputTokens: meta.inputTokens ?? existing?.inputTokens ?? 0, - outputTokens: meta.outputTokens ?? existing?.outputTokens ?? 0, - costUsd: meta.costUsd ?? existing?.costUsd ?? 0, - savedVsOpusUsd: meta.savedVsOpusUsd ?? existing?.savedVsOpusUsd ?? 0, - }; - fs.writeFileSync(metaPath(sessionId), JSON.stringify(updated, null, 2)); - }); -} -/** - * Load session metadata. - */ -export function loadSessionMeta(sessionId) { - try { - return JSON.parse(fs.readFileSync(metaPath(sessionId), 'utf-8')); - } - catch { - return null; - } -} -/** - * Load full session history from JSONL. - */ -export function loadSessionHistory(sessionId) { - try { - const content = fs.readFileSync(sessionPath(sessionId), 'utf-8'); - const lines = content.trim().split('\n').filter(Boolean); - const results = []; - for (const line of lines) { - try { - results.push(JSON.parse(line)); - } - catch { - // Skip corrupted lines — partial writes from crashes - continue; - } - } - return results; - } - catch { - return []; - } -} -/** - * List all saved sessions, newest first. - */ -export function listSessions() { - const sessionsDir = getSessionsDir(); - try { - const files = fs.readdirSync(sessionsDir) - .filter(f => f.endsWith('.meta.json')); - const metas = []; - for (const file of files) { - try { - const meta = JSON.parse(fs.readFileSync(path.join(sessionsDir, file), 'utf-8')); - metas.push(meta); - } - catch { /* skip corrupted */ } - } - // Filter out ghost sessions (0 messages) - const filtered = metas.filter(m => m.messageCount > 0); - return filtered.sort((a, b) => b.updatedAt - a.updatedAt); - } - catch { - return []; - } -} -/** - * Prune old sessions beyond MAX_SESSIONS. - */ -/** - * Prune old sessions beyond MAX_SESSIONS. - * Accepts optional activeSessionId to protect from deletion. - */ -export function pruneOldSessions(activeSessionId) { - const sessions = listSessions(); - if (sessions.length <= MAX_SESSIONS) - return; - const toDelete = sessions - .slice(MAX_SESSIONS) - .filter(s => s.id !== activeSessionId); // Never delete active session - for (const s of toDelete) { - try { - fs.unlinkSync(sessionPath(s.id)); - } - catch { /* ok */ } - try { - fs.unlinkSync(metaPath(s.id)); - } - catch { /* ok */ } - } - // Also clean up ghost sessions (0 messages, older than 5 minutes) - const fiveMinAgo = Date.now() - 5 * 60 * 1000; - for (const s of sessions) { - if (s.id === activeSessionId) - continue; - if (s.messageCount === 0 && s.createdAt < fiveMinAgo) { - try { - fs.unlinkSync(sessionPath(s.id)); - } - catch { /* ok */ } - try { - fs.unlinkSync(metaPath(s.id)); - } - catch { /* ok */ } - } - } -} diff --git a/dist/social/a11y.d.ts b/dist/social/a11y.d.ts deleted file mode 100644 index efe286b4..00000000 --- a/dist/social/a11y.d.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Helpers for finding elements in the flat [depth-idx] ref tree produced by - * SocialBrowser.snapshot(). Ported from social-bot's bot/browser.py regex - * model, where elements are located by role + label rather than CSS/XPath. - * - * The mental model: snapshot() returns a string like - * - * [0-0] main: Timeline - * [1-0] article: post by user - * [2-0] link: Mar 16 - * [2-1] StaticText: hello world - * [1-1] button: Reply - * [1-2] textbox: Post text - * - * …and these helpers find the refs via regex on that string. - */ -/** - * Find all refs matching a role and a label pattern. - * - * @param tree The snapshot output string - * @param role AX role, e.g. "button", "link", "textbox", "article" - * @param label Regex source for the label (default `.*` — any). Substring matches count. - * @returns Array of ref ids like ["0-0", "1-3"] in document order - */ -export declare function findRefs(tree: string, role: string, label?: string): string[]; -/** - * Find refs AND their labels. Useful when you want both the click target - * (ref) and the visible text (label) in one pass. - */ -export declare function findRefsWithLabels(tree: string, role: string, label?: string): Array<{ - ref: string; - label: string; -}>; -/** - * Find text content inside the tree (not a ref — just the visible string). - * Useful for reading static text like tweet snippets. - */ -export declare function findStaticText(tree: string): string[]; -/** - * Split an X timeline/search snapshot into per-article blocks so we can - * process each tweet independently. Returns the text slice for each article, - * starting at the `[N-M] article:` line and ending at the next article or - * end-of-tree. - */ -export declare function extractArticleBlocks(tree: string): Array<{ - ref: string; - text: string; -}>; -/** - * Regex pattern for X's "time-link" text: "Mar 16", "5h", "just now", "2d", etc. - * This doubles as the "this is a tweet" signal in social-bot — the only link - * inside an article block with this label shape is the permalink to the tweet. - */ -export declare const X_TIME_LINK_PATTERN = "(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d+(?:,?\\s+\\d{4})?|\\d+[smhd]|\\d+\\s+(?:second|minute|hour|day|week|month|year)s?\\s+ago|just now|now|yesterday|\\d{1,2}:\\d{2}\\s*[AaPp][Mm]|\\d{4}\u5E74\\d{1,2}\u6708\\d{1,2}\u65E5"; diff --git a/dist/social/a11y.js b/dist/social/a11y.js deleted file mode 100644 index f2be19d1..00000000 --- a/dist/social/a11y.js +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Helpers for finding elements in the flat [depth-idx] ref tree produced by - * SocialBrowser.snapshot(). Ported from social-bot's bot/browser.py regex - * model, where elements are located by role + label rather than CSS/XPath. - * - * The mental model: snapshot() returns a string like - * - * [0-0] main: Timeline - * [1-0] article: post by user - * [2-0] link: Mar 16 - * [2-1] StaticText: hello world - * [1-1] button: Reply - * [1-2] textbox: Post text - * - * …and these helpers find the refs via regex on that string. - */ -/** - * Find all refs matching a role and a label pattern. - * - * @param tree The snapshot output string - * @param role AX role, e.g. "button", "link", "textbox", "article" - * @param label Regex source for the label (default `.*` — any). Substring matches count. - * @returns Array of ref ids like ["0-0", "1-3"] in document order - */ -export function findRefs(tree, role, label = '.*') { - const re = new RegExp(`\\[(\\d+-\\d+)\\]\\s+${escapeRegex(role)}:\\s*${label}`, 'g'); - const out = []; - let m; - while ((m = re.exec(tree)) !== null) { - out.push(m[1]); - } - return out; -} -/** - * Find refs AND their labels. Useful when you want both the click target - * (ref) and the visible text (label) in one pass. - */ -export function findRefsWithLabels(tree, role, label = '.*') { - const re = new RegExp(`\\[(\\d+-\\d+)\\]\\s+${escapeRegex(role)}:\\s*(${label})`, 'g'); - const out = []; - let m; - while ((m = re.exec(tree)) !== null) { - out.push({ ref: m[1], label: m[2].trim() }); - } - return out; -} -/** - * Find text content inside the tree (not a ref — just the visible string). - * Useful for reading static text like tweet snippets. - */ -export function findStaticText(tree) { - const re = /\[\d+-\d+\]\s+StaticText:\s*(.+)/g; - const out = []; - let m; - while ((m = re.exec(tree)) !== null) { - out.push(m[1].trim()); - } - return out; -} -/** - * Split an X timeline/search snapshot into per-article blocks so we can - * process each tweet independently. Returns the text slice for each article, - * starting at the `[N-M] article:` line and ending at the next article or - * end-of-tree. - */ -export function extractArticleBlocks(tree) { - const articleStarts = []; - const re = /\[(\d+-\d+)\]\s+article:/g; - let m; - while ((m = re.exec(tree)) !== null) { - articleStarts.push({ ref: m[1], pos: m.index }); - } - const out = []; - for (let i = 0; i < articleStarts.length; i++) { - const start = articleStarts[i].pos; - const end = i + 1 < articleStarts.length ? articleStarts[i + 1].pos : tree.length; - out.push({ ref: articleStarts[i].ref, text: tree.slice(start, end) }); - } - return out; -} -/** - * Regex pattern for X's "time-link" text: "Mar 16", "5h", "just now", "2d", etc. - * This doubles as the "this is a tweet" signal in social-bot — the only link - * inside an article block with this label shape is the permalink to the tweet. - */ -// Matches all known X time-link formats: -// "Mar 16", "Apr 12, 2026", "5h", "5m", "2d", "30s", "just now", "now" -// "31 seconds ago", "35 minutes ago", "4 hours ago" (full-word format) -// "Yesterday", "Apr 12", "12:30 AM", "2026年4月12日" (CJK) -export const X_TIME_LINK_PATTERN = '(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d+(?:,?\\s+\\d{4})?|\\d+[smhd]|\\d+\\s+(?:second|minute|hour|day|week|month|year)s?\\s+ago|just now|now|yesterday|\\d{1,2}:\\d{2}\\s*[AaPp][Mm]|\\d{4}年\\d{1,2}月\\d{1,2}日'; -function escapeRegex(s) { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} diff --git a/dist/social/ai.d.ts b/dist/social/ai.d.ts deleted file mode 100644 index 10e24632..00000000 --- a/dist/social/ai.d.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * AI layer for Franklin's social subsystem. - * - * Two functions mirroring social-bot/bot/ai_engine.py: - * - detectProduct() — keyword-score product router (no LLM, zero cost) - * - generateReply() — calls Franklin's ModelClient for actual reply text - * - * Key improvements over social-bot: - * - Uses Franklin's multi-model router (tier-based: free / cheap / premium) - * instead of hardcoded Claude Sonnet for every call — throwaway replies - * can run on free NVIDIA models, high-value leads can escalate to Opus. - * - x402 payment flow handled by ModelClient — no Anthropic billing relationship. - * - SKIP detection lives in the caller so we can commit a 'skipped' record - * for visibility in stats. - */ -import type { ProductConfig, SocialConfig } from './config.js'; -import type { Chain } from '../config.js'; -export interface GenerateReplyOptions { - post: { - title: string; - snippet: string; - platform: 'x' | 'reddit'; - }; - product: ProductConfig; - config: SocialConfig; - model: string; - apiUrl: string; - chain: Chain; - debug?: boolean; -} -export interface GenerateReplyResult { - reply: string | null; - raw: string; - usage: { - inputTokens: number; - outputTokens: number; - }; - cost: number; -} -/** - * Score each product by how many of its trigger_keywords appear in the post. - * Returns the top-scoring product, or null if no product has any matches. - * - * Deterministic, zero-cost, debuggable. Social-bot uses the exact same - * pattern and it's the right call for this stage — no need to pay an LLM - * to ask "which of my products does this post mention". - */ -export declare function detectProduct(postText: string, products: ProductConfig[]): ProductConfig | null; -/** - * Build the system prompt for a given product + style ruleset. - */ -export declare function buildSystemPrompt(product: ProductConfig, config: SocialConfig): string; -/** - * Build the user prompt containing the post content. - */ -export declare function buildUserPrompt(post: GenerateReplyOptions['post']): string; -/** - * Generate a reply via Franklin's ModelClient. Returns { reply: null } if - * the model said SKIP or the output was too short to be useful. - */ -export declare function generateReply(opts: GenerateReplyOptions): Promise; diff --git a/dist/social/ai.js b/dist/social/ai.js deleted file mode 100644 index b810c8f1..00000000 --- a/dist/social/ai.js +++ /dev/null @@ -1,103 +0,0 @@ -/** - * AI layer for Franklin's social subsystem. - * - * Two functions mirroring social-bot/bot/ai_engine.py: - * - detectProduct() — keyword-score product router (no LLM, zero cost) - * - generateReply() — calls Franklin's ModelClient for actual reply text - * - * Key improvements over social-bot: - * - Uses Franklin's multi-model router (tier-based: free / cheap / premium) - * instead of hardcoded Claude Sonnet for every call — throwaway replies - * can run on free NVIDIA models, high-value leads can escalate to Opus. - * - x402 payment flow handled by ModelClient — no Anthropic billing relationship. - * - SKIP detection lives in the caller so we can commit a 'skipped' record - * for visibility in stats. - */ -import { ModelClient } from '../agent/llm.js'; -import { estimateCost } from '../pricing.js'; -/** - * Score each product by how many of its trigger_keywords appear in the post. - * Returns the top-scoring product, or null if no product has any matches. - * - * Deterministic, zero-cost, debuggable. Social-bot uses the exact same - * pattern and it's the right call for this stage — no need to pay an LLM - * to ask "which of my products does this post mention". - */ -export function detectProduct(postText, products) { - if (products.length === 0) - return null; - const text = postText.toLowerCase(); - let best = null; - for (const p of products) { - let score = 0; - for (const kw of p.trigger_keywords) { - if (text.includes(kw.toLowerCase())) - score++; - } - if (!best || score > best.score) { - best = { product: p, score }; - } - } - return best && best.score > 0 ? best.product : null; -} -/** - * Build the system prompt for a given product + style ruleset. - */ -export function buildSystemPrompt(product, config) { - const rules = config.reply_style.rules.map((r) => `- ${r}`).join('\n'); - return (`You are replying on behalf of the maker of "${product.name}".\n\n` + - `Product description:\n${product.description}\n\n` + - `Reply style rules:\n${rules}\n\n` + - `You are hands-on, experienced, and speak from lived reality. ` + - `You never sound like a marketer. You do not use emojis or hashtags. ` + - `If the post is not a good fit for the product, reply with exactly: SKIP`); -} -/** - * Build the user prompt containing the post content. - */ -export function buildUserPrompt(post) { - return (`Platform: ${post.platform}\n` + - `Post title: ${post.title.slice(0, 200)}\n\n` + - `Post content:\n${post.snippet.slice(0, 800)}\n\n` + - `Write a reply following the rules in the system prompt. ` + - `If the post is not relevant to the product, respond with SKIP only.`); -} -/** - * Generate a reply via Franklin's ModelClient. Returns { reply: null } if - * the model said SKIP or the output was too short to be useful. - */ -export async function generateReply(opts) { - const system = buildSystemPrompt(opts.product, opts.config); - const user = buildUserPrompt(opts.post); - const maxLen = opts.config.x.max_length; - const client = new ModelClient({ - apiUrl: opts.apiUrl, - chain: opts.chain, - debug: opts.debug, - }); - const result = await client.complete({ - model: opts.model, - messages: [{ role: 'user', content: user }], - system, - max_tokens: 400, - stream: true, - temperature: 0.7, - }); - // Extract the text from content parts - const text = result.content - .filter((p) => p.type === 'text') - .map((p) => p.text) - .join('') - .trim(); - const cost = estimateCost(opts.model, result.usage.inputTokens, result.usage.outputTokens, 1); - // SKIP detection — model may say "SKIP", "SKIP." or short/empty - if (!text || text.toUpperCase().startsWith('SKIP') || text.length < 20) { - return { reply: null, raw: text, usage: result.usage, cost }; - } - // Trim to max length with a small buffer - let reply = text; - if (reply.length > maxLen + 50) { - reply = reply.slice(0, maxLen).replace(/\s+\S*$/, '') + '…'; - } - return { reply, raw: text, usage: result.usage, cost }; -} diff --git a/dist/social/browser-pool.d.ts b/dist/social/browser-pool.d.ts deleted file mode 100644 index b58c0e9c..00000000 --- a/dist/social/browser-pool.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Singleton browser pool for Franklin's social subsystem. - * Wraps SocialBrowser with idle-timeout lifecycle management so the - * browser stays warm across sequential social tool calls but shuts - * down automatically after 5 minutes of inactivity. - */ -import { SocialBrowser } from './browser.js'; -declare class BrowserPool { - private browser; - private idleTimer; - /** - * Get a ready-to-use browser instance. If one is already running, - * reset the idle timer and return it. Otherwise launch a new one. - */ - getBrowser(): Promise; - /** - * Signal that the caller is done with the browser for now. - * Starts (or resets) the idle timer. When it fires the browser - * is closed automatically. - */ - releaseBrowser(): void; - /** - * Immediately close the browser and clear the idle timer. - */ - closeBrowser(): Promise; - private resetIdleTimer; -} -export declare const browserPool: BrowserPool; -export {}; diff --git a/dist/social/browser-pool.js b/dist/social/browser-pool.js deleted file mode 100644 index 5fb28396..00000000 --- a/dist/social/browser-pool.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Singleton browser pool for Franklin's social subsystem. - * Wraps SocialBrowser with idle-timeout lifecycle management so the - * browser stays warm across sequential social tool calls but shuts - * down automatically after 5 minutes of inactivity. - */ -import { SocialBrowser } from './browser.js'; -const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes -class BrowserPool { - browser = null; - idleTimer = null; - /** - * Get a ready-to-use browser instance. If one is already running, - * reset the idle timer and return it. Otherwise launch a new one. - */ - async getBrowser() { - if (this.browser) { - this.resetIdleTimer(); - return this.browser; - } - const browser = new SocialBrowser({ headless: false }); - await browser.launch(); - this.browser = browser; - this.resetIdleTimer(); - return this.browser; - } - /** - * Signal that the caller is done with the browser for now. - * Starts (or resets) the idle timer. When it fires the browser - * is closed automatically. - */ - releaseBrowser() { - this.resetIdleTimer(); - } - /** - * Immediately close the browser and clear the idle timer. - */ - async closeBrowser() { - if (this.idleTimer) { - clearTimeout(this.idleTimer); - this.idleTimer = null; - } - if (this.browser) { - await this.browser.close(); - this.browser = null; - } - } - resetIdleTimer() { - if (this.idleTimer) { - clearTimeout(this.idleTimer); - } - this.idleTimer = setTimeout(async () => { - await this.closeBrowser(); - }, IDLE_TIMEOUT); - } -} -export const browserPool = new BrowserPool(); diff --git a/dist/social/browser.d.ts b/dist/social/browser.d.ts deleted file mode 100644 index fd241739..00000000 --- a/dist/social/browser.d.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Native Playwright-core wrapper for Franklin's social subsystem. - * - * Mirrors the 9 browser primitives social-bot exposes via its `browse` CLI - * (open, snapshot, click, type, press, scroll, screenshot, getUrl, close). - * Persistent context so login state survives across runs: - * - * ~/.blockrun/social-chrome-profile/ - * - * Unlike social-bot's shell=True subprocess calls, every interaction goes - * through Playwright's argv-based API — no shell injection surface even if - * the LLM generates `$(rm -rf /)` as reply text. - */ -export declare const SOCIAL_PROFILE_DIR: string; -/** - * Ref assigned to every interactive AX node. Format matches social-bot: - * [depth-index] - * e.g. [0-3], [2-17]. Depth is the tree nesting level; index is the - * order within that level. - */ -export interface AxRef { - id: string; - role: string; - name: string; - selector: string; -} -interface AxNode { - role?: string; - name?: string; - value?: string; - description?: string; - children?: AxNode[]; -} -/** - * Walk an AX tree and produce: - * 1. A flat text dump with [depth-idx] refs (for regex-based element finding) - * 2. A map of ref ID → role/name/selector for click-by-ref lookups - * - * The flat text shape intentionally mirrors social-bot's `browse snapshot` - * output so code patterns and regexes are directly portable. - */ -export declare function serializeAxTree(root: AxNode): { - tree: string; - refs: Map; -}; -export interface BrowserOptions { - headless?: boolean; - channel?: 'chrome' | 'chromium' | 'msedge'; - slowMo?: number; - viewport?: { - width: number; - height: number; - }; -} -/** - * Franklin's social browser driver. Lazy-imports playwright-core so the - * rest of the CLI stays fast to start. - */ -export declare class SocialBrowser { - private context; - private page; - private lastRefs; - private opts; - constructor(opts?: BrowserOptions); - launch(): Promise; - close(): Promise; - open(url: string): Promise; - /** - * Capture the page as a flat [N-M] ref tree (social-bot style). - * Also stores the ref map internally so click(ref) can find the node. - */ - snapshot(): Promise; - /** - * Click by ref from the last snapshot. Throws if the ref isn't known. - * The ref map is reset on every snapshot() call. - */ - click(ref: string): Promise; - clickXY(x: number, y: number): Promise; - /** - * Type text into the currently focused element. Safe against any content - * in `text` — Playwright passes it as argv, not through a shell. - */ - type(text: string): Promise; - press(key: string): Promise; - scroll(x: number, y: number, dx: number, dy: number): Promise; - screenshot(filePath: string): Promise; - getUrl(): Promise; - getTitle(): Promise; - waitForTimeout(ms: number): Promise; - /** - * Resolve a ref from the last snapshot to its href attribute. - * Returns the href string, or null if the ref isn't a link or has no href. - */ - getHref(ref: string): Promise; - /** - * Block until the user closes the browser tab (used by the login flow). - * Resolves when the context is closed. - */ - waitForClose(): Promise; - private requirePage; -} -export {}; diff --git a/dist/social/browser.js b/dist/social/browser.js deleted file mode 100644 index aa289a46..00000000 --- a/dist/social/browser.js +++ /dev/null @@ -1,241 +0,0 @@ -/** - * Native Playwright-core wrapper for Franklin's social subsystem. - * - * Mirrors the 9 browser primitives social-bot exposes via its `browse` CLI - * (open, snapshot, click, type, press, scroll, screenshot, getUrl, close). - * Persistent context so login state survives across runs: - * - * ~/.blockrun/social-chrome-profile/ - * - * Unlike social-bot's shell=True subprocess calls, every interaction goes - * through Playwright's argv-based API — no shell injection surface even if - * the LLM generates `$(rm -rf /)` as reply text. - */ -import path from 'node:path'; -import fs from 'node:fs'; -import os from 'node:os'; -// ─── Persistent profile location ─────────────────────────────────────────── -export const SOCIAL_PROFILE_DIR = path.join(os.homedir(), '.blockrun', 'social-chrome-profile'); -function ensureProfileDir() { - if (!fs.existsSync(SOCIAL_PROFILE_DIR)) { - fs.mkdirSync(SOCIAL_PROFILE_DIR, { recursive: true }); - } -} -/** - * Walk an AX tree and produce: - * 1. A flat text dump with [depth-idx] refs (for regex-based element finding) - * 2. A map of ref ID → role/name/selector for click-by-ref lookups - * - * The flat text shape intentionally mirrors social-bot's `browse snapshot` - * output so code patterns and regexes are directly portable. - */ -export function serializeAxTree(root) { - const lines = []; - const refs = new Map(); - // Counter per-depth so each depth gets sequential indexes - const depthCounters = []; - // Counter per (role,name) to disambiguate multiple same-named elements - const nameOccurrences = new Map(); - function walk(node, depth) { - if (!node) - return; - const role = node.role || ''; - const name = (node.name || '').trim().slice(0, 120); - // Skip uninteresting nodes — they'd pollute the tree - const isInteresting = role && role !== 'none' && role !== 'presentation' && role !== 'generic'; - if (isInteresting) { - while (depthCounters.length <= depth) - depthCounters.push(0); - const idx = depthCounters[depth]++; - const id = `${depth}-${idx}`; - const labelStr = name || (node.value || '').trim().slice(0, 120); - const indent = ' '.repeat(depth); - lines.push(`${indent}[${id}] ${role}: ${labelStr}`); - // Build a Playwright locator. Prefer getByRole+name, fall back to - // nth match if there are duplicates. - const key = `${role}||${labelStr}`; - const occ = nameOccurrences.get(key) || 0; - nameOccurrences.set(key, occ + 1); - let selector; - if (labelStr) { - // Escape quotes in the name - const escaped = labelStr.replace(/"/g, '\\"'); - selector = occ === 0 - ? `role=${role}[name="${escaped}"]` - : `role=${role}[name="${escaped}"] >> nth=${occ}`; - } - else { - selector = `role=${role} >> nth=${idx}`; - } - refs.set(id, { id, role, name: labelStr, selector }); - } - if (node.children) { - for (const child of node.children) { - walk(child, isInteresting ? depth + 1 : depth); - } - } - } - walk(root, 0); - return { tree: lines.join('\n'), refs }; -} -/** - * Franklin's social browser driver. Lazy-imports playwright-core so the - * rest of the CLI stays fast to start. - */ -export class SocialBrowser { - context = null; - page = null; - lastRefs = new Map(); - opts; - constructor(opts = {}) { - this.opts = { - headless: opts.headless ?? false, - channel: opts.channel ?? 'chrome', - slowMo: opts.slowMo ?? 150, - viewport: opts.viewport ?? { width: 1280, height: 900 }, - }; - } - async launch() { - ensureProfileDir(); - // Lazy import — playwright-core is ~2MB and we don't want to pay the - // import cost on every franklin command (e.g. `franklin --version`) - const { chromium } = await import('playwright-core'); - try { - this.context = await chromium.launchPersistentContext(SOCIAL_PROFILE_DIR, { - headless: this.opts.headless, - channel: this.opts.channel, - slowMo: this.opts.slowMo, - viewport: this.opts.viewport, - // Pretend to be a regular Chrome (not headless fingerprint) - args: [ - '--disable-blink-features=AutomationControlled', - '--no-default-browser-check', - ], - }); - } - catch (err) { - const msg = err.message; - if (msg.includes('Executable doesn') || msg.includes("wasn't found")) { - throw new Error(`Chrome/Chromium not found. Run:\n franklin social setup\n\n` + - `Or install manually:\n npx playwright install chromium\n\n` + - `Original error: ${msg}`); - } - throw err; - } - // Reuse existing tab if any, else open new - const existing = this.context.pages(); - this.page = existing.length > 0 ? existing[0] : await this.context.newPage(); - } - async close() { - if (this.context) { - await this.context.close().catch(() => { }); - this.context = null; - this.page = null; - } - } - // ─── Primitives ──────────────────────────────────────────────────────── - async open(url) { - this.requirePage(); - await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); - } - /** - * Capture the page as a flat [N-M] ref tree (social-bot style). - * Also stores the ref map internally so click(ref) can find the node. - */ - async snapshot() { - this.requirePage(); - // Playwright's accessibility snapshot returns a full AX tree - const axRoot = await this.page.accessibility.snapshot({ interestingOnly: false }); - if (!axRoot) - return ''; - const { tree, refs } = serializeAxTree(axRoot); - this.lastRefs = refs; - return tree; - } - /** - * Click by ref from the last snapshot. Throws if the ref isn't known. - * The ref map is reset on every snapshot() call. - */ - async click(ref) { - this.requirePage(); - const axRef = this.lastRefs.get(ref); - if (!axRef) { - throw new Error(`Unknown ref "${ref}". Refs are only valid until the next snapshot() call. Known refs: ${this.lastRefs.size}`); - } - await this.page.locator(axRef.selector).first().click({ timeout: 15000 }); - } - async clickXY(x, y) { - this.requirePage(); - await this.page.mouse.click(x, y); - } - /** - * Type text into the currently focused element. Safe against any content - * in `text` — Playwright passes it as argv, not through a shell. - */ - async type(text) { - this.requirePage(); - await this.page.keyboard.type(text, { delay: 20 }); - } - async press(key) { - this.requirePage(); - await this.page.keyboard.press(key); - } - async scroll(x, y, dx, dy) { - this.requirePage(); - await this.page.mouse.move(x, y); - await this.page.mouse.wheel(dx, dy); - } - async screenshot(filePath) { - this.requirePage(); - await this.page.screenshot({ path: filePath, fullPage: false }); - } - async getUrl() { - this.requirePage(); - return this.page.url(); - } - async getTitle() { - this.requirePage(); - return this.page.title(); - } - async waitForTimeout(ms) { - this.requirePage(); - await this.page.waitForTimeout(ms); - } - /** - * Resolve a ref from the last snapshot to its href attribute. - * Returns the href string, or null if the ref isn't a link or has no href. - */ - async getHref(ref) { - this.requirePage(); - const axRef = this.lastRefs.get(ref); - if (!axRef) - return null; - try { - const el = this.page.locator(axRef.selector).first(); - // Try the element itself, then walk up to find the nearest - const href = await el.evaluate((node) => { - const anchor = node.closest('a') || (node.tagName === 'A' ? node : null); - return anchor ? anchor.href : null; - }); - return href; - } - catch { - return null; - } - } - /** - * Block until the user closes the browser tab (used by the login flow). - * Resolves when the context is closed. - */ - async waitForClose() { - this.requirePage(); - await new Promise((resolve) => { - this.context.on('close', () => resolve()); - this.page.on('close', () => resolve()); - }); - } - requirePage() { - if (!this.page) - throw new Error('SocialBrowser not launched — call launch() first'); - } -} diff --git a/dist/social/config.d.ts b/dist/social/config.d.ts deleted file mode 100644 index 5449b420..00000000 --- a/dist/social/config.d.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Typed config for Franklin's social subsystem. - * Stored at ~/.blockrun/social-config.json. Default written on first run. - */ -export interface ProductConfig { - name: string; - description: string; - trigger_keywords: string[]; -} -export interface SocialConfig { - version: 1; - handle: string; - products: ProductConfig[]; - x: { - search_queries: string[]; - daily_target: number; - min_delay_seconds: number; - max_length: number; - login_detection: string; - }; - reply_style: { - rules: string[]; - model_tier: 'free' | 'cheap' | 'premium'; - }; -} -export declare const CONFIG_PATH: string; -/** - * Load config from disk. If missing, write defaults and return them. - * Returns the parsed config or throws on malformed JSON. - */ -export declare function loadConfig(): SocialConfig; -/** - * Persist config back to disk. - */ -export declare function saveConfig(cfg: SocialConfig): void; -/** - * Whether the config is "ready" to run — has a handle and at least one - * product with keywords. - */ -export declare function isConfigReady(cfg: SocialConfig): { - ready: boolean; - reason?: string; -}; diff --git a/dist/social/config.js b/dist/social/config.js deleted file mode 100644 index 4f3a200a..00000000 --- a/dist/social/config.js +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Typed config for Franklin's social subsystem. - * Stored at ~/.blockrun/social-config.json. Default written on first run. - */ -import path from 'node:path'; -import fs from 'node:fs'; -import os from 'node:os'; -export const CONFIG_PATH = path.join(os.homedir(), '.blockrun', 'social-config.json'); -const DEFAULT_CONFIG = { - version: 1, - handle: '', - products: [ - { - name: 'Your Product', - description: 'Replace this with a one-paragraph description of what your product does, ' + - 'who it is for, and what pain it solves. Franklin will use this verbatim as ' + - 'the AI persona when replying to relevant posts.', - trigger_keywords: [], - }, - ], - x: { - search_queries: [], - daily_target: 20, - min_delay_seconds: 300, - max_length: 260, - login_detection: '', - }, - reply_style: { - rules: [ - 'Sound like a real human with experience, not a bot', - 'Be specific — reference details from the post you are replying to', - 'Maximum 2-3 sentences, conversational tone', - 'No marketing speak, no emojis, no hashtags', - 'If the product fits naturally, mention it once and only once', - 'If the product does not fit, reply with just: SKIP', - ], - model_tier: 'cheap', - }, -}; -/** - * Load config from disk. If missing, write defaults and return them. - * Returns the parsed config or throws on malformed JSON. - */ -export function loadConfig() { - const dir = path.dirname(CONFIG_PATH); - if (!fs.existsSync(dir)) - fs.mkdirSync(dir, { recursive: true }); - if (!fs.existsSync(CONFIG_PATH)) { - fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2)); - return { ...DEFAULT_CONFIG }; - } - const raw = fs.readFileSync(CONFIG_PATH, 'utf8'); - const parsed = JSON.parse(raw); - if (parsed.version !== 1) { - throw new Error(`Unsupported social config version ${parsed.version} (expected 1)`); - } - return parsed; -} -/** - * Persist config back to disk. - */ -export function saveConfig(cfg) { - const dir = path.dirname(CONFIG_PATH); - if (!fs.existsSync(dir)) - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2)); -} -/** - * Whether the config is "ready" to run — has a handle and at least one - * product with keywords. - */ -export function isConfigReady(cfg) { - if (!cfg.handle) - return { ready: false, reason: 'handle not set' }; - if (cfg.products.length === 0) - return { ready: false, reason: 'no products configured' }; - const hasKeywords = cfg.products.some((p) => p.trigger_keywords.length > 0); - if (!hasKeywords) - return { ready: false, reason: 'no trigger keywords on any product' }; - if (cfg.x.search_queries.length === 0) - return { ready: false, reason: 'no x.search_queries configured' }; - return { ready: true }; -} diff --git a/dist/social/db.d.ts b/dist/social/db.d.ts deleted file mode 100644 index 991538ed..00000000 --- a/dist/social/db.d.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * JSONL-backed dedup + reply log for Franklin's social subsystem. - * - * Deliberately avoids SQLite (no new native dep). Two files at: - * - * ~/.blockrun/social-replies.jsonl — append-only reply log - * ~/.blockrun/social-prekeys.jsonl — append-only snippet-level dedup - * - * Both are read into memory at startup for O(1) lookups. At 30 replies/day - * this hits 10K rows after a year — still <1MB, still fits in memory. - * - * Schema improvements over social-bot's bot/db.py: - * - `status: 'failed'` does NOT block retry (social-bot blacklists failures - * permanently, which breaks on transient network errors) - * - Pre-key dedup happens BEFORE LLM call, saving tokens on duplicates - * - Per-platform + per-handle scoping so running the bot with two accounts - * against the same DB doesn't cross-contaminate - */ -export type ReplyStatus = 'posted' | 'failed' | 'skipped' | 'drafted'; -export type Platform = 'x' | 'reddit'; -export interface ReplyRecord { - platform: Platform; - handle: string; - post_url: string; - post_title: string; - post_snippet: string; - reply_text: string; - product?: string; - status: ReplyStatus; - error_msg?: string; - cost_usd?: number; - created_at: string; -} -export interface PreKeyRecord { - platform: Platform; - handle: string; - pre_key: string; - created_at: string; -} -/** - * Compute a stable pre-key for a candidate post from its snippet fields. - * Used BEFORE the LLM generates a reply so we can skip duplicates without - * wasting any tokens. - */ -export declare function computePreKey(parts: { - author?: string; - snippet: string; - time?: string; -}): string; -/** - * Has this post been seen before (by pre-key)? If true, skip generation. - */ -export declare function hasPreKey(platform: Platform, handle: string, preKey: string): boolean; -/** - * Commit a pre-key so we don't re-consider this post. Called after we've - * decided to act on a post (either drafted, posted, or skipped by AI). - */ -export declare function commitPreKey(platform: Platform, handle: string, preKey: string): void; -/** - * Has this canonical URL been successfully posted to before? - * - * Only counts status='posted' — unlike social-bot, we do NOT permanently - * blacklist 'failed' attempts, so transient errors can be retried. - */ -export declare function hasPosted(platform: Platform, handle: string, postUrl: string): boolean; -/** - * Count today's successful posts for a handle/platform (used for daily caps). - */ -export declare function countPostedToday(platform: Platform, handle: string): number; -/** - * Append a reply record. Status can be 'drafted' (dry-run), 'posted', - * 'failed' (transient, retry OK), or 'skipped' (AI returned SKIP). - */ -export declare function logReply(rec: Omit & { - post_url: string; -}): void; -/** - * Stats summary for `franklin social stats`. - */ -export declare function getStats(platform?: Platform, handle?: string): { - total: number; - posted: number; - failed: number; - skipped: number; - drafted: number; - today: number; - totalCost: number; - byProduct: Record; -}; -/** - * Canonicalise a URL for stable dedup keys: - * - lowercase host - * - strip trailing slash - * - strip tracking params (?s=, ?t=, utm_*) - * - x.com and twitter.com are aliases - */ -export declare function normaliseUrl(raw: string): string; -/** - * Test helper — reset in-memory indexes so the next call re-reads from disk. - * Not exported from the public API via index.ts. - */ -export declare function _resetForTest(): void; diff --git a/dist/social/db.js b/dist/social/db.js deleted file mode 100644 index 7f1a6c42..00000000 --- a/dist/social/db.js +++ /dev/null @@ -1,248 +0,0 @@ -/** - * JSONL-backed dedup + reply log for Franklin's social subsystem. - * - * Deliberately avoids SQLite (no new native dep). Two files at: - * - * ~/.blockrun/social-replies.jsonl — append-only reply log - * ~/.blockrun/social-prekeys.jsonl — append-only snippet-level dedup - * - * Both are read into memory at startup for O(1) lookups. At 30 replies/day - * this hits 10K rows after a year — still <1MB, still fits in memory. - * - * Schema improvements over social-bot's bot/db.py: - * - `status: 'failed'` does NOT block retry (social-bot blacklists failures - * permanently, which breaks on transient network errors) - * - Pre-key dedup happens BEFORE LLM call, saving tokens on duplicates - * - Per-platform + per-handle scoping so running the bot with two accounts - * against the same DB doesn't cross-contaminate - */ -import path from 'node:path'; -import fs from 'node:fs'; -import os from 'node:os'; -import crypto from 'node:crypto'; -const STORE_DIR = path.join(os.homedir(), '.blockrun'); -const REPLIES_PATH = path.join(STORE_DIR, 'social-replies.jsonl'); -const PREKEYS_PATH = path.join(STORE_DIR, 'social-prekeys.jsonl'); -// ─── In-memory indexes, loaded lazily on first use ───────────────────────── -let repliesLoaded = false; -let replies = []; -let repliesByUrl = new Map(); // canonical URL → records -let repliesToday = new Map(); // handle:platform:date → count -let preKeysLoaded = false; -let preKeysSet = new Set(); // composite key for O(1) lookup -function ensureDir() { - if (!fs.existsSync(STORE_DIR)) - fs.mkdirSync(STORE_DIR, { recursive: true }); -} -function loadReplies() { - if (repliesLoaded) - return; - ensureDir(); - replies = []; - repliesByUrl.clear(); - repliesToday.clear(); - if (fs.existsSync(REPLIES_PATH)) { - const text = fs.readFileSync(REPLIES_PATH, 'utf8'); - for (const line of text.split('\n')) { - if (!line.trim()) - continue; - try { - const rec = JSON.parse(line); - replies.push(rec); - const list = repliesByUrl.get(rec.post_url) ?? []; - list.push(rec); - repliesByUrl.set(rec.post_url, list); - if (rec.status === 'posted') { - const dayKey = `${rec.handle}:${rec.platform}:${rec.created_at.slice(0, 10)}`; - repliesToday.set(dayKey, (repliesToday.get(dayKey) ?? 0) + 1); - } - } - catch { - // Skip malformed lines — append-only file may have a partial last line - } - } - } - repliesLoaded = true; -} -function loadPreKeys() { - if (preKeysLoaded) - return; - ensureDir(); - preKeysSet.clear(); - if (fs.existsSync(PREKEYS_PATH)) { - const text = fs.readFileSync(PREKEYS_PATH, 'utf8'); - for (const line of text.split('\n')) { - if (!line.trim()) - continue; - try { - const rec = JSON.parse(line); - preKeysSet.add(compositePreKey(rec.platform, rec.handle, rec.pre_key)); - } - catch { - // Skip malformed lines - } - } - } - preKeysLoaded = true; -} -function compositePreKey(platform, handle, preKey) { - return `${platform}|${handle}|${preKey}`; -} -// ─── Public API ──────────────────────────────────────────────────────────── -/** - * Compute a stable pre-key for a candidate post from its snippet fields. - * Used BEFORE the LLM generates a reply so we can skip duplicates without - * wasting any tokens. - */ -export function computePreKey(parts) { - const normalised = (parts.author ?? '').trim().toLowerCase() + '|' + - parts.snippet.trim().slice(0, 80).toLowerCase() + '|' + - (parts.time ?? '').trim(); - return crypto.createHash('sha256').update(normalised).digest('hex').slice(0, 16); -} -/** - * Has this post been seen before (by pre-key)? If true, skip generation. - */ -export function hasPreKey(platform, handle, preKey) { - loadPreKeys(); - return preKeysSet.has(compositePreKey(platform, handle, preKey)); -} -/** - * Commit a pre-key so we don't re-consider this post. Called after we've - * decided to act on a post (either drafted, posted, or skipped by AI). - */ -export function commitPreKey(platform, handle, preKey) { - loadPreKeys(); - const composite = compositePreKey(platform, handle, preKey); - if (preKeysSet.has(composite)) - return; - preKeysSet.add(composite); - const rec = { - platform, - handle, - pre_key: preKey, - created_at: new Date().toISOString(), - }; - ensureDir(); - fs.appendFileSync(PREKEYS_PATH, JSON.stringify(rec) + '\n'); -} -/** - * Has this canonical URL been successfully posted to before? - * - * Only counts status='posted' — unlike social-bot, we do NOT permanently - * blacklist 'failed' attempts, so transient errors can be retried. - */ -export function hasPosted(platform, handle, postUrl) { - loadReplies(); - const recs = repliesByUrl.get(normaliseUrl(postUrl)) ?? []; - return recs.some((r) => r.platform === platform && r.handle === handle && r.status === 'posted'); -} -/** - * Count today's successful posts for a handle/platform (used for daily caps). - */ -export function countPostedToday(platform, handle) { - loadReplies(); - const today = new Date().toISOString().slice(0, 10); - const key = `${handle}:${platform}:${today}`; - return repliesToday.get(key) ?? 0; -} -/** - * Append a reply record. Status can be 'drafted' (dry-run), 'posted', - * 'failed' (transient, retry OK), or 'skipped' (AI returned SKIP). - */ -export function logReply(rec) { - loadReplies(); - const record = { - ...rec, - post_url: normaliseUrl(rec.post_url), - created_at: new Date().toISOString(), - }; - replies.push(record); - const list = repliesByUrl.get(record.post_url) ?? []; - list.push(record); - repliesByUrl.set(record.post_url, list); - if (record.status === 'posted') { - const dayKey = `${record.handle}:${record.platform}:${record.created_at.slice(0, 10)}`; - repliesToday.set(dayKey, (repliesToday.get(dayKey) ?? 0) + 1); - } - ensureDir(); - fs.appendFileSync(REPLIES_PATH, JSON.stringify(record) + '\n'); -} -/** - * Stats summary for `franklin social stats`. - */ -export function getStats(platform, handle) { - loadReplies(); - const today = new Date().toISOString().slice(0, 10); - const filtered = replies.filter((r) => { - if (platform && r.platform !== platform) - return false; - if (handle && r.handle !== handle) - return false; - return true; - }); - const byProduct = {}; - let totalCost = 0; - let todayCount = 0; - const statusCounts = { posted: 0, failed: 0, skipped: 0, drafted: 0 }; - for (const r of filtered) { - statusCounts[r.status] = - (statusCounts[r.status] ?? 0) + 1; - if (r.product) - byProduct[r.product] = (byProduct[r.product] ?? 0) + 1; - if (r.cost_usd) - totalCost += r.cost_usd; - if (r.status === 'posted' && r.created_at.startsWith(today)) - todayCount++; - } - return { - total: filtered.length, - ...statusCounts, - today: todayCount, - totalCost, - byProduct, - }; -} -/** - * Canonicalise a URL for stable dedup keys: - * - lowercase host - * - strip trailing slash - * - strip tracking params (?s=, ?t=, utm_*) - * - x.com and twitter.com are aliases - */ -export function normaliseUrl(raw) { - try { - const u = new URL(raw); - u.hostname = u.hostname.toLowerCase(); - if (u.hostname === 'twitter.com' || u.hostname === 'mobile.twitter.com') { - u.hostname = 'x.com'; - } - // Strip common tracking params - const toStrip = []; - u.searchParams.forEach((_v, k) => { - if (k.startsWith('utm_') || k === 's' || k === 't' || k === 'ref') - toStrip.push(k); - }); - for (const k of toStrip) - u.searchParams.delete(k); - let s = u.toString(); - if (s.endsWith('/')) - s = s.slice(0, -1); - return s; - } - catch { - return raw.trim(); - } -} -/** - * Test helper — reset in-memory indexes so the next call re-reads from disk. - * Not exported from the public API via index.ts. - */ -export function _resetForTest() { - repliesLoaded = false; - preKeysLoaded = false; - replies = []; - repliesByUrl.clear(); - repliesToday.clear(); - preKeysSet.clear(); -} diff --git a/dist/social/preflight.d.ts b/dist/social/preflight.d.ts deleted file mode 100644 index 379d6811..00000000 --- a/dist/social/preflight.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Pre-flight checks before social tools can run. - * Validates config readiness and browser login state. - */ -import type { SocialBrowser } from './browser.js'; -/** - * Verify that social config is ready and the user is logged in to X. - * Returns the browser instance on success so callers can reuse it. - * - * Login detection order: - * 1. Check Cookies DB for auth_token (fast, no browser needed) - * 2. Fallback: open x.com/home and check AX tree for login_detection string - */ -export declare function checkSocialReady(): Promise<{ - ready: boolean; - reason?: string; - browser?: SocialBrowser; -}>; diff --git a/dist/social/preflight.js b/dist/social/preflight.js deleted file mode 100644 index 381a8d68..00000000 --- a/dist/social/preflight.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Pre-flight checks before social tools can run. - * Validates config readiness and browser login state. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { loadConfig, isConfigReady } from './config.js'; -import { browserPool } from './browser-pool.js'; -import { SOCIAL_PROFILE_DIR } from './browser.js'; -/** - * Quick cookie check — verify auth_token exists in the profile's Cookies DB. - * Much faster and more reliable than loading X in a browser and inspecting - * the accessibility tree for a username string. - */ -function hasSavedAuthCookie() { - const cookiesPath = path.join(SOCIAL_PROFILE_DIR, 'Default', 'Cookies'); - if (!fs.existsSync(cookiesPath)) - return false; - try { - // Read the SQLite file as binary and look for the auth_token cookie. - // This avoids requiring sqlite3 as a dependency — the cookie name is - // stored as plain text in the DB file. - const raw = fs.readFileSync(cookiesPath); - return raw.includes('auth_token'); - } - catch { - return false; - } -} -/** - * Verify that social config is ready and the user is logged in to X. - * Returns the browser instance on success so callers can reuse it. - * - * Login detection order: - * 1. Check Cookies DB for auth_token (fast, no browser needed) - * 2. Fallback: open x.com/home and check AX tree for login_detection string - */ -export async function checkSocialReady() { - const cfg = loadConfig(); - const configStatus = isConfigReady(cfg); - if (!configStatus.ready) { - return { ready: false, reason: configStatus.reason }; - } - // Fast path: check saved cookies first - if (!hasSavedAuthCookie()) { - return { ready: false, reason: 'Not logged in to X (no auth_token cookie). Run: franklin social login x' }; - } - // Cookies exist — browser login should work. Open browser for caller to use. - const browser = await browserPool.getBrowser(); - await browser.open('https://x.com/home'); - await browser.waitForTimeout(2500); - const tree = await browser.snapshot(); - // If login_detection is set, verify it as a secondary check. - // But don't fail if cookies exist — X may just not show the handle in AX tree. - if (cfg.x.login_detection && !tree.includes(cfg.x.login_detection)) { - // Check if we're actually on the home feed (not redirected to login page) - const isLoginPage = tree.includes('Sign in') && tree.includes('Create account'); - if (isLoginPage) { - browserPool.releaseBrowser(); - return { ready: false, reason: 'Cookie expired. Run: franklin social login x' }; - } - // Cookies valid, just handle not found in AX tree — proceed anyway - } - return { ready: true, browser }; -} diff --git a/dist/social/x.d.ts b/dist/social/x.d.ts deleted file mode 100644 index 79e5807f..00000000 --- a/dist/social/x.d.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * X (Twitter) flow for Franklin's social subsystem. - * - * Port of social-bot/bot/x_bot.py with three meaningful changes: - * - * 1. Pre-key dedup runs BEFORE the LLM call (social-bot runs it after, - * wasting Sonnet tokens on every duplicate). - * 2. 'failed' status does NOT blacklist — only 'posted' does. - * A transient network error can be retried on the next run. - * 3. Reply textbox is located via Playwright role selectors, not by - * counting buttons in a list — less fragile to X DOM changes. - * - * Every browser interaction uses argv-based Playwright calls — zero shell - * injection surface even if the LLM emits `$(rm -rf /)` in reply text. - */ -import { SocialBrowser } from './browser.js'; -import type { SocialConfig } from './config.js'; -import type { Chain } from '../config.js'; -export interface RunOptions { - config: SocialConfig; - model: string; - apiUrl: string; - chain: Chain; - dryRun: boolean; - debug?: boolean; - onProgress?: (msg: string) => void; -} -export interface RunResult { - considered: number; - dedupSkipped: number; - llmSkipped: number; - drafted: number; - posted: number; - failed: number; - totalCost: number; -} -export interface CandidatePost { - snippetRef: string; - articleRef: string; - snippet: string; - timeText: string; -} -/** - * Main entry point. Iterates every search query in config.x.search_queries - * and processes every visible candidate until the daily target is hit. - */ -export declare function runX(opts: RunOptions): Promise; -/** - * Post a reply to the currently-open tweet page. - * Locates the reply textbox, types the reply (paragraphs joined with - * Enter+Enter), clicks the reply button, confirms the "Your post was sent" - * banner. - */ -export declare function postReply(browser: SocialBrowser, reply: string): Promise; diff --git a/dist/social/x.js b/dist/social/x.js deleted file mode 100644 index 54bf97cc..00000000 --- a/dist/social/x.js +++ /dev/null @@ -1,292 +0,0 @@ -/** - * X (Twitter) flow for Franklin's social subsystem. - * - * Port of social-bot/bot/x_bot.py with three meaningful changes: - * - * 1. Pre-key dedup runs BEFORE the LLM call (social-bot runs it after, - * wasting Sonnet tokens on every duplicate). - * 2. 'failed' status does NOT blacklist — only 'posted' does. - * A transient network error can be retried on the next run. - * 3. Reply textbox is located via Playwright role selectors, not by - * counting buttons in a list — less fragile to X DOM changes. - * - * Every browser interaction uses argv-based Playwright calls — zero shell - * injection surface even if the LLM emits `$(rm -rf /)` in reply text. - */ -import { SocialBrowser } from './browser.js'; -import { findRefs, findStaticText, extractArticleBlocks, X_TIME_LINK_PATTERN } from './a11y.js'; -import { computePreKey, hasPreKey, commitPreKey, hasPosted, countPostedToday, logReply, } from './db.js'; -import { detectProduct, generateReply } from './ai.js'; -import { bus } from '../events/bus.js'; -import { makeEvent } from '../events/types.js'; -/** - * Main entry point. Iterates every search query in config.x.search_queries - * and processes every visible candidate until the daily target is hit. - */ -export async function runX(opts) { - const log = opts.onProgress ?? (() => { }); - const handle = opts.config.handle || 'unknown'; - const result = { - considered: 0, - dedupSkipped: 0, - llmSkipped: 0, - drafted: 0, - posted: 0, - failed: 0, - totalCost: 0, - }; - const alreadyToday = countPostedToday('x', handle); - const remainingBudget = Math.max(0, opts.config.x.daily_target - alreadyToday); - if (remainingBudget === 0) { - log(`Daily target of ${opts.config.x.daily_target} already hit today. Nothing to do.`); - return result; - } - log(`Daily budget: ${remainingBudget} posts remaining (of ${opts.config.x.daily_target})`); - const browser = new SocialBrowser({ headless: false }); - try { - await browser.launch(); - log('Browser launched. Checking login state…'); - // Verify we're logged in. If the login_detection string isn't visible on - // x.com's home page, the user needs to run `franklin social login x`. - await browser.open('https://x.com/home'); - await browser.waitForTimeout(2500); - const homeTree = await browser.snapshot(); - const loginMarker = opts.config.x.login_detection || opts.config.handle; - if (loginMarker && !homeTree.includes(loginMarker)) { - throw new Error(`Not logged in to x.com (looked for "${loginMarker}" on /home). ` + - `Run: franklin social login x`); - } - log('Login confirmed.'); - let postedThisRun = 0; - for (const query of opts.config.x.search_queries) { - if (postedThisRun >= remainingBudget) { - log(`Hit daily budget (${postedThisRun}) — stopping early.`); - break; - } - log(`\nSearching X for: ${query}`); - const searchUrl = `https://x.com/search?q=${encodeURIComponent(query)}&src=typed_query&f=live`; - await browser.open(searchUrl); - await browser.waitForTimeout(3500); - const searchTree = await browser.snapshot(); - const articles = extractArticleBlocks(searchTree); - log(` Found ${articles.length} posts in results`); - for (const article of articles) { - if (postedThisRun >= remainingBudget) - break; - result.considered++; - // Extract the clickable time-link (our open-tweet handle) and the - // first visible static text (our snippet). - const timeRefs = findRefs(article.text, 'link', X_TIME_LINK_PATTERN); - if (timeRefs.length === 0) - continue; - const timeRef = timeRefs[0]; - const texts = findStaticText(article.text); - const snippet = texts.slice(0, 3).join(' ').trim(); - if (!snippet || snippet.length < 20) - continue; - const timeLinkMatch = new RegExp(`\\[${timeRef}\\][^\\n]*`).exec(article.text); - const timeText = timeLinkMatch ? timeLinkMatch[0] : ''; - // ── Pre-key dedup: BEFORE any LLM call ── - const preKey = computePreKey({ snippet, time: timeText }); - if (hasPreKey('x', handle, preKey)) { - result.dedupSkipped++; - continue; - } - // Product routing (zero-cost keyword score) - const product = detectProduct(snippet, opts.config.products); - if (!product) { - commitPreKey('x', handle, preKey); // never retry — no product matches - continue; - } - log(`\n → ${snippet.slice(0, 80)}…`); - log(` product: ${product.name}`); - // Generate reply (this is where we spend LLM tokens) - let gen; - try { - gen = await generateReply({ - post: { title: snippet.slice(0, 120), snippet, platform: 'x' }, - product, - config: opts.config, - model: opts.model, - apiUrl: opts.apiUrl, - chain: opts.chain, - debug: opts.debug, - }); - } - catch (err) { - log(` ✗ generateReply failed: ${err.message}`); - commitPreKey('x', handle, preKey); - continue; - } - result.totalCost += gen.cost; - if (!gen.reply) { - log(` AI said SKIP`); - result.llmSkipped++; - commitPreKey('x', handle, preKey); - logReply({ - platform: 'x', - handle, - post_url: `preview:${preKey}`, - post_title: snippet.slice(0, 120), - post_snippet: snippet, - reply_text: '', - product: product.name, - status: 'skipped', - cost_usd: gen.cost, - }); - continue; - } - result.drafted++; - log(` draft: ${gen.reply}`); - // ── Dry-run short-circuit ── - if (opts.dryRun) { - commitPreKey('x', handle, preKey); - logReply({ - platform: 'x', - handle, - post_url: `preview:${preKey}`, - post_title: snippet.slice(0, 120), - post_snippet: snippet, - reply_text: gen.reply, - product: product.name, - status: 'drafted', - cost_usd: gen.cost, - }); - continue; - } - // ── Live path: open the tweet, dedup by canonical URL, post ── - let canonicalUrl = ''; - try { - await browser.click(timeRef); - await browser.waitForTimeout(3000); - canonicalUrl = await browser.getUrl(); - } - catch (err) { - log(` ✗ failed to open tweet: ${err.message}`); - logReply({ - platform: 'x', - handle, - post_url: `preview:${preKey}`, - post_title: snippet.slice(0, 120), - post_snippet: snippet, - reply_text: gen.reply, - product: product.name, - status: 'failed', - error_msg: `open-tweet: ${err.message}`, - cost_usd: gen.cost, - }); - result.failed++; - commitPreKey('x', handle, preKey); - continue; - } - if (hasPosted('x', handle, canonicalUrl)) { - log(` already posted to ${canonicalUrl} — backing out`); - commitPreKey('x', handle, preKey); - await browser.press('Alt+ArrowLeft').catch(() => { }); - await browser.waitForTimeout(1500); - continue; - } - // Post the reply - try { - await postReply(browser, gen.reply); - log(` ✓ posted to ${canonicalUrl}`); - result.posted++; - postedThisRun++; - logReply({ - platform: 'x', - handle, - post_url: canonicalUrl, - post_title: snippet.slice(0, 120), - post_snippet: snippet, - reply_text: gen.reply, - product: product.name, - status: 'posted', - cost_usd: gen.cost, - }); - commitPreKey('x', handle, preKey); - bus.emit(makeEvent({ - type: 'post.published', - source: 'social', - costUsd: gen.cost, - data: { platform: 'x', url: canonicalUrl, text: gen.reply }, - })); - // Respect the rate-limit / anti-spam delay between successes - await browser.waitForTimeout(opts.config.x.min_delay_seconds * 1000); - } - catch (err) { - log(` ✗ post failed: ${err.message}`); - result.failed++; - logReply({ - platform: 'x', - handle, - post_url: canonicalUrl, - post_title: snippet.slice(0, 120), - post_snippet: snippet, - reply_text: gen.reply, - product: product.name, - status: 'failed', - error_msg: err.message, - cost_usd: gen.cost, - }); - // Don't commitPreKey — allow retry on next run - await browser.press('Escape').catch(() => { }); - await browser.waitForTimeout(2000); - } - } - } - } - finally { - await browser.close(); - } - return result; -} -/** - * Post a reply to the currently-open tweet page. - * Locates the reply textbox, types the reply (paragraphs joined with - * Enter+Enter), clicks the reply button, confirms the "Your post was sent" - * banner. - */ -export async function postReply(browser, reply) { - // Snapshot and find the reply textbox - const tree = await browser.snapshot(); - const boxRefs = findRefs(tree, 'textbox', 'Post (your reply|text).*'); - if (boxRefs.length === 0) { - // Fallback: any textbox containing "reply" or "post" - const fallback = findRefs(tree, 'textbox', '(?:[Rr]eply|[Pp]ost).*'); - if (fallback.length === 0) - throw new Error('reply textbox not found'); - await browser.click(fallback[0]); - } - else { - await browser.click(boxRefs[0]); - } - await browser.waitForTimeout(700); - // Type paragraphs separated by double-enter. - // Strip any `$` so it never triggers a variable interpolation in some - // downstream tool. (Not required for Playwright argv, but defense in depth.) - const paragraphs = reply.split(/\n{2,}/).map((p) => p.replace(/\s+$/, '')); - for (let i = 0; i < paragraphs.length; i++) { - if (i > 0) { - await browser.press('Enter'); - await browser.press('Enter'); - } - await browser.type(paragraphs[i]); - } - await browser.waitForTimeout(700); - // Click the reply (submit) button. The modal's submit button is labelled - // "Reply" — we take the FIRST match because the inline compose-below-tweet - // form and the modal don't coexist in the DOM. - const snapAfter = await browser.snapshot(); - const replyBtns = findRefs(snapAfter, 'button', 'Reply'); - if (replyBtns.length === 0) - throw new Error('reply submit button not found'); - // If multiple reply buttons (e.g. a toolbar Reply + submit Reply), the - // submit is usually the last one with the 'Reply' label. - await browser.click(replyBtns[replyBtns.length - 1]); - await browser.waitForTimeout(2500); - // Confirm - const confirm = await browser.snapshot(); - if (!/Your post was sent|Reply sent|Your reply was sent/.test(confirm)) { - throw new Error('post-send confirmation banner not found'); - } -} diff --git a/dist/stats/failures.d.ts b/dist/stats/failures.d.ts deleted file mode 100644 index 264303f9..00000000 --- a/dist/stats/failures.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Structured failure logging for self-evolution analysis. - * Append-only JSONL at ~/.blockrun/failures.jsonl (capped 500 records). - */ -export interface FailureRecord { - timestamp: number; - model: string; - failureType: 'tool_error' | 'model_error' | 'permission_denied' | 'agent_loop'; - toolName?: string; - errorMessage: string; - recoveryAction?: string; -} -export declare function recordFailure(record: FailureRecord): void; -export declare function loadFailures(limit?: number): FailureRecord[]; -export declare function getFailureStats(): { - byTool: Map; - byType: Map; - total: number; - recentFailures: FailureRecord[]; -}; diff --git a/dist/stats/failures.js b/dist/stats/failures.js deleted file mode 100644 index 78b0e072..00000000 --- a/dist/stats/failures.js +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Structured failure logging for self-evolution analysis. - * Append-only JSONL at ~/.blockrun/failures.jsonl (capped 500 records). - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { BLOCKRUN_DIR } from '../config.js'; -const FAILURES_FILE = path.join(BLOCKRUN_DIR, 'failures.jsonl'); -const MAX_RECORDS = 500; -export function recordFailure(record) { - try { - fs.mkdirSync(path.dirname(FAILURES_FILE), { recursive: true }); - fs.appendFileSync(FAILURES_FILE, JSON.stringify(record) + '\n'); - // Trim to MAX_RECORDS (only check periodically to avoid constant reads) - if (Math.random() < 0.1) { - trimFailures(); - } - } - catch { - // Fire-and-forget — never block the critical path - } -} -function trimFailures() { - try { - if (!fs.existsSync(FAILURES_FILE)) - return; - const lines = fs.readFileSync(FAILURES_FILE, 'utf-8').trim().split('\n'); - if (lines.length > MAX_RECORDS) { - const trimmed = lines.slice(-MAX_RECORDS).join('\n') + '\n'; - fs.writeFileSync(FAILURES_FILE, trimmed); - } - } - catch { - // ignore - } -} -export function loadFailures(limit = 100) { - try { - if (!fs.existsSync(FAILURES_FILE)) - return []; - const lines = fs.readFileSync(FAILURES_FILE, 'utf-8').trim().split('\n').filter(Boolean); - return lines.slice(-limit).map(l => JSON.parse(l)); - } - catch { - return []; - } -} -export function getFailureStats() { - const records = loadFailures(500); - const byTool = new Map(); - const byType = new Map(); - for (const r of records) { - if (r.toolName) - byTool.set(r.toolName, (byTool.get(r.toolName) ?? 0) + 1); - byType.set(r.failureType, (byType.get(r.failureType) ?? 0) + 1); - } - return { - byTool, - byType, - total: records.length, - recentFailures: records.slice(-10), - }; -} diff --git a/dist/stats/format.d.ts b/dist/stats/format.d.ts deleted file mode 100644 index 1ae437e0..00000000 --- a/dist/stats/format.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Shared formatting utilities for token counts, costs, and model names. - */ -export declare function formatTokens(n: number): string; -export declare function formatUsd(n: number): string; -export declare function shortModelName(model: string): string; diff --git a/dist/stats/format.js b/dist/stats/format.js deleted file mode 100644 index 5dc273a6..00000000 --- a/dist/stats/format.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Shared formatting utilities for token counts, costs, and model names. - */ -export function formatTokens(n) { - if (n < 1000) - return String(n); - if (n < 1_000_000) - return `${(n / 1000).toFixed(1)}K`; - return `${(n / 1_000_000).toFixed(2)}M`; -} -export function formatUsd(n) { - if (n === 0) - return '$0'; - if (n < 0.01) - return `$${n.toFixed(4)}`; - if (n < 1) - return `$${n.toFixed(3)}`; - return `$${n.toFixed(2)}`; -} -export function shortModelName(model) { - const idx = model.indexOf('/'); - return idx > -1 ? model.slice(idx + 1) : model; -} diff --git a/dist/stats/insights.d.ts b/dist/stats/insights.d.ts deleted file mode 100644 index 26baf2b6..00000000 --- a/dist/stats/insights.d.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Session insights engine. - * - * Rich usage analytics from the stats tracker history. - * Inspired by hermes-agent's `agent/insights.py` and Claude Code's /insights. - * - * Provides: - * - Per-model cost and request breakdown - * - Daily activity trend (sparkline) - * - Top sessions by cost - * - Tool usage patterns - * - Cost projections and efficiency metrics - */ -export interface InsightsReport { - /** Window size in days */ - days: number; - /** Records within the window */ - windowRecords: number; - /** Total cost in window */ - totalCostUsd: number; - /** Total input tokens in window */ - totalInputTokens: number; - /** Total output tokens in window */ - totalOutputTokens: number; - /** Savings vs always using Claude Opus */ - savedVsOpusUsd: number; - /** Per-model breakdown, sorted by cost desc */ - byModel: Array<{ - model: string; - requests: number; - costUsd: number; - inputTokens: number; - outputTokens: number; - avgLatencyMs: number; - percentOfTotal: number; - }>; - /** Daily activity (last N days), oldest first */ - daily: Array<{ - date: string; - requests: number; - costUsd: number; - }>; - /** Projections */ - projections: { - avgCostPerDay: number; - projectedMonthlyUsd: number; - projectedYearlyUsd: number; - }; - /** Average request cost */ - avgRequestCostUsd: number; - /** Efficiency: cost per 1K tokens */ - costPer1KTokens: number; -} -export declare function generateInsights(days?: number): InsightsReport; -export declare function formatInsights(report: InsightsReport, days: number): string; diff --git a/dist/stats/insights.js b/dist/stats/insights.js deleted file mode 100644 index 42a63669..00000000 --- a/dist/stats/insights.js +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Session insights engine. - * - * Rich usage analytics from the stats tracker history. - * Inspired by hermes-agent's `agent/insights.py` and Claude Code's /insights. - * - * Provides: - * - Per-model cost and request breakdown - * - Daily activity trend (sparkline) - * - Top sessions by cost - * - Tool usage patterns - * - Cost projections and efficiency metrics - */ -import { loadStats } from './tracker.js'; -import { OPUS_PRICING, MODEL_PRICING } from '../pricing.js'; -import { formatTokens, formatUsd, shortModelName } from './format.js'; -// ─── Generate Report ────────────────────────────────────────────────────── -export function generateInsights(days = 30) { - const stats = loadStats(); - const now = Date.now(); - const windowStart = now - days * 24 * 60 * 60 * 1000; - const windowHistory = stats.history.filter(r => r.timestamp >= windowStart); - // Aggregate totals - let totalCost = 0; - let totalInput = 0; - let totalOutput = 0; - const modelAgg = new Map(); - for (const r of windowHistory) { - totalCost += r.costUsd; - totalInput += r.inputTokens; - totalOutput += r.outputTokens; - const existing = modelAgg.get(r.model) ?? { - requests: 0, - costUsd: 0, - inputTokens: 0, - outputTokens: 0, - totalLatencyMs: 0, - }; - existing.requests++; - existing.costUsd += r.costUsd; - existing.inputTokens += r.inputTokens; - existing.outputTokens += r.outputTokens; - existing.totalLatencyMs += r.latencyMs; - modelAgg.set(r.model, existing); - } - // Build byModel array sorted by cost - const byModel = []; - for (const [model, agg] of modelAgg.entries()) { - byModel.push({ - model, - requests: agg.requests, - costUsd: agg.costUsd, - inputTokens: agg.inputTokens, - outputTokens: agg.outputTokens, - avgLatencyMs: agg.requests > 0 ? Math.round(agg.totalLatencyMs / agg.requests) : 0, - percentOfTotal: totalCost > 0 ? (agg.costUsd / totalCost) * 100 : 0, - }); - } - byModel.sort((a, b) => b.costUsd - a.costUsd); - // Daily activity - const dailyMap = new Map(); - for (let i = 0; i < days; i++) { - const d = new Date(now - i * 24 * 60 * 60 * 1000); - const key = d.toISOString().slice(0, 10); - dailyMap.set(key, { requests: 0, costUsd: 0 }); - } - for (const r of windowHistory) { - const key = new Date(r.timestamp).toISOString().slice(0, 10); - const existing = dailyMap.get(key); - if (existing) { - existing.requests++; - existing.costUsd += r.costUsd; - } - } - const daily = Array.from(dailyMap.entries()) - .map(([date, v]) => ({ date, ...v })) - .sort((a, b) => a.date.localeCompare(b.date)); - // Calculate savings vs Opus - const opusCostPer1M = (OPUS_PRICING.input + OPUS_PRICING.output) / 2; - const opusWouldCost = ((totalInput + totalOutput) / 1_000_000) * opusCostPer1M; - const savedVsOpusUsd = Math.max(0, opusWouldCost - totalCost); - // Projections - const avgCostPerDay = days > 0 ? totalCost / days : 0; - const projections = { - avgCostPerDay, - projectedMonthlyUsd: avgCostPerDay * 30, - projectedYearlyUsd: avgCostPerDay * 365, - }; - // Efficiency - const totalTokens = totalInput + totalOutput; - const costPer1KTokens = totalTokens > 0 ? (totalCost / totalTokens) * 1000 : 0; - const avgRequestCostUsd = windowHistory.length > 0 ? totalCost / windowHistory.length : 0; - return { - days, - windowRecords: windowHistory.length, - totalCostUsd: totalCost, - totalInputTokens: totalInput, - totalOutputTokens: totalOutput, - savedVsOpusUsd, - byModel, - daily, - projections, - avgRequestCostUsd, - costPer1KTokens, - }; -} -// ─── Format for Display ─────────────────────────────────────────────────── -function sparkline(values) { - if (values.length === 0) - return ''; - const max = Math.max(...values); - if (max === 0) - return '▁'.repeat(values.length); - const chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; - return values.map(v => chars[Math.min(7, Math.floor((v / max) * 8))]).join(''); -} -export function formatInsights(report, days) { - const sep = '─'.repeat(60); - const lines = []; - lines.push(''); - lines.push(sep); - lines.push(` RUNCODE INSIGHTS — last ${days} days`); - lines.push(sep); - if (report.windowRecords === 0) { - lines.push(''); - lines.push(' No activity in this window.'); - lines.push(''); - lines.push(sep); - lines.push(''); - return lines.join('\n'); - } - // Summary - lines.push(''); - lines.push(` Requests: ${report.windowRecords}`); - lines.push(` Total cost: ${formatUsd(report.totalCostUsd)}`); - lines.push(` Input tokens: ${formatTokens(report.totalInputTokens)}`); - lines.push(` Output tokens: ${formatTokens(report.totalOutputTokens)}`); - lines.push(` Avg/request: ${formatUsd(report.avgRequestCostUsd)} (${formatUsd(report.costPer1KTokens)}/1K tokens)`); - if (report.savedVsOpusUsd > 0) { - lines.push(` Saved vs Opus: ${formatUsd(report.savedVsOpusUsd)} by using cheaper models`); - } - // Projections - lines.push(''); - lines.push(' Projection:'); - lines.push(` Per day: ${formatUsd(report.projections.avgCostPerDay)}`); - lines.push(` Per month: ${formatUsd(report.projections.projectedMonthlyUsd)}`); - lines.push(` Per year: ${formatUsd(report.projections.projectedYearlyUsd)}`); - // Per-model breakdown - if (report.byModel.length > 0) { - lines.push(''); - lines.push(' By model:'); - for (const m of report.byModel.slice(0, 10)) { - const name = shortModelName(m.model).padEnd(30); - const cost = formatUsd(m.costUsd).padStart(8); - const pct = `${m.percentOfTotal.toFixed(0)}%`.padStart(4); - const reqs = `${m.requests}req`.padStart(7); - lines.push(` ${name} ${cost} ${pct} ${reqs}`); - } - } - // Daily activity sparkline - if (report.daily.length > 0) { - const costs = report.daily.map(d => d.costUsd); - const requests = report.daily.map(d => d.requests); - lines.push(''); - lines.push(' Daily activity:'); - lines.push(` Requests: ${sparkline(requests)} ${report.daily[0].date} → ${report.daily[report.daily.length - 1].date}`); - lines.push(` Cost: ${sparkline(costs)}`); - } - lines.push(''); - lines.push(sep); - lines.push(''); - return lines.join('\n'); -} -// Silence unused import warning -void MODEL_PRICING; diff --git a/dist/stats/session-tracker.d.ts b/dist/stats/session-tracker.d.ts deleted file mode 100644 index ae246d9f..00000000 --- a/dist/stats/session-tracker.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Session-scoped per-model usage tracking. - * In-memory only — resets on new session. Used by /cost and UI footer. - */ -export interface SessionModelUsage { - requests: number; - inputTokens: number; - outputTokens: number; - costUsd: number; - lastTier?: string; -} -export declare function recordSessionUsage(model: string, inputTokens: number, outputTokens: number, costUsd: number, tier?: string): void; -export declare function getSessionModelBreakdown(): Array<{ - model: string; - requests: number; - inputTokens: number; - outputTokens: number; - costUsd: number; - lastTier?: string; -}>; -export declare function resetSession(): void; diff --git a/dist/stats/session-tracker.js b/dist/stats/session-tracker.js deleted file mode 100644 index ff5f0b46..00000000 --- a/dist/stats/session-tracker.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Session-scoped per-model usage tracking. - * In-memory only — resets on new session. Used by /cost and UI footer. - */ -const sessionModels = new Map(); -export function recordSessionUsage(model, inputTokens, outputTokens, costUsd, tier) { - const existing = sessionModels.get(model) ?? { - requests: 0, - inputTokens: 0, - outputTokens: 0, - costUsd: 0, - }; - existing.requests++; - existing.inputTokens += inputTokens; - existing.outputTokens += outputTokens; - existing.costUsd += costUsd; - if (tier) - existing.lastTier = tier; - sessionModels.set(model, existing); -} -export function getSessionModelBreakdown() { - return Array.from(sessionModels.entries()) - .map(([model, usage]) => ({ model, ...usage })) - .sort((a, b) => b.costUsd - a.costUsd); -} -export function resetSession() { - sessionModels.clear(); -} diff --git a/dist/stats/tracker.d.ts b/dist/stats/tracker.d.ts deleted file mode 100644 index 92ab0aac..00000000 --- a/dist/stats/tracker.d.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Usage tracking for runcode - * Records all requests with cost, tokens, and latency for stats display - */ -export declare function getStatsFilePath(): string; -export interface UsageRecord { - timestamp: number; - model: string; - inputTokens: number; - outputTokens: number; - costUsd: number; - latencyMs: number; - fallback?: boolean; -} -export interface ModelStats { - requests: number; - costUsd: number; - inputTokens: number; - outputTokens: number; - fallbackCount: number; - avgLatencyMs: number; - totalLatencyMs: number; -} -export interface Stats { - version: number; - totalRequests: number; - totalCostUsd: number; - totalInputTokens: number; - totalOutputTokens: number; - totalFallbacks: number; - byModel: Record; - history: UsageRecord[]; - firstRequest?: number; - lastRequest?: number; -} -export declare function loadStats(): Stats; -export declare function saveStats(stats: Stats): void; -export declare function clearStats(): void; -/** Flush stats to disk immediately (call on process exit) */ -export declare function flushStats(): void; -/** - * Record a completed request for stats tracking - */ -export declare function recordUsage(model: string, inputTokens: number, outputTokens: number, costUsd: number, latencyMs: number, fallback?: boolean): void; -/** - * Get stats summary for display - */ -export declare function getStatsSummary(): { - stats: Stats; - opusCost: number; - saved: number; - savedPct: number; - avgCostPerRequest: number; - period: string; -}; diff --git a/dist/stats/tracker.js b/dist/stats/tracker.js deleted file mode 100644 index 53a21f89..00000000 --- a/dist/stats/tracker.js +++ /dev/null @@ -1,211 +0,0 @@ -/** - * Usage tracking for runcode - * Records all requests with cost, tokens, and latency for stats display - */ -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { OPUS_PRICING } from '../pricing.js'; -import { BLOCKRUN_DIR } from '../config.js'; -let resolvedStatsFile = null; -function preferredStatsFile() { - return path.join(BLOCKRUN_DIR, 'runcode-stats.json'); -} -function fallbackStatsFile() { - return path.join(os.tmpdir(), 'runcode', 'runcode-stats.json'); -} -export function getStatsFilePath() { - if (resolvedStatsFile) - return resolvedStatsFile; - for (const file of [preferredStatsFile(), fallbackStatsFile()]) { - try { - fs.mkdirSync(path.dirname(file), { recursive: true }); - resolvedStatsFile = file; - return file; - } - catch { - // Try the next candidate. - } - } - resolvedStatsFile = preferredStatsFile(); - return resolvedStatsFile; -} -function withWritableStatsFile(action) { - const preferred = preferredStatsFile(); - const fallback = fallbackStatsFile(); - try { - action(getStatsFilePath()); - } - catch (err) { - const code = err.code; - const shouldFallback = (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') && - resolvedStatsFile === preferred; - if (!shouldFallback) - throw err; - fs.mkdirSync(path.dirname(fallback), { recursive: true }); - resolvedStatsFile = fallback; - action(fallback); - } -} -const EMPTY_STATS = { - version: 1, - totalRequests: 0, - totalCostUsd: 0, - totalInputTokens: 0, - totalOutputTokens: 0, - totalFallbacks: 0, - byModel: {}, - history: [], -}; -export function loadStats() { - try { - const statsFile = getStatsFilePath(); - if (fs.existsSync(statsFile)) { - const data = JSON.parse(fs.readFileSync(statsFile, 'utf-8')); - // Migration: add missing fields - return { - ...EMPTY_STATS, - ...data, - version: 1, - }; - } - } - catch { - /* ignore parse errors, return empty */ - } - return { ...EMPTY_STATS }; -} -export function saveStats(stats) { - try { - withWritableStatsFile((statsFile) => { - fs.mkdirSync(path.dirname(statsFile), { recursive: true }); - // Keep only last 1000 history records - stats.history = stats.history.slice(-1000); - fs.writeFileSync(statsFile, JSON.stringify(stats, null, 2)); - }); - } - catch { - /* ignore write errors */ - } -} -export function clearStats() { - cachedStats = null; - if (flushTimer) { - clearTimeout(flushTimer); - flushTimer = null; - } - resolvedStatsFile = null; - for (const statsFile of new Set([preferredStatsFile(), fallbackStatsFile()])) { - try { - if (fs.existsSync(statsFile)) { - fs.unlinkSync(statsFile); - } - } - catch { - /* ignore */ - } - } -} -// ─── In-memory stats cache with debounced write ───────────────────────── -// Prevents concurrent load→modify→save from losing data in proxy mode -let cachedStats = null; -let flushTimer = null; -const FLUSH_DELAY_MS = 2000; -function getCachedStats() { - if (!cachedStats) { - cachedStats = loadStats(); - } - return cachedStats; -} -function scheduleSave() { - if (flushTimer) - return; // Already scheduled - flushTimer = setTimeout(() => { - flushTimer = null; - if (cachedStats) - saveStats(cachedStats); - }, FLUSH_DELAY_MS); -} -/** Flush stats to disk immediately (call on process exit) */ -export function flushStats() { - if (flushTimer) { - clearTimeout(flushTimer); - flushTimer = null; - } - if (cachedStats) - saveStats(cachedStats); -} -/** - * Record a completed request for stats tracking - */ -export function recordUsage(model, inputTokens, outputTokens, costUsd, latencyMs, fallback = false) { - const stats = getCachedStats(); - const now = Date.now(); - // Update totals - stats.totalRequests++; - stats.totalCostUsd += costUsd; - stats.totalInputTokens += inputTokens; - stats.totalOutputTokens += outputTokens; - if (fallback) - stats.totalFallbacks++; - // Update timestamps - if (!stats.firstRequest) - stats.firstRequest = now; - stats.lastRequest = now; - // Update per-model stats - if (!stats.byModel[model]) { - stats.byModel[model] = { - requests: 0, - costUsd: 0, - inputTokens: 0, - outputTokens: 0, - fallbackCount: 0, - avgLatencyMs: 0, - totalLatencyMs: 0, - }; - } - const modelStats = stats.byModel[model]; - modelStats.requests++; - modelStats.costUsd += costUsd; - modelStats.inputTokens += inputTokens; - modelStats.outputTokens += outputTokens; - modelStats.totalLatencyMs += latencyMs; - modelStats.avgLatencyMs = modelStats.totalLatencyMs / modelStats.requests; - if (fallback) - modelStats.fallbackCount++; - // Add to history - stats.history.push({ - timestamp: now, - model, - inputTokens, - outputTokens, - costUsd, - latencyMs, - fallback, - }); - scheduleSave(); -} -/** - * Get stats summary for display - */ -export function getStatsSummary() { - const stats = loadStats(); - // Calculate what it would cost with Claude Opus - const opusCost = (stats.totalInputTokens / 1_000_000) * OPUS_PRICING.input + - (stats.totalOutputTokens / 1_000_000) * OPUS_PRICING.output; - const saved = opusCost - stats.totalCostUsd; - const savedPct = opusCost > 0 ? (saved / opusCost) * 100 : 0; - const avgCostPerRequest = stats.totalRequests > 0 ? stats.totalCostUsd / stats.totalRequests : 0; - // Calculate period - let period = 'No data'; - if (stats.firstRequest && stats.lastRequest) { - const days = Math.ceil((stats.lastRequest - stats.firstRequest) / (1000 * 60 * 60 * 24)); - if (days === 0) - period = 'Today'; - else if (days === 1) - period = '1 day'; - else - period = `${days} days`; - } - return { stats, opusCost, saved, savedPct, avgCostPerRequest, period }; -} diff --git a/dist/tools/askuser.d.ts b/dist/tools/askuser.d.ts deleted file mode 100644 index cf2ddbc3..00000000 --- a/dist/tools/askuser.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * AskUser capability — let the agent ask the user a clarifying question. - * The question is displayed and the response is returned as tool output. - */ -import type { CapabilityHandler } from '../agent/types.js'; -export declare const askUserCapability: CapabilityHandler; diff --git a/dist/tools/askuser.js b/dist/tools/askuser.js deleted file mode 100644 index 7e3420e6..00000000 --- a/dist/tools/askuser.js +++ /dev/null @@ -1,76 +0,0 @@ -/** - * AskUser capability — let the agent ask the user a clarifying question. - * The question is displayed and the response is returned as tool output. - */ -import readline from 'node:readline'; -import chalk from 'chalk'; -async function execute(input, ctx) { - const { question, options } = input; - if (!question) { - return { output: 'Error: question is required', isError: true }; - } - // Ink UI path: use the provided callback to avoid raw-mode stdin conflict - if (ctx.onAskUser) { - try { - const answer = await ctx.onAskUser(question, options); - return { output: answer || '(no response)' }; - } - catch { - return { output: 'User did not respond.', isError: false }; - } - } - // Non-ink fallback (CLI piped / scripted mode) - if (!process.stdin.isTTY) { - return { - output: `[Non-interactive mode] Cannot prompt user. Proceed with a reasonable assumption. Question was: ${question}`, - isError: false, - }; - } - // Bare TTY fallback (no ink UI) — use readline - console.error(''); - console.error(chalk.yellow(' ╭─ Question ────────────────────────────')); - console.error(chalk.yellow(` │ ${question}`)); - if (options && options.length > 0) { - for (let i = 0; i < options.length; i++) { - console.error(chalk.dim(` │ ${i + 1}. ${options[i]}`)); - } - } - console.error(chalk.yellow(' ╰───────────────────────────────────────')); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr, - terminal: true, - }); - return new Promise((resolve) => { - let answered = false; - rl.question(chalk.bold(' answer> '), (answer) => { - answered = true; - rl.close(); - resolve({ output: answer.trim() || '(no response)' }); - }); - rl.on('close', () => { - if (!answered) - resolve({ output: 'User closed input without responding.', isError: false }); - }); - }); -} -export const askUserCapability = { - spec: { - name: 'AskUser', - description: 'Ask the user a clarifying question. Use when you need more information before proceeding.', - input_schema: { - type: 'object', - properties: { - question: { type: 'string', description: 'The question to ask the user' }, - options: { - type: 'array', - items: { type: 'string' }, - description: 'Optional list of suggested answers to present', - }, - }, - required: ['question'], - }, - }, - execute, - concurrent: false, -}; diff --git a/dist/tools/bash.d.ts b/dist/tools/bash.d.ts deleted file mode 100644 index 8918c359..00000000 --- a/dist/tools/bash.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Bash capability — execute shell commands with timeout and output capture. - */ -import type { CapabilityHandler } from '../agent/types.js'; -export declare const bashCapability: CapabilityHandler; diff --git a/dist/tools/bash.js b/dist/tools/bash.js deleted file mode 100644 index 975cf284..00000000 --- a/dist/tools/bash.js +++ /dev/null @@ -1,341 +0,0 @@ -/** - * Bash capability — execute shell commands with timeout and output capture. - */ -import { spawn } from 'node:child_process'; -// ─── Smart Output Compression ───────────────────────────────────────────── -// Learned from RTK (Rust Token Killer): strip noise before sending to LLM. -// Applied after capture, before the 32KB cap — reduces tokens on verbose commands. -const ANSI_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g; -function stripAnsi(s) { - return s.replace(ANSI_RE, ''); -} -function collapseBlankLines(s) { - // Collapse 3+ consecutive blank lines → 1 blank line - return s.replace(/\n{3,}/g, '\n\n'); -} -/** Extract the base command word (first non-env token). */ -function baseCmd(command) { - // Strip leading env var assignments (FOO=bar cmd → cmd) - const stripped = command.replace(/^(?:[A-Z_][A-Z0-9_]*=\S*\s+)*/, '').trimStart(); - return stripped.split(/\s+/)[0] ?? ''; -} -function compressOutput(command, output) { - // 1. Always strip ANSI escape codes - let out = stripAnsi(output); - const cmd = baseCmd(command); - const fullCmd = command.trimStart(); - // 2. Git command-aware compression - if (cmd === 'git') { - const sub = fullCmd.split(/\s+/)[1] ?? ''; - out = compressGit(sub, out); - } - // 3. Package manager installs — keep only errors + final summary - else if (/^(npm|pnpm|yarn|bun)\s+(install|i|add|ci)\b/.test(fullCmd)) { - out = compressInstall(out); - } - // 4. Test runners — keep only failures + summary line - else if (/^(npm|pnpm|bun)\s+test\b|^(jest|vitest|mocha)\b/.test(fullCmd)) { - out = compressTests(out); - } - // 5. Build commands — keep errors/warnings, drop verbose compile lines - else if (/^(npm|pnpm|bun)\s+(run\s+)?(build|compile)\b|^tsc\b/.test(fullCmd)) { - out = compressBuild(out); - } - // 6. cargo - else if (cmd === 'cargo') { - const sub = fullCmd.split(/\s+/)[1] ?? ''; - if (sub === 'test' || sub === 'nextest') - out = compressTests(out); - else if (sub === 'build' || sub === 'check' || sub === 'clippy') - out = compressBuild(out); - else if (sub === 'install') - out = compressInstall(out); - } - // 7. Always collapse excessive blank lines - out = collapseBlankLines(out); - return out; -} -function compressGit(sub, out) { - switch (sub) { - case 'add': { - // git add is usually silent. Strip any blank output. - const trimmed = out.trim(); - return trimmed || 'ok'; - } - case 'commit': { - // Keep: [branch abc1234] message + stats line. Strip verbose output. - const lines = out.split('\n'); - const kept = lines.filter(l => /^\[.+\]/.test(l) || // [main abc1234] commit msg - /\d+ file/.test(l) || // 2 files changed, 10 insertions - /^\s*(create|delete) mode/.test(l) || - l.trim() === ''); - return kept.join('\n').trim() || out.trim(); - } - case 'push': { - // Strip verbose remote "enumerating/counting/compressing" lines - const lines = out.split('\n').filter(l => !/^remote:\s*(Enumerating|Counting|Compressing|Writing|Total|Delta)/.test(l) && - !/^Counting objects|^Compressing objects|^Writing objects/.test(l) && - l.trim() !== ''); - return lines.join('\n').trim() || 'ok'; - } - case 'pull': { - // Strip "remote: Counting..." lines, keep summary - const lines = out.split('\n').filter(l => !/^remote:\s*(Enumerating|Counting|Compressing|Writing|Total|Delta)/.test(l) && - !/^Counting objects|^Compressing objects/.test(l)); - return collapseBlankLines(lines.join('\n')).trim(); - } - case 'fetch': { - const lines = out.split('\n').filter(l => !/^remote:\s*(Enumerating|Counting|Compressing|Writing|Total|Delta)/.test(l)); - return lines.join('\n').trim(); - } - case 'log': { - // Already terse if user uses --oneline; just collapse blanks - return out.trim(); - } - default: - return out; - } -} -function compressInstall(out) { - const lines = out.split('\n'); - const kept = []; - for (const line of lines) { - const l = line.trim(); - // Drop pure progress lines - if (/^(Downloading|Fetching|Resolving|Progress|Preparing|Caching)/.test(l)) - continue; - if (/^[\s.]*$/.test(l)) - continue; - // Keep errors, warnings, and summary lines - kept.push(line); - } - // If no lines kept, return original trimmed (don't lose error info) - const result = kept.join('\n').trim(); - return result || out.trim(); -} -function compressTests(out) { - const lines = out.split('\n'); - // Look for failure sections and summary - const kept = []; - let inFailure = false; - for (const line of lines) { - const l = line.trim(); - // Detect failure/error blocks - if (/^(FAIL|FAILED|Error:|●|✕|✗|×|error\[)/.test(l)) { - inFailure = true; - } - // Summary lines (always keep) - if (/^(Tests?|Test Suites?|Suites?|PASS|FAIL|ok\s|error|warning|\d+ (test|spec|example))/.test(l) || - /\d+\s*(passed|failed|skipped|pending|todo)/.test(l)) { - kept.push(line); - inFailure = false; - continue; - } - if (inFailure) { - kept.push(line); - // End failure block on blank line after content - if (l === '' && kept[kept.length - 2]?.trim() !== '') - inFailure = false; - } - } - // If nothing matched (e.g. all passed with no verbose output), return original - if (kept.length === 0) - return out.trim(); - return collapseBlankLines(kept.join('\n')).trim(); -} -function compressBuild(out) { - const lines = out.split('\n'); - const kept = lines.filter(l => { - const t = l.trim(); - if (t === '') - return false; - // Drop pure progress/info lines from bundlers/compilers - if (/^(Compiling|Finished|Checking|warning: unused import)/.test(t) && - !/^(Compiling.*error|Finished.*error)/.test(t)) { - // Keep "Finished" summary - if (/^Finished/.test(t)) - return true; - return false; - } - return true; - }); - return collapseBlankLines(kept.join('\n')).trim() || out.trim(); -} -const MAX_OUTPUT_BYTES = 512 * 1024; // 512KB capture buffer (prevents OOM) -const MAX_RETURN_CHARS = 32_000; // 32KB return cap (~8,000 tokens) — prevents context bloat -const DEFAULT_TIMEOUT_MS = 120_000; // 2 minutes -async function execute(input, ctx) { - const { command, timeout } = input; - if (!command || typeof command !== 'string') { - return { output: 'Error: command is required', isError: true }; - } - const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 600_000); - return new Promise((resolve) => { - const shell = process.env.SHELL || '/bin/bash'; - let child; - try { - child = spawn(shell, ['-c', command], { - cwd: ctx.workingDir, - env: { - ...process.env, - RUNCODE: '1', // Let scripts detect they're running inside runcode - RUNCODE_WORKDIR: ctx.workingDir, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - } - catch (spawnErr) { - resolve({ output: `Error spawning shell: ${spawnErr.message}`, isError: true }); - return; - } - let stdout = ''; - let stderr = ''; - let outputBytes = 0; - let truncated = false; - let killed = false; - let abortedByUser = false; - const timer = setTimeout(() => { - killed = true; - child.kill('SIGTERM'); - setTimeout(() => { - try { - child.kill('SIGKILL'); - } - catch { /* already dead */ } - }, 5000); // Give 5s for graceful shutdown before SIGKILL - }, timeoutMs); - // Handle abort signal - const onAbort = () => { - killed = true; - abortedByUser = true; - child.kill('SIGTERM'); - }; - ctx.abortSignal.addEventListener('abort', onAbort, { once: true }); - // Emit last non-empty line to UI progress (throttled to avoid flooding) - let lastProgressEmit = 0; - const emitProgress = (text) => { - if (!ctx.onProgress) - return; - const now = Date.now(); - if (now - lastProgressEmit < 200) - return; // max 5 updates/sec - lastProgressEmit = now; - const lastLine = text.split('\n').map(l => l.trim()).filter(Boolean).pop(); - if (lastLine) - ctx.onProgress(lastLine.slice(0, 120)); - }; - child.stdout?.on('data', (chunk) => { - if (truncated) - return; - const remaining = MAX_OUTPUT_BYTES - outputBytes; - if (remaining <= 0) { - truncated = true; - return; - } - const text = chunk.toString('utf-8'); - if (chunk.length <= remaining) { - stdout += text; - outputBytes += chunk.length; - } - else { - stdout += text.slice(0, remaining); - outputBytes = MAX_OUTPUT_BYTES; - truncated = true; - } - emitProgress(text); - }); - child.stderr?.on('data', (chunk) => { - if (truncated) - return; - const remaining = MAX_OUTPUT_BYTES - outputBytes; - if (remaining <= 0) { - truncated = true; - return; - } - const text = chunk.toString('utf-8'); - if (chunk.length <= remaining) { - stderr += text; - outputBytes += chunk.length; - } - else { - stderr += text.slice(0, remaining); - outputBytes = MAX_OUTPUT_BYTES; - truncated = true; - } - emitProgress(text); - }); - child.on('close', (code) => { - clearTimeout(timer); - ctx.abortSignal.removeEventListener('abort', onAbort); - let result = ''; - if (stdout) - result += stdout; - if (stderr) { - if (result) - result += '\n'; - result += stderr; - } - if (truncated) { - result += '\n\n... (output truncated — command produced >512KB)'; - } - // Smart compression: strip ANSI, collapse blank lines, command-aware filters - result = compressOutput(command, result); - // Cap returned output to prevent context bloat. - // Keep the LAST part (most relevant for errors/test failures/build output). - if (result.length > MAX_RETURN_CHARS) { - const lines = result.split('\n'); - let trimmed = ''; - for (let i = lines.length - 1; i >= 0; i--) { - const candidate = lines[i] + '\n' + trimmed; - if (candidate.length > MAX_RETURN_CHARS) - break; - trimmed = candidate; - } - const omitted = result.length - trimmed.length; - result = `... (${omitted.toLocaleString()} chars omitted from start)\n${trimmed}`; - } - if (killed) { - const reason = abortedByUser - ? 'aborted by user' - : `timeout after ${timeoutMs / 1000}s. Set timeout param up to 600000ms for longer.`; - resolve({ - output: result + `\n\n(command killed — ${reason})`, - isError: true, - }); - return; - } - if (code !== 0 && code !== null) { - resolve({ - output: result || `Command exited with code ${code}`, - isError: true, - }); - return; - } - resolve({ output: result || '(no output)' }); - }); - child.on('error', (err) => { - clearTimeout(timer); - ctx.abortSignal.removeEventListener('abort', onAbort); - resolve({ - output: `Error spawning command: ${err.message}`, - isError: true, - }); - }); - }); -} -export const bashCapability = { - spec: { - name: 'Bash', - description: 'Execute shell commands. Do NOT use cat/head/tail to read files — use Read instead. Do NOT use grep/rg to search — use Grep instead. Do NOT use sed/awk to edit — use Edit instead. Do NOT use echo/heredoc to create files — use Write instead. Reserve Bash for: builds, installs, git, npm/pip, processes, scripts, network, and anything needing a shell. Output capped at 512KB. Default timeout 2min, max 10min.', - input_schema: { - type: 'object', - properties: { - command: { type: 'string', description: 'The shell command to execute' }, - timeout: { type: 'number', description: 'Timeout in milliseconds (default: 120000, max: 600000)' }, - }, - required: ['command'], - }, - }, - execute, - concurrent: false, -}; diff --git a/dist/tools/edit.d.ts b/dist/tools/edit.d.ts deleted file mode 100644 index 78c80a6e..00000000 --- a/dist/tools/edit.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Edit capability — targeted string replacement in files. - */ -import type { CapabilityHandler } from '../agent/types.js'; -export declare const editCapability: CapabilityHandler; diff --git a/dist/tools/edit.js b/dist/tools/edit.js deleted file mode 100644 index 9050da63..00000000 --- a/dist/tools/edit.js +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Edit capability — targeted string replacement in files. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { partiallyReadFiles } from './read.js'; -/** - * Normalize curly/smart quotes to straight quotes. - * Claude Code does this to handle API-sanitized strings and editor paste artifacts. - */ -function normalizeQuotes(str) { - return str - .replace(/[\u201C\u201D]/g, '"') // " " → " - .replace(/[\u2018\u2019]/g, "'"); // ' ' → ' -} -async function execute(input, ctx) { - const { file_path: filePath, old_string: oldStr, new_string: newStr, replace_all: replaceAll } = input; - if (!filePath) { - return { output: 'Error: file_path is required', isError: true }; - } - if (oldStr === undefined || oldStr === null) { - return { output: 'Error: old_string is required', isError: true }; - } - if (newStr === undefined || newStr === null) { - return { output: 'Error: new_string is required', isError: true }; - } - if (oldStr === newStr) { - return { output: 'Error: old_string and new_string are identical', isError: true }; - } - const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(ctx.workingDir, filePath); - // Warn if the file was only partially read — editing without full context risks mistakes - const isPartial = partiallyReadFiles.has(resolved); - try { - if (!fs.existsSync(resolved)) { - return { output: `Error: file not found: ${resolved}`, isError: true }; - } - const content = fs.readFileSync(resolved, 'utf-8'); - // Try exact match first, then quote-normalized fallback - let effectiveOldStr = oldStr; - if (!content.includes(oldStr)) { - const normalized = normalizeQuotes(oldStr); - const contentNormalized = normalizeQuotes(content); - if (normalized !== oldStr && contentNormalized.includes(normalized)) { - // Find the original text in content that corresponds to the normalized match - const idx = contentNormalized.indexOf(normalized); - effectiveOldStr = content.slice(idx, idx + normalized.length); - } - } - if (!content.includes(effectiveOldStr)) { - // Find lines containing fragments of old_string for helpful context - const lines = content.split('\n'); - const searchTerms = oldStr.split('\n').map(l => l.trim()).filter(l => l.length > 3); - const matchedLines = []; - if (searchTerms.length > 0) { - for (let i = 0; i < lines.length && matchedLines.length < 5; i++) { - if (searchTerms.some(term => lines[i].includes(term))) { - matchedLines.push({ num: i + 1, text: lines[i] }); - } - } - } - let hint; - if (matchedLines.length > 0) { - const preview = matchedLines.map(m => `${m.num}\t${m.text}`).join('\n'); - hint = `\n\nSimilar lines found:\n${preview}\n\nCheck for whitespace or formatting differences.`; - } - else { - const preview = lines.slice(0, 10).map((l, i) => `${i + 1}\t${l}`).join('\n'); - hint = `\n\nFirst 10 lines of file:\n${preview}`; - } - return { - output: `Error: old_string not found in ${resolved}.${hint}`, - isError: true, - }; - } - let updated; - let matchCount; - if (replaceAll) { - matchCount = content.split(effectiveOldStr).length - 1; - updated = content.split(effectiveOldStr).join(newStr); - } - else { - const firstIdx = content.indexOf(effectiveOldStr); - const secondIdx = content.indexOf(effectiveOldStr, firstIdx + 1); - if (secondIdx !== -1) { - const positions = []; - let searchFrom = 0; - while (true) { - const idx = content.indexOf(effectiveOldStr, searchFrom); - if (idx === -1) - break; - const lineNum = content.slice(0, idx).split('\n').length; - positions.push(lineNum); - searchFrom = idx + 1; - } - return { - output: `Error: old_string matches ${positions.length} locations (lines: ${positions.join(', ')}). ` + - `Provide more context to make it unique, or use replace_all: true.`, - isError: true, - }; - } - matchCount = 1; - updated = content.slice(0, firstIdx) + newStr + content.slice(firstIdx + effectiveOldStr.length); - } - fs.writeFileSync(resolved, updated, 'utf-8'); - // File has been modified — remove from partial-read tracking so next read is fresh - partiallyReadFiles.delete(resolved); - // Build a concise diff preview - const oldLines = effectiveOldStr.split('\n'); - const newLines = newStr.split('\n'); - let diffPreview = ''; - if (oldLines.length <= 5 && newLines.length <= 5) { - const removed = oldLines.map(l => `- ${l}`).join('\n'); - const added = newLines.map(l => `+ ${l}`).join('\n'); - diffPreview = `\n${removed}\n${added}`; - } - else { - diffPreview = ` (${oldLines.length} lines → ${newLines.length} lines)`; - } - const partialWarning = isPartial - ? '\nNote: file was only partially read before this edit.' - : ''; - return { - output: `Updated ${resolved} — ${matchCount} replacement${matchCount > 1 ? 's' : ''} made.${diffPreview}${partialWarning}`, - }; - } - catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { output: `Error editing file: ${msg}`, isError: true }; - } -} -export const editCapability = { - spec: { - name: 'Edit', - description: 'Replace an exact string in a file. old_string must appear exactly once unless replace_all=true. Preferred over Write for modifying existing files — sends only the diff. Always Read a file before editing it.', - input_schema: { - type: 'object', - properties: { - file_path: { type: 'string', description: 'Absolute path' }, - old_string: { type: 'string', description: 'Text to find' }, - new_string: { type: 'string', description: 'Replacement text' }, - replace_all: { type: 'boolean', description: 'Replace all occurrences' }, - }, - required: ['file_path', 'old_string', 'new_string'], - }, - }, - execute, - concurrent: false, -}; diff --git a/dist/tools/glob.d.ts b/dist/tools/glob.d.ts deleted file mode 100644 index b5ae2c9b..00000000 --- a/dist/tools/glob.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Glob capability — file pattern matching using native fs. - */ -import type { CapabilityHandler } from '../agent/types.js'; -export declare const globCapability: CapabilityHandler; diff --git a/dist/tools/glob.js b/dist/tools/glob.js deleted file mode 100644 index 786e1366..00000000 --- a/dist/tools/glob.js +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Glob capability — file pattern matching using native fs. - */ -import fs from 'node:fs'; -import path from 'node:path'; -const MAX_RESULTS = 200; -const MAX_OUTPUT_CHARS = 12_000; // ~3,000 tokens — prevents huge glob results from blowing up context -/** - * Simple glob matcher supporting *, **, and ? wildcards. - * No external dependencies. - */ -function globMatch(pattern, text) { - const regexStr = pattern - .replace(/\\/g, '/') - .split('**/') - .map(segment => segment - .replace(/[.+^${}()|[\]\\]/g, '\\$&') - .replace(/\*/g, '[^/]*') - .replace(/\?/g, '[^/]')) - .join('(?:.*/)?'); - const regex = new RegExp(`^${regexStr}$`); - return regex.test(text.replace(/\\/g, '/')); -} -function walkDirectory(dir, baseDir, pattern, results, depth, visited) { - if (depth > 50 || results.length >= MAX_RESULTS) - return; - // Symlink loop protection - const visitedSet = visited ?? new Set(); - let realDir; - try { - realDir = fs.realpathSync(dir); - } - catch { - return; - } - if (visitedSet.has(realDir)) - return; - visitedSet.add(realDir); - let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } - catch { - return; // Permission denied or similar - } - for (const entry of entries) { - if (results.length >= MAX_RESULTS) - break; - // Skip hidden dirs and common large dirs - const isDir = entry.isDirectory() || (entry.isSymbolicLink() && isSymlinkDir(path.join(dir, entry.name))); - if (entry.name.startsWith('.') && isDir) - continue; - if (entry.name === 'node_modules' || entry.name === '__pycache__' || entry.name === '.git') - continue; - const fullPath = path.join(dir, entry.name); - const relativePath = path.relative(baseDir, fullPath); - if (entry.isFile() || (entry.isSymbolicLink() && !isDir)) { - if (globMatch(pattern, relativePath)) { - results.push(fullPath); - } - } - else if (isDir) { - // Recurse for ** patterns; for patterns with /, only recurse if current dir is on the path - if (pattern.includes('**')) { - walkDirectory(fullPath, baseDir, pattern, results, depth + 1, visitedSet); - } - else if (pattern.includes('/')) { - // Check if this directory could be part of the pattern path - const relativePath = path.relative(baseDir, fullPath); - const patternDir = pattern.split('/').slice(0, -1).join('/'); - if (patternDir.startsWith(relativePath) || relativePath.startsWith(patternDir)) { - walkDirectory(fullPath, baseDir, pattern, results, depth + 1, visitedSet); - } - } - } - } -} -function isSymlinkDir(p) { - try { - return fs.statSync(p).isDirectory(); - } - catch { - return false; - } -} -async function execute(input, ctx) { - const { pattern, path: searchPath } = input; - if (!pattern) { - return { output: 'Error: pattern is required', isError: true }; - } - const baseDir = searchPath - ? (path.isAbsolute(searchPath) ? searchPath : path.resolve(ctx.workingDir, searchPath)) - : ctx.workingDir; - if (!fs.existsSync(baseDir)) { - return { output: `Error: directory not found: ${baseDir}`, isError: true }; - } - const results = []; - walkDirectory(baseDir, baseDir, pattern, results, 0); - // Sort by modification time (most recent first) - const withMtime = results.map(f => { - try { - return { path: f, mtime: fs.statSync(f).mtimeMs }; - } - catch { - return { path: f, mtime: 0 }; - } - }); - withMtime.sort((a, b) => b.mtime - a.mtime); - // Convert to relative paths to save tokens (same as Claude Code) - const sorted = withMtime.map(f => { - const rel = path.relative(ctx.workingDir, f.path); - return rel.startsWith('..') ? f.path : rel; - }); - if (sorted.length === 0) { - // Suggest recursive pattern if user used non-recursive glob - const hint = !pattern.includes('**') && !pattern.includes('/') - ? ` Try "**/${pattern}" for recursive search.` - : ''; - return { output: `No files matched pattern "${pattern}" in ${baseDir}.${hint}` }; - } - let output = sorted.join('\n'); - if (sorted.length >= MAX_RESULTS) { - output += `\n\n... (limited to ${MAX_RESULTS} results. Use a more specific pattern to narrow results.)`; - } - // Cap total output length to prevent context bloat - if (output.length > MAX_OUTPUT_CHARS) { - const lines = output.split('\n'); - let trimmed = ''; - let count = 0; - for (const line of lines) { - if ((trimmed + line).length > MAX_OUTPUT_CHARS) - break; - trimmed += (trimmed ? '\n' : '') + line; - count++; - } - const remaining = lines.length - count; - if (remaining > 0) { - output = `${trimmed}\n... (${remaining} more paths not shown — use a more specific pattern)`; - } - } - return { output }; -} -export const globCapability = { - spec: { - name: 'Glob', - description: 'Find files by glob pattern (e.g. \'**/*.ts\'). Returns up to 200 paths sorted by modification time. Skips node_modules/.git. Use this instead of find/ls in Bash. Use Grep to search file contents.', - input_schema: { - type: 'object', - properties: { - pattern: { type: 'string', description: 'Glob pattern to match files (e.g. "**/*.ts")' }, - path: { type: 'string', description: 'Directory to search in. Defaults to working directory.' }, - }, - required: ['pattern'], - }, - }, - execute, - concurrent: true, -}; diff --git a/dist/tools/grep.d.ts b/dist/tools/grep.d.ts deleted file mode 100644 index 9677b6ce..00000000 --- a/dist/tools/grep.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Grep capability — search file contents using ripgrep or native fallback. - */ -import type { CapabilityHandler } from '../agent/types.js'; -export declare const grepCapability: CapabilityHandler; diff --git a/dist/tools/grep.js b/dist/tools/grep.js deleted file mode 100644 index c1ffb8b0..00000000 --- a/dist/tools/grep.js +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Grep capability — search file contents using ripgrep or native fallback. - */ -import { execSync, execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -const MAX_GREP_OUTPUT_CHARS = 16_000; // ~4,000 tokens — prevents huge grep results -let _hasRipgrep = null; -function hasRipgrep() { - if (_hasRipgrep !== null) - return _hasRipgrep; - try { - execSync('rg --version', { stdio: 'pipe' }); - _hasRipgrep = true; - } - catch { - _hasRipgrep = false; - } - return _hasRipgrep; -} -async function execute(input, ctx) { - const opts = input; - if (!opts.pattern) { - return { output: 'Error: pattern is required', isError: true }; - } - const searchPath = opts.path - ? (path.isAbsolute(opts.path) ? opts.path : path.resolve(ctx.workingDir, opts.path)) - : ctx.workingDir; - if (!fs.existsSync(searchPath)) { - return { output: `Error: path not found: ${searchPath}`, isError: true }; - } - const mode = opts.output_mode || 'files_with_matches'; - const limit = opts.head_limit ?? 250; - if (hasRipgrep()) { - return runRipgrep(opts, searchPath, mode, limit, ctx.workingDir); - } - return runNativeGrep(opts, searchPath, mode, limit, ctx.workingDir); -} -function toRelative(absPath, cwd) { - const rel = path.relative(cwd, absPath); - return rel.startsWith('..') ? absPath : rel; -} -function runRipgrep(opts, searchPath, mode, limit, cwd) { - const args = []; - // Limit line length to prevent base64/minified content from cluttering output - args.push('--max-columns', '500'); - switch (mode) { - case 'files_with_matches': - args.push('-l'); - break; - case 'count': - args.push('-c'); - break; - case 'content': - args.push('-n'); - if (opts.context && opts.context > 0) { - args.push(`-C${opts.context}`); - } - else { - if (opts.before_context && opts.before_context > 0) - args.push(`-B${opts.before_context}`); - if (opts.after_context && opts.after_context > 0) - args.push(`-A${opts.after_context}`); - } - break; - } - if (opts.case_insensitive) - args.push('-i'); - if (opts.multiline) - args.push('-U', '--multiline-dotall'); - if (opts.glob) - args.push(`--glob=${opts.glob}`); - // Always exclude common noise + lock files (huge, rarely useful) - args.push('--glob=!node_modules', '--glob=!.git', '--glob=!dist', '--glob=!*.lock', '--glob=!package-lock.json', '--glob=!pnpm-lock.yaml'); - args.push('--', opts.pattern); - args.push(searchPath); - try { - const result = execFileSync('rg', args, { - encoding: 'utf-8', - maxBuffer: 2 * 1024 * 1024, - stdio: ['pipe', 'pipe', 'pipe'], - }); - const lines = result.split('\n').filter(Boolean); - const limited = limit > 0 ? lines.slice(0, limit) : lines; - // Convert absolute paths to relative paths to save tokens (same as Claude Code) - const relativized = limited.map(line => { - // Lines: /abs/path or /abs/path:rest (content mode) - const colonIdx = line.indexOf(':'); - if (colonIdx > 0 && line.startsWith('/')) { - const filePart = line.slice(0, colonIdx); - return toRelative(filePart, cwd) + line.slice(colonIdx); - } - return line.startsWith('/') ? toRelative(line, cwd) : line; - }); - let output = relativized.join('\n'); - if (lines.length > limited.length) { - output += `\n\n... (${lines.length - limited.length} more results, use head_limit to see more)`; - } - // Cap total output to prevent context bloat - if (output.length > MAX_GREP_OUTPUT_CHARS) { - output = output.slice(0, MAX_GREP_OUTPUT_CHARS) + `\n... (output capped at ${MAX_GREP_OUTPUT_CHARS / 1000}KB — use more specific pattern or head_limit)`; - } - return { output: output || 'No matches found' }; - } - catch (err) { - const exitErr = err; - if (exitErr.status === 1) { - return { output: 'No matches found' }; - } - return { - output: `Grep error: ${exitErr.stderr || err.message}`, - isError: true, - }; - } -} -function runNativeGrep(opts, searchPath, mode, limit, cwd) { - const args = ['-r', '-n']; - if (opts.case_insensitive) - args.push('-i'); - switch (mode) { - case 'files_with_matches': - args.push('-l'); - break; - case 'count': - args.push('-c'); - break; - } - if (opts.glob) { - // Native grep --include doesn't support ** or path separators - // Extract file extension pattern for best compatibility - const nativeGlob = opts.glob - .replace(/^\*\*\//, '') // Strip leading **/ - .replace(/^.*\//, '') // Strip path prefix (src/ etc.) - .replace(/\*\*/, '*'); // Convert ** to * for flat matching - args.push(`--include=${nativeGlob}`); - } - args.push('--exclude-dir=node_modules', '--exclude-dir=.git', '--exclude-dir=dist', '--exclude=*.lock', '--exclude=package-lock.json', '--exclude=pnpm-lock.yaml'); - args.push('-e', opts.pattern, searchPath); - try { - const result = execFileSync('grep', args, { - encoding: 'utf-8', - maxBuffer: 2 * 1024 * 1024, - stdio: ['pipe', 'pipe', 'pipe'], - }); - const lines = result.split('\n').filter(Boolean); - const limited = limit > 0 ? lines.slice(0, limit) : lines; - const relativized = limited.map(line => { - const colonIdx = line.indexOf(':'); - if (colonIdx > 0 && line.startsWith('/')) { - return toRelative(line.slice(0, colonIdx), cwd) + line.slice(colonIdx); - } - return line.startsWith('/') ? toRelative(line, cwd) : line; - }); - let output = relativized.join('\n'); - if (lines.length > limited.length) { - output += `\n\n... (${lines.length - limited.length} more results)`; - } - if (output.length > MAX_GREP_OUTPUT_CHARS) { - output = output.slice(0, MAX_GREP_OUTPUT_CHARS) + `\n... (output capped at ${MAX_GREP_OUTPUT_CHARS / 1000}KB)`; - } - return { output: output || 'No matches found' }; - } - catch (err) { - const exitErr = err; - if (exitErr.status === 1) { - return { output: 'No matches found' }; - } - return { output: `Grep error: ${err.message}`, isError: true }; - } -} -export const grepCapability = { - spec: { - name: 'Grep', - description: 'Search file contents by regex. Use this instead of grep/rg in Bash. Default: returns file paths. Use output_mode=\'content\' for matching lines with context. Use Glob to find files by name pattern.', - input_schema: { - type: 'object', - properties: { - pattern: { type: 'string', description: 'Regex pattern' }, - path: { type: 'string', description: 'File or dir to search (default: cwd)' }, - glob: { type: 'string', description: 'File filter e.g. "*.ts"' }, - output_mode: { type: 'string', description: '"content" | "files_with_matches" | "count". Default: files_with_matches' }, - context: { type: 'number', description: 'Context lines around match' }, - before_context: { type: 'number', description: 'Lines before match' }, - after_context: { type: 'number', description: 'Lines after match' }, - case_insensitive: { type: 'boolean' }, - head_limit: { type: 'number', description: 'Max results (default 250)' }, - multiline: { type: 'boolean', description: 'Match across lines' }, - }, - required: ['pattern'], - }, - }, - execute, - concurrent: true, -}; diff --git a/dist/tools/imagegen.d.ts b/dist/tools/imagegen.d.ts deleted file mode 100644 index 78cd9a89..00000000 --- a/dist/tools/imagegen.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Image Generation capability — generate images via BlockRun API. - * Uses x402 payment on Solana or Base. - */ -import type { CapabilityHandler } from '../agent/types.js'; -export declare const imageGenCapability: CapabilityHandler; diff --git a/dist/tools/imagegen.js b/dist/tools/imagegen.js deleted file mode 100644 index 6a073d4e..00000000 --- a/dist/tools/imagegen.js +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Image Generation capability — generate images via BlockRun API. - * Uses x402 payment on Solana or Base. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm'; -import { loadChain, API_URLS, VERSION } from '../config.js'; -async function execute(input, ctx) { - const { prompt, output_path, size, model } = input; - if (!prompt) { - return { output: 'Error: prompt is required', isError: true }; - } - const chain = loadChain(); - const apiUrl = API_URLS[chain]; - const endpoint = `${apiUrl}/v1/images/generations`; - const imageModel = model || 'dall-e-3'; - const imageSize = size || '1024x1024'; - // Default output path - const outPath = output_path - ? (path.isAbsolute(output_path) ? output_path : path.resolve(ctx.workingDir, output_path)) - : path.resolve(ctx.workingDir, `generated-${Date.now()}.png`); - const body = JSON.stringify({ - model: imageModel, - prompt, - n: 1, - size: imageSize, - response_format: 'b64_json', - }); - const headers = { - 'Content-Type': 'application/json', - 'User-Agent': `runcode/${VERSION}`, - }; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 60_000); // 60s timeout - try { - // First request — will get 402 - let response = await fetch(endpoint, { - method: 'POST', - signal: controller.signal, - headers, - body, - }); - // Handle x402 payment - if (response.status === 402) { - const paymentHeaders = await signPayment(response, chain, endpoint); - if (!paymentHeaders) { - return { output: 'Payment failed. Check wallet balance with: runcode balance', isError: true }; - } - response = await fetch(endpoint, { - method: 'POST', - signal: controller.signal, - headers: { ...headers, ...paymentHeaders }, - body, - }); - } - if (!response.ok) { - const errText = await response.text().catch(() => ''); - return { output: `Image generation failed (${response.status}): ${errText.slice(0, 200)}`, isError: true }; - } - const result = await response.json(); - const imageData = result.data?.[0]; - if (!imageData) { - return { output: 'No image data returned from API', isError: true }; - } - // Save image - if (imageData.b64_json) { - const buffer = Buffer.from(imageData.b64_json, 'base64'); - fs.mkdirSync(path.dirname(outPath), { recursive: true }); - fs.writeFileSync(outPath, buffer); - } - else if (imageData.url) { - // Download from URL (with 30s timeout) - const dlCtrl = new AbortController(); - const dlTimeout = setTimeout(() => dlCtrl.abort(), 30_000); - const imgResp = await fetch(imageData.url, { signal: dlCtrl.signal }); - clearTimeout(dlTimeout); - const buffer = Buffer.from(await imgResp.arrayBuffer()); - fs.mkdirSync(path.dirname(outPath), { recursive: true }); - fs.writeFileSync(outPath, buffer); - } - else { - return { output: 'No image data (b64_json or url) in response', isError: true }; - } - const fileSize = fs.statSync(outPath).size; - const sizeKB = (fileSize / 1024).toFixed(1); - const revisedPrompt = imageData.revised_prompt ? `\nRevised prompt: ${imageData.revised_prompt}` : ''; - return { - output: `Image saved to ${outPath} (${sizeKB}KB, ${imageSize})${revisedPrompt}\n\nOpen with: open ${outPath}`, - }; - } - catch (err) { - const msg = err.message || ''; - if (msg.includes('abort')) { - return { output: 'Image generation timed out (60s limit). Try a simpler prompt.', isError: true }; - } - return { output: `Error: ${msg}`, isError: true }; - } - finally { - clearTimeout(timeout); - } -} -// ─── Payment ─────────────────────────────────────────────────────────────── -async function signPayment(response, chain, endpoint) { - try { - const paymentHeader = await extractPaymentReq(response); - if (!paymentHeader) - return null; - if (chain === 'solana') { - const wallet = await getOrCreateSolanaWallet(); - const paymentRequired = parsePaymentRequired(paymentHeader); - const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK); - const secretBytes = await solanaKeyToBytes(wallet.privateKey); - const feePayer = details.extra?.feePayer || details.recipient; - const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, { - resourceUrl: details.resource?.url || endpoint, - resourceDescription: details.resource?.description || 'RunCode image generation', - maxTimeoutSeconds: details.maxTimeoutSeconds || 300, - extra: details.extra, - }); - return { 'PAYMENT-SIGNATURE': payload }; - } - else { - const wallet = getOrCreateWallet(); - const paymentRequired = parsePaymentRequired(paymentHeader); - const details = extractPaymentDetails(paymentRequired); - const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', { - resourceUrl: details.resource?.url || endpoint, - resourceDescription: details.resource?.description || 'RunCode image generation', - maxTimeoutSeconds: details.maxTimeoutSeconds || 300, - extra: details.extra, - }); - return { 'PAYMENT-SIGNATURE': payload }; - } - } - catch (err) { - console.error(`[runcode] Image payment error: ${err.message}`); - return null; - } -} -async function extractPaymentReq(response) { - let header = response.headers.get('payment-required'); - if (!header) { - try { - const body = (await response.json()); - if (body.x402 || body.accepts) { - header = btoa(JSON.stringify(body)); - } - } - catch { /* ignore */ } - } - return header; -} -// ─── Export ──────────────────────────────────────────────────────────────── -export const imageGenCapability = { - spec: { - name: 'ImageGen', - description: 'Generate an image from a text prompt using DALL-E. Costs USDC from the user\'s wallet — confirm before generating. Saves to a local file. Default size: 1024x1024. Do NOT call repeatedly to iterate on style — ask the user first.', - input_schema: { - type: 'object', - properties: { - prompt: { type: 'string', description: 'Text description of the image to generate' }, - output_path: { type: 'string', description: 'Where to save the image. Default: generated-.png in working directory' }, - size: { type: 'string', description: 'Image size: 1024x1024, 1792x1024, or 1024x1792. Default: 1024x1024' }, - model: { type: 'string', description: 'Image model to use. Default: dall-e-3' }, - }, - required: ['prompt'], - }, - }, - execute, - concurrent: false, -}; diff --git a/dist/tools/index.d.ts b/dist/tools/index.d.ts deleted file mode 100644 index 59ee226d..00000000 --- a/dist/tools/index.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Tool registry — exports all available capabilities for the agent. - */ -import type { CapabilityHandler } from '../agent/types.js'; -import { readCapability } from './read.js'; -import { writeCapability } from './write.js'; -import { editCapability } from './edit.js'; -import { bashCapability } from './bash.js'; -import { globCapability } from './glob.js'; -import { grepCapability } from './grep.js'; -import { webFetchCapability } from './webfetch.js'; -import { webSearchCapability } from './websearch.js'; -import { taskCapability } from './task.js'; -/** All capabilities available to the runcode agent (excluding sub-agent, which needs config). */ -export declare const allCapabilities: CapabilityHandler[]; -export { readCapability, writeCapability, editCapability, bashCapability, globCapability, grepCapability, webFetchCapability, webSearchCapability, taskCapability, }; -export { createSubAgentCapability } from './subagent.js'; diff --git a/dist/tools/index.js b/dist/tools/index.js deleted file mode 100644 index 5dbcab47..00000000 --- a/dist/tools/index.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Tool registry — exports all available capabilities for the agent. - */ -import { readCapability } from './read.js'; -import { writeCapability } from './write.js'; -import { editCapability } from './edit.js'; -import { bashCapability } from './bash.js'; -import { globCapability } from './glob.js'; -import { grepCapability } from './grep.js'; -import { webFetchCapability } from './webfetch.js'; -import { webSearchCapability } from './websearch.js'; -import { taskCapability } from './task.js'; -import { imageGenCapability } from './imagegen.js'; -import { askUserCapability } from './askuser.js'; -import { tradingSignalCapability, tradingMarketCapability } from './trading.js'; -import { searchXCapability } from './searchx.js'; -import { postToXCapability } from './posttox.js'; -/** All capabilities available to the runcode agent (excluding sub-agent, which needs config). */ -export const allCapabilities = [ - readCapability, - writeCapability, - editCapability, - bashCapability, - globCapability, - grepCapability, - webFetchCapability, - webSearchCapability, - taskCapability, - imageGenCapability, - askUserCapability, - tradingSignalCapability, - tradingMarketCapability, - searchXCapability, - postToXCapability, -]; -export { readCapability, writeCapability, editCapability, bashCapability, globCapability, grepCapability, webFetchCapability, webSearchCapability, taskCapability, }; -export { createSubAgentCapability } from './subagent.js'; diff --git a/dist/tools/posttox.d.ts b/dist/tools/posttox.d.ts deleted file mode 100644 index 89d65d7a..00000000 --- a/dist/tools/posttox.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * PostToX capability — post a reply to a tweet on X. - * The agent MUST confirm the reply text with the user before calling this tool. - * Requires the pre_key from a SearchX result. - */ -import type { CapabilityHandler } from '../agent/types.js'; -export declare const postToXCapability: CapabilityHandler; diff --git a/dist/tools/posttox.js b/dist/tools/posttox.js deleted file mode 100644 index c68f4b2a..00000000 --- a/dist/tools/posttox.js +++ /dev/null @@ -1,137 +0,0 @@ -/** - * PostToX capability — post a reply to a tweet on X. - * The agent MUST confirm the reply text with the user before calling this tool. - * Requires the pre_key from a SearchX result. - */ -import { checkSocialReady } from '../social/preflight.js'; -import { browserPool } from '../social/browser-pool.js'; -import { extractArticleBlocks, findRefs, findStaticText, X_TIME_LINK_PATTERN, } from '../social/a11y.js'; -import { computePreKey, commitPreKey, hasPosted, logReply } from '../social/db.js'; -import { loadConfig } from '../social/config.js'; -import { postReply } from '../social/x.js'; -import { bus } from '../events/bus.js'; -import { makeEvent } from '../events/types.js'; -async function execute(input, _ctx) { - const { pre_key, reply_text, search_query } = input; - if (!pre_key || !reply_text || !search_query) { - return { - output: 'Error: pre_key, reply_text, and search_query are all required', - isError: true, - }; - } - // ── Preflight: config + login ────────────────────────────────────────── - const preflight = await checkSocialReady(); - if (!preflight.ready) { - return { - output: `PostToX not ready: ${preflight.reason}`, - isError: true, - }; - } - const config = loadConfig(); - const handle = config.handle || 'unknown'; - let browser; - try { - browser = await browserPool.getBrowser(); - // ── Navigate to search results to re-find the target post ──────── - const searchUrl = `https://x.com/search?q=${encodeURIComponent(search_query)}&src=typed_query&f=live`; - await browser.open(searchUrl); - await browser.waitForTimeout(3500); - const tree = await browser.snapshot(); - // ── Find the article matching the given pre_key ────────────────── - const articles = extractArticleBlocks(tree); - let matchedTimeRef = null; - for (const article of articles) { - const timeRefs = findRefs(article.text, 'link', X_TIME_LINK_PATTERN); - if (timeRefs.length === 0) - continue; - const texts = findStaticText(article.text); - const snippet = texts.slice(0, 3).join(' ').trim(); - if (!snippet) - continue; - const timeLinkMatch = new RegExp(`\\[${timeRefs[0]}\\]\\s+link:\\s*(.+)`).exec(article.text); - const timeText = timeLinkMatch ? timeLinkMatch[1].trim() : ''; - const candidatePreKey = computePreKey({ snippet, time: timeText }); - if (candidatePreKey === pre_key) { - matchedTimeRef = timeRefs[0]; - break; - } - } - if (!matchedTimeRef) { - return { - output: 'Post not found in current results. It may have scrolled off or been deleted.', - isError: true, - }; - } - // ── Click through to the tweet page ────────────────────────────── - await browser.click(matchedTimeRef); - await browser.waitForTimeout(3000); - const canonicalUrl = await browser.getUrl(); - // ── Check if already posted to this URL ────────────────────────── - if (hasPosted('x', handle, canonicalUrl)) { - return { - output: `Already replied to this post: ${canonicalUrl}`, - isError: true, - }; - } - // ── Post the reply ─────────────────────────────────────────────── - await postReply(browser, reply_text); - // ── Record success ─────────────────────────────────────────────── - commitPreKey('x', handle, pre_key); - logReply({ - platform: 'x', - handle, - post_url: canonicalUrl, - post_title: '', - post_snippet: '', - reply_text, - status: 'posted', - }); - // ── Emit post.published event ──────────────────────────────────── - await bus.emit(makeEvent({ - type: 'post.published', - source: 'social', - data: { - platform: 'x', - url: canonicalUrl, - text: reply_text, - }, - })); - return { - output: `Reply posted successfully to ${canonicalUrl}`, - }; - } - catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { output: `PostToX error: ${msg}`, isError: true }; - } - finally { - browserPool.releaseBrowser(); - } -} -export const postToXCapability = { - spec: { - name: 'PostToX', - description: 'Post a reply to a tweet on X. The agent MUST confirm the reply text with ' + - 'the user before calling this tool. Requires the pre_key from a SearchX result.', - input_schema: { - type: 'object', - properties: { - pre_key: { - type: 'string', - description: 'preKey of the target post (from SearchX results)', - }, - reply_text: { - type: 'string', - description: 'The reply text to post', - }, - search_query: { - type: 'string', - description: 'The original search query (to re-find the post)', - }, - }, - required: ['pre_key', 'reply_text', 'search_query'], - }, - }, - execute, - concurrent: false, -}; diff --git a/dist/tools/read.d.ts b/dist/tools/read.d.ts deleted file mode 100644 index a6ece0a5..00000000 --- a/dist/tools/read.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Read capability — reads files with line numbers. - */ -import type { CapabilityHandler } from '../agent/types.js'; -/** - * Tracks files that were only partially read (offset or limit applied). - * Edit tool uses this to warn when editing without full context. - * Exported so edit.ts can check and clear entries. - */ -export declare const partiallyReadFiles: Set; -export declare const readCapability: CapabilityHandler; diff --git a/dist/tools/read.js b/dist/tools/read.js deleted file mode 100644 index 1a1e64f6..00000000 --- a/dist/tools/read.js +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Read capability — reads files with line numbers. - */ -import fs from 'node:fs'; -import path from 'node:path'; -/** - * Tracks files that were only partially read (offset or limit applied). - * Edit tool uses this to warn when editing without full context. - * Exported so edit.ts can check and clear entries. - */ -export const partiallyReadFiles = new Set(); -async function execute(input, ctx) { - const { file_path: filePath, offset, limit } = input; - if (!filePath) { - return { output: 'Error: file_path is required', isError: true }; - } - const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(ctx.workingDir, filePath); - try { - const stat = fs.statSync(resolved); - if (stat.isDirectory()) { - // Helpfully list directory contents instead of just erroring - const entries = fs.readdirSync(resolved, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name + '/'); - const files = entries.filter(e => e.isFile()).map(e => e.name); - const listing = [...dirs.sort(), ...files.sort()].slice(0, 100); - return { output: `Directory: ${resolved}\n${listing.join('\n')}${entries.length > 100 ? `\n... (${entries.length - 100} more)` : ''}` }; - } - // Size guard: skip huge files - const maxBytes = 2 * 1024 * 1024; // 2MB - if (stat.size > maxBytes) { - return { output: `Error: file is too large (${(stat.size / 1024 / 1024).toFixed(1)}MB). Use offset/limit to read a portion.`, isError: true }; - } - // Detect binary files - const ext = path.extname(resolved).toLowerCase(); - const binaryExts = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', '.pdf', '.zip', '.tar', '.gz', '.woff', '.woff2', '.ttf', '.eot', '.mp3', '.mp4', '.wav', '.avi', '.mov', '.exe', '.dll', '.so', '.dylib']); - if (binaryExts.has(ext)) { - const sizeStr = stat.size >= 1024 ? `${(stat.size / 1024).toFixed(1)}KB` : `${stat.size}B`; - return { output: `Binary file: ${resolved} (${ext}, ${sizeStr}). Cannot display contents.` }; - } - const raw = fs.readFileSync(resolved, 'utf-8'); - const allLines = raw.split('\n'); - const startLine = Math.max(0, (Math.max(1, offset ?? 1)) - 1); - const maxLines = limit ?? 2000; - const endLine = Math.min(allLines.length, startLine + maxLines); - const slice = allLines.slice(startLine, endLine); - // Track partial reads — file was not read from the beginning or was truncated - const isPartial = startLine > 0 || endLine < allLines.length; - if (isPartial) { - partiallyReadFiles.add(resolved); - } - else { - // Full read — clear any stale partial flag - partiallyReadFiles.delete(resolved); - } - // Format with line numbers (cat -n style) - const numbered = slice.map((line, i) => `${startLine + i + 1}\t${line}`); - let result = numbered.join('\n'); - if (endLine < allLines.length) { - result += `\n\n... (${allLines.length - endLine} more lines. Use offset=${endLine + 1} to continue.)`; - } - return { output: result || '(empty file)' }; - } - catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('ENOENT')) { - return { output: `Error: file not found: ${resolved}`, isError: true }; - } - if (msg.includes('EACCES')) { - return { output: `Error: permission denied: ${resolved}`, isError: true }; - } - return { output: `Error reading file: ${msg}`, isError: true }; - } -} -export const readCapability = { - spec: { - name: 'Read', - description: 'Read a file with line numbers. Use offset/limit for large files (>2000 lines). Use this instead of cat/head/tail in Bash. Reads over 2MB are rejected. Cannot read binary files or images.', - input_schema: { - type: 'object', - properties: { - file_path: { type: 'string', description: 'Absolute path' }, - offset: { type: 'number', description: 'Start line (1-based)' }, - limit: { type: 'number', description: 'Max lines (default 2000)' }, - }, - required: ['file_path'], - }, - }, - execute, - concurrent: true, -}; diff --git a/dist/tools/searchx.d.ts b/dist/tools/searchx.d.ts deleted file mode 100644 index 2464010a..00000000 --- a/dist/tools/searchx.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * SearchX capability — search X (Twitter) for posts matching a query. - * Returns candidate posts with snippets, tweet URLs, and product relevance scores. - * - * Works in two modes: - * - **Basic** (no config): browser-only search, returns snippets + URLs - * - **Enhanced** (with social config): adds product routing, dedup, login detection - */ -import type { CapabilityHandler } from '../agent/types.js'; -export declare function detectNotificationsIntent(query: string | undefined, handle: string, knownHandles?: string[]): boolean; -export declare const searchXCapability: CapabilityHandler; diff --git a/dist/tools/searchx.js b/dist/tools/searchx.js deleted file mode 100644 index 93c9107c..00000000 --- a/dist/tools/searchx.js +++ /dev/null @@ -1,288 +0,0 @@ -/** - * SearchX capability — search X (Twitter) for posts matching a query. - * Returns candidate posts with snippets, tweet URLs, and product relevance scores. - * - * Works in two modes: - * - **Basic** (no config): browser-only search, returns snippets + URLs - * - **Enhanced** (with social config): adds product routing, dedup, login detection - */ -import { checkSocialReady } from '../social/preflight.js'; -import { extractArticleBlocks, findRefs, findStaticText, X_TIME_LINK_PATTERN, } from '../social/a11y.js'; -import { computePreKey, hasPreKey } from '../social/db.js'; -import { detectProduct } from '../social/ai.js'; -import { loadConfig, isConfigReady } from '../social/config.js'; -import { browserPool } from '../social/browser-pool.js'; -// ─── Intent detection (code-level, not LLM-level) ────────────────────────── -// When the user asks "check my @handle mentions/notifications/互动", -// the tool itself routes to x.com/notifications. No LLM judgment needed. -const NOTIFICATION_KEYWORDS = [ - 'notification', 'notifications', - 'mention', 'mentions', 'mentioned', - 'reply', 'replies', - 'interact', 'interaction', 'interactions', - '互动', '通知', '提及', '回复', '看看', - 'check my', 'my account', 'my x', - 'to:', 'from:', '@', -]; -export function detectNotificationsIntent(query, handle, knownHandles) { - if (!query) - return false; - const q = query.toLowerCase(); - // Collect all handles the user might reference (personal + org accounts) - const handles = new Set(); - const addHandle = (h) => { - const clean = h.replace(/^@/, '').toLowerCase().trim(); - if (clean.length >= 3) - handles.add(clean); - }; - addHandle(handle); - if (knownHandles) - knownHandles.forEach(addHandle); - // Check if query mentions any known handle - let mentionsOwnHandle = false; - let matchedHandle = ''; - for (const h of handles) { - if (q.includes(h)) { - mentionsOwnHandle = true; - matchedHandle = h; - break; - } - } - const hasInteractionKeyword = NOTIFICATION_KEYWORDS.some(kw => q.includes(kw)); - // Route to notifications if: mentions own handle + interaction keyword - // OR query is literally just the handle (e.g. "blockrunai", "@BlockRunAI") - if (mentionsOwnHandle && hasInteractionKeyword) - return true; - if (mentionsOwnHandle && q.replace(/[@:]/g, '').trim() === matchedHandle) - return true; - return false; -} -async function execute(input, _ctx) { - const { query, max_results, mode } = input; - if (!query && mode !== 'notifications') { - return { output: 'Error: query is required (or set mode to "notifications")', isError: true }; - } - const maxResults = Math.min(Math.max(max_results ?? 10, 1), 50); - // ── Config: load if available, degrade gracefully if not ──────────── - const config = loadConfig(); - const configStatus = isConfigReady(config); - const enhanced = configStatus.ready; - const handle = config.handle || 'unknown'; - // ── Auto-detect notifications intent from query ───────────────────── - // Skill-level routing: the code decides, not the LLM. - // If the query mentions any known handle + interaction keywords, - // or explicitly asks for notifications, route to notifications page. - // Extract known handles from config: search queries may contain org handles - // like "BlockRunAI" even if the personal handle is "@bc1beat". - const knownHandles = []; - if (config.x?.search_queries) { - for (const sq of config.x.search_queries) { - // Extract @-handles and capitalized brand names from search queries - const atHandles = sq.match(/@\w+/g); - if (atHandles) - knownHandles.push(...atHandles); - // Also add single-word brand tokens (like "BlockRunAI") - const words = sq.split(/\s+/).filter(w => /^[A-Z]/.test(w) && w.length >= 5); - knownHandles.push(...words); - } - } - const isNotifications = mode === 'notifications' || detectNotificationsIntent(query, handle, knownHandles); - // In enhanced mode, verify login via preflight - if (enhanced) { - const preflight = await checkSocialReady(); - if (!preflight.ready) { - if (isNotifications) { - return { - output: 'Not logged in to X. Run `franklin social login x` first — notifications require authentication.', - isError: true, - }; - } - // Search can sometimes work without login — fall through - } - } - let browser; - try { - browser = await browserPool.getBrowser(); - // ── Choose page: notifications vs search ────────────────────────── - const targetUrl = isNotifications - ? 'https://x.com/notifications' - : `https://x.com/search?q=${encodeURIComponent(query)}&src=typed_query&f=live`; - try { - await browser.open(targetUrl); - } - catch (err) { - const msg = err instanceof Error ? err.message : String(err); - browserPool.releaseBrowser(); - if (msg.includes('Timeout') || msg.includes('timeout')) { - return { - output: `SearchX: X.com timed out (network issue or blocked). Try again later or check your connection.`, - isError: true, - }; - } - return { output: `SearchX: Failed to open X.com: ${msg.slice(0, 200)}`, isError: true }; - } - await browser.waitForTimeout(4000); - const tree = await browser.snapshot(); - // ── Diagnose page state ─────────────────────────────────────────── - const isLoginWall = tree.includes('Sign in') && tree.includes('Create account'); - const isRateLimit = tree.includes('Rate limit') || tree.includes('Something went wrong'); - const treeLen = tree.length; - if (isLoginWall) { - return { - output: `SearchX: X is showing a login wall. Run \`franklin social login x\` to authenticate.\n\nTree preview (${treeLen} chars):\n${tree.slice(0, 500)}`, - isError: true, - }; - } - if (isRateLimit) { - return { - output: `SearchX: X returned an error page (rate limit or server issue). Try again in a minute.\n\nTree preview (${treeLen} chars):\n${tree.slice(0, 500)}`, - isError: true, - }; - } - // ── Extract articles ─────────────────────────────────────────────── - const articles = extractArticleBlocks(tree); - const candidates = []; - for (const article of articles) { - if (candidates.length >= maxResults) - break; - // Extract snippet from static text (first 3 lines) - const texts = findStaticText(article.text); - const snippet = texts.slice(0, 3).join(' ').trim(); - if (!snippet || snippet.length < 10) - continue; - // Find time-link ref (permalink to the tweet) — optional - const timeRefs = findRefs(article.text, 'link', X_TIME_LINK_PATTERN); - const timeRef = timeRefs[0] ?? null; - // Fallback: if no time-link, try to find ANY link in the article - // that looks like a tweet permalink (/username/status/...) - let tweetUrl = null; - let timeText = ''; - if (timeRef) { - const timeLinkMatch = new RegExp(`\\[${timeRef}\\]\\s+link:\\s*(.+)`).exec(article.text); - timeText = timeLinkMatch ? timeLinkMatch[1].trim() : ''; - try { - const href = await browser.getHref(timeRef); - if (href) { - tweetUrl = href.startsWith('http') - ? href - : `https://x.com${href.startsWith('/') ? '' : '/'}${href}`; - } - } - catch { - // Non-fatal — we still have the snippet - } - } - else { - // No time-link matched — try all links in the article for a permalink - const allLinks = findRefs(article.text, 'link'); - for (const linkRef of allLinks.slice(0, 5)) { - try { - const href = await browser.getHref(linkRef); - if (href && /\/status\/\d+/.test(href)) { - tweetUrl = href.startsWith('http') - ? href - : `https://x.com${href.startsWith('/') ? '' : '/'}${href}`; - // Extract time text from this link's label - const labelMatch = new RegExp(`\\[${linkRef}\\]\\s+link:\\s*(.+)`).exec(article.text); - timeText = labelMatch ? labelMatch[1].trim() : ''; - break; - } - } - catch { /* try next */ } - } - } - // Dedup (enhanced mode only) - const preKey = enhanced ? computePreKey({ snippet, time: timeText }) : ''; - const alreadySeen = enhanced ? hasPreKey('x', handle, preKey) : false; - // Product routing (enhanced mode only) - const product = enhanced ? detectProduct(snippet, config.products) : null; - candidates.push({ - index: candidates.length + 1, - snippet, - timeText, - tweetUrl, - preKey, - productMatch: product?.name ?? null, - alreadySeen, - }); - } - // ── Format output ────────────────────────────────────────────────── - if (candidates.length === 0) { - // Include diagnostic info — show first article block so we can debug the parser - let diag; - if (articles.length === 0) { - diag = `No article blocks found in AX tree (${treeLen} chars). Tree preview:\n${tree.slice(0, 800)}`; - } - else { - const sample = articles[0].text.slice(0, 600); - diag = `Found ${articles.length} article blocks but extracted 0 candidates.\nFirst article AX dump:\n${sample}`; - } - return { - output: `No candidate posts found for query: "${query}"\n\n` + - 'Tell the user: "No X posts found for this query. Try a different keyword or check back later."\n' + - 'Do NOT use WebSearch or WebFetch as a fallback — they cannot access X.com content.\n' + - 'Do NOT fabricate or invent X post links.\n\n' + - `[debug] ${diag}`, - }; - } - const lines = candidates.map((c) => { - const url = c.tweetUrl ? `\n url: ${c.tweetUrl}` : ''; - if (enhanced) { - const seen = c.alreadySeen ? ' [SEEN]' : ''; - const product = c.productMatch ? ` | product: ${c.productMatch}` : ' | product: none'; - return (`${c.index}. ${c.snippet.slice(0, 200)}${url}\n` + - ` time: ${c.timeText} | pre_key: ${c.preKey}${product}${seen}`); - } - // Basic mode: simpler output - return (`${c.index}. ${c.snippet.slice(0, 200)}${url}\n` + - ` time: ${c.timeText}`); - }); - const header = isNotifications - ? `X Notifications (${candidates.length} items):` - : `SearchX results for "${query}" (${candidates.length} candidates):`; - let output = `${header}\n\n${lines.join('\n\n')}`; - // Explicit instructions to prevent model from hallucinating additional posts - output += '\n\n---\n'; - output += 'IMPORTANT: The posts above are the ONLY real X posts found. '; - output += 'Present ONLY these posts to the user. Do NOT fabricate additional posts. '; - output += 'Do NOT use WebSearch or WebFetch to find X posts — they cannot access X.com content. '; - output += 'If the user wants more, suggest refining the search query.'; - if (!enhanced) { - output += '\nTip: Run `franklin social setup` to enable product routing, dedup, and auto-replies.'; - } - return { output }; - } - catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { output: `SearchX error: ${msg}`, isError: true }; - } - finally { - browserPool.releaseBrowser(); - } -} -export const searchXCapability = { - spec: { - name: 'SearchX', - description: 'The ONLY tool that can access X (Twitter). Returns real posts with URLs. ' + - 'Use mode "search" to find posts by keyword. Use mode "notifications" to check mentions/replies. ' + - 'Call ONCE per topic — do not retry. WebSearch/WebFetch CANNOT access X.com.', - input_schema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query (required for search mode, optional for notifications mode)' }, - max_results: { - type: 'number', - description: 'Max posts to return (default 10)', - }, - mode: { - type: 'string', - enum: ['search', 'notifications'], - description: 'Mode: "search" to find posts by keyword, "notifications" to check your mentions/replies/interactions that need response. Default: search', - }, - }, - required: [], - }, - }, - execute, - concurrent: false, -}; diff --git a/dist/tools/subagent.d.ts b/dist/tools/subagent.d.ts deleted file mode 100644 index 39cf984a..00000000 --- a/dist/tools/subagent.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * SubAgent capability — spawn a child agent for independent tasks. - */ -import type { CapabilityHandler } from '../agent/types.js'; -export declare function createSubAgentCapability(apiUrl: string, chain: 'base' | 'solana', capabilities: CapabilityHandler[]): CapabilityHandler; diff --git a/dist/tools/subagent.js b/dist/tools/subagent.js deleted file mode 100644 index f3dce1bd..00000000 --- a/dist/tools/subagent.js +++ /dev/null @@ -1,116 +0,0 @@ -/** - * SubAgent capability — spawn a child agent for independent tasks. - */ -import { ModelClient } from '../agent/llm.js'; -import { assembleInstructions } from '../agent/context.js'; -// These will be injected at registration time -let registeredApiUrl = ''; -let registeredChain = 'base'; -let registeredCapabilities = []; -async function execute(input, ctx) { - const { prompt, description, model } = input; - if (!prompt) { - return { output: 'Error: prompt is required', isError: true }; - } - const client = new ModelClient({ - apiUrl: registeredApiUrl, - chain: registeredChain, - }); - const capabilityMap = new Map(); - // Sub-agents get a subset of tools (no sub-agent recursion) - const subTools = registeredCapabilities.filter(c => c.spec.name !== 'Agent'); - for (const cap of subTools) { - capabilityMap.set(cap.spec.name, cap); - } - const toolDefs = subTools.map(c => c.spec); - const systemInstructions = assembleInstructions(ctx.workingDir); - const systemPrompt = systemInstructions.join('\n\n'); - const history = [ - { role: 'user', content: prompt }, - ]; - const maxTurns = 30; - const SUB_AGENT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minute total timeout - const deadline = Date.now() + SUB_AGENT_TIMEOUT_MS; - let turn = 0; - let finalText = ''; - while (turn < maxTurns) { - if (Date.now() > deadline) { - return { output: `[${description || 'sub-agent'}] timed out after 5 minutes (${turn} turns completed).`, isError: true }; - } - turn++; - const { content: parts } = await client.complete({ - model: model || 'anthropic/claude-sonnet-4.6', - messages: history, - system: systemPrompt, - tools: toolDefs, - max_tokens: 16384, - stream: true, - }, ctx.abortSignal); - history.push({ role: 'assistant', content: parts }); - // Collect text and invocations - const invocations = []; - for (const part of parts) { - if (part.type === 'text') { - finalText = part.text; - } - else if (part.type === 'tool_use') { - invocations.push(part); - } - } - if (invocations.length === 0) - break; - // Execute tools - const outcomes = []; - for (const inv of invocations) { - const handler = capabilityMap.get(inv.name); - let result; - if (handler) { - try { - result = await handler.execute(inv.input, ctx); - } - catch (err) { - result = { - output: `Error: ${err.message}`, - isError: true, - }; - } - } - else { - result = { output: `Unknown tool: ${inv.name}`, isError: true }; - } - outcomes.push({ - type: 'tool_result', - tool_use_id: inv.id, - content: result.output, - is_error: result.isError, - }); - } - history.push({ role: 'user', content: outcomes }); - } - const label = description || 'sub-agent'; - return { - output: finalText || `[${label}] completed after ${turn} turn(s) with no text output.`, - }; -} -export function createSubAgentCapability(apiUrl, chain, capabilities) { - registeredApiUrl = apiUrl; - registeredChain = chain; - registeredCapabilities = capabilities; - return { - spec: { - name: 'Agent', - description: 'Launch a sub-agent for independent tasks. The sub-agent has its own context and tools.', - input_schema: { - type: 'object', - properties: { - prompt: { type: 'string', description: 'The task for the sub-agent to perform' }, - description: { type: 'string', description: 'Short description of what the sub-agent will do' }, - model: { type: 'string', description: 'Model for the sub-agent. Default: claude-sonnet-4.6' }, - }, - required: ['prompt'], - }, - }, - execute, - concurrent: false, - }; -} diff --git a/dist/tools/task.d.ts b/dist/tools/task.d.ts deleted file mode 100644 index 32f58c06..00000000 --- a/dist/tools/task.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Task capability — in-session task tracking for the agent. - */ -import type { CapabilityHandler } from '../agent/types.js'; -export declare const taskCapability: CapabilityHandler; diff --git a/dist/tools/task.js b/dist/tools/task.js deleted file mode 100644 index 40b393cf..00000000 --- a/dist/tools/task.js +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Task capability — in-session task tracking for the agent. - */ -// In-memory task store (per session) -const tasks = []; -let nextId = 1; -async function execute(input, _ctx) { - const { action, subject, description, task_id, status } = input; - switch (action) { - case 'create': { - if (!subject) { - return { output: 'Error: subject is required for create', isError: true }; - } - const task = { - id: nextId++, - subject, - status: 'pending', - description, - }; - tasks.push(task); - return { output: `Task #${task.id} created: ${task.subject}` }; - } - case 'update': { - if (!task_id) { - return { output: 'Error: task_id is required for update', isError: true }; - } - const task = tasks.find(t => t.id === task_id); - if (!task) { - return { output: `Error: task #${task_id} not found`, isError: true }; - } - if (status) - task.status = status; - if (subject) - task.subject = subject; - if (description) - task.description = description; - return { output: `Task #${task.id} updated: ${task.status} — ${task.subject}` }; - } - case 'list': { - if (tasks.length === 0) { - return { output: 'No tasks.' }; - } - const pending = tasks.filter(t => t.status !== 'completed').length; - const done = tasks.filter(t => t.status === 'completed').length; - const lines = tasks.map(t => { - const icon = t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '→' : '○'; - return `${icon} #${t.id} [${t.status}] ${t.subject}`; - }); - lines.push(`\n${done} done, ${pending} remaining`); - return { output: lines.join('\n') }; - } - case 'delete': { - if (!task_id) { - return { output: 'Error: task_id is required for delete', isError: true }; - } - const idx = tasks.findIndex(t => t.id === task_id); - if (idx === -1) { - return { output: `Error: task #${task_id} not found`, isError: true }; - } - const removed = tasks.splice(idx, 1)[0]; - return { output: `Task #${removed.id} deleted: ${removed.subject}` }; - } - default: - return { output: `Error: unknown action "${action}". Use create, update, or list.`, isError: true }; - } -} -export const taskCapability = { - spec: { - name: 'Task', - description: 'Track multi-step work within a session. Use for complex tasks with 3+ steps to maintain progress. Do NOT use for simple single-step requests. Actions: create, update (status/subject), list, delete. Tasks are ephemeral — they reset when the session ends.', - input_schema: { - type: 'object', - properties: { - action: { - type: 'string', - description: 'Action: "create", "update", "list", or "delete"', - }, - subject: { type: 'string', description: 'Task title (for create/update)' }, - description: { type: 'string', description: 'Task description (for create/update)' }, - task_id: { type: 'number', description: 'Task ID (for update)' }, - status: { - type: 'string', - description: 'New status: "pending", "in_progress", or "completed" (for update)', - }, - }, - required: ['action'], - }, - }, - execute, - concurrent: false, -}; diff --git a/dist/tools/trading.d.ts b/dist/tools/trading.d.ts deleted file mode 100644 index a209e69f..00000000 --- a/dist/tools/trading.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { CapabilityHandler } from '../agent/types.js'; -export declare const tradingSignalCapability: CapabilityHandler; -export declare const tradingMarketCapability: CapabilityHandler; diff --git a/dist/tools/trading.js b/dist/tools/trading.js deleted file mode 100644 index b2a6690f..00000000 --- a/dist/tools/trading.js +++ /dev/null @@ -1,168 +0,0 @@ -import { getPrice, getOHLCV, getTrending, getMarketOverview } from '../trading/data.js'; -import { rsi, macd, bollingerBands, volatility } from '../trading/metrics.js'; -import { bus } from '../events/bus.js'; -import { makeEvent } from '../events/types.js'; -function formatUsd(n) { - if (n >= 1e12) - return `$${(n / 1e12).toFixed(2)}T`; - if (n >= 1e9) - return `$${(n / 1e9).toFixed(2)}B`; - if (n >= 1e6) - return `$${(n / 1e6).toFixed(2)}M`; - if (n >= 1e3) - return `$${(n / 1e3).toFixed(1)}K`; - return `$${n.toFixed(2)}`; -} -async function executeSignal(input, _ctx) { - const { ticker, days = 30 } = input; - if (!ticker) { - return { output: 'Error: ticker is required', isError: true }; - } - const upper = ticker.toUpperCase(); - const [priceResult, ohlcvResult] = await Promise.all([ - getPrice(upper), - getOHLCV(upper, days), - ]); - if (typeof priceResult === 'string') { - return { output: `Error fetching price: ${priceResult}`, isError: true }; - } - if (typeof ohlcvResult === 'string') { - return { output: `Error fetching OHLCV: ${ohlcvResult}`, isError: true }; - } - const { closes } = ohlcvResult; - const rsiResult = rsi(closes); - const macdResult = macd(closes); - const bbResult = bollingerBands(closes); - const volResult = volatility(closes); - // Determine overall direction from indicators - let bullish = 0; - let bearish = 0; - if (rsiResult.interpretation === 'oversold') - bullish++; - if (rsiResult.interpretation === 'overbought') - bearish++; - if (macdResult.trend === 'bullish') - bullish++; - if (macdResult.trend === 'bearish') - bearish++; - if (bbResult.position === 'below') - bullish++; - if (bbResult.position === 'above') - bearish++; - const direction = bullish > bearish ? 'bullish' : bearish > bullish ? 'bearish' : 'neutral'; - const confidence = Math.max(bullish, bearish) / 3; - bus.emit(makeEvent({ - type: 'signal.detected', - source: 'trading', - data: { - asset: upper, - direction, - confidence, - indicators: { - rsi: rsiResult.value, - macd: macdResult.macd, - volatility: volResult.annualized, - }, - summary: `${upper} ${direction} (confidence ${(confidence * 100).toFixed(0)}%)`, - }, - })); - const { price, change24h, marketCap, volume24h } = priceResult; - const last5 = closes.slice(-5).map(c => c.toFixed(2)).join(', '); - const output = [ - `## ${upper} Signal Report`, - '', - `**Price:** $${price.toLocaleString()} USD (${change24h > 0 ? '+' : ''}${change24h.toFixed(2)}% 24h)`, - `**Market Cap:** ${formatUsd(marketCap)}`, - `**24h Volume:** ${formatUsd(volume24h)}`, - '', - `### Technical Indicators (${days}d lookback)`, - `- **RSI(14):** ${rsiResult.value.toFixed(1)} — ${rsiResult.interpretation}`, - `- **MACD:** ${macdResult.macd.toFixed(4)} / Signal: ${macdResult.signal.toFixed(4)} / Histogram: ${macdResult.histogram.toFixed(4)} — ${macdResult.trend}`, - `- **Bollinger:** Upper ${bbResult.upper.toFixed(2)} / Middle ${bbResult.middle.toFixed(2)} / Lower ${bbResult.lower.toFixed(2)} — Price ${bbResult.position}`, - `- **Volatility:** ${(volResult.annualized * 100).toFixed(1)}% annualized — ${volResult.interpretation}`, - '', - `### Raw Data`, - `Closes (last 5): ${last5}`, - ].join('\n'); - return { output }; -} -export const tradingSignalCapability = { - spec: { - name: 'TradingSignal', - description: 'Get current price, technical indicators (RSI, MACD, Bollinger Bands, volatility), and a signal summary for a cryptocurrency. Returns raw data for the agent to analyze and interpret.', - input_schema: { - type: 'object', - properties: { - ticker: { type: 'string', description: 'Cryptocurrency ticker, e.g. "BTC", "ETH"' }, - days: { type: 'number', description: 'Lookback period for indicators. Default: 30' }, - }, - required: ['ticker'], - }, - }, - execute: executeSignal, - concurrent: true, -}; -async function executeMarket(input, _ctx) { - const { action, ticker } = input; - if (!action) { - return { output: 'Error: action is required', isError: true }; - } - switch (action) { - case 'price': { - if (!ticker) { - return { output: 'Error: ticker is required for price action', isError: true }; - } - const result = await getPrice(ticker.toUpperCase()); - if (typeof result === 'string') { - return { output: `Error: ${result}`, isError: true }; - } - const { price, change24h, marketCap, volume24h } = result; - return { - output: `${ticker.toUpperCase()}: $${price.toLocaleString()} (${change24h > 0 ? '+' : ''}${change24h.toFixed(2)}% 24h), Market Cap: ${formatUsd(marketCap)}, Volume: ${formatUsd(volume24h)}`, - }; - } - case 'trending': { - const result = await getTrending(); - if (typeof result === 'string') { - return { output: `Error: ${result}`, isError: true }; - } - const lines = result.map((c, i) => `${i + 1}. ${c.name} (${c.symbol.toUpperCase()})${c.marketCapRank ? ` — #${c.marketCapRank}` : ''}`); - return { output: `Trending coins:\n${lines.join('\n')}` }; - } - case 'overview': { - const result = await getMarketOverview(); - if (typeof result === 'string') { - return { output: `Error: ${result}`, isError: true }; - } - const header = 'Rank | Coin | Price | 24h Change | Market Cap'; - const sep = '-----|------|-------|------------|----------'; - const rows = result.map((c, i) => `${i + 1} | ${c.name} (${c.symbol.toUpperCase()}) | $${c.price.toLocaleString()} | ${c.change24h > 0 ? '+' : ''}${c.change24h.toFixed(2)}% | ${formatUsd(c.marketCap)}`); - return { output: `Top 20 by Market Cap:\n${header}\n${sep}\n${rows.join('\n')}` }; - } - default: - return { output: `Error: unknown action "${action}". Use: price, trending, overview`, isError: true }; - } -} -export const tradingMarketCapability = { - spec: { - name: 'TradingMarket', - description: 'Get cryptocurrency market data: price lookup, trending coins, or market overview (top 20 by market cap).', - input_schema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['price', 'trending', 'overview'], - description: 'What to fetch: price lookup, trending coins, or market overview', - }, - ticker: { - type: 'string', - description: 'Cryptocurrency ticker (required for price action), e.g. "BTC"', - }, - }, - required: ['action'], - }, - }, - execute: executeMarket, - concurrent: true, -}; diff --git a/dist/tools/validate.d.ts b/dist/tools/validate.d.ts deleted file mode 100644 index 1fd41635..00000000 --- a/dist/tools/validate.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Tool description validation — catches descriptions that discourage the LLM - * from using tools that actually work (like SearchX's old "Requires social config"). - */ -import type { CapabilityHandler } from '../agent/types.js'; -export interface ToolValidationIssue { - toolName: string; - issue: string; - severity: 'warning' | 'error'; -} -export declare function validateToolDescriptions(tools: CapabilityHandler[]): ToolValidationIssue[]; diff --git a/dist/tools/validate.js b/dist/tools/validate.js deleted file mode 100644 index 39976059..00000000 --- a/dist/tools/validate.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Tool description validation — catches descriptions that discourage the LLM - * from using tools that actually work (like SearchX's old "Requires social config"). - */ -// Patterns in tool descriptions that make LLMs avoid using the tool -const BLOCKER_PATTERNS = [ - /\brequires?\b.*\b(?:config|setup|login|install|key|token|credential)\b/i, - /\bmust\s+(?:configure|set\s*up|install|login)\b/i, - /\bneeds?\s+(?:configuration|setup|api\s*key)\b/i, -]; -export function validateToolDescriptions(tools) { - const issues = []; - const names = new Set(); - for (const tool of tools) { - const name = tool.spec.name; - const desc = tool.spec.description; - // Duplicate names - if (names.has(name)) { - issues.push({ toolName: name, issue: 'Duplicate tool name — LLM will confuse them', severity: 'error' }); - } - names.add(name); - // Description length - if (desc.length < 20) { - issues.push({ toolName: name, issue: `Description too short (${desc.length} chars) — LLM may not understand when to use this tool`, severity: 'warning' }); - } - if (desc.length > 500) { - issues.push({ toolName: name, issue: `Description too long (${desc.length} chars) — wastes context window`, severity: 'warning' }); - } - // Blocker patterns — phrases that make the LLM think the tool won't work - for (const pattern of BLOCKER_PATTERNS) { - if (pattern.test(desc)) { - issues.push({ - toolName: name, - issue: `Description contains blocking language: "${desc.match(pattern)?.[0]}" — LLM may avoid using this tool even when it would work`, - severity: 'warning', - }); - break; // One warning per tool is enough - } - } - } - return issues; -} diff --git a/dist/tools/webfetch.d.ts b/dist/tools/webfetch.d.ts deleted file mode 100644 index eafe7790..00000000 --- a/dist/tools/webfetch.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * WebFetch capability — fetch web page content. - */ -import type { CapabilityHandler } from '../agent/types.js'; -export declare const webFetchCapability: CapabilityHandler; diff --git a/dist/tools/webfetch.js b/dist/tools/webfetch.js deleted file mode 100644 index e63bdcec..00000000 --- a/dist/tools/webfetch.js +++ /dev/null @@ -1,187 +0,0 @@ -/** - * WebFetch capability — fetch web page content. - */ -import { USER_AGENT } from '../config.js'; -const MAX_BODY_BYTES = 256 * 1024; // 256KB -const DEFAULT_MAX_LENGTH = 12_288; -const HTML_READ_AHEAD_BYTES = 8_192; -// ─── Session cache ────────────────────────────────────────────────────────── -// Avoids re-fetching the same URL within a session (common in research tasks). -// 15-min TTL, max 50 entries. -const CACHE_TTL_MS = 15 * 60 * 1000; -const MAX_CACHE_ENTRIES = 50; -const fetchCache = new Map(); -function cacheKey(url, maxLength) { - return `${url}::${maxLength}`; -} -function getCached(key) { - const entry = fetchCache.get(key); - if (!entry) - return null; - if (Date.now() > entry.expiresAt) { - fetchCache.delete(key); - return null; - } - return entry.output; -} -function setCached(key, output) { - // Evict oldest entry if at capacity - if (fetchCache.size >= MAX_CACHE_ENTRIES) { - const firstKey = fetchCache.keys().next().value; - if (firstKey) - fetchCache.delete(firstKey); - } - fetchCache.set(key, { output, expiresAt: Date.now() + CACHE_TTL_MS }); -} -// ─── Execute ──────────────────────────────────────────────────────────────── -async function execute(input, ctx) { - const { url, max_length } = input; - if (!url) { - return { output: 'Error: url is required', isError: true }; - } - // Basic URL validation - let parsed; - try { - parsed = new URL(url); - } - catch { - return { output: `Error: invalid URL: ${url}`, isError: true }; - } - if (!['http:', 'https:'].includes(parsed.protocol)) { - return { output: `Error: only http/https URLs are supported`, isError: true }; - } - const maxLen = Math.min(max_length ?? DEFAULT_MAX_LENGTH, MAX_BODY_BYTES); - const key = cacheKey(url, maxLen); - // Check cache first - const cached = getCached(key); - if (cached) { - return { output: cached + '\n\n(cached)' }; - } - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 30_000); - const onAbort = () => controller.abort(); - ctx.abortSignal.addEventListener('abort', onAbort, { once: true }); - try { - const response = await fetch(url, { - signal: controller.signal, - headers: { - 'User-Agent': USER_AGENT, - 'Accept': 'text/html,application/json,text/plain,*/*', - }, - redirect: 'follow', - }); - if (!response.ok) { - return { - output: `HTTP ${response.status} ${response.statusText} for ${url}`, - isError: true, - }; - } - const contentType = response.headers.get('content-type') || ''; - // Read body with size limit - const reader = response.body?.getReader(); - if (!reader) { - return { output: 'Error: no response body', isError: true }; - } - const chunks = []; - let totalBytes = 0; - const readBudget = contentType.includes('html') - ? Math.min(maxLen + HTML_READ_AHEAD_BYTES, MAX_BODY_BYTES) - : maxLen; - try { - while (totalBytes < readBudget) { - const { done, value } = await reader.read(); - if (done) - break; - chunks.push(value); - totalBytes += value.length; - } - } - finally { - reader.releaseLock(); - } - const decoder = new TextDecoder(); - const rawBody = decoder.decode(Buffer.concat(chunks)); - let body = rawBody; - // Format response based on content type - if (contentType.includes('json')) { - try { - const parsedJson = JSON.parse(rawBody.slice(0, maxLen)); - body = JSON.stringify(parsedJson, null, 2).slice(0, maxLen); - } - catch { /* leave as-is if not valid JSON */ } - } - else if (contentType.includes('html')) { - body = stripHtml(rawBody).slice(0, maxLen); - } - else { - body = rawBody.slice(0, maxLen); - } - let output = `URL: ${url}\nStatus: ${response.status}\nContent-Type: ${contentType}\n\n${body}`; - if (totalBytes >= readBudget || rawBody.length > maxLen) { - output += '\n\n... (content truncated)'; - } - // Cache successful responses - setCached(key, output); - return { output }; - } - catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (ctx.abortSignal.aborted) { - return { output: `Error: request aborted for ${url}`, isError: true }; - } - if (msg.includes('abort')) { - return { output: `Error: request timed out after 30s for ${url}`, isError: true }; - } - return { output: `Error fetching ${url}: ${msg}`, isError: true }; - } - finally { - clearTimeout(timeout); - ctx.abortSignal.removeEventListener('abort', onAbort); - } -} -function stripHtml(html) { - return html - // Remove non-content elements - .replace(/]*>[\s\S]*?<\/script>/gi, '') - .replace(/]*>[\s\S]*?<\/style>/gi, '') - .replace(/]*>[\s\S]*?<\/nav>/gi, '') - .replace(/]*>[\s\S]*?<\/header>/gi, '') - .replace(/]*>[\s\S]*?<\/footer>/gi, '') - .replace(/]*>[\s\S]*?<\/aside>/gi, '') - .replace(/]*>[\s\S]*?<\/noscript>/gi, '') - .replace(/]*>[\s\S]*?<\/svg>/gi, '') - .replace(/<(path|g|defs|clipPath|symbol|use|mask|rect|circle|ellipse|polygon|polyline|line)\b[^>]*>/gi, ' ') - .replace(/]*>[\s\S]*?<\/form>/gi, '') - // Convert block elements to newlines for readability - .replace(/<\/?(p|div|h[1-6]|li|br|tr)[^>]*>/gi, '\n') - // Strip remaining tags - .replace(/<[^>]+>/g, ' ') - .replace(/<[^>\n]*$/g, '') - // Decode entities - .replace(/ /g, ' ') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - // Clean whitespace - .replace(/[ \t]+/g, ' ') - .replace(/\n{3,}/g, '\n\n') - .trim(); -} -export const webFetchCapability = { - spec: { - name: 'WebFetch', - description: 'Fetch a web page and return its content as text. For searching the web, use WebSearch instead. Cannot access X.com (use SearchX). Large pages are truncated. Prefer WebSearch for discovery, WebFetch for reading a specific known URL.', - input_schema: { - type: 'object', - properties: { - url: { type: 'string', description: 'The URL to fetch' }, - max_length: { type: 'number', description: 'Max content bytes to return. Default: 256KB' }, - }, - required: ['url'], - }, - }, - execute, - concurrent: true, -}; diff --git a/dist/tools/websearch.d.ts b/dist/tools/websearch.d.ts deleted file mode 100644 index 0b7f07b3..00000000 --- a/dist/tools/websearch.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * WebSearch capability — search the web via BlockRun API or DuckDuckGo fallback. - */ -import type { CapabilityHandler } from '../agent/types.js'; -export declare const webSearchCapability: CapabilityHandler; diff --git a/dist/tools/websearch.js b/dist/tools/websearch.js deleted file mode 100644 index 15f67419..00000000 --- a/dist/tools/websearch.js +++ /dev/null @@ -1,122 +0,0 @@ -/** - * WebSearch capability — search the web via BlockRun API or DuckDuckGo fallback. - */ -import { VERSION } from '../config.js'; -const MAX_RESULTS_CAP = 8; -const MAX_SNIPPET_CHARS = 220; -const MAX_OUTPUT_CHARS = 3_200; -async function execute(input, _ctx) { - const { query, max_results } = input; - if (!query) { - return { output: 'Error: query is required', isError: true }; - } - const maxResults = Math.min(Math.max(max_results ?? 5, 1), MAX_RESULTS_CAP); - // Try DuckDuckGo HTML search (no API key needed) - try { - const encoded = encodeURIComponent(query); - const url = `https://html.duckduckgo.com/html/?q=${encoded}`; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15_000); - const response = await fetch(url, { - signal: controller.signal, - headers: { - 'User-Agent': `runcode/${VERSION} (coding-agent)`, - }, - }); - clearTimeout(timeout); - if (!response.ok) { - return { output: `Search failed: HTTP ${response.status}`, isError: true }; - } - const html = await response.text(); - const results = parseDuckDuckGoResults(html, maxResults); - if (results.length === 0) { - return { output: `No results found for: ${query}` }; - } - const lines = []; - let totalChars = `Search results for "${query}":\n\n`.length; - for (let i = 0; i < results.length; i++) { - const r = results[i]; - const snippet = r.snippet.length > MAX_SNIPPET_CHARS - ? r.snippet.slice(0, MAX_SNIPPET_CHARS - 3) + '...' - : r.snippet; - const block = `${i + 1}. ${r.title}\n ${r.url}\n ${snippet}`; - if (lines.length > 0 && totalChars + block.length + 2 > MAX_OUTPUT_CHARS) { - lines.push(`... (${results.length - i} more results omitted)`); - break; - } - lines.push(block); - totalChars += block.length + 2; - } - return { output: `Search results for "${query}":\n\n${lines.join('\n\n')}` }; - } - catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('abort')) { - return { output: `Search timed out after 15s for: ${query}`, isError: true }; - } - return { output: `Search error: ${msg}`, isError: true }; - } -} -function parseDuckDuckGoResults(html, maxResults) { - const results = []; - const seenUrls = new Set(); - // Primary parser: match result blocks by class names - const linkRegex = /]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi; - const snippetRegex = /]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi; - let links = [...html.matchAll(linkRegex)]; - let snippets = [...html.matchAll(snippetRegex)]; - // Fallback parser if primary finds nothing (DDG may have updated HTML) - if (links.length === 0) { - const fallbackLink = /]*class="[^"]*result[^"]*"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi; - links = [...html.matchAll(fallbackLink)]; - } - for (let i = 0; i < Math.min(links.length, maxResults); i++) { - const link = links[i]; - const snippet = snippets[i]; - let url = link[1] || ''; - // DuckDuckGo wraps URLs in redirect — extract the actual URL - const uddgMatch = url.match(/uddg=([^&]+)/); - if (uddgMatch) { - url = decodeURIComponent(uddgMatch[1]); - } - // Skip internal DDG links - if (url.startsWith('/') || url.includes('duckduckgo.com')) - continue; - if (seenUrls.has(url)) - continue; - seenUrls.add(url); - results.push({ - title: stripTags(link[2] || '').trim(), - url, - snippet: stripTags(snippet?.[1] || '').trim(), - }); - } - return results; -} -function stripTags(html) { - return html - .replace(/<[^>]+>/g, '') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/ /g, ' ') - .replace(/\s+/g, ' '); -} -export const webSearchCapability = { - spec: { - name: 'WebSearch', - description: 'Search the web via DuckDuckGo. Returns titles, URLs, and snippets. Cannot access X.com content (use SearchX for X posts). Do NOT rephrase and retry the same search — if results are empty, stop. Max 3-5 searches per topic.', - input_schema: { - type: 'object', - properties: { - query: { type: 'string', description: 'The search query' }, - max_results: { type: 'number', description: 'Max number of results. Default: 5' }, - }, - required: ['query'], - }, - }, - execute, - concurrent: true, -}; diff --git a/dist/tools/write.d.ts b/dist/tools/write.d.ts deleted file mode 100644 index 022a16ed..00000000 --- a/dist/tools/write.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Write capability — creates or overwrites files. - */ -import type { CapabilityHandler } from '../agent/types.js'; -export declare const writeCapability: CapabilityHandler; diff --git a/dist/tools/write.js b/dist/tools/write.js deleted file mode 100644 index cdb6a941..00000000 --- a/dist/tools/write.js +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Write capability — creates or overwrites files. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { partiallyReadFiles } from './read.js'; -function withTrailingSep(value) { - return value.endsWith(path.sep) ? value : value + path.sep; -} -function isWithinDir(target, dir) { - const normalizedTarget = path.resolve(target); - const normalizedDir = withTrailingSep(path.resolve(dir)); - return normalizedTarget === normalizedDir.slice(0, -1) || normalizedTarget.startsWith(normalizedDir); -} -function getAllowedTempDirs() { - const candidates = new Set([path.resolve(os.tmpdir())]); - for (const dir of [...candidates]) { - try { - candidates.add(path.resolve(fs.realpathSync(dir))); - } - catch { - // Best effort only. - } - if (dir.startsWith('/private/')) { - candidates.add(dir.slice('/private'.length)); - } - else { - candidates.add(path.join('/private', dir)); - } - } - return [...candidates]; -} -async function execute(input, ctx) { - const { file_path: filePath, content } = input; - if (!filePath) { - return { output: 'Error: file_path is required', isError: true }; - } - if (content === undefined || content === null) { - return { output: 'Error: content is required', isError: true }; - } - const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(ctx.workingDir, filePath); - // Safety: block system paths and sensitive home directories - // Resolve symlinks to prevent traversal attacks - const home = os.homedir(); - const allowedTempDirs = getAllowedTempDirs(); - const dangerousPaths = [ - '/etc/', '/usr/', '/bin/', '/sbin/', '/var/', '/System/', - path.join(home, '.ssh') + '/', - path.join(home, '.aws') + '/', - path.join(home, '.kube') + '/', - path.join(home, '.gnupg') + '/', - path.join(home, '.config/gcloud') + '/', - ]; - // Check both the resolved path and the real path (after symlink resolution) - const checkPath = (p) => !allowedTempDirs.some(dir => isWithinDir(p, dir)) && - dangerousPaths.some(dp => p.startsWith(dp)); - if (checkPath(resolved)) { - return { output: `Error: refusing to write to sensitive path: ${resolved}`, isError: true }; - } - // Also check parent dir's real path if it already exists (symlink protection) - const parentDir = path.dirname(resolved); - try { - if (fs.existsSync(parentDir)) { - const realParent = fs.realpathSync(parentDir); - if (checkPath(realParent + '/')) { - return { output: `Error: refusing to write — path resolves to sensitive location: ${realParent}`, isError: true }; - } - } - } - catch { /* parent doesn't exist yet, will be created */ } - // Also check if target file itself is a symlink to a sensitive location - try { - if (fs.existsSync(resolved) && fs.lstatSync(resolved).isSymbolicLink()) { - const realTarget = fs.realpathSync(resolved); - if (checkPath(realTarget)) { - return { output: `Error: refusing to write — symlink resolves to sensitive location: ${realTarget}`, isError: true }; - } - } - } - catch { /* file doesn't exist yet, ok */ } - try { - // Ensure parent directory exists - const parentDir = path.dirname(resolved); - fs.mkdirSync(parentDir, { recursive: true }); - const existed = fs.existsSync(resolved); - fs.writeFileSync(resolved, content, 'utf-8'); - partiallyReadFiles.delete(resolved); - const lineCount = content.split('\n').length; - const byteCount = Buffer.byteLength(content, 'utf-8'); - const sizeStr = byteCount >= 1024 ? `${(byteCount / 1024).toFixed(1)}KB` : `${byteCount}B`; - return { - output: `${existed ? 'Updated' : 'Created'} ${resolved} (${lineCount} lines, ${sizeStr})`, - }; - } - catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { output: `Error writing file: ${msg}`, isError: true }; - } -} -export const writeCapability = { - spec: { - name: 'Write', - description: 'Create a new file or completely overwrite an existing one. For targeted edits to existing files, prefer Edit (sends only the diff). Use this instead of echo/heredoc in Bash.', - input_schema: { - type: 'object', - properties: { - file_path: { type: 'string', description: 'Absolute path' }, - content: { type: 'string', description: 'File content' }, - }, - required: ['file_path', 'content'], - }, - }, - execute, - concurrent: false, -}; diff --git a/dist/trading/config.d.ts b/dist/trading/config.d.ts deleted file mode 100644 index ff244849..00000000 --- a/dist/trading/config.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Typed config for Franklin's trading subsystem. - * Stored at ~/.blockrun/trading-config.json. Default written on first run. - */ -export interface TradingConfig { - version: 1; - watchlist: string[]; - signals: { - rsi_oversold: number; - rsi_overbought: number; - }; - model_tier: 'free' | 'cheap' | 'premium'; -} -export declare const CONFIG_PATH: string; -/** - * Load config from disk. If missing, write defaults and return them. - * Returns the parsed config or throws on malformed JSON. - */ -export declare function loadTradingConfig(): TradingConfig; -/** - * Persist config back to disk. - */ -export declare function saveTradingConfig(cfg: TradingConfig): void; diff --git a/dist/trading/config.js b/dist/trading/config.js deleted file mode 100644 index 3f310aa1..00000000 --- a/dist/trading/config.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Typed config for Franklin's trading subsystem. - * Stored at ~/.blockrun/trading-config.json. Default written on first run. - */ -import path from 'node:path'; -import fs from 'node:fs'; -import os from 'node:os'; -export const CONFIG_PATH = path.join(os.homedir(), '.blockrun', 'trading-config.json'); -const DEFAULT_CONFIG = { - version: 1, - watchlist: ['BTC', 'ETH', 'SOL'], - signals: { - rsi_oversold: 30, - rsi_overbought: 70, - }, - model_tier: 'cheap', -}; -/** - * Load config from disk. If missing, write defaults and return them. - * Returns the parsed config or throws on malformed JSON. - */ -export function loadTradingConfig() { - const dir = path.dirname(CONFIG_PATH); - if (!fs.existsSync(dir)) - fs.mkdirSync(dir, { recursive: true }); - if (!fs.existsSync(CONFIG_PATH)) { - fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2)); - return { ...DEFAULT_CONFIG }; - } - const raw = fs.readFileSync(CONFIG_PATH, 'utf8'); - const parsed = JSON.parse(raw); - if (parsed.version !== 1) { - throw new Error(`Unsupported trading config version ${parsed.version} (expected 1)`); - } - return parsed; -} -/** - * Persist config back to disk. - */ -export function saveTradingConfig(cfg) { - const dir = path.dirname(CONFIG_PATH); - if (!fs.existsSync(dir)) - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2)); -} diff --git a/dist/trading/data.d.ts b/dist/trading/data.d.ts deleted file mode 100644 index 77c660b8..00000000 --- a/dist/trading/data.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -export interface PriceData { - price: number; - change24h: number; - volume24h: number; - marketCap: number; -} -export interface OHLCVData { - closes: number[]; - timestamps: number[]; -} -export interface TrendingCoin { - id: string; - name: string; - symbol: string; - marketCapRank: number | null; -} -export interface MarketCoin { - id: string; - symbol: string; - name: string; - price: number; - change24h: number; - marketCap: number; - volume24h: number; -} -export declare function resolveId(ticker: string): string; -export declare function getPrice(ticker: string): Promise; -export declare function getOHLCV(ticker: string, days?: number): Promise; -export declare function getTrending(): Promise; -export declare function getMarketOverview(): Promise; diff --git a/dist/trading/data.js b/dist/trading/data.js deleted file mode 100644 index 29879ef6..00000000 --- a/dist/trading/data.js +++ /dev/null @@ -1,112 +0,0 @@ -const BASE = "https://api.coingecko.com/api/v3"; -const UA = "franklin/3.3.0 (trading)"; -const TIMEOUT = 10_000; -const TICKER_MAP = { - BTC: "bitcoin", ETH: "ethereum", SOL: "solana", BNB: "binancecoin", XRP: "ripple", - ADA: "cardano", DOGE: "dogecoin", AVAX: "avalanche-2", DOT: "polkadot", MATIC: "matic-network", - LINK: "chainlink", UNI: "uniswap", ATOM: "cosmos", LTC: "litecoin", NEAR: "near", - APT: "aptos", ARB: "arbitrum", OP: "optimism", SUI: "sui", SEI: "sei-network", - FIL: "filecoin", AAVE: "aave", MKR: "maker", SNX: "synthetix-network-token", - COMP: "compound-governance-token", INJ: "injective-protocol", TIA: "celestia", - PEPE: "pepe", WIF: "dogwifcoin", RENDER: "render-token", -}; -const cache = new Map(); -function cached(key, ttlMs, fn) { - const hit = cache.get(key); - if (hit && hit.expiry > Date.now()) - return Promise.resolve(hit.data); - return fn().then(data => { - cache.set(key, { data, expiry: Date.now() + ttlMs }); - return data; - }); -} -const TTL_PRICE = 5 * 60_000; -const TTL_OHLCV = 60 * 60_000; -const TTL_TRENDING = 15 * 60_000; -// Fetch helper -async function geckofetch(path) { - const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), TIMEOUT); - try { - const res = await fetch(`${BASE}${path}`, { - headers: { "User-Agent": UA }, - signal: ctrl.signal, - }); - if (res.status === 429) - return "rate-limited: CoinGecko 429 — retry later"; - if (!res.ok) - return `CoinGecko error ${res.status}`; - return await res.json(); - } - catch (e) { - if (e instanceof DOMException && e.name === "AbortError") - return "request timed out"; - return String(e); - } - finally { - clearTimeout(timer); - } -} -export function resolveId(ticker) { - return TICKER_MAP[ticker.toUpperCase()] ?? ticker.toLowerCase(); -} -export async function getPrice(ticker) { - const id = resolveId(ticker); - return cached(`price:${id}`, TTL_PRICE, async () => { - const raw = await geckofetch(`/simple/price?ids=${id}&vs_currencies=usd&include_24hr_change=true&include_market_cap=true&include_24hr_vol=true`); - if (typeof raw === "string") - return raw; - const d = raw[id]; - if (!d) - return `no data for ${ticker}`; - return { - price: d.usd, - change24h: d.usd_24h_change, - volume24h: d.usd_24h_vol, - marketCap: d.usd_market_cap, - }; - }); -} -export async function getOHLCV(ticker, days = 30) { - const id = resolveId(ticker); - return cached(`ohlcv:${id}:${days}`, TTL_OHLCV, async () => { - const raw = await geckofetch(`/coins/${id}/market_chart?vs_currency=usd&days=${days}&interval=daily`); - if (typeof raw === "string") - return raw; - const prices = raw.prices; - return { - timestamps: prices.map(p => p[0]), - closes: prices.map(p => p[1]), - }; - }); -} -export async function getTrending() { - return cached("trending", TTL_TRENDING, async () => { - const raw = await geckofetch("/search/trending"); - if (typeof raw === "string") - return raw; - const coins = raw.coins; - return coins.map(c => ({ - id: c.item.id, - name: c.item.name, - symbol: c.item.symbol, - marketCapRank: c.item.market_cap_rank, - })); - }); -} -export async function getMarketOverview() { - return cached("markets", TTL_TRENDING, async () => { - const raw = await geckofetch("/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=20&page=1"); - if (typeof raw === "string") - return raw; - return raw.map(c => ({ - id: c.id, - symbol: c.symbol, - name: c.name, - price: c.current_price, - change24h: c.price_change_percentage_24h, - marketCap: c.market_cap, - volume24h: c.total_volume, - })); - }); -} diff --git a/dist/trading/metrics.d.ts b/dist/trading/metrics.d.ts deleted file mode 100644 index 89b16465..00000000 --- a/dist/trading/metrics.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -export interface RSIResult { - value: number; - values: number[]; - interpretation: 'oversold' | 'neutral' | 'overbought'; -} -export interface MACDResult { - macd: number; - signal: number; - histogram: number; - trend: 'bullish' | 'bearish' | 'neutral'; -} -export interface BollingerResult { - upper: number; - middle: number; - lower: number; - bandwidth: number; - position: 'above' | 'within' | 'below'; -} -export interface VolatilityResult { - daily: number; - annualized: number; - interpretation: 'low' | 'medium' | 'high'; -} -export declare function sma(data: number[], period: number): number; -export declare function ema(closes: number[], period: number): number[]; -export declare function rsi(closes: number[], period?: number): RSIResult; -export declare function macd(closes: number[], fast?: number, slow?: number, signal?: number): MACDResult; -export declare function bollingerBands(closes: number[], period?: number, stdDev?: number): BollingerResult; -export declare function volatility(closes: number[], period?: number): VolatilityResult; diff --git a/dist/trading/metrics.js b/dist/trading/metrics.js deleted file mode 100644 index eb15d6f4..00000000 --- a/dist/trading/metrics.js +++ /dev/null @@ -1,105 +0,0 @@ -export function sma(data, period) { - if (data.length < period) - return NaN; - const slice = data.slice(data.length - period); - return slice.reduce((sum, v) => sum + v, 0) / period; -} -export function ema(closes, period) { - const result = new Array(closes.length).fill(NaN); - if (closes.length < period) - return result; - let sum = 0; - for (let i = 0; i < period; i++) - sum += closes[i]; - result[period - 1] = sum / period; - const k = 2 / (period + 1); - for (let i = period; i < closes.length; i++) { - result[i] = closes[i] * k + result[i - 1] * (1 - k); - } - return result; -} -export function rsi(closes, period = 14) { - const values = new Array(closes.length).fill(NaN); - if (closes.length < period + 1) { - return { value: NaN, values, interpretation: 'neutral' }; - } - const gains = []; - const losses = []; - for (let i = 1; i < closes.length; i++) { - const diff = closes[i] - closes[i - 1]; - gains.push(diff > 0 ? diff : 0); - losses.push(diff < 0 ? -diff : 0); - } - let avgGain = gains.slice(0, period).reduce((s, v) => s + v, 0) / period; - let avgLoss = losses.slice(0, period).reduce((s, v) => s + v, 0) / period; - const computeRSI = (ag, al) => al === 0 ? 100 : 100 - 100 / (1 + ag / al); - values[period] = computeRSI(avgGain, avgLoss); - for (let i = period; i < gains.length; i++) { - avgGain = (avgGain * (period - 1) + gains[i]) / period; - avgLoss = (avgLoss * (period - 1) + losses[i]) / period; - values[i + 1] = computeRSI(avgGain, avgLoss); - } - const latest = values[values.length - 1]; - const interpretation = latest < 30 ? 'oversold' : latest > 70 ? 'overbought' : 'neutral'; - return { value: latest, values, interpretation }; -} -export function macd(closes, fast = 12, slow = 26, signal = 9) { - const emaFast = ema(closes, fast); - const emaSlow = ema(closes, slow); - const macdLine = closes.map((_, i) => isNaN(emaFast[i]) || isNaN(emaSlow[i]) ? NaN : emaFast[i] - emaSlow[i]); - const validMacd = macdLine.filter((v) => !isNaN(v)); - const signalLine = ema(validMacd, signal); - const padded = new Array(macdLine.length - validMacd.length) - .fill(NaN) - .concat(signalLine); - const histogram = macdLine.map((v, i) => isNaN(v) || isNaN(padded[i]) ? NaN : v - padded[i]); - const last = macdLine[macdLine.length - 1]; - const lastSignal = padded[padded.length - 1]; - const lastHist = histogram[histogram.length - 1]; - const prevHist = histogram[histogram.length - 2]; - let trend = 'neutral'; - if (!isNaN(lastHist) && !isNaN(prevHist)) { - if (lastHist > 0 && lastHist > prevHist) - trend = 'bullish'; - else if (lastHist < 0 && lastHist < prevHist) - trend = 'bearish'; - } - return { macd: last, signal: lastSignal, histogram: lastHist, trend }; -} -export function bollingerBands(closes, period = 20, stdDev = 2) { - if (closes.length < period) { - return { - upper: NaN, - middle: NaN, - lower: NaN, - bandwidth: NaN, - position: 'within', - }; - } - const slice = closes.slice(closes.length - period); - const middle = slice.reduce((s, v) => s + v, 0) / period; - const variance = slice.reduce((s, v) => s + (v - middle) ** 2, 0) / period; - const sigma = Math.sqrt(variance); - const upper = middle + stdDev * sigma; - const lower = middle - stdDev * sigma; - const bandwidth = (upper - lower) / middle; - const price = closes[closes.length - 1]; - const position = price > upper ? 'above' : price < lower ? 'below' : 'within'; - return { upper, middle, lower, bandwidth, position }; -} -export function volatility(closes, period = 14) { - if (closes.length < period + 1) { - return { daily: NaN, annualized: NaN, interpretation: 'medium' }; - } - const returns = []; - const start = closes.length - period - 1; - for (let i = start + 1; i < closes.length; i++) { - returns.push(Math.log(closes[i] / closes[i - 1])); - } - const mean = returns.reduce((s, v) => s + v, 0) / returns.length; - const variance = returns.reduce((s, v) => s + (v - mean) ** 2, 0) / (returns.length - 1); - const daily = Math.sqrt(variance); - const annualized = daily * Math.sqrt(365); - const interpretation = annualized < 0.3 ? 'low' : annualized > 0.8 ? 'high' : 'medium'; - return { daily, annualized, interpretation }; -} diff --git a/dist/ui/app.d.ts b/dist/ui/app.d.ts deleted file mode 100644 index 2c815202..00000000 --- a/dist/ui/app.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * RunCode ink-based terminal UI. - * Real-time streaming, thinking animation, tool progress, slash commands. - */ -import type { StreamEvent } from '../agent/types.js'; -export interface InkUIHandle { - handleEvent: (event: StreamEvent) => void; - updateModel: (model: string) => void; - updateBalance: (balance: string) => void; - onTurnDone: (cb: () => void) => void; - waitForInput: () => Promise; - onAbort: (cb: () => void) => void; - cleanup: () => void; - requestPermission: (toolName: string, description: string) => Promise<'yes' | 'no' | 'always'>; - requestAskUser: (question: string, options?: string[]) => Promise; -} -export declare function launchInkUI(opts: { - model: string; - workDir: string; - version: string; - walletAddress?: string; - walletBalance?: string; - chain?: string; - showPicker?: boolean; - onModelChange?: (model: string) => void; -}): InkUIHandle; diff --git a/dist/ui/app.js b/dist/ui/app.js deleted file mode 100644 index e8bd9a5a..00000000 --- a/dist/ui/app.js +++ /dev/null @@ -1,586 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -/** - * RunCode ink-based terminal UI. - * Real-time streaming, thinking animation, tool progress, slash commands. - */ -import chalk from 'chalk'; -import { useState, useEffect, useCallback, useRef } from 'react'; -import { render, Static, Box, Text, useApp, useInput, useStdout } from 'ink'; -import Spinner from 'ink-spinner'; -import TextInput from 'ink-text-input'; -import { renderMarkdown } from './markdown.js'; -import { resolveModel, PICKER_CATEGORIES, PICKER_MODELS_FLAT, } from './model-picker.js'; -import { estimateCost } from '../pricing.js'; -import { formatTokens, shortModelName } from '../stats/format.js'; -// ─── Full-width input box ────────────────────────────────────────────────── -function InputBox({ input, setInput, onSubmit, model, balance, sessionCost, queued, queuedCount, focused, busy, contextPct }) { - const { stdout } = useStdout(); - const cols = stdout?.columns ?? 80; - const innerWidth = Math.min(Math.max(30, cols - 4), cols - 2); - const placeholder = busy - ? (queued - ? `⏎ ${queuedCount ?? 1} queued: ${queued.slice(0, 40)}` - : 'Working...') - : 'Type a message...'; - return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: '╭' + '─'.repeat(cols - 2) + '╮' }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "\u2502 " }), busy && !input ? _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " "] }) : null, _jsx(Box, { width: busy && !input ? innerWidth - 4 : innerWidth, children: _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: placeholder, focus: focused !== false }) }), _jsxs(Text, { dimColor: true, children: [' '.repeat(Math.max(0, cols - innerWidth - 4)), "\u2502"] })] }), _jsx(Text, { dimColor: true, children: '╰' + '─'.repeat(cols - 2) + '╯' }), _jsx(Box, { marginLeft: 1, children: _jsxs(Text, { dimColor: true, children: [busy ? _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }) : null, busy ? ' ' : '', model, " \u00B7 ", balance, sessionCost > 0.00001 ? _jsxs(Text, { color: "yellow", children: [" -$", sessionCost.toFixed(4)] }) : '', contextPct !== undefined && contextPct > 0 ? (_jsxs(Text, { color: contextPct > 85 ? 'red' : contextPct > 70 ? 'yellow' : undefined, children: [' · ctx ', contextPct, '%'] })) : null, (queuedCount ?? 0) > 0 ? _jsxs(Text, { color: "cyan", children: [" \u00B7 ", queuedCount, " queued"] }) : null, ' · esc'] }) })] })); -} -function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain, startWithPicker, onSubmit, onModelChange, onAbort, onExit, }) { - const { exit } = useApp(); - const [input, setInput] = useState(''); - const [streamText, setStreamText] = useState(''); - const [thinking, setThinking] = useState(false); - const [waiting, setWaiting] = useState(false); - const [tools, setTools] = useState(new Map()); - // Completed tool results committed to Static (permanent scrollback — no re-render artifacts) - const [completedTools, setCompletedTools] = useState([]); - // Full responses committed to Static immediately — goes into terminal scrollback like Claude Code - const [committedResponses, setCommittedResponses] = useState([]); - // Short preview of latest response shown in dynamic area (last ~5 lines, cleared on next turn) - const [responsePreview, setResponsePreview] = useState(''); - const [currentModel, setCurrentModel] = useState(initialModel || PICKER_MODELS_FLAT[0].id); - const [ready, setReady] = useState(!startWithPicker); - const [mode, setMode] = useState(startWithPicker ? 'model-picker' : 'input'); - const [pickerIdx, setPickerIdx] = useState(0); - const [statusMsg, setStatusMsg] = useState(''); - const [statusTone, setStatusTone] = useState('success'); - const [turnTokens, setTurnTokens] = useState({ input: 0, output: 0, calls: 0 }); - const [contextPct, setContextPct] = useState(0); - const [totalCost, setTotalCost] = useState(0); - const [showHelp, setShowHelp] = useState(false); - const [showWallet, setShowWallet] = useState(false); - const [balance, setBalance] = useState(walletBalance); - // Parse the fetched balance to a number so we can compute live balance = fetchedBalance - sessionCost. - // costAtLastFetch tracks totalCost when balance was last fetched, to avoid double-subtracting. - const parseBalanceNum = (s) => { - const m = s.match(/\$([\d.]+)/); - return m ? parseFloat(m[1]) : null; - }; - const [baseBalanceNum, setBaseBalanceNum] = useState(() => parseBalanceNum(walletBalance)); - const [costAtLastFetch, setCostAtLastFetch] = useState(0); - const costAtLastFetchRef = useRef(0); - const baseBalanceNumRef = useRef(parseBalanceNum(walletBalance)); - const [thinkingText, setThinkingText] = useState(''); - const [lastPrompt, setLastPrompt] = useState(''); - const [inputHistory, setInputHistory] = useState([]); - const [historyIdx, setHistoryIdx] = useState(-1); - const [permissionRequest, setPermissionRequest] = useState(null); - const [askUserRequest, setAskUserRequest] = useState(null); - const [askUserInput, setAskUserInput] = useState(''); - // Messages queued while agent is busy — auto-submitted FIFO when turns complete. - const [queuedInputs, setQueuedInputs] = useState([]); - const turnDoneCallbackRef = useRef(null); - // Refs to read current state values inside memoized event handlers (avoids stale closures) - const streamTextRef = useRef(''); - const turnTokensRef = useRef({ input: 0, output: 0, calls: 0 }); - const totalCostRef = useRef(0); - const turnCostRef = useRef(0); // per-turn cost (reset each turn) - const turnModelRef = useRef(undefined); - const turnTierRef = useRef(undefined); - const turnSavingsRef = useRef(undefined); - const queuedInputsRef = useRef([]); - // Keep refs in sync so memoized event handlers can read current values - streamTextRef.current = streamText; - turnTokensRef.current = turnTokens; - totalCostRef.current = totalCost; - queuedInputsRef.current = queuedInputs; - costAtLastFetchRef.current = costAtLastFetch; - baseBalanceNumRef.current = baseBalanceNum; - // Compute live balance = fetchedBalance - spend_since_last_fetch - const liveBalance = baseBalanceNum !== null - ? `$${Math.max(0, baseBalanceNum - (totalCost - costAtLastFetch)).toFixed(2)} USDC` - : balance; - const showStatus = useCallback((text, tone = 'success', durationMs = 3000) => { - setStatusTone(tone); - setStatusMsg(text); - if (durationMs > 0) { - setTimeout(() => setStatusMsg(''), durationMs); - } - }, []); - const commitResponse = useCallback((text, tokens = turnTokensRef.current, cost = turnCostRef.current) => { - if (!text.trim()) - return; - setCommittedResponses((rs) => [...rs, { - key: String(Date.now() + Math.random()), - text, - tokens, - cost, - model: turnModelRef.current, - tier: turnTierRef.current, - savings: turnSavingsRef.current, - }]); - const allLines = text.split('\n'); - if (allLines.length > 20) { - setResponsePreview(' ↑ scroll to see full reply\n' + allLines.slice(-20).join('\n')); - } - else { - setResponsePreview(''); - } - }, []); - // Permission dialog key handler — captures y/n/a when dialog is visible. - // ink 6.x: useInput handlers all fire regardless of TextInput focus prop, - // so we handle here AND block TextInput onChange (see focused prop below). - useInput((ch, _key) => { - if (!permissionRequest) - return; - // Clear any character that leaked into the text input - setInput(''); - const c = ch.toLowerCase(); - if (c === 'y') { - const r = permissionRequest.resolve; - setPermissionRequest(null); - r('yes'); - } - else if (c === 'n') { - const r = permissionRequest.resolve; - setPermissionRequest(null); - r('no'); - } - else if (c === 'a') { - const r = permissionRequest.resolve; - setPermissionRequest(null); - r('always'); - } - }, { isActive: !!permissionRequest }); - // Key handler for picker + esc + abort - const isPickerOrEsc = mode === 'model-picker' || (mode === 'input' && ready && !input) || !ready; - useInput((ch, key) => { - // Escape during generation → abort current turn (skip if permission dialog open) - if (key.escape && !ready && !permissionRequest) { - onAbort(); - showStatus('Aborted', 'warning', 3000); - setReady(true); - setWaiting(false); - setThinking(false); - return; - } - // Esc to quit (only when input is empty and in input mode) - if (key.escape && mode === 'input' && ready && !input) { - onExit(); - exit(); - return; - } - // Arrow key navigation for model picker - if (mode !== 'model-picker') - return; - if (key.upArrow) - setPickerIdx(i => Math.max(0, i - 1)); - else if (key.downArrow) - setPickerIdx(i => Math.min(PICKER_MODELS_FLAT.length - 1, i + 1)); - else if (key.return) { - const selected = PICKER_MODELS_FLAT[pickerIdx]; - setCurrentModel(selected.id); - onModelChange(selected.id); - showStatus(`Model → ${selected.label}`, 'success', 3000); - setMode('input'); - setReady(true); - } - else if (key.escape) { - setMode('input'); - setReady(true); - } - }, { isActive: isPickerOrEsc }); - // Input history: Up/Down arrow when in ready input mode - useInput((_ch, key) => { - if (key.upArrow && inputHistory.length > 0) { - const newIdx = historyIdx < 0 ? inputHistory.length - 1 : Math.max(0, historyIdx - 1); - setHistoryIdx(newIdx); - setInput(inputHistory[newIdx]); - } - else if (key.downArrow) { - if (historyIdx >= 0 && historyIdx < inputHistory.length - 1) { - const newIdx = historyIdx + 1; - setHistoryIdx(newIdx); - setInput(inputHistory[newIdx]); - } - else { - setHistoryIdx(-1); - setInput(''); - } - } - }, { isActive: ready && mode === 'input' }); - const handleSubmit = useCallback((value) => { - const trimmed = value.trim(); - if (!trimmed) - return; - // If agent is busy, queue the message — it will be auto-submitted when the turn finishes - if (!ready) { - setQueuedInputs(prev => [...prev, trimmed]); - setInput(''); - showStatus(`Queued message (${queuedInputsRef.current.length + 1} pending)`, 'warning', 1500); - return; - } - // Bare exit/quit (no slash needed) - const lower = trimmed.toLowerCase(); - if (lower === 'exit' || lower === 'quit' || lower === 'q') { - onExit(); - exit(); - return; - } - // ── Slash commands ── - if (trimmed.startsWith('/')) { - setInput(''); - setShowHelp(false); - setShowWallet(false); - const parts = trimmed.split(/\s+/); - const cmd = parts[0].toLowerCase(); - switch (cmd) { - case '/exit': - case '/quit': - onExit(); - exit(); - return; - case '/model': - case '/models': - if (parts[1]) { - const resolved = resolveModel(parts[1]); - setCurrentModel(resolved); - onModelChange(resolved); - showStatus(`Model → ${resolved}`, 'success', 3000); - } - else { - const idx = PICKER_MODELS_FLAT.findIndex(m => m.id === currentModel); - setPickerIdx(idx >= 0 ? idx : 0); - setMode('model-picker'); - } - return; - case '/wallet': - case '/balance': - setShowWallet(true); - setShowHelp(false); - return; - case '/cost': - case '/usage': - showStatus(`Cost: $${totalCost.toFixed(4)} this session`, 'success', 4000); - return; - case '/help': - setShowHelp(true); - setShowWallet(false); - return; - case '/clear': - setStreamText(''); - setTools(new Map()); - setTurnTokens({ input: 0, output: 0, calls: 0 }); - turnCostRef.current = 0; - turnModelRef.current = undefined; - turnTierRef.current = undefined; - turnSavingsRef.current = undefined; - setWaiting(true); - setReady(false); - // Pass through to agent loop to clear the actual conversation history - onSubmit('/clear'); - return; - case '/retry': - if (!lastPrompt) { - showStatus('No previous prompt to retry', 'warning', 3000); - return; - } - setStreamText(''); - setThinking(false); - setThinkingText(''); - setTools(new Map()); - setReady(false); - setWaiting(true); - setTurnTokens({ input: 0, output: 0, calls: 0 }); - turnCostRef.current = 0; - turnModelRef.current = undefined; - turnTierRef.current = undefined; - turnSavingsRef.current = undefined; - onSubmit(lastPrompt); - return; - default: - // All other slash commands pass through to the agent loop's command registry - setStreamText(''); - setThinking(false); - setThinkingText(''); - setTools(new Map()); - setWaiting(true); - setReady(false); - onSubmit(trimmed); - return; - } - } - // ── Normal prompt ── - // Show user message in scrollback so the conversation is readable - setCommittedResponses(rs => [...rs, { - key: `user-${Date.now()}`, - text: chalk.cyan('❯') + ' ' + trimmed, - tokens: { input: 0, output: 0, calls: 0 }, - cost: 0, - }]); - setResponsePreview(''); - setLastPrompt(trimmed); - setInputHistory(prev => [...prev.slice(-49), trimmed]); // Keep last 50 - setHistoryIdx(-1); - setInput(''); - setStreamText(''); - setThinking(false); - setThinkingText(''); - setTools(new Map()); - setCompletedTools([]); - setReady(false); - setWaiting(true); - setStatusMsg(''); - setShowHelp(false); - setShowWallet(false); - setTurnTokens({ input: 0, output: 0, calls: 0 }); - turnCostRef.current = 0; - turnModelRef.current = undefined; - turnTierRef.current = undefined; - turnSavingsRef.current = undefined; - onSubmit(trimmed); - }, [ready, currentModel, totalCost, onSubmit, onModelChange, onAbort, onExit, exit, lastPrompt, inputHistory, showStatus]); - // Expose event handler, balance updater, and permission bridge - useEffect(() => { - globalThis.__runcode_ui = { - updateModel: (model) => { setCurrentModel(model); }, - updateBalance: (bal) => { - setBalance(bal); - const num = parseBalanceNum(bal); - if (num !== null) { - setBaseBalanceNum(num); - // Reset cost baseline — the fetched balance already reflects costs up to this point - setCostAtLastFetch(totalCostRef.current); - } - }, - onTurnDone: (cb) => { turnDoneCallbackRef.current = cb; }, - requestPermission: (toolName, description) => { - return new Promise((resolve) => { - // Ring the terminal bell — causes tab to show notification badge in iTerm2/Terminal.app - process.stderr.write('\x07'); - setPermissionRequest({ toolName, description, resolve }); - }); - }, - requestAskUser: (question, options) => { - return new Promise((resolve) => { - process.stderr.write('\x07'); - setAskUserInput(''); - setAskUserRequest({ question, options, resolve }); - }); - }, - handleEvent: (event) => { - switch (event.kind) { - case 'text_delta': - setWaiting(false); - setThinking(false); - setStreamText(prev => prev + event.text); - break; - case 'thinking_delta': - setWaiting(false); - setThinking(true); - setThinkingText(prev => { - // Keep last 500 chars of thinking for display - const updated = prev + event.text; - return updated.length > 500 ? updated.slice(-500) : updated; - }); - break; - case 'capability_start': - setWaiting(false); - setTools(prev => { - const next = new Map(prev); - next.set(event.id, { - name: event.name, startTime: Date.now(), - done: false, error: false, - preview: event.preview || '', - liveOutput: '', - elapsed: 0, - }); - return next; - }); - break; - case 'capability_progress': - setTools(prev => { - const t = prev.get(event.id); - if (!t || t.done) - return prev; - const next = new Map(prev); - next.set(event.id, { ...t, liveOutput: event.text }); - return next; - }); - break; - case 'capability_done': { - setTools(prev => { - const next = new Map(prev); - const t = next.get(event.id); - if (t) { - // On success: show input preview (command/path). On error: show error output. - const resultPreview = event.result.isError - ? event.result.output.replace(/\n/g, ' ').slice(0, 150) - : (t.preview || event.result.output.replace(/\n/g, ' ').slice(0, 120)); - const completed = { - ...t, - key: event.id, - done: true, - error: !!event.result.isError, - preview: resultPreview, - liveOutput: '', - elapsed: Date.now() - t.startTime, - }; - // Move to Static (permanent scrollback) — prevents re-render artifacts - setCompletedTools(prev2 => [...prev2, completed]); - next.delete(event.id); - } - return next; - }); - break; - } - case 'usage': { - setCurrentModel(event.model); - setTurnTokens(prev => ({ - input: prev.input + event.inputTokens, - output: prev.output + event.outputTokens, - calls: prev.calls + (event.calls ?? 1), - })); - const turnCallCost = estimateCost(event.model, event.inputTokens, event.outputTokens, event.calls ?? 1); - turnCostRef.current += turnCallCost; - setTotalCost(prev => prev + turnCallCost); - // Capture routing metadata for this turn - turnModelRef.current = event.model; - if (event.tier) - turnTierRef.current = event.tier; - if (event.savings !== undefined) - turnSavingsRef.current = event.savings; - if (event.contextPct !== undefined) - setContextPct(event.contextPct); - break; - } - case 'turn_done': { - const text = streamTextRef.current; - if (text.trim()) { - commitResponse(text, turnTokensRef.current, turnCostRef.current); - setStreamText(''); - } - if (event.reason === 'error' && event.error) { - commitResponse(`Error: ${event.error}`, turnTokensRef.current, turnCostRef.current); - showStatus('Turn failed', 'error', 5000); - } - else if (event.reason === 'aborted') { - showStatus('Aborted', 'warning', 3000); - } - else if (event.reason === 'max_turns') { - showStatus('Stopped after reaching max turns', 'warning', 5000); - } - else { - setStatusMsg(''); - } - setReady(true); - setWaiting(false); - setThinking(false); - setThinkingText(''); - // Trigger balance refresh after each completed turn - turnDoneCallbackRef.current?.(); - // Ring the terminal bell so the user knows the AI finished - // (shows notification badge in iTerm2/Terminal.app when tabbed away) - process.stderr.write('\x07'); - // Auto-submit any queued message while agent was busy - const queued = queuedInputsRef.current[0]; - if (queued) { - setQueuedInputs((prev) => prev.slice(1)); - // Small delay so React can flush the ready=true state first - setTimeout(() => { - const fn = globalThis.__runcode_submit; - if (typeof fn === 'function') - fn(queued); - }, 50); - } - break; - } - } - }, - }; - globalThis.__runcode_submit = (msg) => { - handleSubmit(msg); - }; - return () => { - delete globalThis.__runcode_ui; - delete globalThis.__runcode_submit; - }; - }, [handleSubmit, commitResponse, showStatus]); - // ── Render ── - // Note: the tree is ALWAYS the same shape across mode changes. Static - // components (completedTools, committedResponses) stay mounted so Ink - // doesn't discard already-committed scrollback when the model picker - // opens/closes. The picker is rendered inline below scrollback, and the - // InputBox is hidden while it's active. - const inPicker = mode === 'model-picker'; - return (_jsxs(Box, { flexDirection: "column", children: [statusMsg && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: statusTone === 'error' ? 'red' : statusTone === 'warning' ? 'yellow' : 'green', children: statusMsg }) })), showHelp && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Commands" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/model" }), " [name] Switch model (picker if no name)"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/wallet" }), " Show wallet address & balance"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/cost" }), " Session cost & savings"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/retry" }), " Retry the last prompt"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/compact" }), " Compress conversation history"] }), _jsx(Text, { dimColor: true, children: " \u2500\u2500 Coding \u2500\u2500" }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/test" }), " Run tests"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/fix" }), " Fix last error"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/review" }), " Code review"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/explain" }), " file Explain code"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/search" }), " query Search codebase"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/session-search" }), " q Search past sessions"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/refactor" }), " desc Refactor code"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/scaffold" }), " desc Generate boilerplate"] }), _jsx(Text, { dimColor: true, children: " \u2500\u2500 Git \u2500\u2500" }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/commit" }), " Commit changes"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/push" }), " Push to remote"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/pr" }), " Create pull request"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/status" }), " Git status"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/diff" }), " Git diff"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/log" }), " Git log"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/branch" }), " [name] Branches"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/stash" }), " Stash changes"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/undo" }), " Undo last commit"] }), _jsx(Text, { dimColor: true, children: " \u2500\u2500 Analysis \u2500\u2500" }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/security" }), " Security audit"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/lint" }), " Quality check"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/optimize" }), " Performance check"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/todo" }), " Find TODOs"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/deps" }), " Dependencies"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/clean" }), " Dead code removal"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/context" }), " Session info (model, tokens, mode)"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/plan" }), " Enter plan mode (read-only tools)"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/execute" }), " Exit plan mode (enable all tools)"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/sessions" }), " List saved sessions"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/resume" }), " id Resume a saved session"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/clear" }), " Clear conversation history"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/doctor" }), " Diagnose setup issues"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/help" }), " This help"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/exit" }), " Quit"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: " Shortcuts: sonnet, opus, gpt, gemini, deepseek, flash, free, r1, o4, nano, mini, haiku" })] })), showWallet && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Wallet" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" Chain: ", _jsx(Text, { color: "magenta", children: chain })] }), _jsxs(Text, { children: [" Address: ", _jsx(Text, { color: "cyan", children: walletAddress })] }), _jsxs(Text, { children: [" Balance: ", _jsx(Text, { color: "green", children: balance })] })] })), _jsx(Static, { items: completedTools, children: (tool) => (_jsx(Box, { marginLeft: 1, children: tool.error - ? _jsxs(Text, { color: "red", children: [" \u2717 ", tool.name, " ", _jsxs(Text, { dimColor: true, children: [tool.elapsed, "ms", tool.preview ? ` — ${tool.preview}` : ''] })] }) - : _jsxs(Text, { color: "green", children: [" \u2713 ", tool.name, " ", _jsxs(Text, { dimColor: true, children: [tool.elapsed, "ms", tool.preview ? ` — ${tool.preview}` : ''] })] }) }, tool.key)) }), _jsx(Static, { items: committedResponses, children: (r) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { wrap: "wrap", children: renderMarkdown(r.text) }), (r.tokens.input > 0 || r.tokens.output > 0) && (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { dimColor: true, children: [r.tier && _jsxs(Text, { color: "cyan", children: [r.tier, " "] }), r.model ? shortModelName(r.model) : '', r.model ? ' · ' : '', r.tokens.calls > 0 && r.tokens.input === 0 - ? `${r.tokens.calls} calls` - : `${formatTokens(r.tokens.input)} in / ${formatTokens(r.tokens.output)} out`, r.cost > 0 ? ` · $${r.cost.toFixed(4)}` : '', r.savings !== undefined && r.savings > 0 ? _jsxs(Text, { color: "green", children: [" saved ", Math.round(r.savings * 100), "%"] }) : ''] }) }))] }, r.key)) }), permissionRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 1, children: [_jsx(Text, { color: "yellow", children: " \u256D\u2500 Permission required \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "yellow", children: [" \u2502 ", _jsx(Text, { bold: true, children: permissionRequest.toolName })] }), permissionRequest.description.split('\n').map((line, i) => (_jsxs(Text, { dimColor: true, children: [" \u2502 ", line] }, i))), _jsx(Text, { color: "yellow", children: " \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsx(Box, { marginLeft: 3, children: _jsxs(Text, { children: [_jsx(Text, { bold: true, color: "green", children: "[y]" }), _jsx(Text, { dimColor: true, children: " yes " }), _jsx(Text, { bold: true, color: "cyan", children: "[a]" }), _jsx(Text, { dimColor: true, children: " always " }), _jsx(Text, { bold: true, color: "red", children: "[n]" }), _jsx(Text, { dimColor: true, children: " no" })] }) })] })), askUserRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 1, children: [_jsx(Text, { color: "cyan", children: " \u256D\u2500 Question \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "cyan", children: [" \u2502 ", _jsx(Text, { bold: true, children: askUserRequest.question })] }), askUserRequest.options && askUserRequest.options.length > 0 && (askUserRequest.options.map((opt, i) => (_jsxs(Text, { dimColor: true, children: [" \u2502 ", i + 1, ". ", opt] }, i)))), _jsx(Text, { color: "cyan", children: " \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Box, { marginLeft: 3, children: [_jsx(Text, { bold: true, children: "answer> " }), _jsx(TextInput, { value: askUserInput, onChange: setAskUserInput, onSubmit: (val) => { - const answer = val.trim() || '(no response)'; - const r = askUserRequest.resolve; - setAskUserRequest(null); - setAskUserInput(''); - r(answer); - }, focus: true })] })] })), Array.from(tools.entries()).map(([id, tool]) => (_jsxs(Box, { flexDirection: "column", marginLeft: 1, children: [_jsxs(Text, { color: "cyan", children: [' ', _jsx(Spinner, { type: "dots" }), ' ', tool.name, tool.preview ? _jsxs(Text, { dimColor: true, children: [": ", tool.preview.slice(0, 60)] }) : null, _jsx(Text, { dimColor: true, children: (() => { const s = Math.round((Date.now() - tool.startTime) / 1000); return s > 0 ? ` ${s}s` : ''; })() })] }), tool.liveOutput ? (_jsxs(Text, { color: "yellow", children: [' ', tool.liveOutput.slice(0, 100)] })) : null] }, id))), thinking && (_jsxs(Box, { flexDirection: "column", marginLeft: 1, children: [_jsxs(Text, { color: "magenta", children: [" ", _jsx(Spinner, { type: "dots" }), " thinking", completedTools.length > 0 ? _jsxs(Text, { dimColor: true, children: [' ', "(step ", completedTools.length + 1, ")"] }) : null] }), thinkingText && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [' ', thinkingText.split('\n').pop()?.slice(0, 100)] }))] })), waiting && !thinking && tools.size === 0 && (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { color: "yellow", children: [" ", _jsx(Spinner, { type: "dots" }), " ", _jsxs(Text, { dimColor: true, children: [currentModel, completedTools.length > 0 ? ` · step ${completedTools.length + 1}` : ''] })] }) })), streamText && (_jsx(Box, { marginTop: 0, marginBottom: 0, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(streamText) }) })), responsePreview && !streamText && (_jsx(Box, { flexDirection: "column", marginBottom: 0, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(responsePreview) }) })), inPicker && (() => { - let flatIdx = 0; - return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { bold: true, children: "Select a model " }), _jsx(Text, { dimColor: true, children: "(\u2191\u2193 navigate, Enter select, Esc cancel)" })] }), PICKER_CATEGORIES.map((cat) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2500\u2500 ", cat.category, " \u2500\u2500"] }) }), cat.models.map((m) => { - const myIdx = flatIdx++; - const isSelected = myIdx === pickerIdx; - const isCurrent = m.id === currentModel; - const isHighlight = m.highlight === true; - return (_jsxs(Box, { marginLeft: 2, children: [_jsxs(Text, { inverse: isSelected, color: isSelected ? 'cyan' : isHighlight ? 'yellow' : undefined, bold: isSelected || isHighlight, children: [' ', m.label.padEnd(26), ' '] }), _jsxs(Text, { dimColor: true, children: [" ", m.shortcut.padEnd(14)] }), _jsx(Text, { color: m.price === 'FREE' ? 'green' : isHighlight ? 'yellow' : undefined, dimColor: !isHighlight && m.price !== 'FREE', children: m.price }), isCurrent && _jsx(Text, { color: "green", children: " \u2190" })] }, m.id)); - })] }, cat.category))), _jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(Text, { dimColor: true, children: "Your conversation stays above \u2014 picking a model keeps all history intact." }) })] })); - })(), !inPicker && (_jsx(InputBox, { input: (permissionRequest || askUserRequest) ? '' : input, setInput: (permissionRequest || askUserRequest) ? () => { } : setInput, onSubmit: (permissionRequest || askUserRequest) ? () => { } : handleSubmit, model: currentModel, balance: liveBalance, sessionCost: totalCost, queued: queuedInputs[0] || undefined, queuedCount: queuedInputs.length, focused: !permissionRequest && !askUserRequest, busy: !askUserRequest && (waiting || thinking || tools.size > 0), contextPct: contextPct }))] })); -} -export function launchInkUI(opts) { - let resolveInput = null; - let pendingInput = null; // Queue for inputs that arrive before waitForInput - let exiting = false; - let abortCallback = null; - const instance = render(_jsx(RunCodeApp, { initialModel: opts.model, workDir: opts.workDir, walletAddress: opts.walletAddress || 'not set — run: runcode setup', walletBalance: opts.walletBalance || 'unknown', chain: opts.chain || 'base', startWithPicker: opts.showPicker, onSubmit: (value) => { - if (resolveInput) { - resolveInput(value); - resolveInput = null; - } - else { - // Agent loop hasn't called waitForInput yet — queue the input - pendingInput = value; - } - }, onModelChange: (model) => { opts.onModelChange?.(model); }, onAbort: () => { abortCallback?.(); }, onExit: () => { - exiting = true; - if (resolveInput) { - resolveInput(null); - resolveInput = null; - } - } })); - return { - handleEvent: (event) => { - const ui = globalThis.__runcode_ui; - ui?.handleEvent(event); - }, - updateModel: (model) => { - const ui = globalThis.__runcode_ui; - ui?.updateModel(model); - }, - updateBalance: (bal) => { - const ui = globalThis.__runcode_ui; - ui?.updateBalance(bal); - }, - onTurnDone: (cb) => { - const ui = globalThis.__runcode_ui; - ui?.onTurnDone(cb); - }, - waitForInput: () => { - if (exiting) - return Promise.resolve(null); - // If user already submitted while we were processing, return immediately - if (pendingInput !== null) { - const input = pendingInput; - pendingInput = null; - return Promise.resolve(input); - } - return new Promise((resolve) => { resolveInput = resolve; }); - }, - onAbort: (cb) => { abortCallback = cb; }, - cleanup: () => { instance.unmount(); }, - requestPermission: (toolName, description) => { - const ui = globalThis.__runcode_ui; - return ui?.requestPermission(toolName, description) ?? Promise.resolve('no'); - }, - requestAskUser: (question, options) => { - const ui = globalThis.__runcode_ui; - return ui?.requestAskUser(question, options) ?? Promise.resolve('(no response)'); - }, - }; -} diff --git a/dist/ui/markdown.d.ts b/dist/ui/markdown.d.ts deleted file mode 100644 index 80435ad1..00000000 --- a/dist/ui/markdown.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Markdown renderer for terminal output. - * Converts markdown to ANSI-formatted text using chalk. - * Shared between Ink UI and basic terminal UI. - */ -/** - * Render a complete markdown string to ANSI-colored terminal output. - */ -export declare function renderMarkdown(text: string): string; diff --git a/dist/ui/markdown.js b/dist/ui/markdown.js deleted file mode 100644 index 45623fbb..00000000 --- a/dist/ui/markdown.js +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Markdown renderer for terminal output. - * Converts markdown to ANSI-formatted text using chalk. - * Shared between Ink UI and basic terminal UI. - */ -import chalk from 'chalk'; -/** - * Render a complete markdown string to ANSI-colored terminal output. - */ -export function renderMarkdown(text) { - const lines = text.split('\n'); - const out = []; - let inCodeBlock = false; - for (const line of lines) { - // Code block toggle - if (line.startsWith('```')) { - inCodeBlock = !inCodeBlock; - out.push(chalk.dim(line)); - continue; - } - if (inCodeBlock) { - out.push(chalk.cyan(line)); - continue; - } - // Headers - if (line.startsWith('### ')) { - out.push(chalk.bold(line.slice(4))); - continue; - } - if (line.startsWith('## ')) { - out.push(chalk.bold.underline(line.slice(3))); - continue; - } - if (line.startsWith('# ')) { - out.push(chalk.bold.underline(line.slice(2))); - continue; - } - // Horizontal rule - if (/^[-=─]{3,}$/.test(line.trim())) { - out.push(chalk.dim('─'.repeat(40))); - continue; - } - // Blockquotes - if (line.startsWith('> ')) { - out.push(chalk.dim('│ ') + chalk.italic(renderInline(line.slice(2)))); - continue; - } - // Bullet points - if (line.match(/^(\s*)[-*] /)) { - out.push(line.replace(/^(\s*)[-*] /, '$1• ').replace(/^(\s*• )(.*)/, (_, prefix, rest) => prefix + renderInline(rest))); - continue; - } - // Table rows — render with dim separators - if (line.includes('|') && line.trim().startsWith('|')) { - // Separator row (|---|---|) - if (/^\s*\|[\s-:]+\|/.test(line) && !line.match(/[a-zA-Z]/)) { - out.push(chalk.dim(line)); - continue; - } - // Data row — bold headers in first row, dim pipes - const cells = line.split('|').map(c => c.trim()).filter(Boolean); - const formatted = cells.map(c => renderInline(c)).join(chalk.dim(' │ ')); - out.push(chalk.dim('│ ') + formatted + chalk.dim(' │')); - continue; - } - // Everything else — inline formatting - out.push(renderInline(line)); - } - return out.join('\n'); -} -/** - * Render inline markdown formatting (bold, italic, code, links). - */ -function renderInline(text) { - return text - // Inline code (process first to protect contents from other formatting) - .replace(/`([^`]+)`/g, (_, t) => chalk.cyan(t)) - // Bold - .replace(/\*\*([^*]+)\*\*/g, (_, t) => chalk.bold(t)) - // Italic - .replace(/(? chalk.italic(t)) - // Strikethrough - .replace(/~~([^~]+)~~/g, (_, t) => chalk.strikethrough(t)) - // Links — show label in blue, URL dimmed - .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => chalk.blue.underline(label) + chalk.dim(` (${url})`)); -} diff --git a/dist/ui/model-picker.d.ts b/dist/ui/model-picker.d.ts deleted file mode 100644 index 628153da..00000000 --- a/dist/ui/model-picker.d.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Interactive model picker for runcode. - * Shows categorized model list, supports shortcuts and arrow-key selection. - */ -export declare const MODEL_SHORTCUTS: Record; -/** - * Resolve a model name — supports shortcuts. - */ -export declare function resolveModel(input: string): string; -export interface ModelEntry { - id: string; - shortcut: string; - label: string; - price: string; - highlight?: boolean; -} -export interface ModelCategory { - category: string; - models: ModelEntry[]; -} -/** - * Single source of truth for the /model picker. - * ~30 models across 6 categories. Every ID here is present in src/pricing.ts - * and every shortcut is in MODEL_SHORTCUTS above. - * - * Both the Ink UI picker (src/ui/app.tsx) and the readline picker - * (pickModel() below) import from this array. To add or remove models, - * edit this one place. - */ -export declare const PICKER_CATEGORIES: ModelCategory[]; -/** Flat list of all picker models (for index-based navigation). */ -export declare const PICKER_MODELS_FLAT: ModelEntry[]; -/** - * Show interactive model picker. Returns the selected model ID. - * Falls back to text input if terminal doesn't support raw mode. - */ -export declare function pickModel(currentModel?: string): Promise; diff --git a/dist/ui/model-picker.js b/dist/ui/model-picker.js deleted file mode 100644 index 7c08e2a2..00000000 --- a/dist/ui/model-picker.js +++ /dev/null @@ -1,200 +0,0 @@ -/** - * Interactive model picker for runcode. - * Shows categorized model list, supports shortcuts and arrow-key selection. - */ -import readline from 'node:readline'; -import chalk from 'chalk'; -// ─── Model Shortcuts (same as proxy) ─────────────────────────────────────── -export const MODEL_SHORTCUTS = { - // Routing profiles - auto: 'blockrun/auto', - smart: 'blockrun/auto', - eco: 'blockrun/eco', - premium: 'blockrun/premium', - // Anthropic - sonnet: 'anthropic/claude-sonnet-4.6', - claude: 'anthropic/claude-sonnet-4.6', - opus: 'anthropic/claude-opus-4.6', - haiku: 'anthropic/claude-haiku-4.5-20251001', - // OpenAI - gpt: 'openai/gpt-5.4', - gpt5: 'openai/gpt-5.4', - 'gpt-5': 'openai/gpt-5.4', - 'gpt-5.4': 'openai/gpt-5.4', - 'gpt-5.4-pro': 'openai/gpt-5.4-pro', - 'gpt-5.3': 'openai/gpt-5.3', - 'gpt-5.2': 'openai/gpt-5.2', - 'gpt-5.2-pro': 'openai/gpt-5.2-pro', - 'gpt-4.1': 'openai/gpt-4.1', - codex: 'openai/gpt-5.3-codex', - nano: 'openai/gpt-5-nano', - mini: 'openai/gpt-5-mini', - o3: 'openai/o3', - o4: 'openai/o4-mini', - 'o4-mini': 'openai/o4-mini', - o1: 'openai/o1', - // Google - gemini: 'google/gemini-2.5-pro', - flash: 'google/gemini-2.5-flash', - 'gemini-3': 'google/gemini-3.1-pro', - // xAI - grok: 'xai/grok-3', - 'grok-4': 'xai/grok-4-0709', - 'grok-fast': 'xai/grok-4-1-fast-reasoning', - // DeepSeek - deepseek: 'deepseek/deepseek-chat', - r1: 'deepseek/deepseek-reasoner', - // Free - free: 'nvidia/nemotron-ultra-253b', - nemotron: 'nvidia/nemotron-ultra-253b', - 'deepseek-free': 'nvidia/deepseek-v3.2', - devstral: 'nvidia/devstral-2-123b', - 'qwen-coder': 'nvidia/qwen3-coder-480b', - maverick: 'nvidia/llama-4-maverick', - // Others - minimax: 'minimax/minimax-m2.7', - glm: 'zai/glm-5.1', - 'glm-turbo': 'zai/glm-5.1-turbo', - 'glm5': 'zai/glm-5.1', - kimi: 'moonshot/kimi-k2.5', -}; -/** - * Resolve a model name — supports shortcuts. - */ -export function resolveModel(input) { - const lower = input.trim().toLowerCase(); - return MODEL_SHORTCUTS[lower] || input.trim(); -} -/** - * Single source of truth for the /model picker. - * ~30 models across 6 categories. Every ID here is present in src/pricing.ts - * and every shortcut is in MODEL_SHORTCUTS above. - * - * Both the Ink UI picker (src/ui/app.tsx) and the readline picker - * (pickModel() below) import from this array. To add or remove models, - * edit this one place. - */ -export const PICKER_CATEGORIES = [ - { - category: '🔥 Promo (flat $0.001/call)', - models: [ - { id: 'zai/glm-5.1', shortcut: 'glm', label: 'GLM-5.1', price: '$0.001/call', highlight: true }, - { id: 'zai/glm-5.1-turbo', shortcut: 'glm-turbo', label: 'GLM-5.1 Turbo', price: '$0.001/call', highlight: true }, - ], - }, - { - category: '🧠 Smart routing (auto-pick)', - models: [ - { id: 'blockrun/auto', shortcut: 'auto', label: 'Auto', price: 'routed' }, - { id: 'blockrun/eco', shortcut: 'eco', label: 'Eco', price: 'cheapest' }, - { id: 'blockrun/premium', shortcut: 'premium', label: 'Premium', price: 'best' }, - ], - }, - { - category: '✨ Premium frontier', - models: [ - { id: 'anthropic/claude-sonnet-4.6', shortcut: 'sonnet', label: 'Claude Sonnet 4.6', price: '$3/$15' }, - { id: 'anthropic/claude-opus-4.6', shortcut: 'opus', label: 'Claude Opus 4.6', price: '$5/$25' }, - { id: 'openai/gpt-5.4', shortcut: 'gpt', label: 'GPT-5.4', price: '$2.5/$15' }, - { id: 'openai/gpt-5.4-pro', shortcut: 'gpt-5.4-pro', label: 'GPT-5.4 Pro', price: '$30/$180' }, - { id: 'google/gemini-2.5-pro', shortcut: 'gemini', label: 'Gemini 2.5 Pro', price: '$1.25/$10' }, - { id: 'google/gemini-3.1-pro', shortcut: 'gemini-3', label: 'Gemini 3.1 Pro', price: '$2/$12' }, - { id: 'xai/grok-4-0709', shortcut: 'grok-4', label: 'Grok 4', price: '$0.2/$1.5' }, - { id: 'xai/grok-3', shortcut: 'grok', label: 'Grok 3', price: '$3/$15' }, - ], - }, - { - category: '🔬 Reasoning', - models: [ - { id: 'openai/o3', shortcut: 'o3', label: 'O3', price: '$2/$8' }, - { id: 'openai/o4-mini', shortcut: 'o4', label: 'O4 Mini', price: '$1.1/$4.4' }, - { id: 'openai/o1', shortcut: 'o1', label: 'O1', price: '$15/$60' }, - { id: 'openai/gpt-5.3-codex', shortcut: 'codex', label: 'GPT-5.3 Codex', price: '$1.75/$14' }, - { id: 'deepseek/deepseek-reasoner', shortcut: 'r1', label: 'DeepSeek R1', price: '$0.28/$0.42' }, - { id: 'xai/grok-4-1-fast-reasoning', shortcut: 'grok-fast', label: 'Grok 4.1 Fast R.', price: '$0.2/$0.5' }, - ], - }, - { - category: '💰 Budget', - models: [ - { id: 'anthropic/claude-haiku-4.5-20251001', shortcut: 'haiku', label: 'Claude Haiku 4.5', price: '$1/$5' }, - { id: 'openai/gpt-5-mini', shortcut: 'mini', label: 'GPT-5 Mini', price: '$0.25/$2' }, - { id: 'openai/gpt-5-nano', shortcut: 'nano', label: 'GPT-5 Nano', price: '$0.05/$0.4' }, - { id: 'google/gemini-2.5-flash', shortcut: 'flash', label: 'Gemini 2.5 Flash', price: '$0.3/$2.5' }, - { id: 'deepseek/deepseek-chat', shortcut: 'deepseek', label: 'DeepSeek V3', price: '$0.28/$0.42' }, - { id: 'moonshot/kimi-k2.5', shortcut: 'kimi', label: 'Kimi K2.5', price: '$0.6/$3' }, - { id: 'minimax/minimax-m2.7', shortcut: 'minimax', label: 'Minimax M2.7', price: '$0.3/$1.2' }, - ], - }, - { - category: '🆓 Free (no USDC needed)', - models: [ - { id: 'nvidia/nemotron-ultra-253b', shortcut: 'free', label: 'Nemotron Ultra 253B', price: 'FREE' }, - { id: 'nvidia/qwen3-coder-480b', shortcut: 'qwen-coder', label: 'Qwen3 Coder 480B', price: 'FREE' }, - { id: 'nvidia/devstral-2-123b', shortcut: 'devstral', label: 'Devstral 2 123B', price: 'FREE' }, - { id: 'nvidia/llama-4-maverick', shortcut: 'maverick', label: 'Llama 4 Maverick', price: 'FREE' }, - { id: 'nvidia/deepseek-v3.2', shortcut: 'deepseek-free', label: 'DeepSeek V3.2', price: 'FREE' }, - { id: 'nvidia/gpt-oss-120b', shortcut: 'gpt-oss', label: 'GPT OSS 120B', price: 'FREE' }, - ], - }, -]; -/** Flat list of all picker models (for index-based navigation). */ -export const PICKER_MODELS_FLAT = PICKER_CATEGORIES.flatMap(c => c.models); -// Kept for backward compatibility with the readline pickModel() below. -const PICKER_MODELS = PICKER_CATEGORIES; -/** - * Show interactive model picker. Returns the selected model ID. - * Falls back to text input if terminal doesn't support raw mode. - */ -export async function pickModel(currentModel) { - // Flatten for numbering - const allModels = []; - for (const cat of PICKER_MODELS) { - allModels.push(...cat.models); - } - // Display - console.error(''); - console.error(chalk.bold(' Select a model:\n')); - let idx = 1; - for (const cat of PICKER_MODELS) { - console.error(chalk.dim(` ── ${cat.category} ──`)); - for (const m of cat.models) { - const current = m.id === currentModel ? chalk.green(' ←') : ''; - const priceStr = m.price === 'FREE' ? chalk.green(m.price) : chalk.dim(m.price); - console.error(` ${chalk.cyan(String(idx).padStart(2))}. ${m.label.padEnd(24)} ${chalk.dim(m.shortcut.padEnd(12))} ${priceStr}${current}`); - idx++; - } - console.error(''); - } - console.error(chalk.dim(' Enter number, shortcut, or full model ID:')); - // Read input - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr, - terminal: process.stdin.isTTY ?? false, - }); - return new Promise((resolve) => { - let answered = false; - rl.question(chalk.bold(' model> '), (answer) => { - answered = true; - rl.close(); - const trimmed = answer.trim(); - if (!trimmed) { - resolve(null); // Keep current - return; - } - // Try number - const num = parseInt(trimmed, 10); - if (!isNaN(num) && num >= 1 && num <= allModels.length) { - resolve(allModels[num - 1].id); - return; - } - // Try shortcut or full ID - resolve(resolveModel(trimmed)); - }); - rl.on('close', () => { - if (!answered) - resolve(null); - }); - }); -} diff --git a/dist/ui/terminal.d.ts b/dist/ui/terminal.d.ts deleted file mode 100644 index edbebbcf..00000000 --- a/dist/ui/terminal.d.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Terminal UI for runcode - * Raw terminal input/output with markdown rendering and diff display. - * No heavy dependencies — just chalk and readline. - */ -import type { StreamEvent } from '../agent/types.js'; -export declare class TerminalUI { - private spinner; - private activeCapabilities; - private totalInputTokens; - private totalOutputTokens; - private sessionModel; - private mdRenderer; - private lineQueue; - private lineWaiters; - private stdinEOF; - constructor(); - /** - * Prompt the user for input. Returns null on EOF/exit. - * Uses a line-queue approach so piped input works across multiple calls. - */ - promptUser(promptText?: string): Promise; - private nextLine; - /** No-op kept for API compatibility — readline closes when stdin EOF. */ - closeInput(): void; - /** - * Handle a stream event from the agent loop. - */ - handleEvent(event: StreamEvent): void; - /** Check if input is a slash command. Returns true if handled locally (don't pass to agent). */ - handleSlashCommand(input: string): boolean; - printWelcome(model: string, workDir: string): void; - printUsageSummary(): void; - printGoodbye(): void; -} diff --git a/dist/ui/terminal.js b/dist/ui/terminal.js deleted file mode 100644 index dac46f10..00000000 --- a/dist/ui/terminal.js +++ /dev/null @@ -1,337 +0,0 @@ -/** - * Terminal UI for runcode - * Raw terminal input/output with markdown rendering and diff display. - * No heavy dependencies — just chalk and readline. - */ -import readline from 'node:readline'; -import chalk from 'chalk'; -import { estimateCost } from '../pricing.js'; -// ─── Spinner ─────────────────────────────────────────────────────────────── -const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; -class Spinner { - interval = null; - frameIdx = 0; - label = ''; - start(label) { - this.stop(); - this.label = label; - this.frameIdx = 0; - this.interval = setInterval(() => { - const frame = SPINNER_FRAMES[this.frameIdx % SPINNER_FRAMES.length]; - process.stderr.write(`\r${chalk.cyan(frame)} ${chalk.dim(this.label)} `); - this.frameIdx++; - }, 80); - } - stop() { - if (this.interval) { - clearInterval(this.interval); - this.interval = null; - process.stderr.write('\r' + ' '.repeat(this.label.length + 10) + '\r'); - } - } -} -// ─── Markdown Renderer ───────────────────────────────────────────────────── -/** - * Simple streaming markdown renderer. - * Buffers content and renders when complete blocks are available. - */ -class MarkdownRenderer { - buffer = ''; - inCodeBlock = false; - codeBlockLang = ''; - /** - * Feed text delta and return rendered ANSI output. - */ - feed(text) { - this.buffer += text; - let output = ''; - // Process complete lines - while (this.buffer.includes('\n')) { - const nlIdx = this.buffer.indexOf('\n'); - const line = this.buffer.slice(0, nlIdx); - this.buffer = this.buffer.slice(nlIdx + 1); - output += this.renderLine(line) + '\n'; - } - return output; - } - /** - * Flush remaining buffer. - */ - flush() { - if (this.buffer.length === 0) - return ''; - const result = this.renderLine(this.buffer); - this.buffer = ''; - return result; - } - renderLine(line) { - // Code block toggle - if (line.startsWith('```')) { - if (this.inCodeBlock) { - this.inCodeBlock = false; - this.codeBlockLang = ''; - return chalk.dim('```'); - } - else { - this.inCodeBlock = true; - this.codeBlockLang = line.slice(3).trim(); - return chalk.dim('```' + this.codeBlockLang); - } - } - // Inside code block — render dim - if (this.inCodeBlock) { - return chalk.cyan(line); - } - // Headers - if (line.startsWith('### ')) - return chalk.bold(line.slice(4)); - if (line.startsWith('## ')) - return chalk.bold.underline(line.slice(3)); - if (line.startsWith('# ')) - return chalk.bold.underline(line.slice(2)); - // Horizontal rule - if (/^[-=]{3,}$/.test(line.trim())) - return chalk.dim('─'.repeat(40)); - // Bullet points - if (line.match(/^(\s*)[-*] /)) { - return line.replace(/^(\s*)[-*] /, '$1• '); - } - // Numbered lists - if (/^\s*\d+\.\s/.test(line)) { - return this.renderInline(line); - } - // Blockquotes - if (line.startsWith('> ')) { - return chalk.dim('│ ') + chalk.italic(this.renderInline(line.slice(2))); - } - // Tables — leave as-is (chalk doesn't help much) - // Inline formatting - return this.renderInline(line); - } - renderInline(text) { - // Process in order: code first (to protect from other formatting), then bold, italic, links - return text - // Inline code (process first to protect contents) - .replace(/`([^`]+)`/g, (_, t) => `\x00CODE${chalk.cyan(t)}\x00END`) - // Bold (before italic to avoid ** being consumed by *) - .replace(/\*\*([^*]+)\*\*/g, (_, t) => chalk.bold(t)) - // Italic (only single * not preceded/followed by *) - .replace(/(? chalk.italic(t)) - // Strikethrough - .replace(/~~([^~]+)~~/g, (_, t) => chalk.strikethrough(t)) - // Links - .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => chalk.blue.underline(label) + chalk.dim(` (${url})`)) - // Restore code markers - .replace(/\x00CODE/g, '').replace(/\x00END/g, ''); - } -} -// ─── Terminal UI ─────────────────────────────────────────────────────────── -export class TerminalUI { - spinner = new Spinner(); - activeCapabilities = new Map(); - totalInputTokens = 0; - totalOutputTokens = 0; - sessionModel = ''; - mdRenderer = new MarkdownRenderer(); - // Line queue for piped (non-TTY) input — buffers all stdin lines eagerly - lineQueue = []; - lineWaiters = []; - stdinEOF = false; - constructor() { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr, - terminal: false, // Always treat as non-TTY so line events fire for piped input - }); - rl.on('line', (line) => { - if (this.lineWaiters.length > 0) { - // Someone is already waiting — deliver immediately - const waiter = this.lineWaiters.shift(); - waiter(line); - } - else { - // Buffer the line for the next promptUser() call - this.lineQueue.push(line); - } - }); - rl.on('close', () => { - this.stdinEOF = true; - // Keep lineQueue intact — buffered lines should still drain before signaling EOF. - // If there are active waiters, queue is already empty (nextLine checks queue first), - // so it's safe to resolve them with null now. - for (const waiter of this.lineWaiters) - waiter(null); - this.lineWaiters = []; - }); - } - /** - * Prompt the user for input. Returns null on EOF/exit. - * Uses a line-queue approach so piped input works across multiple calls. - */ - async promptUser(promptText) { - const prompt = promptText ?? chalk.bold.green('> '); - process.stderr.write(prompt); - const raw = await this.nextLine(); - if (raw === null) - return null; - const trimmed = raw.trim(); - if (trimmed === '/exit' || trimmed === '/quit') - return null; - return trimmed; - } - nextLine() { - if (this.lineQueue.length > 0) { - return Promise.resolve(this.lineQueue.shift()); - } - if (this.stdinEOF) { - return Promise.resolve(null); - } - return new Promise((resolve) => { - this.lineWaiters.push(resolve); - }); - } - /** No-op kept for API compatibility — readline closes when stdin EOF. */ - closeInput() { - // Nothing to do — readline closes itself on stdin EOF - } - /** - * Handle a stream event from the agent loop. - */ - handleEvent(event) { - switch (event.kind) { - case 'text_delta': { - this.spinner.stop(); - // Render markdown - const rendered = this.mdRenderer.feed(event.text); - if (rendered) - process.stdout.write(rendered); - break; - } - case 'thinking_delta': - this.spinner.stop(); - process.stderr.write(chalk.dim(event.text)); - break; - case 'capability_start': { - // Flush any pending markdown text before showing tool status - this.spinner.stop(); - const flushed = this.mdRenderer.flush(); - if (flushed) - process.stdout.write(flushed + '\n'); - this.activeCapabilities.set(event.id, { - name: event.name, - startTime: Date.now(), - }); - this.spinner.start(`${event.name}...`); - break; - } - case 'capability_input_delta': - break; - case 'capability_done': { - this.spinner.stop(); - const cap = this.activeCapabilities.get(event.id); - const capName = cap?.name || 'unknown'; - const elapsed = cap ? Date.now() - cap.startTime : 0; - this.activeCapabilities.delete(event.id); - const timeStr = elapsed > 100 ? chalk.dim(` ${elapsed}ms`) : ''; - if (event.result.isError) { - console.error(chalk.red(` ✗ ${capName}`) + - timeStr + - chalk.red(`: ${truncateOutput(event.result.output, 200)}`)); - } - else { - // Show diff-like output for Edit tool - const output = event.result.output; - if (capName === 'Edit' && output.includes('replacement')) { - console.error(chalk.green(` ✓ ${capName}`) + timeStr + chalk.dim(` — ${output}`)); - } - else if (capName === 'Write') { - console.error(chalk.green(` ✓ ${capName}`) + timeStr + chalk.dim(` — ${output}`)); - } - else if (capName === 'Bash') { - // Show command output preview - const preview = truncateOutput(output, 120); - console.error(chalk.green(` ✓ ${capName}`) + timeStr); - if (preview && preview !== '(no output)') { - const lines = output.split('\n').slice(0, 5); - for (const line of lines) { - console.error(chalk.dim(` │ ${line.slice(0, 100)}`)); - } - if (output.split('\n').length > 5) { - console.error(chalk.dim(` │ ... (${output.split('\n').length - 5} more lines)`)); - } - } - } - else { - const preview = truncateOutput(output, 120); - console.error(chalk.green(` ✓ ${capName}`) + timeStr + chalk.dim(` — ${preview}`)); - } - } - break; - } - case 'usage': - this.totalInputTokens += event.inputTokens; - this.totalOutputTokens += event.outputTokens; - if (event.model) - this.sessionModel = event.model; - break; - case 'turn_done': { - this.spinner.stop(); - // Flush any remaining markdown - const remaining = this.mdRenderer.flush(); - if (remaining) - process.stdout.write(remaining); - process.stdout.write('\n'); - if (event.reason === 'error') { - console.error(chalk.red(`\nAgent error: ${event.error}`)); - } - else if (event.reason === 'max_turns') { - console.error(chalk.yellow('\nMax turns reached.')); - } - // Reset renderer for next turn - this.mdRenderer = new MarkdownRenderer(); - break; - } - } - } - /** Check if input is a slash command. Returns true if handled locally (don't pass to agent). */ - handleSlashCommand(input) { - const parts = input.trim().split(/\s+/); - const cmd = parts[0].toLowerCase(); - switch (cmd) { - case '/cost': - case '/usage': { - const cost = this.sessionModel - ? estimateCost(this.sessionModel, this.totalInputTokens, this.totalOutputTokens) - : 0; - const costStr = cost > 0 ? ` · $${cost.toFixed(4)} USDC` : ''; - console.error(chalk.dim(`\n Tokens: ${this.totalInputTokens.toLocaleString()} in / ${this.totalOutputTokens.toLocaleString()} out${costStr}\n`)); - return true; - } - default: - // All other slash commands pass through to the agent loop (commands.ts handles them) - return false; - } - } - printWelcome(model, workDir) { - console.error(chalk.dim(`Model: ${model}`)); - console.error(chalk.dim(`Dir: ${workDir}`)); - console.error(chalk.dim(`Type /exit to quit, /help for commands.\n`)); - } - printUsageSummary() { - if (this.totalInputTokens > 0 || this.totalOutputTokens > 0) { - console.error(chalk.dim(`\nTokens: ${this.totalInputTokens.toLocaleString()} in / ${this.totalOutputTokens.toLocaleString()} out`)); - } - } - printGoodbye() { - this.closeInput(); - this.printUsageSummary(); - console.error(chalk.dim('\nGoodbye.\n')); - } -} -// ─── Helpers ─────────────────────────────────────────────────────────────── -function truncateOutput(text, maxLen) { - const oneLine = text.replace(/\n/g, ' ').trim(); - if (oneLine.length <= maxLen) - return oneLine; - return oneLine.slice(0, maxLen - 3) + '...'; -} diff --git a/dist/wallet/manager.d.ts b/dist/wallet/manager.d.ts deleted file mode 100644 index caa3dd51..00000000 --- a/dist/wallet/manager.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export declare function walletExists(): boolean; -export declare function setupWallet(): { - address: string; - isNew: boolean; -}; -export declare function setupSolanaWallet(): Promise<{ - address: string; - isNew: boolean; -}>; -export declare function getAddress(): string; diff --git a/dist/wallet/manager.js b/dist/wallet/manager.js deleted file mode 100644 index ebeb14f0..00000000 --- a/dist/wallet/manager.js +++ /dev/null @@ -1,23 +0,0 @@ -import { getOrCreateWallet, scanWallets, getWalletAddress, getOrCreateSolanaWallet, scanSolanaWallets, } from '@blockrun/llm'; -import { loadChain } from '../config.js'; -export function walletExists() { - const chain = loadChain(); - if (chain === 'solana') { - return scanSolanaWallets().length > 0; - } - return scanWallets().length > 0; -} -export function setupWallet() { - const { address, isNew } = getOrCreateWallet(); - return { address, isNew }; -} -export async function setupSolanaWallet() { - const { address, isNew } = await getOrCreateSolanaWallet(); - return { address, isNew }; -} -export function getAddress() { - const addr = getWalletAddress(); - if (!addr) - throw new Error('No wallet found. Run `runcode setup` first.'); - return addr; -} diff --git a/src/agent/context.ts b/src/agent/context.ts index 7b91c875..5f7f11e7 100644 --- a/src/agent/context.ts +++ b/src/agent/context.ts @@ -1,5 +1,5 @@ /** - * Context Manager for runcode + * Context Manager for Franklin * Assembles system instructions, reads project config, injects environment info. */ @@ -10,7 +10,7 @@ import { loadLearnings, decayLearnings, saveLearnings, formatForPrompt } from '. // ─── System Instructions Assembly ────────────────────────────────────────── -const BASE_INSTRUCTIONS = `You are runcode, an AI coding agent that helps users with software engineering tasks. +const BASE_INSTRUCTIONS = `You are Franklin, an AI coding agent that helps users with software engineering tasks. You have access to tools for reading, writing, editing files, running shell commands, searching codebases, web browsing, and more. # Core Principles