[object Object]

← 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

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)