#!/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(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/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);
})();