Guest User

Untitled

a guest
Jul 5th, 2026
69
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
  1. #!/usr/bin/env node
  2. // coffee.mjs is a fully local only node.js no dependencies coding agent.
  3. // Usage: coffee.mjs              (interactive REPL)
  4. //        coffee.mjs -p "prompt"  (one-shot, exits after response)
  5. //        echo "prompt" | coffee.mjs  (pipe mode, one-shot)
  6.  
  7. // ── Imports ───────────────────────────────────────────────────────────
  8. import { createInterface } from 'readline/promises';
  9. import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'fs';
  10. import { fileURLToPath } from 'url';
  11. import { basename, dirname, join } from 'path';
  12.  
  13. // directory where coffee.mjs resides. this is also where it starts to look for /tools and /skills
  14. const __dirname = dirname(fileURLToPath(import.meta.url));
  15.  
  16. // ── Constants & Configs for Edit!  ────────────────────────────────────
  17. const DEBUG = true; //saves raw message history to logs subfolder and may print extra text to console during output.
  18.  
  19. const BASE_URL      = 'http://127.0.0.1:8080/v1';
  20. const CHAT_URL      = BASE_URL + '/chat/completions';
  21. const MODELS_URL    = BASE_URL + '/models';
  22. //const REASONING_EFFORT = 'medium';  //doesn't work from testing
  23. const THINKING_BUDGET = 2048;  //this tested working.
  24. const TRUNCATE_DISPLAY = 1000;
  25.  
  26. let THINKING = true; // flag to enable/disable thinking/reasoning in the LLM. can be toggled using /think
  27. let model_name = '';
  28. let context_limit = 128000;
  29. const max_saved_sessions = 20;
  30.  
  31. const SYSTEM_PROMPT = `You are coffee.mjs a minimal node.js coding agent in a raw terminal.
  32. The user sees a transcript, not a chat UI. There is no markdown renderer: avoid
  33. headings, bullets, tables, bold, inline-code styling, and code fences unless the
  34. user explicitly asks or code is the product. Keep responses concise. No banter.
  35.  
  36. After long thought chains, always state out your thought's summary or conclusion so they become
  37. a visible part of the transcript.
  38.  
  39. When user asks a question, do not assume they want you to implement the changes right away.  Always
  40. identify and answer the question first! then ask user if you should implement any changes.
  41.  
  42. ## Rule 0 — Be Honest
  43. If you don't know, say so. If you're guessing, label it as a guess. If a task is ambiguous,
  44. ask before proceeding. Surface confusion — don't hide it behind confident-sounding wrong
  45. answers. This is the foundation: everything else depends on truthful self-assessment.
  46.  
  47. ## Rule 1 — Understand Before Acting
  48. Read the relevant files. Run commands to inspect the environment. Summarize findings and
  49. gaps before proposing a plan. The sequence is: inspect → summarize → plan → execute.
  50.  
  51. ## Rule 2 — Minimum Viable Change
  52. Do the smallest thing that solves the stated problem. No features beyond what was asked.
  53. No abstractions for single-use code. If you're adding a dependency, justify it in one line.
  54. Don't touch adjacent code — match existing style, leave unrelated dead code alone.
  55. Follow YAGNI principles: don't build what you might need later.
  56. Prefer one-liner solutions when they're clear.
  57.  
  58. ## Rule 3 — Verify, Don't Assume
  59. After every change, prove it landed. Re-read the file. Run a command. Check the actual output —
  60. don't trust "success" messages. Run the smallest possible test (\`grep\`, \`node -e\`, \`cat\`, \`ls\`)
  61. to confirm before saying "done."
  62.  
  63. ## Rule 6 — Push Back
  64. If the user's approach is overcomplicated, say so. If a simpler solution exists, offer it.
  65. If the request has a hidden cost, surface it. Don't be a yes-machine. Being useful sometimes
  66. means saying "you don't need to do that" or "there's a simpler way."
  67. Present tradeoffs when they exist.
  68.  
  69. Current Detected OS: ${process.platform}
  70. Current Working Directory: ${process.cwd()}
  71. Current System Date: ${new Date().toISOString()}
  72.  
  73. Show file paths clearly when working with files.
  74.  
  75. ## Tool Use Protocol
  76. - You MUST output a visible text message explaining which tool you are calling and why BEFORE any tool calls.
  77. - Tool calls without a preceding explanation are forbidden — they break the transcript and cause you to lose context on the next turn.
  78. - After receiving tool results, analyze them before proceeding. State what they mean for the task.
  79. - When you have enough information, answer the user directly without further tool calls to exit the agentic loop.`;
  80.  
  81. const COMPACT_PROMPT = `
  82. Summarize this conversation for context continuity. Output in this exact format:
  83.  
  84. ## Goal
  85. [What the user is trying to accomplish]
  86.  
  87. ## Progress
  88. ### Done
  89. - [Completed tasks]
  90. ### In Progress
  91. - [Current work]
  92.  
  93. ## Key Decisions
  94. - [Important decisions and rationale]
  95.  
  96. ## Files
  97. <read-files>
  98. [path to each file read]
  99. </read-files>
  100. <modified-files>
  101. [path to each file edited or created]
  102. </modified-files>
  103.  
  104. ## Next Steps
  105. 1. [What should happen next]
  106.  
  107. Extract file paths from tool calls (read, edit, write, bash commands that create/modify files).
  108. Be detailed but concise. Preserve all information needed to continue the work.`;
  109.  
  110. // Colors
  111. const gray = (s) => `\x1b[90m${s}\x1b[0m`;
  112. const orange = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
  113. const yellow = (s) => `\x1b[93m${s}\x1b[0m`;
  114. const blue = (s) => `\x1b[94m${s}\x1b[0m`;
  115.  
  116. // ── State variables ───────────────────────────────────────────────────
  117. let contextTokens = 0;
  118. let checkedContextLimit = false;  //flag used to determine when to poll for context_limit from the server
  119. let currentAbort = null;  // for handling ctrl+c to interrupt prompt generation
  120. let QUIET = false; // flag determines if reasoning_content gets printed out to console.
  121. let tools, toolSchemas, skills;
  122. let readLine;
  123.  
  124. // ── Utility functions ─────────────────────────────────────────────────
  125. const convertNewline = (text) => text.replace(/\\n/g, '\n');
  126. const debugPrint = (text) => DEBUG && console.log(text);
  127. const debugLog = (text, logfile, overwrite = false) => {
  128.     if(!DEBUG) return;
  129.     const logDir = join(__dirname, 'logs');
  130.     if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
  131.     const logPath = join(logDir, logfile);
  132.     const output = convertNewline(text);
  133.     if (overwrite) {
  134.         writeFileSync(logPath, output, 'utf-8');
  135.     } else {
  136.         appendFileSync(logPath, output, 'utf-8');
  137.     }
  138. };
  139.  
  140. // Handles some formatting during printing. It's got some ugly type mixing, but why use javascript if we can't abuse this.
  141. function formattedPrint(delta) {
  142.     if (delta.reasoning_content && !QUIET)
  143.         process.stdout.write(gray(delta.reasoning_content));
  144.     else if (delta.content)
  145.         process.stdout.write(delta.content);
  146.     else
  147.         process.stdout.write(orange(delta));
  148. }
  149.  
  150. // Node.js implementation for input handling.
  151. function createReadLine() {
  152.     const rl = createInterface({
  153.         input: process.stdin,
  154.         output: process.stdout,
  155.     });
  156.     // readline intercepts ctrl+c initially, so we surface it up when we detect a close.
  157.     rl.on('close', () => {
  158.         if (currentAbort) currentAbort.abort();
  159.     });
  160.     return rl;
  161. }
  162.  
  163. // Prompt user to pick from a numbered list. Reuses existing readLine.
  164. async function userSelect(prompt_text, options, cancellable = false) {
  165.     if (cancellable) console.log('  0) Cancel');
  166.     for (const [i, opt] of options.entries()) {
  167.         console.log(`  ${i + 1}) ${opt}`);
  168.     }
  169.  
  170.     while (true) {
  171.         const answer = await readLine.question(prompt_text + ' ');
  172.         const num = parseInt(answer, 10);
  173.         if (cancellable && num === 0) return null;
  174.         if (!isNaN(num) && num >= 1 && num <= options.length) return options[num - 1];
  175.         console.log(`Pick ${cancellable ? '0' : '1'}-${options.length}`);
  176.     }
  177. }
  178.  
  179.  
  180. async function pickLLM() {
  181.     const prev_model_name = model_name;
  182.     const result = await fetch(MODELS_URL);
  183.     const body = await result.json();
  184.    
  185.     if (body.data) {
  186.         const model_list = body.data.map(model => model.id);
  187.         model_name = await userSelect("Select Model>",model_list);
  188.     }
  189.    
  190.     //sending a quick message to kick off the load.
  191.     fetch(CHAT_URL, {
  192.         method: 'POST',
  193.         headers: { 'Content-Type': 'application/json' },
  194.         body: JSON.stringify({
  195.             model: model_name,
  196.             messages: [
  197.                 { role: 'user', 'content': 'hi' },
  198.             ],
  199.             stream: false,
  200.             chat_template_kwargs: { enable_thinking: false },
  201.         }),
  202.     });
  203.    
  204.     if( prev_model_name != model_name)
  205.         checkedContextLimit = false;
  206. }
  207.  
  208.  
  209. // Get command like arguments
  210. const getArg = (key) => {
  211.   const i = process.argv.indexOf(key);
  212.   return i >= 0 && process.argv[i + 1];
  213. };
  214.  
  215. // Detect if error is because of exceeding context length
  216. const isCtx = (e) =>
  217.   /^(context_length_exceeded|exceed_context_size_error)$/.test(
  218.     e.code || e.error?.code || ''
  219.   ) ||
  220.   /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(
  221.     e.message || ''
  222.   );
  223.  
  224. // Async generator: yields parsed delta objects from an Server-Side Events stream. used in run()
  225. async function* sseDeltas(response) {
  226.     const decoder = new TextDecoder();
  227.     let buffer = '';
  228.  
  229.     for await (const chunk of response.body) {
  230.         buffer += decoder.decode(chunk, { stream: true });
  231.         let pos;
  232.  
  233.         // SSE uses double newlines to separate complete events.
  234.         while ((pos = buffer.indexOf('\n\n')) >= 0) {
  235.             const event = buffer.slice(0, pos);
  236.             buffer = buffer.slice(pos + 2); // skip past \n\n delimiter
  237.  
  238.             for (const line of event.split('\n')) {
  239.                 if (!line.startsWith('data: ')) continue;
  240.                 const payload = line.slice(6); // strip "data: " prefix
  241.                 if (payload === '[DONE]') continue;
  242.  
  243.                 let json;
  244.                 try { json = JSON.parse(payload); } catch { continue; }
  245.  
  246.                 if (json.error) {
  247.                     const err = new Error(json.error.message || JSON.stringify(json.error));
  248.                     err.code = json.error.code;
  249.                     throw err;
  250.                 }
  251.  
  252.                 const delta = json.choices?.[0]?.delta;
  253.                 if (delta) yield delta;
  254.                 if (json.usage) yield { usage: json.usage };
  255.             }
  256.         }
  257.     }
  258. }
  259. // ── Core functions ────────────────────────────────────────────────────
  260. async function loadTools() {
  261.     skills = [];
  262.     const toolsDir = __dirname + '/tools/';
  263.     if(!existsSync(toolsDir)) {
  264.         console.log(yellow('Error! no tools folder found'));
  265.         return;
  266.     }
  267.     const files = readdirSync(toolsDir);
  268.  
  269.     const toolMods = await Promise.all(
  270.        files
  271.            .filter((file) => file.endsWith('.mjs'))
  272.            .map((file) =>
  273.                 // the ?v=+Date.now() is for cache busting.
  274.                 import(toolsDir + file + '?v=' + Date.now()).catch((e) => {
  275.                     console.log(yellow(`Tool load error: ${file} ${e.message}`));
  276.                     return {
  277.                         default: {
  278.                             name: file.replace('.mjs', ''),
  279.                             description: `[broken] ${e.message}`,
  280.                             parameters: { type: 'object', properties: {} },
  281.                             handler: () => `Error: tool failed to load — ${e.message}`,
  282.                         },
  283.                     };
  284.                 })
  285.             )
  286.         );
  287.  
  288. // Normalize: single tool object or array of tool objects
  289. const allDefs = toolMods.flatMap((mod) => {
  290.     const d = mod.default;
  291.     if (Array.isArray(d)) return d;
  292.     return d ? [d] : [];
  293. });
  294.  
  295. // Build runtime lookup: { "bash": handlerFn, "read": handlerFn, ... }
  296. tools = Object.fromEntries(allDefs.map((t) => [t.name, t.handler]));
  297.  
  298. toolSchemas = allDefs.map((t) => ({
  299.    type: 'function',
  300.    function: {
  301.        name: t.name,
  302.        description: t.description,
  303.        parameters: t.parameters,
  304.    },
  305. }));
  306.     // ── Discover skills ───────────────────────────────────────────
  307.     // Scan ./skills/ for SKILL.md files with YAML frontmatter (name:, description:).
  308.     const parseFrontmatter = (content) => ({
  309.         name: content.match(/^name:\s*(.+)$/m)?.[1],
  310.         description: content.match(/^description:\s*(.+)$/m)?.[1] || '',
  311.         body: content,
  312.     });
  313.     const skillDir = `${__dirname}/skills/`;
  314.     skills = existsSync(skillDir) ? readdirSync(skillDir)
  315.         .filter((entry) => existsSync(`${skillDir}${entry}/SKILL.md`))
  316.         .map((entry) => parseFrontmatter(readFileSync(`${skillDir}${entry}/SKILL.md`, 'utf-8')))
  317.         : [];
  318. }
  319.  
  320. function printSkillsNTools()
  321. {
  322.     if (skills && skills.length) {
  323.         console.log('Skills: ' + skills.map((s) => `${s.name}`).join(', '));
  324.     }
  325.     if (tools && Object.keys(tools).length) {
  326.         console.log('Tools: ' + Object.keys(tools).join(', '));
  327.     }
  328. }
  329.  
  330. // ── Session Save/Load ─────────────────────────────────────────────────
  331. // Extract first user prompt, truncated to 60 chars
  332. function firstPrompt(msgs) {
  333.     for (const m of msgs) {
  334.         if (m.role === 'user') {
  335.             const text = (Array.isArray(m.content) ? '' : String(m.content ?? '')).replace(/\n/g, ' ');
  336.             return text.slice(0, 60) || '[empty]';
  337.         }
  338.     }
  339.     return '[no user prompt]';
  340. }
  341.  
  342. // Strip base64 image payloads from history for compact storage
  343. function stripImages(msgs) {
  344.     return msgs.map(m => {
  345.         if (!Array.isArray(m.content)) return m;
  346.         return {
  347.             ...m,
  348.             content: m.content.map(part => {
  349.                 if (part.type === 'image_url' && part.image_url.url.startsWith('data:image')) {
  350.                     return { ...part, image_url: { url: '[IMAGE_DATA_REMOVED]' } };
  351.                 }
  352.                 return part;
  353.             })
  354.         };
  355.     });
  356. }
  357.  
  358. // Enforce max_saved_sessions by deleting oldest files
  359. function enforceMax() {
  360.     if (max_saved_sessions === 0) return;
  361.     const dir = join(__dirname, 'sessions');
  362.     if (!existsSync(dir)) return;
  363.     const files = readdirSync(dir)
  364.         .filter(f => f.endsWith('.json'))
  365.         .sort(); // alphabetical = chronological for YYYY-MM-DD_HH-MM-SS format
  366.     while (files.length > max_saved_sessions) {
  367.         rmSync(join(dir, files.shift()));
  368.     }
  369. }
  370.  
  371. // Save current session to file
  372. function saveSession(msgs) {
  373.     // Skip if no user messages (empty session)
  374.     const hasUser = msgs.some(m => m.role === 'user');
  375.     if (!hasUser) return;  //if nothing to save, just return.
  376.     const dir = join(__dirname, 'sessions');
  377.     mkdirSync(dir, { recursive: true });
  378.     // Strip system message, strip images
  379.     const clean = stripImages(msgs.slice(1));
  380.     const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
  381.     const path = join(dir, `${ts}.json`);
  382.     writeFileSync(path, JSON.stringify({ prompt: firstPrompt(msgs), history: clean }));
  383.     enforceMax();
  384.     console.log(gray(`Session saved to sessions/${ts}.json`));
  385. }
  386.  
  387. // Load session selector and history replacement. num: 1=newest, 2=2nd newest, etc.
  388. async function loadSession(msgs, num) {
  389.     const dir = join(__dirname, 'sessions');
  390.     if (!existsSync(dir) || readdirSync(dir).filter(f => f.endsWith('.json')).length === 0) {
  391.         console.log('No saved sessions found.');
  392.         return;
  393.     }
  394.     const entries = [];
  395.     for (const f of readdirSync(dir).filter(f => f.endsWith('.json')).sort().reverse()) {
  396.         const data = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
  397.         entries.push({ file: f, prompt: data.prompt });
  398.     }
  399.  
  400.     let idx;
  401.     if (num !== undefined) {
  402.         if (num < 1 || num > entries.length) {
  403.             console.log(`Invalid session number. Pick 1-${entries.length} (1 = newest).`);
  404.             return;
  405.         }
  406.         idx = num - 1;
  407.     } else {
  408.         const choices = entries.map(e => `${e.file} - ${e.prompt}`);
  409.         const selected = await userSelect('Select session to load>', choices, true);
  410.         if (!selected) return;
  411.         idx = choices.indexOf(selected);
  412.     }
  413.  
  414.     const data = JSON.parse(readFileSync(join(dir, entries[idx].file), 'utf-8'));
  415.     msgs.length = 0;
  416.     msgs.push(messageZero(), ...data.history);
  417.     console.log(`Loaded session: ${entries[idx].file}`);
  418. }
  419.  
  420. // Currently only called once, but might be used in future if model switching is implemented.
  421. async function getContextSize() {
  422.     const result = await fetch(MODELS_URL);
  423.     const body = await result.json();
  424.  
  425.     if (body.data) {
  426.         //debugPrint('body.data.length = ' + body.data.length);
  427.         for (const model of body.data) {
  428.             let n_ctx = model.meta ? model.meta.n_ctx : 0;
  429.             // debugPrint(`${model.id}: ${n_ctx}`);
  430.             if (n_ctx > 0 ){
  431.                 model_name = model.alias ? model.alias : model.id;
  432.                 context_limit = n_ctx;
  433.             }
  434.         }
  435.     }
  436.     checkedContextLimit = true;
  437. }
  438.  
  439. // Construct system message.
  440. function messageZero() {
  441.     const toolList = Object.keys(tools || {}).join(', ');
  442.     let content = SYSTEM_PROMPT + '\n\nAvailable tools: ' + toolList;
  443.     if (skills && skills.length) {
  444.        
  445.         content += `\n\nSkills can be found in ${__dirname}/skills/<skill_name>/SKILL.md` +
  446.             '\nAvailable skills:\n' +
  447.             skills.map((s) => `- ${s.name}: ${s.description}`).join('\n');
  448.     }
  449.    
  450.     const agentsPath = __dirname + '/AGENTS.md';
  451.     if (existsSync(agentsPath)) {
  452.         content += '\n\nAGENTS.md content ---\n' + readFileSync(agentsPath, 'utf-8');
  453.     }
  454.  
  455.     return { role: 'system', content };
  456. }
  457.  
  458. // Serialize messages to plain text for compact payload. Truncates tool results.
  459. function serializeMessages(msgs, maxToolResultChars = 2000) {
  460.     let out = '';
  461.     for (const m of msgs) {
  462.         if (m.role === 'system') continue;
  463.         const prefix = m.role === 'user' ? '[User]' : m.role === 'assistant' ? '[Assistant]' : '[Tool result]';
  464.         const text = Array.isArray(m.content) ? m.content.map(p => p.text || '').join('\n') : String(m.content ?? '');
  465.         if (m.role === 'tool') {
  466.             const truncated = text.length > maxToolResultChars ? text.slice(0, maxToolResultChars) + '…\n[truncated, ' + text.length + ' total chars]' : text;
  467.             out += prefix + ': ' + truncated + '\n\n';
  468.         } else if (m.tool_calls) {
  469.             const calls = m.tool_calls.map(tc => tc.function.name + '(' + tc.function.arguments.slice(0, 100) + ')').join('; ');
  470.             out += '[Assistant tool calls]: ' + calls + '\n\n';
  471.         } else {
  472.             out += prefix + ': ' + text + '\n\n';
  473.         }
  474.     }
  475.     return out.trim();
  476. }
  477.  
  478. // Walk backwards from end, accumulate char estimates, stop at keepRecentChars.
  479. // Returns the index where summarization should start (messages before this index get summarized).
  480. function findCutPoint(msgs, keepRecentChars = 20000) {  // ~5k tokens kept recent
  481.     let accumulated = 0;
  482.     for (let i = msgs.length - 1; i >= 1; i--) {
  483.         const m = msgs[i];
  484.         let chars;
  485.         if (m.role === 'tool') {
  486.             chars = String(m.content ?? '').length;
  487.         } else {
  488.             chars = Array.isArray(m.content) ? m.content.reduce((s, p) => s + (p.text || '').length, 0) : String(m.content ?? '').length;
  489.         }
  490.         if (m.tool_calls) chars += m.tool_calls.reduce((s, tc) => s + tc.function.arguments.length, 0);
  491.         accumulated += chars;
  492.         if (accumulated >= keepRecentChars) {
  493.             // Ensure we don't cut a tool result from its call — if this is a tool message, include it
  494.             if (m.role === 'tool') return i - 1;
  495.             return i;
  496.         }
  497.     }
  498.     return 1; // fallback: summarize everything after system
  499. }
  500.  
  501. // Compact strategy: keep recent messages, summarize everything before the cut point.
  502. async function compact(messages, note) {
  503.     if (messages.length <= 3) {
  504.         console.log('Compact skipped: too early to compact');
  505.         return;
  506.     }
  507.  
  508.     const cutIndex = findCutPoint(messages);
  509.  
  510.     const toSummarize = messages.slice(1, cutIndex); // skip system, up to cut point
  511.     const kept = messages.slice(cutIndex);            // cut point onward
  512.  
  513.     if (toSummarize.length === 0) {
  514.         console.log(gray('Compact skipped: context too short, no need to summarize'));
  515.         return;
  516.     }
  517.  
  518.     console.log(gray('⟡ Compacting... (keeping ' + kept.length + ' messages, summarizing ' + toSummarize.length + ')'));
  519.  
  520.     const serialized = serializeMessages(toSummarize);
  521.  
  522.     const r = await fetch(CHAT_URL, {
  523.         method: 'POST',
  524.         headers: { 'Content-Type': 'application/json' },
  525.         body: JSON.stringify({
  526.             model: model_name,
  527.             messages: [
  528.                 { role: 'system', content: COMPACT_PROMPT + (note ? '\n\nAdditional instruction: ' + note : '') },
  529.                 { role: 'user', content: serialized },
  530.             ],
  531.             stream: false,
  532.             chat_template_kwargs: { enable_thinking: false },
  533.         }),
  534.     });
  535.  
  536.     if (!r.ok) {
  537.         const e = await r.json().catch(() => ({}));
  538.         throw new Error('compact failed (use /new for new session): ' + (e.error?.message || 'HTTP ' + r.status));
  539.     }
  540.  
  541.     const data = await r.json();
  542.     const summary = (data.choices?.[0]?.message?.content || '').trim();
  543.     if (!summary) throw new Error('compact: empty summary');
  544.  
  545.     contextTokens = 21000;
  546.     const summaryMessage = {
  547.         role: 'user',
  548.         content: '[Earlier turns compacted to free context. Recap:]\n\n' + summary,
  549.     };
  550.  
  551.     messages.length = 0;
  552.     messages.push(messageZero(), summaryMessage, ...kept);
  553.  
  554.     console.log('⟡ Compacted Summary:\n' + summary);
  555. }
  556.  
  557. // Core agentic loop. Posts to the OpenAI interface and processes result.
  558. async function run(messages) {
  559.  
  560.     let compacted = false;
  561.     let toolOnlyTurnCount = 0;
  562.  
  563.     while (true) {
  564.         currentAbort = new AbortController();
  565.  
  566.         const message = { role: 'assistant' };
  567.  
  568.         try {
  569.             const payload = {
  570.                     model: model_name,
  571.                     tools: toolSchemas,
  572.                     stream: true,
  573.                     stream_options: { include_usage: true },
  574.                     //reasoning_effort: REASONING_EFFORT,  //doesn't seem to work
  575.                     thinking_budget_tokens: THINKING_BUDGET,
  576.                     chat_template_kwargs: { enable_thinking: THINKING },
  577.                     messages,
  578.                 };
  579.             const fetchOptions = {
  580.                     method: 'POST',
  581.                     headers: { 'Content-Type': 'application/json' },
  582.                     signal: currentAbort.signal,
  583.                     body: JSON.stringify(payload),
  584.                 }
  585.             //debugPrint(fetchOptions);
  586.             debugLog(JSON.stringify({ ...fetchOptions, body: payload }, null, 2), 'history.log', true);
  587.             const response = await fetch(CHAT_URL, fetchOptions);
  588.  
  589.             if (!response.ok) {
  590.                 const body = await response.json().catch(() => ({}));
  591.                 const err = new Error(body.error?.message || `HTTP ${response.status}`);
  592.                 err.code = body.error?.code;
  593.                 throw err;
  594.             }
  595.             let usage = null;
  596.  
  597.             // Process streaming deltas
  598.             for await (const delta of sseDeltas(response)) {
  599.                 if (delta.reasoning_content)
  600.                     formattedPrint(delta);
  601.                 if (delta.content) {
  602.                     formattedPrint(delta);
  603.                     message.content = (message.content ?? '') + delta.content;
  604.                 }
  605.                 if (delta.tool_calls) {
  606.                     message.tool_calls ||= [];
  607.                     for (const tc of delta.tool_calls) {
  608.                         const slot = (message.tool_calls[tc.index] ||= {
  609.                             id: '',
  610.                             type: 'function',
  611.                             function: { name: '', arguments: '' },
  612.                         });
  613.                         if (tc.id) slot.id = tc.id;
  614.                         if (tc.type) slot.type = tc.type;
  615.                         const fn = tc.function;
  616.                         if (fn?.name) slot.function.name += fn.name;
  617.                         if (fn?.arguments) slot.function.arguments += fn.arguments;
  618.  
  619.                         // Because some tool calls can be very long (i.e. write) I want to print out something first as it streams
  620.                         if (fn?.arguments && slot.function.arguments.length > TRUNCATE_DISPLAY && !slot._shown) {
  621.                             slot._shown = true; // Flag needed to prevent showing the tool call multiple times.
  622.                             const args = slot.function.arguments.slice(0, TRUNCATE_DISPLAY) + '…';
  623.                             process.stdout.write(orange('⟡ Executing Tool: '));
  624.                             process.stdout.write(slot.function.name + ' ');
  625.                             process.stdout.write(orange(args + '\n'));
  626.                         }
  627.                     }
  628.                 }
  629.                 if (delta.usage) //this should only arrive at the very end and only once.
  630.                     usage = delta.usage;
  631.             }
  632.  
  633.             //update contextTokens;
  634.             if (usage) contextTokens = (usage.prompt_tokens || 0) + (usage.completion_tokens || 0);
  635.  
  636.             // Detect stream cut short mid-thinking (context overflow)
  637.             if (THINKING && !message.content && !message.tool_calls && contextTokens / context_limit > 0.9) {
  638.                 process.stdout.write('\n');
  639.                 throw Object.assign(new Error('stream cut short mid-thinking — context full'), { code: 'context_length_exceeded' });
  640.             }
  641.  
  642.             if (message.content) process.stdout.write('\n');
  643.  
  644.             // Safety net: flag tool-only turns for post-execution reminder
  645.             const toolOnlyTurn = message.tool_calls && (!message.content || !message.content.trim());
  646.            
  647.             //if more than 3 tool call turns happen without statement, then user role reminder message kicks in.
  648.             toolOnlyTurnCount = toolOnlyTurn ? ++toolOnlyTurnCount : 0;
  649.            
  650.             // Validate tool call arguments are complete JSON before committing to history
  651.             if (message.tool_calls) {
  652.                 for (const tc of message.tool_calls) {
  653.                     try { JSON.parse(tc.function.arguments); }
  654.                     catch {
  655.                         // Incomplete tool call — likely context overflow truncated the stream
  656.                         const contextUsage = contextTokens / context_limit;
  657.                         if (contextUsage > 0.9) {
  658.                             throw Object.assign(new Error('incomplete tool call — context full'), { code: 'context_length_exceeded' });
  659.                         }
  660.                         throw new Error('incomplete tool call — malformed arguments: ' + tc.function.arguments.slice(0, 80));
  661.                     }
  662.                 }
  663.             }
  664.  
  665.             // Push assistant message now if it has content or tool calls
  666.             if (message.content?.length || message.tool_calls) messages.push(message);
  667.            
  668.             // log request (response already streamed)
  669.             debugLog(",\n" + JSON.stringify({ ...fetchOptions, body: payload }, null, 2), 'history.log', true);
  670.  
  671.             // This is the only exit of the loop. If there are no tool calls and only message.
  672.             if (!message.tool_calls) return;
  673.  
  674.             //debugPrint(yellow('message.tool_calls.length = ' + message.tool_calls.length + '\n'));
  675.             for (const toolCall of message.tool_calls) {
  676.                 const toolCallJSON = {
  677.                     role: 'tool',
  678.                     tool_call_id: String(toolCall.id),
  679.                 }
  680.  
  681.                 try {
  682.                     const name = toolCall.function.name;
  683.                     const args = JSON.parse(toolCall.function.arguments);
  684.  
  685.                     if (!toolCall._shown) {
  686.                             process.stdout.write(orange('⟡ Executing Tool: '));
  687.                             process.stdout.write(name + ' ');
  688.                             process.stdout.write(orange(JSON.stringify(args) + '\n'));
  689.                     }
  690.                     if (!tools[name]) {
  691.                         throw new Error(`unknown tool "${name}". Available: ${Object.keys(tools).join(', ')}`);
  692.                     }
  693.  
  694.                     //the actual tool call
  695.                     const result = String(await tools[name](args));
  696.  
  697.                     if (result.startsWith('data:image/')) {
  698.                         toolCallJSON.content = [{ type: 'image_url', image_url: { url: result } }];
  699.                     } else {
  700.                         toolCallJSON.content = result;
  701.                     }
  702.  
  703.                     if (result) {
  704.                         formattedPrint(`⟡ Finished Executing ${name} Tool. Displaying Results: `);
  705.                         if (result.length > TRUNCATE_DISPLAY) {
  706.                             formattedPrint(`${result.slice(0, TRUNCATE_DISPLAY)}…\n[truncated, ${result.length} total chars]`);
  707.                         } else {
  708.                             formattedPrint(result);
  709.                         }
  710.                         formattedPrint('\n\n');
  711.                     }
  712.                 } catch (e) {
  713.                     const errMsg = e instanceof SyntaxError
  714.                         ? `JSON parse error in arguments: ${e.message}`
  715.                         : String(e).replace(/^Error: /, '');
  716.                     toolCallJSON.content = `Tool Call Error: ${errMsg}`;
  717.                     console.log(yellow(`⟡ TOOL ERROR: ${errMsg}\n`));
  718.                 }
  719.  
  720.                 // Append reminder to the last tool result for tool-only turns
  721.                 if (toolOnlyTurn && toolCall === message.tool_calls[message.tool_calls.length - 1]) {
  722.                     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.';
  723.                     debugPrint(yellow('Tool Call intent reminder triggered!\n'));
  724.                 }
  725.                 messages.push(toolCallJSON);
  726.                 //estimate tool call result's context length and add it to contextTokens.  Actual length will be updated in next run by server.
  727.                 contextTokens += Math.ceil(String(toolCallJSON.content).length / 4);
  728.             }
  729.         } catch (e) {
  730.             if (e.name === 'AbortError') {
  731.                 process.stdout.write(yellow('\n⚠ Interrupted.\n'));
  732.                 return;
  733.             }
  734.             else {
  735.                 // If compacted and failed or some other exception, throw exception
  736.                 if (compacted || !isCtx(e)) throw e;
  737.  
  738.                 // Otherwise compact.
  739.                 compacted = true;
  740.                 await compact(messages);
  741.             }
  742.         }
  743.         if (toolOnlyTurnCount >= 3)
  744.         {   //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.
  745.             messages.push({
  746.                 role: 'user',
  747.                 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.'
  748.             });
  749.             toolOnlyTurnCount = 0;
  750.             debugPrint(yellow('User role tool call reminder triggered!!\n'));
  751.             continue;
  752.         }
  753.     }
  754. }
  755.  
  756. // ── Entry point ───────────────────────────────────────────────────────
  757. readLine = createReadLine();
  758. // Help flag: -h or --help (check before server connection)
  759. if (process.argv.includes('-h') || process.argv.includes('--help')) {
  760.     console.log('Usage: coffee.mjs              (interactive REPL)');
  761.     console.log('       coffee.mjs -p "prompt"  (one-shot, exits after response)');
  762.     console.log('       echo "prompt" | coffee.mjs  (pipe mode, one-shot)');
  763.     process.exit(0);
  764. }
  765.  
  766. try { //also double as server up check.
  767.     await getContextSize();
  768. } catch {
  769.     console.error(yellow('Error: LLM server at ' + BASE_URL + ' is not reachable.'));
  770.     console.error('Start your backend and try again. Edit server address BASE_URL variable at the top of ' + basename(fileURLToPath(import.meta.url)));
  771.     process.exit(1);
  772. }
  773. await loadTools();
  774. if(model_name === '') await pickLLM();
  775.  
  776. const history = [messageZero()];
  777.  
  778. const prompt = getArg('-p');
  779. if (prompt) {
  780.     QUIET = true;
  781.     history.push({ role: 'user', content: prompt });
  782.     await run(history);
  783.     process.exit(0);
  784. }
  785.  
  786. // Pipe mode: echo "prompt" | coffee
  787. if (!process.stdin.isTTY) {
  788.     QUIET = true;
  789.     let input = '';
  790.     for await (const chunk of process.stdin) input += chunk;
  791.     history.push({ role: 'user', content: input.trim() });
  792.     await run(history);
  793.     process.exit(0);
  794. }
  795.  
  796. printSkillsNTools();
  797. console.log('Type /help for list of commands');
  798.  
  799. // Main interactive REPL - read, evaluate, print, loop.
  800. while (true) {
  801.     if( !readLine || readLine.closed)
  802.         readLine = createReadLine();
  803.  
  804.     const percentageUsed = (contextTokens / context_limit * 100).toFixed(1);
  805.     const limitK = Math.round(context_limit / 1000);
  806.     let input;
  807.     try {
  808.         input = await readLine.question(blue(`\n${model_name} context(${percentageUsed}%/${limitK}k)`) +`> `);
  809.     }catch{ //handles ctrl+c in REPL mode, preventing accidental quitting.
  810.         input = '';
  811.     }
  812.  
  813.     try {
  814.         if (input === '/quit') {
  815.             saveSession(history);
  816.             process.exit(0);
  817.         }
  818.         else if (input.startsWith('/compact')) {
  819.             const note = input.slice(8).trim();
  820.             await compact(history, note || undefined);
  821.         }
  822.         else if (input === '/reload') {
  823.             await loadTools();
  824.             history[0] = messageZero();
  825.             console.log('Tools and System message reloaded');
  826.             printSkillsNTools();
  827.         }
  828.         else if (input === '/new') {
  829.             history.length = 1;
  830.             history[0] = messageZero();
  831.             console.log('Session cleared.');
  832.         }
  833.         else if (input === '/model') {
  834.             await pickLLM();
  835.         }
  836.         else if (input === '/think') {
  837.             THINKING = !THINKING;
  838.             console.log(`Thinking ${THINKING ? 'ON' : 'OFF'}.`);
  839.         }
  840.         else if (input === '/help') {
  841.             console.log('Available commands:');
  842.             console.log('  /quit        Exit');
  843.             console.log('  /help        Show this list');
  844.             console.log('  /model       Pick LLM');
  845.             console.log('  /reload      Reload tools & system message');
  846.             console.log('  /compact     Summarize conversation history (accepts optional instructions)');
  847.             console.log('  /new         Clear session');
  848.             console.log('  /save        Save session to file');
  849.             console.log('  /resume [N]  Resume a saved session (N=1 newest)');
  850.             console.log('  /think       Toggle thinking ON/OFF');
  851.             console.log('  /pop         Remove last message (debug)');
  852.             console.log('  ctrl+c       Interrupt text generation');
  853.         }
  854.         else if (input === '/save') {
  855.             saveSession(history);
  856.         }
  857.         else if (input === '/resume' || input.startsWith('/resume ')) {
  858.             const arg = input.slice(8).trim();
  859.             let num = undefined;
  860.             if (arg) {
  861.                 num = Number(arg);
  862.                 if (!Number.isInteger(num) || num < 1) {
  863.                     console.log('Usage: /resume [N]  (N must be a positive integer, 1 = newest)');
  864.                     continue;
  865.                 }
  866.             }
  867.             await loadSession(history, num);
  868.         }
  869.         else if (input === '/pop') {
  870.             history.pop();  //for debugging or to un-stuck
  871.         }
  872.         else if (!input.trim()) {
  873.             continue;
  874.         }
  875.         else {
  876.             history.push({ role: 'user', content: input });
  877.             await run(history);
  878.             //after run() the model has to be done loading.
  879.             if(!checkedContextLimit || model_name === '') {
  880.                 await getContextSize();
  881.             }
  882.         }
  883.     } catch (error) {
  884.         console.error(yellow(`✗ ${error.message}`));
  885.     }
  886. }
  887.  
Add Comment
Please, Sign In to add comment