#!/usr/bin/env node const http = require('node:http'); const https = require('node:https'); const fs = require('node:fs'); const path = require('node:path'); const readline = require('node:readline'); const { execSync } = require('node:child_process'); ////////////////////////////////////////////////////// // // ai.js - minimal agent repl for local ollama models // // one file // zero dependencies (other than node and ollama) // <1k loc // ascii only // suckless configuration // // usage: // // ./ai.js fast # fast but dumb // ./ai.js mid # middle of the road // ./ai.js slow # slow but smart // ./ai.js # same as `./ai.js mid` // // or: // symlink into your PATH // (e.g. `ln -s "$PWD/ai.js" ~/.local/bin/ai`) // to invoke as: // // ai # same as `ai mid` // ai fast // ai mid // ai slow // ////////////////////////////////////////////////////// // configuration /////////////////////////////////// const BASE_URL = 'http://localhost:11434'; const MODES = { fast: { model: 'qwen3.5:4b', think: false }, mid: { model: 'qwen3.5:9b', think: false }, slow: { model: 'qwen3.5:9b', think: true }, }; const NUM_CTX = 32768; // ollama context window (tokens) per request. const MAX_STEPS = 50; // max tool-call rounds before a single user turn aborts. const TOOL_TRUNC = 20000; // tool output is truncated to this many chars before being fed back to the model. const EXCLUDE_DIRS = ['.git', 'node_modules', 'dist', 'build', 'target', '.venv']; const LOG_ENABLED = true; const LOG_DIR = path.join(__dirname, 'ai-logs'); ////////////////////////////////////////////////////// const CWD = process.cwd(); // Load AGENTS.md / CLAUDE.md from cwd upward, stopping at the git root (or filesystem root). // Closer-scope files appear later in the prompt so they take effective priority. function findAgentDocs() { const blocks = []; const seen = new Set(); let dir = path.resolve(CWD); while (true) { for (const name of ['AGENTS.md', 'CLAUDE.md']) { const fp = path.join(dir, name); if (seen.has(fp)) continue; seen.add(fp); try { if (fs.statSync(fp).isFile()) blocks.push({ path: fp, content: fs.readFileSync(fp, 'utf8') }); } catch {} } if (fs.existsSync(path.join(dir, '.git'))) break; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } return blocks.reverse(); } const AGENT_DOCS = findAgentDocs(); const AGENT_DOCS_BLOCK = AGENT_DOCS.length ? '\n\nProject instructions (closer scope overrides):\n' + AGENT_DOCS.map(d => `\n--- ${d.path} ---\n${d.content}`).join('\n') : ''; const SYSTEM = `You are an expert coding assistant. You can read, write, edit, grep, find, ls, fetch URLs, and run bash. Tools: read(path, offset, limit); bash(command); edit(path, oldText, newText); multi_edit(path, edits); write(path, content); grep(pattern, path); glob(pattern, path); find(path, name); ls(path); web_fetch(url). Guidelines: Prefer grep/glob/find/ls over bash for exploration. Use multi_edit when changing several spots in one file. Do not cd in bash commands. Use read instead of cat/sed. Write only for new files or full rewrites. Edit only with exact unique oldText. Read returns lines as "<lineno>\\t<content>". When passing oldText to edit, include only the content, never the "<lineno>\\t" prefix. Be concise. Show file paths clearly. Your output shoult not include markdown tables, or emojis. The user can toggle safety with Ctrl+R. While safety is ON, bash/write/edit/multi_edit are blocked -- analyze or suggest changes instead of attempting them. Current date: ${new Date().toISOString().slice(0, 10)} Current working directory: ${CWD} ${AGENT_DOCS_BLOCK} `; const MODE = process.argv[2] || 'mid'; if (!MODES[MODE]) { process.stderr.write('Usage: ai [fast | mid | slow]\n'); process.exit(1); } const { model: MODEL, think: THINK } = MODES[MODE]; // Per-session JSONL log. Synchronous fd writes so events survive Ctrl+C / process.exit without // relying on stream drain. Toggle via LOG_ENABLED / LOG_DIR at the top of the file. const SESSION_ID = Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); const log = (() => { if (!LOG_ENABLED) return { event() {}, close() {}, path: null }; const fname = `${new Date().toISOString().replace(/[:.]/g, '-')}-${SESSION_ID}.jsonl`; const fp = path.join(LOG_DIR, fname); let fd; try { fs.mkdirSync(LOG_DIR, { recursive: true }); fd = fs.openSync(fp, 'a'); } catch { return { event() {}, close() {}, path: null }; } return { path: fp, event(type, data) { try { fs.writeSync(fd, JSON.stringify({ ts: new Date().toISOString(), session_id: SESSION_ID, type, ...data }) + '\n'); } catch {} }, close() { try { fs.closeSync(fd); } catch {} }, }; })(); const TOOLS = [ { type: 'function', function: { name: 'bash', description: 'Run a shell command', parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] } } }, { type: 'function', function: { name: 'read', description: 'Read file contents', parameters: { type: 'object', properties: { path: { type: 'string' }, offset: { type: 'integer', description: 'Start line (1-indexed)' }, limit: { type: 'integer', description: 'Max lines' } }, required: ['path'] } } }, { type: 'function', function: { name: 'write', description: 'Write content to file, creating parent dirs', parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] } } }, { type: 'function', function: { name: 'edit', description: 'Edit file by replacing exact unique oldText with newText', parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' } }, required: ['path', 'oldText', 'newText'] } } }, { type: 'function', function: { name: 'multi_edit', description: 'Apply multiple sequential edits to one file. Each edit replaces an exact unique oldText with newText, applied in order.', parameters: { type: 'object', properties: { path: { type: 'string' }, edits: { type: 'array', items: { type: 'object', properties: { oldText: { type: 'string' }, newText: { type: 'string' } }, required: ['oldText', 'newText'] } } }, required: ['path', 'edits'] } } }, { type: 'function', function: { name: 'grep', description: 'Recursively search for a pattern', parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' } }, required: ['pattern'] } } }, { type: 'function', function: { name: 'glob', description: 'Find files matching a glob pattern (e.g. **/*.ts, src/**/*.test.js). Honors common ignore dirs.', parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string', description: 'Root directory (default .)' } }, required: ['pattern'] } } }, { type: 'function', function: { name: 'find', description: 'Find files by name glob', parameters: { type: 'object', properties: { path: { type: 'string' }, name: { type: 'string' } }, required: ['path'] } } }, { type: 'function', function: { name: 'ls', description: 'List directory contents', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } } }, { type: 'function', function: { name: 'web_fetch', description: 'Fetch an http(s) URL and return the body as text (HTML stripped to plain text).', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } } }, ]; function shRun(cmd) { try { return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 }); } catch (e) { const out = (e.stdout || '') + (e.stderr || ''); return `${out}${out && !out.endsWith('\n') ? '\n' : ''}[exit:${e.status ?? 1}]`; } } function q(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; } const fileStamps = new Map(); function stampFile(fp) { try { const st = fs.statSync(fp); fileStamps.set(path.resolve(fp), `${st.mtimeMs}:${st.size}`); } catch {} } function checkStale(fp) { if (!fs.existsSync(fp)) return null; const prev = fileStamps.get(path.resolve(fp)); if (!prev) return `Error: read ${fp} before modifying it.`; const st = fs.statSync(fp); if (`${st.mtimeMs}:${st.size}` !== prev) return `Error: ${fp} changed since last read. Re-read before modifying.`; return null; } function globToRegex(glob) { let re = '^'; for (let i = 0; i < glob.length; ) { const c = glob[i]; if (c === '*') { if (glob[i + 1] === '*') { if (glob[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } else { re += '.*'; i += 2; } } else { re += '[^/]*'; i++; } } else if (c === '?') { re += '[^/]'; i++; } else if ('.+^$|()[]{}\\'.includes(c)) { re += '\\' + c; i++; } else { re += c; i++; } } return new RegExp(re + '$'); } function htmlToText(html) { return html .replace(/<script[\s\S]*?<\/script>/gi, '') .replace(/<style[\s\S]*?<\/style>/gi, '') .replace(/<!--[\s\S]*?-->/g, '') .replace(/<\/(?:p|div|h[1-6]|li|tr|br)\s*>/gi, '\n') .replace(/<br\s*\/?>/gi, '\n') .replace(/<[^>]+>/g, '') .replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<') .replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'") .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10))) .replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); } function getURL(urlStr, signal, redirects = 0) { return new Promise((resolve, reject) => { let u; try { u = new URL(urlStr); } catch { return reject(new Error(`Bad URL: ${urlStr}`)); } const lib = u.protocol === 'https:' ? https : http; const req = lib.request({ hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80), path: u.pathname + u.search, method: 'GET', headers: { 'User-Agent': 'ai.js', 'Accept': 'text/html,text/plain,*/*' }, }, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirects < 5) { res.resume(); return getURL(new URL(res.headers.location, urlStr).toString(), signal, redirects + 1).then(resolve, reject); } const chunks = []; let total = 0; const MAX = 5 * 1024 * 1024; res.on('data', (c) => { total += c.length; if (total > MAX) { req.destroy(); return reject(new Error('response exceeded 5MB')); } chunks.push(c); }); res.on('end', () => resolve({ status: res.statusCode, contentType: res.headers['content-type'] || '', body: Buffer.concat(chunks).toString('utf8') })); res.on('error', reject); }); req.on('error', reject); if (signal) { const onAbort = () => req.destroy(Object.assign(new Error('aborted'), { name: 'AbortError' })); if (signal.aborted) onAbort(); else signal.addEventListener('abort', onAbort, { once: true }); } req.setTimeout(15000, () => req.destroy(new Error('request timed out'))); req.end(); }); } const TOOL_FNS = { bash({ command }) { if (!command) return 'Error: command required'; return shRun(command); }, read({ path: fp, offset, limit }) { if (!fp) return 'Error: path required'; if (!fs.existsSync(fp)) return `Error: file not found: ${fp}`; const data = fs.readFileSync(fp, 'utf8'); stampFile(fp); const lines = data.split('\n'); const start = offset && offset > 0 ? offset - 1 : 0; const end = limit ? Math.min(start + limit, lines.length) : lines.length; const width = String(end).length; return lines.slice(start, end) .map((line, i) => `${String(start + i + 1).padStart(width)}\t${line}`) .join('\n'); }, write({ path: fp, content }) { if (!fp) return 'Error: path required'; if (content == null) return 'Error: content required'; const stale = checkStale(fp); if (stale) return stale; fs.mkdirSync(path.dirname(path.resolve(fp)), { recursive: true }); fs.writeFileSync(fp, content); stampFile(fp); return `Wrote to ${fp}`; }, edit({ path: fp, oldText, newText }) { if (!fp) return 'Error: path required'; if (!oldText) return 'Error: oldText must not be empty'; if (!fs.existsSync(fp)) return `Error: file not found: ${fp}`; const stale = checkStale(fp); if (stale) return stale; // Defensive: if every line starts with the read tool's "<num>\t" prefix, strip it. // The model is told not to include it, but small models sometimes copy it back verbatim. const linesIn = oldText.split('\n'); if (linesIn.length > 1 && linesIn.every(l => /^\s*\d+\t/.test(l))) { oldText = linesIn.map(l => l.replace(/^\s*\d+\t/, '')).join('\n'); } const data = fs.readFileSync(fp, 'utf8'); const i = data.indexOf(oldText); if (i === -1) return `Error: oldText not found in ${fp}. Read surrounding lines and retry with exact text.`; if (data.indexOf(oldText, i + oldText.length) !== -1) return `Error: oldText matched multiple times in ${fp}. Use a larger unique oldText block.`; fs.writeFileSync(fp, data.slice(0, i) + (newText || '') + data.slice(i + oldText.length)); stampFile(fp); return `Edited ${fp}`; }, grep({ pattern, path: fp }) { if (!pattern) return 'Error: pattern required'; const target = fp || '.'; const excludes = EXCLUDE_DIRS.map(d => `--exclude-dir=${d}`).join(' '); const LIMIT = 100; // Ask for LIMIT+1 so we can detect truncation and tell the model. const out = shRun(`grep -rnIE ${excludes} -- ${q(pattern)} ${q(target)} | head -${LIMIT + 1}`); const lines = out.split('\n').filter(Boolean); if (lines.length === 0) return 'No matches'; if (lines.length > LIMIT) return lines.slice(0, LIMIT).join('\n') + `\n[truncated at ${LIMIT}; more matches exist -- narrow the pattern or path]`; return lines.join('\n'); }, find({ path: fp, name }) { const target = fp || '.'; const prune = `\\( ${EXCLUDE_DIRS.map(d => `-name ${d}`).join(' -o ')} \\) -prune`; const tail = name ? `-o -name ${q(name)} -print` : '-o -print'; const LIMIT = 100; const out = shRun(`find ${q(target)} ${prune} ${tail} 2>&1 | head -${LIMIT + 1}`); const lines = out.split('\n').filter(Boolean); if (lines.length > LIMIT) return lines.slice(0, LIMIT).join('\n') + `\n[truncated at ${LIMIT}; more matches exist -- narrow the path or name]`; return lines.join('\n'); }, ls({ path: fp }) { return shRun(`ls -la ${q(fp || '.')}`); }, multi_edit({ path: fp, edits }) { if (!fp) return 'Error: path required'; if (!Array.isArray(edits) || edits.length === 0) return 'Error: edits must be a non-empty array'; if (!fs.existsSync(fp)) return `Error: file not found: ${fp}`; const stale = checkStale(fp); if (stale) return stale; let data = fs.readFileSync(fp, 'utf8'); for (let k = 0; k < edits.length; k++) { let { oldText, newText } = edits[k] || {}; if (!oldText) return `Error: edit[${k}].oldText must not be empty`; const linesIn = oldText.split('\n'); if (linesIn.length > 1 && linesIn.every(l => /^\s*\d+\t/.test(l))) { oldText = linesIn.map(l => l.replace(/^\s*\d+\t/, '')).join('\n'); } const i = data.indexOf(oldText); if (i === -1) return `Error: edit[${k}] oldText not found in ${fp}. Re-read and retry with exact text.`; if (data.indexOf(oldText, i + oldText.length) !== -1) return `Error: edit[${k}] oldText matched multiple times. Use a larger unique block.`; data = data.slice(0, i) + (newText || '') + data.slice(i + oldText.length); } fs.writeFileSync(fp, data); stampFile(fp); return `Edited ${fp} (${edits.length} edits)`; }, glob({ pattern, path: fp }) { if (!pattern) return 'Error: pattern required'; const root = fp || '.'; if (!fs.existsSync(root)) return `Error: path not found: ${root}`; const re = globToRegex(pattern); const results = []; const LIMIT = 200; const walk = (dir) => { if (results.length >= LIMIT) return; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const ent of entries) { if (results.length >= LIMIT) return; const full = path.join(dir, ent.name); if (ent.isDirectory()) { if (EXCLUDE_DIRS.includes(ent.name)) continue; walk(full); } else { const rel = path.relative(root, full); if (re.test(rel)) results.push(full); } } }; walk(root); if (!results.length) return 'No matches'; return results.join('\n') + (results.length === LIMIT ? `\n[truncated at ${LIMIT}]` : ''); }, async web_fetch({ url }) { if (!url) return 'Error: url required'; if (!/^https?:\/\//i.test(url)) return 'Error: url must start with http:// or https://'; let res; try { res = await getURL(url, runAbort?.signal); } catch (e) { return `Error: ${e.message}`; } const ct = res.contentType || ''; const body = /html/i.test(ct) ? htmlToText(res.body) : res.body; return `[HTTP ${res.status}${ct ? ` ${ct.split(';')[0].trim()}` : ''}]\n${body}`; }, }; function postJSON(urlStr, body, signal) { const u = new URL(urlStr); const lib = u.protocol === 'https:' ? https : http; const data = Buffer.from(JSON.stringify(body)); return new Promise((resolve, reject) => { const req = lib.request( { hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80), path: u.pathname + u.search, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': data.length }, }, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); if (res.statusCode >= 400) return reject(new Error(`HTTP ${res.statusCode}: ${text.slice(0, 400)}`)); try { resolve(JSON.parse(text)); } catch (e) { reject(new Error(`Bad JSON from server: ${text.slice(0, 200)}`)); } }); } ); req.on('error', reject); if (signal) { const onAbort = () => { req.destroy(Object.assign(new Error('aborted'), { name: 'AbortError' })); }; if (signal.aborted) onAbort(); else signal.addEventListener('abort', onAbort, { once: true }); } req.write(data); req.end(); }); } function callOllama(messages, signal) { return postJSON(`${BASE_URL}/api/chat`, { model: MODEL, messages, tools: TOOLS, stream: false, think: THINK, options: { num_ctx: NUM_CTX } }, signal); } function stripThink(s) { if (typeof s !== 'string' || !s) return s; // Some servers stream <think> tokens around content even when think mode is off, and may emit // only an opening or only a closing tag. Strip paired blocks first, then any stray fragments. return s .replace(/<think>[\s\S]*?<\/think>/g, '') .replace(/^[\s\S]*?<\/think>/, '') .replace(/<think>[\s\S]*$/, '') .trim(); } function truncate(s, n) { if (s.length <= n) return s; const head = s.slice(0, Math.floor(n * 0.7)); const tail = s.slice(-Math.floor(n * 0.2)); return `${head}\n...[${s.length - head.length - tail.length} chars hidden]...\n${tail}`; } function renderInline(text) { // Protect inline code spans first so their contents aren't reinterpreted as bold/italic. const codes = []; text = text.replace(/`([^`\n]+)`/g, (_, c) => `\x00C${codes.push(c) - 1}\x00`); text = text.replace(/\[([^\]\n]+)\]\(([^)\n]+)\)/g, '\x1b[4m$1\x1b[24m \x1b[2m($2)\x1b[22m'); text = text.replace(/\*\*([^*\n]+)\*\*/g, '\x1b[1m$1\x1b[22m'); text = text.replace(/__([^_\n]+)__/g, '\x1b[1m$1\x1b[22m'); // Negative lookbehind/ahead so the * inside **bold** and the _ in snake_case don't trigger italic. text = text.replace(/(?<![*\w])\*([^*\n]+)\*(?!\*)/g, '\x1b[3m$1\x1b[23m'); text = text.replace(/(?<![_\w])_([^_\n]+)_(?!_|\w)/g, '\x1b[3m$1\x1b[23m'); text = text.replace(/~~([^~\n]+)~~/g, '\x1b[9m$1\x1b[29m'); return text.replace(/\x00C(\d+)\x00/g, (_, n) => `\x1b[33m${codes[+n]}\x1b[39m`); } function renderMarkdown(text) { if (!text) return text; const out = []; let inFence = false; for (const line of text.split('\n')) { const fence = line.match(/^\s*```(\w*)/); if (fence) { if (!inFence) { inFence = true; out.push(`\x1b[2m+-${fence[1] ? ' ' + fence[1] : ''}\x1b[0m`); } else { inFence = false; out.push(`\x1b[2m+-\x1b[0m`); } continue; } if (inFence) { out.push(`\x1b[2m|\x1b[0m \x1b[36m${line}\x1b[39m`); continue; } if (/^\s*(?:---+|\*\*\*+|___+)\s*$/.test(line)) { out.push(`\x1b[2m${'-'.repeat(40)}\x1b[0m`); continue; } const h = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/); if (h) { // Strip inner bold/code markers so trailing-text doesn't lose the heading style mid-line. const t = h[2].replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1'); out.push(h[1].length === 1 ? `\x1b[1;4m${t}\x1b[0m` : `\x1b[1m${t}\x1b[0m`); continue; } const bq = line.match(/^>\s?(.*)$/); if (bq) { out.push(`\x1b[2m|\x1b[22m \x1b[3m${renderInline(bq[1])}\x1b[23m`); continue; } const bul = line.match(/^(\s*)[-*+]\s+(.*)$/); if (bul) { out.push(`${bul[1]}\x1b[36m*\x1b[39m ${renderInline(bul[2])}`); continue; } const num = line.match(/^(\s*)(\d+\.)\s+(.*)$/); if (num) { out.push(`${num[1]}\x1b[36m${num[2]}\x1b[39m ${renderInline(num[3])}`); continue; } out.push(renderInline(line)); } return out.join('\n'); } function logTool(name, args) { const summary = JSON.stringify(args); process.stderr.write(`\x1b[36m$ ${name}\x1b[0m \x1b[2m${summary.length > 120 ? summary.slice(0, 120) + '...' : summary}\x1b[0m\n`); } function startStopwatch() { const t0 = Date.now(); const fmt = () => { const e = Math.floor((Date.now() - t0) / 1000); const mm = String(Math.floor(e / 60)).padStart(2, '0'); const ss = String(e % 60).padStart(2, '0'); return `\x1b[2m${'-'.repeat(20)} ${mm}:${ss}\x1b[0m`; }; if (!process.stderr.isTTY) { return () => process.stderr.write(`${fmt()}\n`); } const render = () => process.stderr.write(`\r\x1b[2K${fmt()}`); render(); const timer = setInterval(render, 1000); return () => { clearInterval(timer); process.stderr.write(`\r\x1b[2K${fmt()}\n`); }; } const SLASH_COMMANDS = ['/help', '/clear', '/quit']; const HELP = '\n' + ' /help\n' + ' /clear\n' + ' /quit\n' + ' @path attach file\n' + ' !cmd run command\n' + '\n' + ' ctrl+r toggle safety\n' + ' ctrl+c cancel / exit at empty prompt\n' + ' ctrl+d exit\n' + ' ctrl+l clear screen\n' + ' ctrl+a move to start of line\n' + ' ctrl+e move to end of line\n' + ' ctrl+u delete to start of line\n' + ' ctrl+k delete to end of line\n' + ' ctrl+w delete previous word\n' + ' ctrl+b / ctrl+f move cursor back / forward\n' + ' ctrl+p / ctrl+n prefix-aware history prev / next\n' + ' up / down prefix-aware history prev / next\n' + ' tab complete @path or /command\n'; function completer(line) { const frag = line.match(/\S*$/)[0]; if (frag.startsWith('/') && line.trimStart() === frag) { const hits = SLASH_COMMANDS.filter(c => c.startsWith(frag)); return [hits.length ? hits : SLASH_COMMANDS, frag]; } if (frag.startsWith('@')) { const p = frag.slice(1); const slash = p.lastIndexOf('/'); const dir = slash === -1 ? '.' : (p.slice(0, slash) || '/'); const base = slash === -1 ? p : p.slice(slash + 1); const dirPrefix = slash === -1 ? '' : p.slice(0, slash + 1); try { const hits = fs.readdirSync(dir, { withFileTypes: true }) .filter(e => e.name.startsWith(base) && (base.startsWith('.') || !e.name.startsWith('.'))) .map(e => `@${dirPrefix}${e.name}${e.isDirectory() ? '/' : ''}`); return [hits, frag]; } catch { return [[], frag]; } } return [[], frag]; } function expandAtMentions(text) { const files = []; const re = /@(\S+)/g; let m; while ((m = re.exec(text)) !== null) { const p = m[1]; try { if (fs.statSync(p).isFile()) files.push(p); } catch {} } if (files.length === 0) return { content: text, files: [] }; let content = text; for (const fp of files) { content += `\n\n--- ${fp} ---\n${fs.readFileSync(fp, 'utf8')}`; } return { content, files }; } let aborted = false; let runInProgress = false; let runAbort = null; let safetyOn = false; const WRITE_TOOLS = new Set(['bash', 'write', 'edit', 'multi_edit']); function handleSigint() { if (runInProgress) { aborted = true; if (runAbort) runAbort.abort(); log.event('abort', {}); process.stderr.write('\n\x1b[33m[aborted]\x1b[0m\n'); } else { process.stderr.write('\n[exit]\n'); log.close(); process.exit(130); } } process.on('SIGINT', handleSigint); let tokenCount = 0; function buildStatusLines() { return [`\x1b[2m${'='.repeat(20)} ${MODE} | ctx: ${tokenCount}\x1b[0m`]; } function ask(rl, prompt, statusLines = []) { return new Promise((resolve) => { const onClose = () => resolve(null); rl.once('close', onClose); // Print status above the prompt -- readline's _refreshLine clears from the input row down, // so anything written before rl.question stays put while the user types. Drawing below the // prompt and trying to restore the cursor with DECSC/DECRC is unreliable: \n at the bottom // row scrolls the screen, the saved cursor position is absolute, and "restore" then lands // on the status line -- every keystroke ends up in the status text. if (statusLines.length) { process.stderr.write('\n\n'); for (const line of statusLines) process.stderr.write(line + '\n'); } rl.question(prompt, (a) => { rl.off('close', onClose); resolve(a); }); }); } function processResponse(resp, step) { // Ollama returns prompt_eval_count (input tokens for this call) and eval_count (generated tokens). // When the prompt is fully cached, prompt_eval_count may be omitted -- keep the previous value. if (typeof resp.prompt_eval_count === 'number') { tokenCount = resp.prompt_eval_count + (resp.eval_count || 0); } const msg = resp.message; if (!msg) return null; const toolCalls = msg.tool_calls || []; // Ensure every tool_call has an id so the matching tool reply can reference it. toolCalls.forEach((tc, i) => { if (!tc.id) tc.id = `call_${step}_${i}`; }); const rawContent = msg.content || ''; const rawThinking = msg.thinking || ''; // Thinking arrives via Ollama's separate `thinking` field (think:true) or inline <think>...</think>. // Log raw, then strip from msg.content so it doesn't bloat future requests. const hadThinking = !!(rawThinking && String(rawThinking).trim()) || /<think>[\s\S]*?<\/think>/.test(rawContent); log.event('assistant', { step, content: rawContent, thinking: rawThinking, tool_calls: toolCalls, prompt_eval_count: resp.prompt_eval_count, eval_count: resp.eval_count, total_duration_ms: resp.total_duration ? Math.round(resp.total_duration / 1e6) : null, eval_duration_ms: resp.eval_duration ? Math.round(resp.eval_duration / 1e6) : null, }); msg.content = stripThink(rawContent); delete msg.thinking; return { msg, toolCalls, hadThinking, visibleContent: msg.content || '' }; } async function runToolCall(tc, step) { const name = tc.function?.name; let args = tc.function?.arguments || {}; if (typeof args === 'string') { try { args = JSON.parse(args); } catch { args = {}; } } logTool(name, args); log.event('tool_call', { step, name, args }); const t0 = Date.now(); const fn = TOOL_FNS[name]; let out; let isError = false; if (!fn) { out = `Error: unknown tool: ${name}`; isError = true; } else if (safetyOn && WRITE_TOOLS.has(name)) { out = `Error: safety is on; ${name} is blocked. Respond with analysis or suggested changes; do not retry. The user can toggle safety with Ctrl+R.`; isError = true; } else { try { out = String((await fn(args)) ?? ''); } catch (e) { out = `Error: ${e.message}`; isError = true; } } // bash returning a non-zero exit (shRun appends [exit:N]) is also a failure worth surfacing. if (!isError && /\[exit:\d+\]\s*$/.test(out)) isError = true; if (isError) process.stderr.write(`\x1b[31m${out}${out.endsWith('\n') ? '' : '\n'}\x1b[0m`); log.event('tool_result', { step, name, content: out, duration_ms: Date.now() - t0, error: isError }); return { role: 'tool', tool_call_id: tc.id, name, content: truncate(out, TOOL_TRUNC) }; } async function runTurn(messages, opts = {}) { runInProgress = true; aborted = false; runAbort = new AbortController(); const t0 = Date.now(); let status; try { status = await _runTurn(messages, opts); return status; } finally { log.event('turn_end', { status, duration_ms: Date.now() - t0, tokens: tokenCount }); runInProgress = false; runAbort = null; } } async function _runTurn(messages) { for (let step = 1; step <= MAX_STEPS; step++) { if (aborted) return 130; process.stderr.write('\n\n'); const stopStopwatch = startStopwatch(); let resp; try { resp = await callOllama(messages, runAbort.signal); } catch (e) { stopStopwatch(); if (aborted) return 130; // localhost connection failures often surface as AggregateError (one rejection per address // family) whose top-level message is empty -- inspect e.errors[] for the underlying code. const errs = e.errors || [e]; const code = e.code || errs.find(x => x?.code)?.code; const refused = code === 'ECONNREFUSED' || errs.some(x => x?.code === 'ECONNREFUSED'); const msg = refused ? `cannot reach ollama at ${BASE_URL} -- is it running?` : (e.message || code || String(e)); log.event('api_error', { step, message: e.message, code, errors: errs.map(x => ({ message: x?.message, code: x?.code })) }); console.error(`\x1b[31m[!] API: ${msg}\x1b[0m`); return 1; } stopStopwatch(); const processed = processResponse(resp, step); if (!processed) { console.error(`\x1b[31m[!] No message in response\x1b[0m`); return 1; } const { msg, toolCalls, hadThinking, visibleContent } = processed; if (hadThinking) process.stderr.write(`\x1b[2m* thinking\x1b[0m\n`); messages.push(msg); if (toolCalls.length === 0) { // Final assistant message goes to stdout so callers can pipe `ai ... > out.txt` and capture // just the answer; intermediate (pre-tool-call) messages and all status go to stderr below. if (visibleContent) console.log(process.stdout.isTTY ? renderMarkdown(visibleContent) : visibleContent); return 0; } if (visibleContent) { process.stderr.write(`${process.stderr.isTTY ? renderMarkdown(visibleContent) : visibleContent}\n`); } for (const tc of toolCalls) { messages.push(await runToolCall(tc, step)); } } console.error(`\x1b[31m[!] Max steps reached (${MAX_STEPS})\x1b[0m`); return 1; } (async () => { let messages = [{ role: 'system', content: SYSTEM }]; log.event('session_start', { mode: MODE, model: MODEL, think: THINK, num_ctx: NUM_CTX, cwd: CWD, node_version: process.version, system: SYSTEM, }); if (log.path) process.stderr.write(`\x1b[2m[log: ${log.path}]\x1b[0m\n`); if (AGENT_DOCS.length) process.stderr.write(`\x1b[2m[loaded: ${AGENT_DOCS.map(d => d.path).join(', ')}]\x1b[0m\n`); process.stderr.write(`\x1b[2m${HELP}\x1b[0m`); process.stderr.write(`\x1b[2m\nsafety: ${safetyOn ? 'on' : 'off'} (ctrl+r to toggle)\x1b[0m`); const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true, completer }); // readline normally swallows Ctrl+C; route it through our shared handler so abort/exit works at the prompt too. rl.on('SIGINT', handleSigint); // Prefix-aware history: up/ctrl-p (down/ctrl-n) cycles only history entries that start with // what's typed before the cursor (fish/zsh-style). Wraps readline's private _ttyWrite to // intercept arrows; non-navigation keys clear the captured prefix so the next up recaptures. let histPrefix = null; let histIndex = -1; const resetHist = () => { histPrefix = null; histIndex = -1; }; const setLine = (s) => { rl.line = s; rl.cursor = s.length; rl._refreshLine(); }; const histUp = () => { if (histPrefix === null) { histPrefix = rl.line.slice(0, rl.cursor); histIndex = -1; } for (let i = histIndex + 1; i < rl.history.length; i++) { if (rl.history[i].startsWith(histPrefix) && rl.history[i] !== rl.line) { histIndex = i; setLine(rl.history[i]); return; } } }; const histDown = () => { if (histPrefix === null) return; for (let i = histIndex - 1; i >= 0; i--) { if (rl.history[i].startsWith(histPrefix) && rl.history[i] !== rl.line) { histIndex = i; setLine(rl.history[i]); return; } } histIndex = -1; setLine(histPrefix); }; const navKeys = new Set(['up', 'down', 'left', 'right', 'home', 'end', 'shift', 'control', 'meta']); const ttyWrite = rl._ttyWrite.bind(rl); rl._ttyWrite = (s, key) => { if (key) { const isUp = key.name === 'up' || (key.ctrl && key.name === 'p'); const isDown = key.name === 'down' || (key.ctrl && key.name === 'n'); if (isUp) return histUp(); if (isDown) return histDown(); if (!navKeys.has(key.name)) resetHist(); } ttyWrite(s, key); }; // Ctrl+R toggles safety at any time. readline (terminal:true) emits keypress events on stdin. process.stdin.on('keypress', (_str, key) => { if (!key || !key.ctrl || key.name !== 'r' || key.shift || key.meta) return; safetyOn = !safetyOn; log.event('safety_toggle', { safety: safetyOn ? 'on' : 'off' }); // Repaint the prompt with the new color. We update rl's internal _prompt via setPrompt and // trigger _refreshLine -- otherwise the next keystroke (e.g. backspace) calls _refreshLine // with the stale prompt and reverts the color. Repaint regardless of whether status is // currently shown, so the toggle is reflected even after an empty-enter hid the status. if (process.stderr.isTTY) { const promptColor = safetyOn ? '\x1b[37m' : '\x1b[31m'; rl.setPrompt(`${promptColor}> \x1b[0m`); rl._refreshLine(); } }); let showStatus = true; while (true) { const promptColor = safetyOn ? '\x1b[37m' : '\x1b[31m'; const line = await ask(rl, `${promptColor}> \x1b[0m`, showStatus ? buildStatusLines() : []); if (line == null) break; const t = line.trim(); if (!t) { showStatus = false; continue; } showStatus = true; if (t === '/clear') { log.event('session_clear', {}); messages = [{ role: 'system', content: SYSTEM }]; tokenCount = 0; process.stderr.write('\x1b[2J\x1b[H'); process.stderr.write('\x1b[2m[context cleared]\x1b[0m\n'); continue; } if (t === '/quit') { log.event('slash', { name: '/quit' }); break; } if (t === '/help') { log.event('slash', { name: '/help' }); process.stderr.write(`\x1b[2m${HELP}\x1b[0m`); continue; } if (t.startsWith('!')) { const cmd = t.slice(1).trim(); if (!cmd) { process.stderr.write('\x1b[2m[shell: no command]\x1b[0m\n'); continue; } logTool('bash', { command: cmd }); const out = shRun(cmd); log.event('shell', { command: cmd, output: out }); const text = out.endsWith('\n') ? out : out + '\n'; const failed = /\[exit:\d+\]\s*$/.test(out); process.stdout.write(failed ? `\x1b[31m${text}\x1b[0m` : text); messages.push({ role: 'user', content: `Shell output of \`${cmd}\`:\n${truncate(out, TOOL_TRUNC)}` }); continue; } const { content, files } = expandAtMentions(t); if (files.length) process.stderr.write(`\x1b[2m[attached: ${files.join(', ')}]\x1b[0m\n`); log.event('user', { raw: t, content, files }); messages.push({ role: 'user', content }); await runTurn(messages); } rl.close(); log.close(); process.exit(0); })();