← back to Visual Factory

src/llm.js

103 lines

// Visual Factory — LLM adapters.
//   ollama()      → text spec/HTML/iterate + vision review, now routed through the
//                   shared exo-vision lib (exo ring primary, $0 local; Gemini fallback
//                   for VISION only, cost-ledgered). Name kept so every call site below
//                   (intake/compose/iterate/vision_check) is untouched.
//   claudeCli()   → critic only (one-call-per-run, Max sub) — never touched Ollama.
//
// TK-12090 Lane E (2026-09-23): direct Ollama (127.0.0.1:11434 / OLLAMA_HOST) calls are
// RETIRED — Ollama is a zombie with ZERO models loaded (connections succeed, model calls
// fail). Replaced with ~/Projects/_shared/lib/exo-vision.mjs. Text has NO Gemini fallback
// in the shared lib by design — an unreachable ring fails honestly (NOT-MEASURED) rather
// than silently degrading (TK-11431 doctrine).

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

const EXO_VISION_LIB = process.env.EXO_VISION_LIB
  || path.join(__dirname, '..', '..', '_shared', 'lib', 'exo-vision.mjs');
const CLAUDE_CLI = process.env.CLAUDE_CLI || 'claude';

let _libPromise = null;
function exoLib() {
  if (!_libPromise) {
    _libPromise = import(pathToFileURL(EXO_VISION_LIB).href)
      .catch(e => { _libPromise = null; throw new Error(`exo-vision lib unavailable (${EXO_VISION_LIB}): ${e.message}`); });
  }
  return _libPromise;
}

function extractJson(text) {
  const m = String(text || '').match(/\{[\s\S]*\}/);
  if (!m) throw new Error(`no JSON object found in response: ${String(text || '').slice(0, 200)}`);
  return JSON.parse(m[0]);
}

async function ollama({ model, system, prompt, format = null, temperature = 0.2, images = null, signal = null }) {
  if (signal?.aborted) throw new Error('ollama call aborted before dispatch');
  const lib = await exoLib();
  const fullPrompt = system ? `${system}\n\n${prompt}` : prompt;

  if (images && images.length) {
    // Vision path. Every current caller (vision_check.js) passes exactly one
    // image; only the first is used if more are ever supplied.
    const r = await lib.visionChat({
      prompt: fullPrompt, image: images[0], model, timeoutMs: 90000, maxTokens: 1500,
    });
    if (!r.ok) throw new Error(r.error || 'vision call not measured (exo ring + Gemini fallback both unavailable)');
    return format === 'json' ? extractJson(r.text) : r.text;
  }

  // Text path — no Gemini fallback in the shared lib (by design). An unreachable
  // ring throws here instead of returning a fabricated success.
  const r = await lib.textChat({ prompt: fullPrompt, model, timeoutMs: 120000, maxTokens: 8192 });
  if (!r.ok) throw new Error(r.error || 'text call not measured (exo ring unavailable; text path has no fallback)');
  return format === 'json' ? extractJson(r.text) : r.text;
}

function claudeCli({ prompt, timeoutMs = 180000, signal = null }) {
  // Pipe prompt via stdin instead of the `-p` CLI flag — avoids arg-length / quoting
  // issues when the prompt contains JSON, newlines, or non-ASCII chars.
  // signal: external AbortSignal (e.g., from stage timeout) — kills the child.
  return new Promise((resolve, reject) => {
    if (signal?.aborted) return reject(new Error('claude CLI aborted before spawn'));
    const child = spawn(CLAUDE_CLI, ['-p'], { stdio: ['pipe', 'pipe', 'pipe'] });
    let stdout = '';
    let stderr = '';
    let settled = false;
    const cleanup = () => {
      clearTimeout(timer);
      if (signal && onAbort) signal.removeEventListener('abort', onAbort);
    };
    const finish = (fn, val) => { if (settled) return; settled = true; cleanup(); fn(val); };

    const timer = setTimeout(() => {
      try { child.kill('SIGTERM'); } catch {}
      finish(reject, new Error(`claude CLI timed out after ${timeoutMs}ms`));
    }, timeoutMs);

    let onAbort = null;
    if (signal) {
      onAbort = () => {
        try { child.kill('SIGTERM'); } catch {}
        // SIGKILL fallback if it doesn't exit cleanly within 2s.
        setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 2000).unref?.();
        finish(reject, new Error('claude CLI aborted (stage timeout or cancellation)'));
      };
      signal.addEventListener('abort', onAbort, { once: true });
    }

    child.stdout.on('data', d => { stdout += d; });
    child.stderr.on('data', d => { stderr += d; });
    child.on('error', err => finish(reject, new Error(`claude CLI spawn error: ${err.message}`)));
    child.on('close', code => {
      if (code !== 0) return finish(reject, new Error(`claude CLI exit ${code}: ${stderr.slice(0, 400) || '(empty stderr)'}`));
      finish(resolve, stdout.trim());
    });
    try { child.stdin.end(prompt); } catch (e) { finish(reject, new Error(`claude CLI stdin write failed: ${e.message}`)); }
  });
}

module.exports = { ollama, claudeCli, exoLib };