← 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 };