← back to Visual Factory
Remove retired-Ollama dependency; route vision/text through shared exo-vision lib (TK-12090 Lane E)
f96fe855bd44b1863d52cdd3ee2404644c80a023 · 2026-09-23 14:52:33 -0700 · Steve Abrams
Ollama (127.0.0.1:11434) is a zombie — connections succeed, zero models
loaded, every call fails. src/llm.js's ollama() now dispatches through
~/Projects/_shared/lib/exo-vision.mjs (exo ring primary, $0; Gemini
fallback for vision only, cost-ledgered). Text has no fallback by design —
an unreachable ring fails loudly (NOT-MEASURED), never silently. Output
schema of every stage (intake/compose/render/vision_check/critic/iterate)
is unchanged. /health/deep now probes the exo ring instead of a dead
Ollama /api/tags. claudeCli() (critic stage) was never on Ollama — untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY
Files touched
M public/index.htmlM server.jsM src/llm.jsM src/pipeline.jsM src/stages/compose.jsM src/stages/critic.jsM src/stages/intake.jsM src/stages/iterate.jsM src/stages/vision_check.js
Diff
commit f96fe855bd44b1863d52cdd3ee2404644c80a023
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 23 14:52:33 2026 -0700
Remove retired-Ollama dependency; route vision/text through shared exo-vision lib (TK-12090 Lane E)
Ollama (127.0.0.1:11434) is a zombie — connections succeed, zero models
loaded, every call fails. src/llm.js's ollama() now dispatches through
~/Projects/_shared/lib/exo-vision.mjs (exo ring primary, $0; Gemini
fallback for vision only, cost-ledgered). Text has no fallback by design —
an unreachable ring fails loudly (NOT-MEASURED), never silently. Output
schema of every stage (intake/compose/render/vision_check/critic/iterate)
is unchanged. /health/deep now probes the exo ring instead of a dead
Ollama /api/tags. claudeCli() (critic stage) was never on Ollama — untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY
---
public/index.html | 2 +-
server.js | 41 ++++++++------
src/llm.js | 129 +++++++++++++++++----------------------------
src/pipeline.js | 8 ++-
src/stages/compose.js | 6 +--
src/stages/critic.js | 8 +--
src/stages/intake.js | 2 +-
src/stages/iterate.js | 4 +-
src/stages/vision_check.js | 16 +++---
9 files changed, 100 insertions(+), 116 deletions(-)
diff --git a/public/index.html b/public/index.html
index 2cd42e0..5d79b04 100644
--- a/public/index.html
+++ b/public/index.html
@@ -475,7 +475,7 @@ async function loadHealth() {
el.className = 'health ' + cls;
el.innerHTML = `
<span>${dot(d.checks.db)}<span class="lbl">db</span> ${escapeHtml(String(d.checks.db))}</span>
- <span>${dot(d.checks.ollama)}<span class="lbl">ollama</span> ${escapeHtml(String(d.checks.ollama))}</span>
+ <span>${dot(d.checks.exo_ring)}<span class="lbl">exo ring</span> ${escapeHtml(String(d.checks.exo_ring))}</span>
<span>${dot(d.checks.claude_cli)}<span class="lbl">critic</span> ${escapeHtml(String(d.checks.claude_cli))}</span>
<span>${dot(d.checks.playwright)}<span class="lbl">playwright</span> ${escapeHtml(String(d.checks.playwright))}</span>
<span class="qd">queue ${q.in_flight||0}/${q.capacity||'?'} in flight · ${q.queued||0} queued</span>`;
diff --git a/server.js b/server.js
index 6652379..c594031 100644
--- a/server.js
+++ b/server.js
@@ -576,32 +576,41 @@ app.get('/health', (_req, res) => {
});
// Deep health: probes every dependency the pipeline needs. The viewer hits this
-// on load so the user gets a clear "Ollama is down" message before submitting
+// on load so the user gets a clear "exo ring is down" message before submitting
// a run that's doomed to fail at compose. Returns 503 when any required dep is
// unhealthy so external monitors can alert.
+//
+// TK-12090 Lane E (2026-09-23): this used to ping Ollama :11434 /api/tags directly.
+// Ollama is RETIRED (a zombie with ZERO models loaded — the connection succeeds,
+// model calls fail, so a naive port-open check would have false-PASSed). Now probes
+// the exo ring via the shared lib's exoPreflight(); text has no fallback (fails
+// honestly), vision falls back to Gemini (paid, cost-ledgered) if the ring is down.
+const { exoLib } = require('./src/llm');
+
app.get('/health/deep', async (_req, res) => {
const checks = {
db: 'unknown',
- ollama: 'unknown',
+ exo_ring: 'unknown',
claude_cli: 'unknown',
playwright: 'unknown',
};
- const ollamaModels = { compose: process.env.COMPOSE_MODEL || 'qwen3:14b', vision: process.env.VISION_MODEL || 'llava:latest' };
await Promise.allSettled([
pg.query('SELECT 1').then(() => { checks.db = 'ok'; }, e => { checks.db = `error: ${e.message}`; }),
(async () => {
try {
- const ctrl = new AbortController();
- const t = setTimeout(() => ctrl.abort(), 3000);
- const r = await fetch(`${process.env.OLLAMA_HOST || 'http://127.0.0.1:11434'}/api/tags`, { signal: ctrl.signal });
- clearTimeout(t);
- if (!r.ok) { checks.ollama = `http ${r.status}`; return; }
- const j = await r.json();
- const have = new Set((j.models || []).map(m => m.name));
- const missing = Object.values(ollamaModels).filter(m => !have.has(m));
- checks.ollama = missing.length ? `ok but missing: ${missing.join(', ')}` : 'ok';
- } catch (e) { checks.ollama = `error: ${e.message}`; }
+ const lib = await exoLib();
+ const pre = await lib.exoPreflight();
+ if (pre.up) {
+ checks.exo_ring = pre.liveVisionModel
+ ? `ok (vision live: ${pre.liveVisionModel})`
+ : 'ok, no live vision instance — vision calls will fall back to Gemini ($paid); text calls will succeed';
+ } else if ((process.env.VISION_FALLBACK || 'gemini') === 'none') {
+ checks.exo_ring = `down (${pre.error}) — VISION_FALLBACK=none, compose/vision/iterate will fail NOT-MEASURED`;
+ } else {
+ checks.exo_ring = `down (${pre.error}) — text (compose/intake/iterate) will fail; vision falls back to Gemini ($paid)`;
+ }
+ } catch (e) { checks.exo_ring = `error: ${e.message}`; }
})(),
(async () => {
try {
@@ -619,11 +628,11 @@ app.get('/health/deep', async (_req, res) => {
// Codex P1 #12 fix: every required dep gates `ok`. The route docstring says
// "probes every dependency the pipeline needs," so missing claude_cli or
- // playwright must surface as 503, not silently green. Ollama "ok but missing"
- // (model not pulled) also blocks ok — pipeline will fail at compose/vision.
+ // playwright must surface as 503, not silently green. exo_ring not starting
+ // with "ok" (down, or down with no fallback) also blocks ok.
const ok =
checks.db === 'ok' &&
- checks.ollama === 'ok' &&
+ /^ok/.test(checks.exo_ring) &&
checks.claude_cli === 'ok' &&
checks.playwright === 'ok';
res.status(ok ? 200 : 503).json({
diff --git a/src/llm.js b/src/llm.js
index 4b17798..0f385a2 100644
--- a/src/llm.js
+++ b/src/llm.js
@@ -1,92 +1,59 @@
// Visual Factory — LLM adapters.
-// All-local, all-free. No Anthropic API.
-// ollama() → qwen3:14b for spec/HTML/iterate; llava for vision
-// claudeCli() → critic only (one-call-per-run, Max sub)
+// 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 fs = require('node:fs/promises');
+const path = require('node:path');
+const { pathToFileURL } = require('node:url');
-// P1 fix 2026-05-04: default to MS1 per Steve's standing rule
-// `feedback_ollama_default_ms1.md` — Mac2 froze 2026-05-02 under GPU contention.
-const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://192.168.1.133:11434';
-const CLAUDE_CLI = process.env.CLAUDE_CLI || 'claude';
+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';
-async function ollama({ model, system, prompt, format = null, temperature = 0.2, images = null, signal = null }) {
- const message = { role: 'user', content: prompt };
- if (images && images.length) {
- message.images = await Promise.all(images.map(async p => {
- const buf = await fs.readFile(p);
- return buf.toString('base64');
- }));
+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}`); });
}
- // num_predict caps llava's output to prevent the degenerate "1000 empty strings"
- // mode that breaks JSON parsing. 1500 tokens is plenty for the small JSON we want.
- const body = {
- model,
- stream: false,
- options: { temperature, num_predict: 1500 },
- messages: [
- ...(system ? [{ role: 'system', content: system }] : []),
- message
- ]
- };
- if (format === 'json') body.format = 'json';
+ return _libPromise;
+}
- // Retry transient fetch failures (socket drops under concurrent load).
- // Backoff: 2s, 5s, 12s.
- const delays = [2000, 5000, 12000];
- let lastErr;
- for (let attempt = 0; attempt <= delays.length; attempt++) {
- if (signal?.aborted) throw new Error('ollama call aborted (stage timeout or cancellation)');
- try {
- const res = await fetch(`${OLLAMA_HOST}/api/chat`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- signal: signal || undefined
- });
- if (!res.ok) {
- const txt = await res.text();
- // Treat 5xx as transient; 4xx as permanent
- if (res.status >= 500 && attempt < delays.length) {
- lastErr = new Error(`ollama ${model} http ${res.status}: ${txt.slice(0, 200)}`);
- } else {
- throw new Error(`ollama ${model} http ${res.status}: ${txt.slice(0, 200)}`);
- }
- } else {
- 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;
- }
- } catch (err) {
- lastErr = err;
- // AbortError = caller cancelled (stage timeout). Don't retry.
- if (err?.name === 'AbortError' || signal?.aborted) {
- throw new Error('ollama call aborted (stage timeout or cancellation)');
- }
- // TypeError: fetch failed = socket-level. Retry.
- if (!(err instanceof TypeError) && !err.message?.includes('ollama') ) throw err;
- if (attempt === delays.length) break;
- }
- // Honor abort during back-off too — otherwise a cancelled stage waits the full 19s.
- // If the signal aborted just before this Promise body runs, addEventListener
- // attaches to an already-aborted signal and never fires. Check first.
- await new Promise((resolve, reject) => {
- if (signal?.aborted) {
- return reject(new Error('ollama call aborted (during retry back-off)'));
- }
- const t = setTimeout(resolve, delays[attempt]);
- if (signal) {
- const onAbort = () => { clearTimeout(t); reject(new Error('ollama call aborted (during retry back-off)')); };
- signal.addEventListener('abort', onAbort, { once: true });
- }
+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;
}
- throw lastErr || new Error(`ollama ${model} failed after retries`);
+
+ // 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 }) {
@@ -132,4 +99,4 @@ function claudeCli({ prompt, timeoutMs = 180000, signal = null }) {
});
}
-module.exports = { ollama, claudeCli };
+module.exports = { ollama, claudeCli, exoLib };
diff --git a/src/pipeline.js b/src/pipeline.js
index 6070704..fc1419c 100644
--- a/src/pipeline.js
+++ b/src/pipeline.js
@@ -114,9 +114,13 @@ async function runPipeline(pg, run) {
// 5: vision_check
trackStage(4, 'vision_check');
await setStage(pg, runId, 4, 'running');
- await logEvent(pg, runId, 4, 'vision_check', 'info', 'starting (llava)', null);
+ await logEvent(pg, runId, 4, 'vision_check', 'info', 'starting (exo ring / gemini fallback)', null);
let vision = await withTimeout('vision_check', signal => runVisionCheck({ pngPath: rendered.pngPath, signal }));
- await logEvent(pg, runId, 4, 'vision_check', vision.issues.length ? 'warn' : 'info',
+ // TK-12090 Lane E: _vision_ok===false (exo ring + Gemini fallback both unreachable/
+ // disabled) must surface at 'warn' even when issues is empty — previously this only
+ // checked issues.length, so a fully NOT-MEASURED vision call logged as 'info' and
+ // was easy to miss (TK-11431 doctrine: an unmeasured input is never quietly green).
+ await logEvent(pg, runId, 4, 'vision_check', (vision.issues.length || vision._vision_ok === false) ? 'warn' : 'info',
`quality=${vision.overall_quality} text_seen=${vision.text_content.length}`, vision);
// 6: critic
diff --git a/src/stages/compose.js b/src/stages/compose.js
index 5367b20..8ce5e84 100644
--- a/src/stages/compose.js
+++ b/src/stages/compose.js
@@ -1,5 +1,5 @@
-// Stage 3 — compose. qwen3:14b writes a single self-contained HTML+CSS doc that fills
-// the target dimensions exactly and respects the spec's palette/typography/content.
+// Stage 3 — compose. The exo-ring text model writes a single self-contained HTML+CSS
+// doc that fills the target dimensions exactly and respects the spec's palette/typography/content.
const fs = require('node:fs/promises');
const path = require('node:path');
@@ -32,7 +32,7 @@ Height: ${spec.height}px
Write the HTML now.`;
const html = stripFences(await ollama({
- model: process.env.COMPOSE_MODEL || 'qwen3:14b',
+ model: process.env.COMPOSE_MODEL || 'mlx-community/Qwen3.6-27B-4bit',
system: SYSTEM,
prompt: userPrompt,
temperature: 0.4,
diff --git a/src/stages/critic.js b/src/stages/critic.js
index 904bf63..4b854a4 100644
--- a/src/stages/critic.js
+++ b/src/stages/critic.js
@@ -1,11 +1,13 @@
-// Stage 6 — critic. Single Claude CLI subprocess call. Compares the llava description
-// of the rendered image against the original brief + spec; returns a JSON review.
+// Stage 6 — critic. Single Claude CLI subprocess call (Steve's Max plan, no Ollama
+// dependency — untouched by TK-12090 Lane E). Compares the exo-ring/Gemini vision
+// description of the rendered image against the original brief + spec; returns a
+// JSON review.
const { claudeCli } = require('../llm');
const PROMPT = (brief, spec, vision) => `You are reviewing an automated visual generation.
-IMPORTANT CALIBRATION: The vision-model description below is from a small local model (llava). It frequently hallucinates colors and misses text. Treat \`vision.text_content\` as a LOWER BOUND on what's actually rendered, not the truth — the renderer wrote real HTML with the spec's content blocks, so they ARE present even if vision didn't read them. Treat \`vision.palette\` and \`vision.mood\` as soft signals, not facts. The strongest reliable signals are:
+IMPORTANT CALIBRATION: The vision-model description below is from a small local vision model (exo ring, or Gemini Flash on fallback). It can hallucinate colors and miss text. Treat \`vision.text_content\` as a LOWER BOUND on what's actually rendered, not the truth — the renderer wrote real HTML with the spec's content blocks, so they ARE present even if vision didn't read them. Treat \`vision.palette\` and \`vision.mood\` as soft signals, not facts. The strongest reliable signals are:
- \`vision.overall_quality\` (the model's holistic read — usually trustworthy)
- \`vision.issues\` (only if it flagged something concrete like clipping/overflow)
diff --git a/src/stages/intake.js b/src/stages/intake.js
index 79da011..77791ac 100644
--- a/src/stages/intake.js
+++ b/src/stages/intake.js
@@ -20,7 +20,7 @@ const KEBAB = /^[a-z0-9-]{2,60}$/;
async function runIntake({ brief, signal = null }) {
const spec = await ollama({
- model: process.env.COMPOSE_MODEL || 'qwen3:14b',
+ model: process.env.COMPOSE_MODEL || 'mlx-community/Qwen3.6-27B-4bit',
system: SYSTEM,
prompt: brief,
format: 'json',
diff --git a/src/stages/iterate.js b/src/stages/iterate.js
index e51c0a9..bacf67a 100644
--- a/src/stages/iterate.js
+++ b/src/stages/iterate.js
@@ -1,4 +1,4 @@
-// Stage 7 — iterate. qwen3:14b applies critic.fix_instructions to the HTML, single retry.
+// Stage 7 — iterate. The exo-ring text model applies critic.fix_instructions to the HTML.
const fs = require('node:fs/promises');
const { ollama } = require('../llm');
@@ -26,7 +26,7 @@ ${review.fix_instructions.map((s, i) => `${i + 1}. ${s}`).join('\n')}
Output the revised raw HTML now.`;
const revised = (await ollama({
- model: process.env.COMPOSE_MODEL || 'qwen3:14b',
+ model: process.env.COMPOSE_MODEL || 'mlx-community/Qwen3.6-27B-4bit',
system: SYSTEM,
prompt: userPrompt,
temperature: 0.3,
diff --git a/src/stages/vision_check.js b/src/stages/vision_check.js
index b3352fd..d0e5cd6 100644
--- a/src/stages/vision_check.js
+++ b/src/stages/vision_check.js
@@ -1,5 +1,6 @@
-// Stage 5 — vision_check. llava describes what's actually on the rendered PNG.
-// This is the ground-truth visual signal that the critic uses to compare against the brief.
+// Stage 5 — vision_check. The exo-ring vision model (Gemini fallback if the ring is
+// down) describes what's actually on the rendered PNG — the ground-truth visual signal
+// that the critic uses to compare against the brief.
const { ollama } = require('../llm');
@@ -15,13 +16,14 @@ Output ONLY a JSON object:
JSON only — no prose, no fences, no explanations.`;
async function runVisionCheck({ pngPath, signal = null }) {
- // Vision is best-effort. llava sometimes degenerates (loops empty strings,
- // fails JSON parse). If it crashes, return a neutral "vision unavailable"
- // shape so the pipeline can still finish and the critic can decide based
- // on the brief alone. The PNG already exists — vision is just a signal.
+ // Vision is best-effort. The vision model can still degenerate (loops empty
+ // strings, fails JSON parse) or the ring/fallback can both be unreachable. If
+ // it crashes, return a neutral "vision unavailable" shape so the pipeline can
+ // still finish and the critic can decide based on the brief alone. The PNG
+ // already exists — vision is just a signal.
try {
const desc = await ollama({
- model: process.env.VISION_MODEL || 'llava:latest',
+ model: process.env.VISION_MODEL || 'mlx-community/Qwen3-VL-4B-Instruct-4bit',
system: SYSTEM,
prompt: 'Describe this image as JSON per the system instructions.',
images: [pngPath],
← b533446 auto-data-snapshot: 2026-09-23T14:48:42 (2 data files) — .en
·
back to Visual Factory
·
(newest)