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