Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- #!/usr/bin/env node
- // coffee.mjs is a fully local only node.js no dependencies coding agent.
- // Usage: coffee.mjs (interactive REPL)
- // coffee.mjs -p "prompt" (one-shot, exits after response)
- // echo "prompt" | coffee.mjs (pipe mode, one-shot)
- // ── Imports ───────────────────────────────────────────────────────────
- import { createInterface } from 'readline/promises';
- import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'fs';
- import { fileURLToPath } from 'url';
- import { basename, dirname, join } from 'path';
- // directory where coffee.mjs resides. this is also where it starts to look for /tools and /skills
- const __dirname = dirname(fileURLToPath(import.meta.url));
- // ── Constants & Configs for Edit! ────────────────────────────────────
- const DEBUG = true; //saves raw message history to logs subfolder and may print extra text to console during output.
- const BASE_URL = 'http://127.0.0.1:8080/v1';
- const CHAT_URL = BASE_URL + '/chat/completions';
- const MODELS_URL = BASE_URL + '/models';
- //const REASONING_EFFORT = 'medium'; //doesn't work from testing
- const THINKING_BUDGET = 2048; //this tested working.
- const TRUNCATE_DISPLAY = 1000;
- let THINKING = true; // flag to enable/disable thinking/reasoning in the LLM. can be toggled using /think
- let model_name = '';
- let context_limit = 128000;
- const max_saved_sessions = 20;
- const SYSTEM_PROMPT = `You are coffee.mjs a minimal node.js coding agent in a raw terminal.
- The user sees a transcript, not a chat UI. There is no markdown renderer: avoid
- headings, bullets, tables, bold, inline-code styling, and code fences unless the
- user explicitly asks or code is the product. Keep responses concise. No banter.
- After long thought chains, always state out your thought's summary or conclusion so they become
- a visible part of the transcript.
- When user asks a question, do not assume they want you to implement the changes right away. Always
- identify and answer the question first! then ask user if you should implement any changes.
- ## Rule 0 — Be Honest
- If you don't know, say so. If you're guessing, label it as a guess. If a task is ambiguous,
- ask before proceeding. Surface confusion — don't hide it behind confident-sounding wrong
- answers. This is the foundation: everything else depends on truthful self-assessment.
- ## Rule 1 — Understand Before Acting
- Read the relevant files. Run commands to inspect the environment. Summarize findings and
- gaps before proposing a plan. The sequence is: inspect → summarize → plan → execute.
- ## Rule 2 — Minimum Viable Change
- Do the smallest thing that solves the stated problem. No features beyond what was asked.
- No abstractions for single-use code. If you're adding a dependency, justify it in one line.
- Don't touch adjacent code — match existing style, leave unrelated dead code alone.
- Follow YAGNI principles: don't build what you might need later.
- Prefer one-liner solutions when they're clear.
- ## Rule 3 — Verify, Don't Assume
- After every change, prove it landed. Re-read the file. Run a command. Check the actual output —
- don't trust "success" messages. Run the smallest possible test (\`grep\`, \`node -e\`, \`cat\`, \`ls\`)
- to confirm before saying "done."
- ## Rule 6 — Push Back
- If the user's approach is overcomplicated, say so. If a simpler solution exists, offer it.
- If the request has a hidden cost, surface it. Don't be a yes-machine. Being useful sometimes
- means saying "you don't need to do that" or "there's a simpler way."
- Present tradeoffs when they exist.
- Current Detected OS: ${process.platform}
- Current Working Directory: ${process.cwd()}
- Current System Date: ${new Date().toISOString()}
- Show file paths clearly when working with files.
- ## Tool Use Protocol
- - You MUST output a visible text message explaining which tool you are calling and why BEFORE any tool calls.
- - Tool calls without a preceding explanation are forbidden — they break the transcript and cause you to lose context on the next turn.
- - After receiving tool results, analyze them before proceeding. State what they mean for the task.
- - When you have enough information, answer the user directly without further tool calls to exit the agentic loop.`;
- const COMPACT_PROMPT = `
- Summarize this conversation for context continuity. Output in this exact format:
- ## Goal
- [What the user is trying to accomplish]
- ## Progress
- ### Done
- - [Completed tasks]
- ### In Progress
- - [Current work]
- ## Key Decisions
- - [Important decisions and rationale]
- ## Files
- <read-files>
- [path to each file read]
- </read-files>
- <modified-files>
- [path to each file edited or created]
- </modified-files>
- ## Next Steps
- 1. [What should happen next]
- Extract file paths from tool calls (read, edit, write, bash commands that create/modify files).
- Be detailed but concise. Preserve all information needed to continue the work.`;
- // Colors
- const gray = (s) => `\x1b[90m${s}\x1b[0m`;
- const orange = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
- const yellow = (s) => `\x1b[93m${s}\x1b[0m`;
- const blue = (s) => `\x1b[94m${s}\x1b[0m`;
- // ── State variables ───────────────────────────────────────────────────
- let contextTokens = 0;
- let checkedContextLimit = false; //flag used to determine when to poll for context_limit from the server
- let currentAbort = null; // for handling ctrl+c to interrupt prompt generation
- let QUIET = false; // flag determines if reasoning_content gets printed out to console.
- let tools, toolSchemas, skills;
- let readLine;
- // ── Utility functions ─────────────────────────────────────────────────
- const convertNewline = (text) => text.replace(/\\n/g, '\n');
- const debugPrint = (text) => DEBUG && console.log(text);
- const debugLog = (text, logfile, overwrite = false) => {
- if(!DEBUG) return;
- const logDir = join(__dirname, 'logs');
- if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
- const logPath = join(logDir, logfile);
- const output = convertNewline(text);
- if (overwrite) {
- writeFileSync(logPath, output, 'utf-8');
- } else {
- appendFileSync(logPath, output, 'utf-8');
- }
- };
- // Handles some formatting during printing. It's got some ugly type mixing, but why use javascript if we can't abuse this.
- function formattedPrint(delta) {
- if (delta.reasoning_content && !QUIET)
- process.stdout.write(gray(delta.reasoning_content));
- else if (delta.content)
- process.stdout.write(delta.content);
- else
- process.stdout.write(orange(delta));
- }
- // Node.js implementation for input handling.
- function createReadLine() {
- const rl = createInterface({
- input: process.stdin,
- output: process.stdout,
- });
- // readline intercepts ctrl+c initially, so we surface it up when we detect a close.
- rl.on('close', () => {
- if (currentAbort) currentAbort.abort();
- });
- return rl;
- }
- // Prompt user to pick from a numbered list. Reuses existing readLine.
- async function userSelect(prompt_text, options, cancellable = false) {
- if (cancellable) console.log(' 0) Cancel');
- for (const [i, opt] of options.entries()) {
- console.log(` ${i + 1}) ${opt}`);
- }
- while (true) {
- const answer = await readLine.question(prompt_text + ' ');
- const num = parseInt(answer, 10);
- if (cancellable && num === 0) return null;
- if (!isNaN(num) && num >= 1 && num <= options.length) return options[num - 1];
- console.log(`Pick ${cancellable ? '0' : '1'}-${options.length}`);
- }
- }
- async function pickLLM() {
- const prev_model_name = model_name;
- const result = await fetch(MODELS_URL);
- const body = await result.json();
- if (body.data) {
- const model_list = body.data.map(model => model.id);
- model_name = await userSelect("Select Model>",model_list);
- }
- //sending a quick message to kick off the load.
- fetch(CHAT_URL, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- model: model_name,
- messages: [
- { role: 'user', 'content': 'hi' },
- ],
- stream: false,
- chat_template_kwargs: { enable_thinking: false },
- }),
- });
- if( prev_model_name != model_name)
- checkedContextLimit = false;
- }
- // Get command like arguments
- const getArg = (key) => {
- const i = process.argv.indexOf(key);
- return i >= 0 && process.argv[i + 1];
- };
- // Detect if error is because of exceeding context length
- const isCtx = (e) =>
- /^(context_length_exceeded|exceed_context_size_error)$/.test(
- e.code || e.error?.code || ''
- ) ||
- /maximum context|context (length|window|size|limit)|prompt is too long|input is too long|too many (input )?tokens|exceeds the (context window|maximum.*tokens)/i.test(
- e.message || ''
- );
- // Async generator: yields parsed delta objects from an Server-Side Events stream. used in run()
- async function* sseDeltas(response) {
- const decoder = new TextDecoder();
- let buffer = '';
- for await (const chunk of response.body) {
- buffer += decoder.decode(chunk, { stream: true });
- let pos;
- // SSE uses double newlines to separate complete events.
- while ((pos = buffer.indexOf('\n\n')) >= 0) {
- const event = buffer.slice(0, pos);
- buffer = buffer.slice(pos + 2); // skip past \n\n delimiter
- for (const line of event.split('\n')) {
- if (!line.startsWith('data: ')) continue;
- const payload = line.slice(6); // strip "data: " prefix
- if (payload === '[DONE]') continue;
- let json;
- try { json = JSON.parse(payload); } catch { continue; }
- if (json.error) {
- const err = new Error(json.error.message || JSON.stringify(json.error));
- err.code = json.error.code;
- throw err;
- }
- const delta = json.choices?.[0]?.delta;
- if (delta) yield delta;
- if (json.usage) yield { usage: json.usage };
- }
- }
- }
- }
- // ── Core functions ────────────────────────────────────────────────────
- async function loadTools() {
- skills = [];
- const toolsDir = __dirname + '/tools/';
- if(!existsSync(toolsDir)) {
- console.log(yellow('Error! no tools folder found'));
- return;
- }
- const files = readdirSync(toolsDir);
- const toolMods = await Promise.all(
- files
- .filter((file) => file.endsWith('.mjs'))
- .map((file) =>
- // the ?v=+Date.now() is for cache busting.
- import(toolsDir + file + '?v=' + Date.now()).catch((e) => {
- console.log(yellow(`Tool load error: ${file} ${e.message}`));
- return {
- default: {
- name: file.replace('.mjs', ''),
- description: `[broken] ${e.message}`,
- parameters: { type: 'object', properties: {} },
- handler: () => `Error: tool failed to load — ${e.message}`,
- },
- };
- })
- )
- );
- // Normalize: single tool object or array of tool objects
- const allDefs = toolMods.flatMap((mod) => {
- const d = mod.default;
- if (Array.isArray(d)) return d;
- return d ? [d] : [];
- });
- // Build runtime lookup: { "bash": handlerFn, "read": handlerFn, ... }
- tools = Object.fromEntries(allDefs.map((t) => [t.name, t.handler]));
- toolSchemas = allDefs.map((t) => ({
- type: 'function',
- function: {
- name: t.name,
- description: t.description,
- parameters: t.parameters,
- },
- }));
- // ── Discover skills ───────────────────────────────────────────
- // Scan ./skills/ for SKILL.md files with YAML frontmatter (name:, description:).
- const parseFrontmatter = (content) => ({
- name: content.match(/^name:\s*(.+)$/m)?.[1],
- description: content.match(/^description:\s*(.+)$/m)?.[1] || '',
- body: content,
- });
- const skillDir = `${__dirname}/skills/`;
- skills = existsSync(skillDir) ? readdirSync(skillDir)
- .filter((entry) => existsSync(`${skillDir}${entry}/SKILL.md`))
- .map((entry) => parseFrontmatter(readFileSync(`${skillDir}${entry}/SKILL.md`, 'utf-8')))
- : [];
- }
- function printSkillsNTools()
- {
- if (skills && skills.length) {
- console.log('Skills: ' + skills.map((s) => `${s.name}`).join(', '));
- }
- if (tools && Object.keys(tools).length) {
- console.log('Tools: ' + Object.keys(tools).join(', '));
- }
- }
- // ── Session Save/Load ─────────────────────────────────────────────────
- // Extract first user prompt, truncated to 60 chars
- function firstPrompt(msgs) {
- for (const m of msgs) {
- if (m.role === 'user') {
- const text = (Array.isArray(m.content) ? '' : String(m.content ?? '')).replace(/\n/g, ' ');
- return text.slice(0, 60) || '[empty]';
- }
- }
- return '[no user prompt]';
- }
- // Strip base64 image payloads from history for compact storage
- function stripImages(msgs) {
- return msgs.map(m => {
- if (!Array.isArray(m.content)) return m;
- return {
- ...m,
- content: m.content.map(part => {
- if (part.type === 'image_url' && part.image_url.url.startsWith('data:image')) {
- return { ...part, image_url: { url: '[IMAGE_DATA_REMOVED]' } };
- }
- return part;
- })
- };
- });
- }
- // Enforce max_saved_sessions by deleting oldest files
- function enforceMax() {
- if (max_saved_sessions === 0) return;
- const dir = join(__dirname, 'sessions');
- if (!existsSync(dir)) return;
- const files = readdirSync(dir)
- .filter(f => f.endsWith('.json'))
- .sort(); // alphabetical = chronological for YYYY-MM-DD_HH-MM-SS format
- while (files.length > max_saved_sessions) {
- rmSync(join(dir, files.shift()));
- }
- }
- // Save current session to file
- function saveSession(msgs) {
- // Skip if no user messages (empty session)
- const hasUser = msgs.some(m => m.role === 'user');
- if (!hasUser) return; //if nothing to save, just return.
- const dir = join(__dirname, 'sessions');
- mkdirSync(dir, { recursive: true });
- // Strip system message, strip images
- const clean = stripImages(msgs.slice(1));
- const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
- const path = join(dir, `${ts}.json`);
- writeFileSync(path, JSON.stringify({ prompt: firstPrompt(msgs), history: clean }));
- enforceMax();
- console.log(gray(`Session saved to sessions/${ts}.json`));
- }
- // Load session selector and history replacement. num: 1=newest, 2=2nd newest, etc.
- async function loadSession(msgs, num) {
- const dir = join(__dirname, 'sessions');
- if (!existsSync(dir) || readdirSync(dir).filter(f => f.endsWith('.json')).length === 0) {
- console.log('No saved sessions found.');
- return;
- }
- const entries = [];
- for (const f of readdirSync(dir).filter(f => f.endsWith('.json')).sort().reverse()) {
- const data = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
- entries.push({ file: f, prompt: data.prompt });
- }
- let idx;
- if (num !== undefined) {
- if (num < 1 || num > entries.length) {
- console.log(`Invalid session number. Pick 1-${entries.length} (1 = newest).`);
- return;
- }
- idx = num - 1;
- } else {
- const choices = entries.map(e => `${e.file} - ${e.prompt}`);
- const selected = await userSelect('Select session to load>', choices, true);
- if (!selected) return;
- idx = choices.indexOf(selected);
- }
- const data = JSON.parse(readFileSync(join(dir, entries[idx].file), 'utf-8'));
- msgs.length = 0;
- msgs.push(messageZero(), ...data.history);
- console.log(`Loaded session: ${entries[idx].file}`);
- }
- // Currently only called once, but might be used in future if model switching is implemented.
- async function getContextSize() {
- const result = await fetch(MODELS_URL);
- const body = await result.json();
- if (body.data) {
- //debugPrint('body.data.length = ' + body.data.length);
- for (const model of body.data) {
- let n_ctx = model.meta ? model.meta.n_ctx : 0;
- // debugPrint(`${model.id}: ${n_ctx}`);
- if (n_ctx > 0 ){
- model_name = model.alias ? model.alias : model.id;
- context_limit = n_ctx;
- }
- }
- }
- checkedContextLimit = true;
- }
- // Construct system message.
- function messageZero() {
- const toolList = Object.keys(tools || {}).join(', ');
- let content = SYSTEM_PROMPT + '\n\nAvailable tools: ' + toolList;
- if (skills && skills.length) {
- content += `\n\nSkills can be found in ${__dirname}/skills/<skill_name>/SKILL.md` +
- '\nAvailable skills:\n' +
- skills.map((s) => `- ${s.name}: ${s.description}`).join('\n');
- }
- const agentsPath = __dirname + '/AGENTS.md';
- if (existsSync(agentsPath)) {
- content += '\n\nAGENTS.md content ---\n' + readFileSync(agentsPath, 'utf-8');
- }
- return { role: 'system', content };
- }
- // Serialize messages to plain text for compact payload. Truncates tool results.
- function serializeMessages(msgs, maxToolResultChars = 2000) {
- let out = '';
- for (const m of msgs) {
- if (m.role === 'system') continue;
- const prefix = m.role === 'user' ? '[User]' : m.role === 'assistant' ? '[Assistant]' : '[Tool result]';
- const text = Array.isArray(m.content) ? m.content.map(p => p.text || '').join('\n') : String(m.content ?? '');
- if (m.role === 'tool') {
- const truncated = text.length > maxToolResultChars ? text.slice(0, maxToolResultChars) + '…\n[truncated, ' + text.length + ' total chars]' : text;
- out += prefix + ': ' + truncated + '\n\n';
- } else if (m.tool_calls) {
- const calls = m.tool_calls.map(tc => tc.function.name + '(' + tc.function.arguments.slice(0, 100) + ')').join('; ');
- out += '[Assistant tool calls]: ' + calls + '\n\n';
- } else {
- out += prefix + ': ' + text + '\n\n';
- }
- }
- return out.trim();
- }
- // Walk backwards from end, accumulate char estimates, stop at keepRecentChars.
- // Returns the index where summarization should start (messages before this index get summarized).
- function findCutPoint(msgs, keepRecentChars = 20000) { // ~5k tokens kept recent
- let accumulated = 0;
- for (let i = msgs.length - 1; i >= 1; i--) {
- const m = msgs[i];
- let chars;
- if (m.role === 'tool') {
- chars = String(m.content ?? '').length;
- } else {
- chars = Array.isArray(m.content) ? m.content.reduce((s, p) => s + (p.text || '').length, 0) : String(m.content ?? '').length;
- }
- if (m.tool_calls) chars += m.tool_calls.reduce((s, tc) => s + tc.function.arguments.length, 0);
- accumulated += chars;
- if (accumulated >= keepRecentChars) {
- // Ensure we don't cut a tool result from its call — if this is a tool message, include it
- if (m.role === 'tool') return i - 1;
- return i;
- }
- }
- return 1; // fallback: summarize everything after system
- }
- // Compact strategy: keep recent messages, summarize everything before the cut point.
- async function compact(messages, note) {
- if (messages.length <= 3) {
- console.log('Compact skipped: too early to compact');
- return;
- }
- const cutIndex = findCutPoint(messages);
- const toSummarize = messages.slice(1, cutIndex); // skip system, up to cut point
- const kept = messages.slice(cutIndex); // cut point onward
- if (toSummarize.length === 0) {
- console.log(gray('Compact skipped: context too short, no need to summarize'));
- return;
- }
- console.log(gray('⟡ Compacting... (keeping ' + kept.length + ' messages, summarizing ' + toSummarize.length + ')'));
- const serialized = serializeMessages(toSummarize);
- const r = await fetch(CHAT_URL, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- model: model_name,
- messages: [
- { role: 'system', content: COMPACT_PROMPT + (note ? '\n\nAdditional instruction: ' + note : '') },
- { role: 'user', content: serialized },
- ],
- stream: false,
- chat_template_kwargs: { enable_thinking: false },
- }),
- });
- if (!r.ok) {
- const e = await r.json().catch(() => ({}));
- throw new Error('compact failed (use /new for new session): ' + (e.error?.message || 'HTTP ' + r.status));
- }
- const data = await r.json();
- const summary = (data.choices?.[0]?.message?.content || '').trim();
- if (!summary) throw new Error('compact: empty summary');
- contextTokens = 21000;
- const summaryMessage = {
- role: 'user',
- content: '[Earlier turns compacted to free context. Recap:]\n\n' + summary,
- };
- messages.length = 0;
- messages.push(messageZero(), summaryMessage, ...kept);
- console.log('⟡ Compacted Summary:\n' + summary);
- }
- // Core agentic loop. Posts to the OpenAI interface and processes result.
- async function run(messages) {
- let compacted = false;
- let toolOnlyTurnCount = 0;
- while (true) {
- currentAbort = new AbortController();
- const message = { role: 'assistant' };
- try {
- const payload = {
- model: model_name,
- tools: toolSchemas,
- stream: true,
- stream_options: { include_usage: true },
- //reasoning_effort: REASONING_EFFORT, //doesn't seem to work
- thinking_budget_tokens: THINKING_BUDGET,
- chat_template_kwargs: { enable_thinking: THINKING },
- messages,
- };
- const fetchOptions = {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- signal: currentAbort.signal,
- body: JSON.stringify(payload),
- }
- //debugPrint(fetchOptions);
- debugLog(JSON.stringify({ ...fetchOptions, body: payload }, null, 2), 'history.log', true);
- const response = await fetch(CHAT_URL, fetchOptions);
- if (!response.ok) {
- const body = await response.json().catch(() => ({}));
- const err = new Error(body.error?.message || `HTTP ${response.status}`);
- err.code = body.error?.code;
- throw err;
- }
- let usage = null;
- // Process streaming deltas
- for await (const delta of sseDeltas(response)) {
- if (delta.reasoning_content)
- formattedPrint(delta);
- if (delta.content) {
- formattedPrint(delta);
- message.content = (message.content ?? '') + delta.content;
- }
- if (delta.tool_calls) {
- message.tool_calls ||= [];
- for (const tc of delta.tool_calls) {
- const slot = (message.tool_calls[tc.index] ||= {
- id: '',
- type: 'function',
- function: { name: '', arguments: '' },
- });
- if (tc.id) slot.id = tc.id;
- if (tc.type) slot.type = tc.type;
- const fn = tc.function;
- if (fn?.name) slot.function.name += fn.name;
- if (fn?.arguments) slot.function.arguments += fn.arguments;
- // Because some tool calls can be very long (i.e. write) I want to print out something first as it streams
- if (fn?.arguments && slot.function.arguments.length > TRUNCATE_DISPLAY && !slot._shown) {
- slot._shown = true; // Flag needed to prevent showing the tool call multiple times.
- const args = slot.function.arguments.slice(0, TRUNCATE_DISPLAY) + '…';
- process.stdout.write(orange('⟡ Executing Tool: '));
- process.stdout.write(slot.function.name + ' ');
- process.stdout.write(orange(args + '\n'));
- }
- }
- }
- if (delta.usage) //this should only arrive at the very end and only once.
- usage = delta.usage;
- }
- //update contextTokens;
- if (usage) contextTokens = (usage.prompt_tokens || 0) + (usage.completion_tokens || 0);
- // Detect stream cut short mid-thinking (context overflow)
- if (THINKING && !message.content && !message.tool_calls && contextTokens / context_limit > 0.9) {
- process.stdout.write('\n');
- throw Object.assign(new Error('stream cut short mid-thinking — context full'), { code: 'context_length_exceeded' });
- }
- if (message.content) process.stdout.write('\n');
- // Safety net: flag tool-only turns for post-execution reminder
- const toolOnlyTurn = message.tool_calls && (!message.content || !message.content.trim());
- //if more than 3 tool call turns happen without statement, then user role reminder message kicks in.
- toolOnlyTurnCount = toolOnlyTurn ? ++toolOnlyTurnCount : 0;
- // Validate tool call arguments are complete JSON before committing to history
- if (message.tool_calls) {
- for (const tc of message.tool_calls) {
- try { JSON.parse(tc.function.arguments); }
- catch {
- // Incomplete tool call — likely context overflow truncated the stream
- const contextUsage = contextTokens / context_limit;
- if (contextUsage > 0.9) {
- throw Object.assign(new Error('incomplete tool call — context full'), { code: 'context_length_exceeded' });
- }
- throw new Error('incomplete tool call — malformed arguments: ' + tc.function.arguments.slice(0, 80));
- }
- }
- }
- // Push assistant message now if it has content or tool calls
- if (message.content?.length || message.tool_calls) messages.push(message);
- // log request (response already streamed)
- debugLog(",\n" + JSON.stringify({ ...fetchOptions, body: payload }, null, 2), 'history.log', true);
- // This is the only exit of the loop. If there are no tool calls and only message.
- if (!message.tool_calls) return;
- //debugPrint(yellow('message.tool_calls.length = ' + message.tool_calls.length + '\n'));
- for (const toolCall of message.tool_calls) {
- const toolCallJSON = {
- role: 'tool',
- tool_call_id: String(toolCall.id),
- }
- try {
- const name = toolCall.function.name;
- const args = JSON.parse(toolCall.function.arguments);
- if (!toolCall._shown) {
- process.stdout.write(orange('⟡ Executing Tool: '));
- process.stdout.write(name + ' ');
- process.stdout.write(orange(JSON.stringify(args) + '\n'));
- }
- if (!tools[name]) {
- throw new Error(`unknown tool "${name}". Available: ${Object.keys(tools).join(', ')}`);
- }
- //the actual tool call
- const result = String(await tools[name](args));
- if (result.startsWith('data:image/')) {
- toolCallJSON.content = [{ type: 'image_url', image_url: { url: result } }];
- } else {
- toolCallJSON.content = result;
- }
- if (result) {
- formattedPrint(`⟡ Finished Executing ${name} Tool. Displaying Results: `);
- if (result.length > TRUNCATE_DISPLAY) {
- formattedPrint(`${result.slice(0, TRUNCATE_DISPLAY)}…\n[truncated, ${result.length} total chars]`);
- } else {
- formattedPrint(result);
- }
- formattedPrint('\n\n');
- }
- } catch (e) {
- const errMsg = e instanceof SyntaxError
- ? `JSON parse error in arguments: ${e.message}`
- : String(e).replace(/^Error: /, '');
- toolCallJSON.content = `Tool Call Error: ${errMsg}`;
- console.log(yellow(`⟡ TOOL ERROR: ${errMsg}\n`));
- }
- // Append reminder to the last tool result for tool-only turns
- if (toolOnlyTurn && toolCall === message.tool_calls[message.tool_calls.length - 1]) {
- toolCallJSON.content += '\n\nREVIEW THE TOOL CALL RESULTS AND THE INTENT OF THIS TOOL CALL! I was supposed to explain why I called this but I forgot! Therefore, this automated reminder was generated by the harness.';
- debugPrint(yellow('Tool Call intent reminder triggered!\n'));
- }
- messages.push(toolCallJSON);
- //estimate tool call result's context length and add it to contextTokens. Actual length will be updated in next run by server.
- contextTokens += Math.ceil(String(toolCallJSON.content).length / 4);
- }
- } catch (e) {
- if (e.name === 'AbortError') {
- process.stdout.write(yellow('\n⚠ Interrupted.\n'));
- return;
- }
- else {
- // If compacted and failed or some other exception, throw exception
- if (compacted || !isCtx(e)) throw e;
- // Otherwise compact.
- compacted = true;
- await compact(messages);
- }
- }
- if (toolOnlyTurnCount >= 3)
- { //this reminder kicks in when the AI has ignored tool call reminders 3 times (assistant role reminders). Therefore, sending a user role reminder to check it.
- messages.push({
- role: 'user',
- content: 'Review the tool call results. Restate the original goal you are working towards and explain your next steps before making more tool calls. Follow the Tool Use Protocol.'
- });
- toolOnlyTurnCount = 0;
- debugPrint(yellow('User role tool call reminder triggered!!\n'));
- continue;
- }
- }
- }
- // ── Entry point ───────────────────────────────────────────────────────
- readLine = createReadLine();
- // Help flag: -h or --help (check before server connection)
- if (process.argv.includes('-h') || process.argv.includes('--help')) {
- console.log('Usage: coffee.mjs (interactive REPL)');
- console.log(' coffee.mjs -p "prompt" (one-shot, exits after response)');
- console.log(' echo "prompt" | coffee.mjs (pipe mode, one-shot)');
- process.exit(0);
- }
- try { //also double as server up check.
- await getContextSize();
- } catch {
- console.error(yellow('Error: LLM server at ' + BASE_URL + ' is not reachable.'));
- console.error('Start your backend and try again. Edit server address BASE_URL variable at the top of ' + basename(fileURLToPath(import.meta.url)));
- process.exit(1);
- }
- await loadTools();
- if(model_name === '') await pickLLM();
- const history = [messageZero()];
- const prompt = getArg('-p');
- if (prompt) {
- QUIET = true;
- history.push({ role: 'user', content: prompt });
- await run(history);
- process.exit(0);
- }
- // Pipe mode: echo "prompt" | coffee
- if (!process.stdin.isTTY) {
- QUIET = true;
- let input = '';
- for await (const chunk of process.stdin) input += chunk;
- history.push({ role: 'user', content: input.trim() });
- await run(history);
- process.exit(0);
- }
- printSkillsNTools();
- console.log('Type /help for list of commands');
- // Main interactive REPL - read, evaluate, print, loop.
- while (true) {
- if( !readLine || readLine.closed)
- readLine = createReadLine();
- const percentageUsed = (contextTokens / context_limit * 100).toFixed(1);
- const limitK = Math.round(context_limit / 1000);
- let input;
- try {
- input = await readLine.question(blue(`\n${model_name} context(${percentageUsed}%/${limitK}k)`) +`> `);
- }catch{ //handles ctrl+c in REPL mode, preventing accidental quitting.
- input = '';
- }
- try {
- if (input === '/quit') {
- saveSession(history);
- process.exit(0);
- }
- else if (input.startsWith('/compact')) {
- const note = input.slice(8).trim();
- await compact(history, note || undefined);
- }
- else if (input === '/reload') {
- await loadTools();
- history[0] = messageZero();
- console.log('Tools and System message reloaded');
- printSkillsNTools();
- }
- else if (input === '/new') {
- history.length = 1;
- history[0] = messageZero();
- console.log('Session cleared.');
- }
- else if (input === '/model') {
- await pickLLM();
- }
- else if (input === '/think') {
- THINKING = !THINKING;
- console.log(`Thinking ${THINKING ? 'ON' : 'OFF'}.`);
- }
- else if (input === '/help') {
- console.log('Available commands:');
- console.log(' /quit Exit');
- console.log(' /help Show this list');
- console.log(' /model Pick LLM');
- console.log(' /reload Reload tools & system message');
- console.log(' /compact Summarize conversation history (accepts optional instructions)');
- console.log(' /new Clear session');
- console.log(' /save Save session to file');
- console.log(' /resume [N] Resume a saved session (N=1 newest)');
- console.log(' /think Toggle thinking ON/OFF');
- console.log(' /pop Remove last message (debug)');
- console.log(' ctrl+c Interrupt text generation');
- }
- else if (input === '/save') {
- saveSession(history);
- }
- else if (input === '/resume' || input.startsWith('/resume ')) {
- const arg = input.slice(8).trim();
- let num = undefined;
- if (arg) {
- num = Number(arg);
- if (!Number.isInteger(num) || num < 1) {
- console.log('Usage: /resume [N] (N must be a positive integer, 1 = newest)');
- continue;
- }
- }
- await loadSession(history, num);
- }
- else if (input === '/pop') {
- history.pop(); //for debugging or to un-stuck
- }
- else if (!input.trim()) {
- continue;
- }
- else {
- history.push({ role: 'user', content: input });
- await run(history);
- //after run() the model has to be done loading.
- if(!checkedContextLimit || model_name === '') {
- await getContextSize();
- }
- }
- } catch (error) {
- console.error(yellow(`✗ ${error.message}`));
- }
- }
Add Comment
Please, Sign In to add comment