(name, args, ctx)
| 76 | } |
| 77 | |
| 78 | async function executeTool(name, args, ctx) { |
| 79 | const { _fullscreenRef, mcpCall, memoryStore, pluginLoader, mcpClient, flags, config, tui } = ctx; |
| 80 | const { execSync } = require('child_process'); |
| 81 | const cwd = process.cwd(); |
| 82 | |
| 83 | // Sanitize all string args — strip ANSI escape sequences the model may have |
| 84 | // hallucinated into command strings (e.g. color codes in bash arguments). |
| 85 | // Uses the comprehensive ANSI stripper from src/security/sanitize.js so |
| 86 | // we cover OSC, DCS, 8-bit C1, and other escape forms too — not just CSI. |
| 87 | function stripAnsi(str) { return secStripAnsi(str); } |
| 88 | if (args && typeof args === 'object') { |
| 89 | for (const key of Object.keys(args)) { |
| 90 | if (typeof args[key] === 'string') args[key] = stripAnsi(args[key]); |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | switch (name) { |
| 95 | case 'read_file': { |
| 96 | const safe = safeResolvePath(args.path, cwd); |
| 97 | if (!safe.ok) return { error: `read_file rejected: ${safe.reason}` }; |
| 98 | const filePath = safe.fullPath; |
| 99 | if (!fs.existsSync(filePath)) return { error: `File not found: ${args.path} (checked: ${filePath})` }; |
| 100 | // Mark as read so the write-guard (Feature 5) lets subsequent writes through |
| 101 | try { getReadTracker().recordRead(filePath, cwd); } catch {} |
| 102 | const content = fs.readFileSync(filePath, 'utf-8'); |
| 103 | const lines = content.split('\n'); |
| 104 | const start = (args.start_line || 1) - 1; |
| 105 | const end = args.end_line || lines.length; |
| 106 | const slice = lines.slice(start, end); |
| 107 | // Sanitize before sending to the model: strip ANSI/control chars and |
| 108 | // redact any secrets the file may contain (e.g. .env, token files). |
| 109 | const safeSlice = slice.map(l => sanitizeToolOutput(l)); |
| 110 | const numbered = safeSlice.map((l, i) => `${String(start + i + 1).padStart(4)}│ ${l}`).join('\n'); |
| 111 | |
| 112 | // Diff-based context (Feature #16): when SMALLCODE_DIFF_CONTEXT=true |
| 113 | // and the model has already read this file, return a diff instead of the |
| 114 | // full content. Falls back to full content if diff is too large or if the |
| 115 | // file hasn't changed. Only applies when no line range is requested. |
| 116 | if (!args.start_line && !args.end_line) { |
| 117 | try { |
| 118 | const tracker = getFileStateTracker(); |
| 119 | const result = tracker.record(filePath, content); |
| 120 | if (result.mode === 'unchanged') { |
| 121 | return { result: `${args.path} (${lines.length} lines — unchanged since last read, no diff)` }; |
| 122 | } |
| 123 | if (result.mode === 'diff') { |
| 124 | return { result: `${args.path} changes since last read (${result.fullLength} lines total):\n${sanitizeToolOutput(result.diff)}` }; |
| 125 | } |
| 126 | // mode === 'full' — fall through to normal path below |
| 127 | } catch {} // diff tracker failure is always non-fatal |
| 128 | } |
| 129 | |
| 130 | // Feature 2: summarize large files (>200 lines, no line range requested) |
| 131 | // This saves context by replacing the full file with signatures + key logic |
| 132 | if (lines.length > 200 && !args.start_line && !args.end_line) { |
| 133 | try { |
| 134 | const { summarizeFileCompiled } = require('./features_adapter'); |
| 135 | if (summarizeFileCompiled) { |
no test coverage detected