← back to The Ai Factory

src/llm.js

76 lines

// The AI Factory — LLM adapters.
//
// Hybrid design (2026-04-30): Ollama (local, free, fast) for mechanical stages;
// Claude CLI subprocess (Steve's Max sub, no per-token charge) for the critic pass.
// No Anthropic API calls — Steve's standing rule "if api costs money.. no".

const { spawn } = require('node:child_process');

// P2 fix 2026-05-04: default to Mac Studio 1 per Steve's standing rule
// `feedback_ollama_default_ms1.md` — bulk LLM workloads point at MS1, not Mac2.
// Mac2 froze 2026-05-02 with v3+v5+codex on the same GPU.
const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://192.168.1.133:11434';
const CLAUDE_CLI = process.env.CLAUDE_CLI || 'claude';

/**
 * Call a local Ollama model. If `format` is 'json', the model is forced to
 * emit valid JSON and the result is parsed before returning.
 */
async function ollama({ model, system, prompt, format = null, temperature = 0.2 }) {
  const body = {
    model,
    stream: false,
    options: { temperature },
    messages: [
      ...(system ? [{ role: 'system', content: system }] : []),
      { role: 'user', content: prompt }
    ]
  };
  if (format === 'json') body.format = 'json';

  const res = await fetch(`${OLLAMA_HOST}/api/chat`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  });
  if (!res.ok) throw new Error(`ollama ${model} http ${res.status}: ${await res.text()}`);
  const data = await res.json();
  const content = data?.message?.content ?? '';
  if (format === 'json') {
    try { return JSON.parse(content); }
    catch (e) { throw new Error(`ollama ${model} did not return valid JSON: ${content.slice(0, 200)}`); }
  }
  return content;
}

/**
 * Run Claude CLI as a one-shot subprocess. Used only for the Manager critic pass
 * where quality matters more than speed. Counts against Steve's Max quota.
 *
 * Pipes the prompt via stdin instead of `-p prompt` arg style — avoids silent
 * exit-1 failures on prompts containing JSON, newlines, or non-ASCII chars.
 * Discovered while building Visual Factory 2026-04-30.
 */
function claudeCli({ prompt, timeoutMs = 120000 }) {
  return new Promise((resolve, reject) => {
    const child = spawn(CLAUDE_CLI, ['-p'], { stdio: ['pipe', 'pipe', 'pipe'] });
    let stdout = '';
    let stderr = '';
    const timer = setTimeout(() => {
      child.kill('SIGTERM');
      reject(new Error(`claude CLI timed out after ${timeoutMs}ms`));
    }, timeoutMs);
    child.stdout.on('data', d => { stdout += d; });
    child.stderr.on('data', d => { stderr += d; });
    child.on('error', err => { clearTimeout(timer); reject(new Error(`claude CLI spawn error: ${err.message}`)); });
    child.on('close', code => {
      clearTimeout(timer);
      if (code !== 0) return reject(new Error(`claude CLI exit ${code}: ${stderr.slice(0, 400) || '(empty stderr)'}`));
      resolve(stdout.trim());
    });
    child.stdin.end(prompt);
  });
}

module.exports = { ollama, claudeCli };