← back to Big Red
server.js
802 lines
'use strict';
require('dotenv').config();
const express = require('express');
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process');
const { WebSocketServer } = require('ws');
const QRCode = require('qrcode');
const multer = require('multer');
const { Pool } = require('pg');
const Anthropic = require('@anthropic-ai/sdk');
const PORT = parseInt(process.env.PORT || '9936', 10);
const ELEVEN_KEY = process.env.ELEVENLABS_API_KEY || '';
const ELEVEN_VOICE = process.env.ELEVENLABS_VOICE_ID || 'Xa9qV4wNbvSkdUWsYLzq';
const LAN_HOST = process.env.LAN_HOST || detectLanHost();
// DW intelligence — only loaded if DATABASE_URL is set so the rest of Big Red
// keeps working in environments without dw_unified (e.g. when deploying the
// avatar app to a friend's box for general chat).
const DW_DB_URL = process.env.DATABASE_URL || '';
const SHOPIFY_STORE = process.env.SHOPIFY_STORE || '';
const SHOPIFY_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || '';
const SHOPIFY_API_VERSION = process.env.SHOPIFY_API_VERSION || '2024-10';
const dwPool = DW_DB_URL ? new Pool({ connectionString: DW_DB_URL, max: 4, idleTimeoutMillis: 20_000 }) : null;
if (dwPool) {
dwPool.on('error', (err) => console.error('[dw pool]', err.message));
}
// Tracks the last time each SKU was verified live against Shopify.
// Lets Big Red answer "is this price still good?" — anything within 24h is
// trusted, anything older triggers a fresh Shopify hit.
const PRICE_LOG = path.join(__dirname, 'data', 'price-verifications.json');
fs.mkdirSync(path.dirname(PRICE_LOG), { recursive: true });
function loadPriceLog() {
try { return JSON.parse(fs.readFileSync(PRICE_LOG, 'utf8')); } catch { return {}; }
}
function savePriceLog(d) {
try { fs.writeFileSync(PRICE_LOG, JSON.stringify(d, null, 2)); } catch (e) { console.error('[price-log]', e.message); }
}
function stampVerification(sku, price, source) {
if (!sku) return;
const log = loadPriceLog();
log[sku] = { price, source, verified_at: new Date().toISOString() };
savePriceLog(log);
}
function getVerification(sku) {
const log = loadPriceLog();
return log[sku] || null;
}
// SKU detection — DW family prefixes (DWKK/DWSC/DWSW/DWWD/DWQW/DWJS/...).
// Returns the first match in upper-case so it can be queried as canonical.
const DW_SKU_RE = /\bDW[A-Z]{2}-?\d{4,7}\b/i;
function detectSku(text) {
if (typeof text !== 'string') return null;
const m = text.match(DW_SKU_RE);
return m ? m[0].toUpperCase().replace(/(DW[A-Z]{2})(\d)/, '$1-$2') : null;
}
// Per-catalog fallback queries — each returns a normalized row when the SKU
// is in that table. Tried in order after shopify_products misses. Schemas
// vary widely across catalogs (some have title, others have pattern_name +
// color_name; some have price_retail, others retail_price) so each catalog
// gets its own SELECT with hand-rolled normalization.
const SISTER_CATALOG_QUERIES = [
{
source: 'scalamandre_pillows',
sql: `SELECT
dw_sku AS sku,
shopify_product_id::text AS shopify_id,
NULL::text AS handle,
pattern_name || COALESCE(' · ' || color_name, '') AS title,
'Scalamandre'::text AS vendor,
CASE WHEN discontinued THEN 'archived' WHEN in_stock THEN 'active' ELSE 'inactive' END AS status,
image_url,
product_url,
price_retail::float AS retail_price,
last_scraped AS synced_at
FROM scalamandre_pillows WHERE dw_sku = $1 LIMIT 1`,
},
{
source: 'scalamandre_catalog',
sql: `SELECT
dw_sku AS sku,
shopify_product_id::text AS shopify_id,
NULL::text AS handle,
pattern_name || COALESCE(' · ' || color_name, '') AS title,
'Scalamandre'::text AS vendor,
CASE WHEN discontinued THEN 'archived' WHEN in_stock THEN 'active' ELSE 'inactive' END AS status,
image_url,
product_url,
price_retail::float AS retail_price,
last_scraped AS synced_at
FROM scalamandre_catalog WHERE dw_sku = $1 LIMIT 1`,
},
{
source: 'kravet_catalog',
sql: `SELECT
dw_sku AS sku,
NULL::text AS shopify_id,
NULL::text AS handle,
pattern_name || COALESCE(' · ' || color_name, '') AS title,
'Kravet'::text AS vendor,
COALESCE(status, 'unknown') AS status,
image_url,
NULL::text AS product_url,
price_retail::float AS retail_price,
last_scraped AS synced_at
FROM kravet_catalog WHERE dw_sku = $1 LIMIT 1`,
},
{
source: 'schumacher_catalog',
sql: `SELECT
dw_sku AS sku,
NULL::text AS shopify_id,
NULL::text AS handle,
pattern_name || COALESCE(' · ' || color_name, '') AS title,
'Schumacher'::text AS vendor,
'unknown'::text AS status,
image_url,
NULL::text AS product_url,
retail_price::float AS retail_price,
price_fetched_at AS synced_at
FROM schumacher_catalog WHERE dw_sku = $1 LIMIT 1`,
},
{
source: 'sisterparish_catalog',
sql: `SELECT
handle AS sku,
shopify_product_id::text AS shopify_id,
shopify_handle AS handle,
title,
vendor,
'unknown'::text AS status,
primary_image AS image_url,
source_url AS product_url,
NULLIF(variants->0->>'dw_retail', '')::float AS retail_price,
updated_at AS synced_at
FROM sisterparish_catalog
WHERE handle = $1 OR shopify_handle = $1 LIMIT 1`,
},
];
async function lookupSkuInPg(sku) {
if (!dwPool) return null;
// 1. Primary: shopify_products (the master synced table). Most DW SKUs
// that have been synced to Shopify live here with rich metadata.
try {
const r = await dwPool.query(
`SELECT shopify_id, handle, title, vendor, status, image_url,
created_at_shopify, updated_at_shopify, synced_at, tags,
'shopify_products'::text AS _source
FROM shopify_products
WHERE sku = $1 OR variant_sku = $1 OR dw_sku = $1
LIMIT 1`,
[sku]
);
if (r.rows[0]) return r.rows[0];
} catch (e) {
console.error('[dw pg/shopify_products]', e.message);
}
// 2. Fallback: sister catalogs in priority order. The DWSC-700* pillows
// Steve worked through earlier in the day live in scalamandre_pillows,
// NOT in shopify_products — without this fallback the lookup returns
// null and Big Red has no facts to phrase from.
for (const q of SISTER_CATALOG_QUERIES) {
try {
const r = await dwPool.query(q.sql, [sku]);
if (r.rows[0]) {
return { ...r.rows[0], _source: q.source };
}
} catch (e) {
// A single bad catalog (e.g. schema drift, missing column) shouldn't
// kill the whole fallback chain — log and continue.
console.error(`[dw pg/${q.source}]`, e.message);
}
}
return null;
}
// In-memory cache for live Shopify SKU lookups. 60s TTL — long enough to
// absorb a chatty back-and-forth about the same SKU within a single turn
// without re-hammering Shopify Admin GraphQL, short enough that a real
// inventory or price change is reflected within a minute. Map grows boundedly
// because we sweep entries older than TTL*4 every 5 min (cap insurance).
const SHOPIFY_TTL_MS = 60_000;
const SHOPIFY_CACHE = new Map(); // sku -> { result, fetched_at }
setInterval(() => {
const cutoff = Date.now() - SHOPIFY_TTL_MS * 4;
let dropped = 0;
for (const [k, v] of SHOPIFY_CACHE.entries()) {
if (v.fetched_at < cutoff) { SHOPIFY_CACHE.delete(k); dropped++; }
}
if (dropped > 0) console.log(`[shopify-cache] swept ${dropped} stale entries`);
}, 5 * 60 * 1000).unref();
async function lookupSkuLive(sku) {
if (!SHOPIFY_STORE || !SHOPIFY_TOKEN) return null;
const cached = SHOPIFY_CACHE.get(sku);
if (cached && (Date.now() - cached.fetched_at) < SHOPIFY_TTL_MS) {
return cached.result ? { ...cached.result, _cache_age_ms: Date.now() - cached.fetched_at } : null;
}
const query = `{
products(first: 1, query: "sku:${sku}") {
edges { node {
id legacyResourceId handle title vendor status onlineStoreUrl
featuredImage { url }
variants(first: 1) { edges { node {
sku price compareAtPrice inventoryQuantity availableForSale
}}}
}}
}
}`;
try {
const r = await fetch(`https://${SHOPIFY_STORE}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
});
if (!r.ok) { console.error('[shopify]', r.status, await r.text().catch(()=>'')); return null; }
const j = await r.json();
const node = j?.data?.products?.edges?.[0]?.node;
if (!node) {
// Cache a null too — repeated "no-such-SKU" queries within a minute
// shouldn't hammer Shopify any more than valid queries should.
SHOPIFY_CACHE.set(sku, { result: null, fetched_at: Date.now() });
return null;
}
const v = node.variants?.edges?.[0]?.node || {};
const result = {
shopify_id: node.legacyResourceId || node.id,
handle: node.handle,
title: node.title,
vendor: node.vendor,
status: node.status,
image: node.featuredImage?.url || '',
online_store_url: node.onlineStoreUrl || `https://${SHOPIFY_STORE.replace('.myshopify.com', '')}/products/${node.handle}`,
price: v.price ? parseFloat(v.price) : null,
compare_at_price: v.compareAtPrice ? parseFloat(v.compareAtPrice) : null,
inventory: v.inventoryQuantity ?? null,
available: v.availableForSale ?? null,
};
SHOPIFY_CACHE.set(sku, { result, fetched_at: Date.now() });
return result;
} catch (e) {
console.error('[shopify lookup]', e.message);
return null;
}
}
// Resolves a SKU end-to-end: PG snapshot for context + live Shopify for price,
// stamping the verification log so subsequent calls know the freshness.
async function resolveDwSku(sku) {
if (!sku) return null;
const [pgRow, live] = await Promise.all([lookupSkuInPg(sku), lookupSkuLive(sku)]);
if (live && live.price != null) {
stampVerification(sku, live.price, 'shopify-admin-live');
}
return {
sku,
pg: pgRow,
live,
verification: getVerification(sku),
};
}
// Frames go to /tmp (not ~/Projects/big-red/tmp) — keeping them outside any
// directory tree that contains a CLAUDE.md cuts claude-CLI cold-start from
// ~3min to ~25s because --add-dir won't trigger memory auto-discovery.
const TMP = path.join(os.tmpdir(), 'big-red-frames');
fs.mkdirSync(TMP, { recursive: true });
function detectLanHost() {
const ifaces = os.networkInterfaces();
for (const name of Object.keys(ifaces)) {
for (const i of ifaces[name] || []) {
if (i.family === 'IPv4' && !i.internal) return i.address;
}
}
return 'localhost';
}
const app = express();
app.use(express.json({ limit: '20mb' }));
app.get('/phone', (req, res) => res.sendFile(path.join(__dirname, 'public', 'phone.html')));
// 404-guard: never serve editor/snapshot backup files even if they leak into public/
app.use((req, res, next) => {
if (/\.(bak|pre-[\w.-]+|orig|swp|swo|tmp)(\?|$|\/)/i.test(req.path) ||
/\.bak\.[\w.-]+(\?|$|\/)/i.test(req.path) ||
/\.pre-[\w.-]+(\?|$|\/)/i.test(req.path)) {
return res.status(404).end();
}
next();
});
app.use(express.static(path.join(__dirname, 'public')));
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
// ---------- pairing ----------
const pairings = new Map(); // code -> { desktopWs, phoneWs, createdAt }
function newPairCode() {
return crypto.randomBytes(4).toString('hex');
}
app.get('/api/pair/new', async (req, res) => {
const code = newPairCode();
pairings.set(code, { desktopWs: null, phoneWs: null, createdAt: Date.now() });
const phoneUrl = `http://${LAN_HOST}:${PORT}/phone?code=${code}`;
const qrDataUrl = await QRCode.toDataURL(phoneUrl, { width: 320, margin: 1 });
res.json({ code, phoneUrl, qrDataUrl, lanHost: LAN_HOST, port: PORT });
});
// ---------- Anthropic SDK helpers ----------
// Was: shelling out to the `claude` CLI via spawn(). That broke in prod
// (Kamatera) because /root/.local/bin/claude doesn't exist there, so every
// /api/chat request 500'd with ENOENT. Now we call the Anthropic Messages API
// directly via @anthropic-ai/sdk. Same signature as the old runClaude so all
// three callsites (chat, analyze, warm-up) keep working unchanged.
// Backend switch. Default 'anthropic' (Messages API) keeps the original
// behaviour; set LLM_BACKEND=ollama to answer from a local/self-hosted Ollama
// model instead (no metered API spend, no Anthropic-credit dependency). On
// Kamatera prod we point OLLAMA_URL at the Mac2 Studio's Ollama exposed over
// the tailnet (tailscale serve --tcp). Anything else falls through to Anthropic.
const LLM_BACKEND = (process.env.LLM_BACKEND || 'anthropic').toLowerCase();
const OLLAMA_URL = (process.env.OLLAMA_URL || 'http://127.0.0.1:11434').replace(/\/+$/, '');
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen3:14b';
const OLLAMA_VISION_MODEL = process.env.OLLAMA_VISION_MODEL || 'qwen2.5vl:7b';
const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY || '';
if (LLM_BACKEND === 'anthropic' && !ANTHROPIC_KEY) {
console.warn('[big-red] WARNING: ANTHROPIC_API_KEY not set — /api/chat and /api/analyze will 500. Add it to .env or set LLM_BACKEND=ollama.');
}
if (LLM_BACKEND === 'ollama') {
console.log(`[big-red] LLM backend: ollama (${OLLAMA_URL}, text=${OLLAMA_MODEL}, vision=${OLLAMA_VISION_MODEL})`);
}
const anthropic = ANTHROPIC_KEY ? new Anthropic({ apiKey: ANTHROPIC_KEY }) : null;
// Local/self-hosted backend via Ollama's OpenAI-compatible endpoint. Same
// contract as runClaude: takes the assembled prompt, returns the reply text.
// Vision requests (imagePath set) route to a vision-capable model; text chat
// uses the main text model. Thinking models (qwen3) may wrap their reasoning in
// <think>…</think> — we strip it so clients only see the final answer.
async function runOllama(prompt, { imagePath = null, timeoutMs = 240_000 } = {}) {
const model = imagePath ? OLLAMA_VISION_MODEL : OLLAMA_MODEL;
let content;
if (imagePath) {
const buf = fs.readFileSync(imagePath);
const ext = path.extname(imagePath).slice(1).toLowerCase();
const media_type =
ext === 'png' ? 'image/png' :
ext === 'webp' ? 'image/webp' :
ext === 'gif' ? 'image/gif' :
'image/jpeg';
content = [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: `data:${media_type};base64,${buf.toString('base64')}` } },
];
} else {
content = prompt;
}
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), timeoutMs);
try {
const r = await fetch(`${OLLAMA_URL}/v1/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model, max_tokens: 1024, stream: false, messages: [{ role: 'user', content }] }),
signal: ac.signal,
});
if (!r.ok) {
const body = await r.text().catch(() => '');
throw new Error(`Ollama ${r.status}: ${body.slice(0, 200)}`);
}
const d = await r.json();
let text = d?.choices?.[0]?.message?.content || '';
text = text.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
return text;
} catch (e) {
if (e.name === 'AbortError') throw new Error('Ollama request timed out');
throw e;
} finally {
clearTimeout(t);
}
}
// Map the legacy `model: 'haiku'` shorthand (used by old CLI callers) to a
// current API model ID. Anything else is passed through verbatim so callers
// can ask for a specific snapshot.
function resolveModelId(m) {
if (!m || m === 'haiku') return 'claude-haiku-4-5-20251001';
if (m === 'sonnet') return 'claude-sonnet-4-5-20251022';
if (m === 'opus') return 'claude-opus-4-7-20251022';
return m;
}
function runClaude(prompt, { imagePath = null, model = 'haiku', timeoutMs = 240_000 } = {}) {
// Local backend takes over when selected — same return contract (reply text).
if (LLM_BACKEND === 'ollama') {
return runOllama(prompt, { imagePath, timeoutMs });
}
if (!anthropic) {
return Promise.reject(new Error('ANTHROPIC_API_KEY missing — cannot call Anthropic API'));
}
// Build the user message content. Plain text by default; if an image path
// is provided, base64-encode it and send a multi-part content array so the
// model can see the image (replaces the old `@path` CLI mechanic).
let content;
if (imagePath) {
const buf = fs.readFileSync(imagePath);
const ext = path.extname(imagePath).slice(1).toLowerCase();
const media_type =
ext === 'png' ? 'image/png' :
ext === 'webp' ? 'image/webp' :
ext === 'gif' ? 'image/gif' :
'image/jpeg';
content = [
{ type: 'image', source: { type: 'base64', media_type, data: buf.toString('base64') } },
{ type: 'text', text: prompt },
];
} else {
content = prompt;
}
const call = anthropic.messages.create({
model: resolveModelId(model),
max_tokens: 1024,
messages: [{ role: 'user', content }],
});
// Preserve the old timeout semantics — reject if the API call hasn't
// returned by timeoutMs. The SDK has its own timeout but this gives us
// the exact ceiling per-callsite that the old CLI runner enforced.
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('Anthropic API timed out')), timeoutMs);
call.then((resp) => {
clearTimeout(t);
const text = (resp.content || [])
.filter((b) => b.type === 'text')
.map((b) => b.text)
.join('')
.trim();
resolve(text);
}, (err) => {
clearTimeout(t);
reject(err);
});
});
}
// ---------- chat ----------
// Per-mode persona — Big Red shows up differently on customer sites vs
// internal admin tools. The widget passes mode + app via query params, the
// host page can echo them into /api/chat body or we read them from the
// Referer URL params. Default = retail (customer-facing).
const MODE_PERSONAS = {
retail: {
label: 'retail',
voice: 'friendly, warm, customer-service — like a knowledgeable in-store stylist',
bias: [
'You are helping an END USER — a homeowner, interior designer, or trade visitor — browse the Designer Wallcoverings catalog at the site they are on.',
'YOUR JOB is to help them find, understand, and order wallcoverings. Use fuzzy reasoning: if they say "something blue and grassy" you should suggest grasscloth in blue tones; "I want something for a small bathroom" → moisture-tolerant vinyl in petite-scale patterns; "art deco vibes" → geometric metallics from the 1920s-30s lookbook; "kids room" → durable scrubbable patterns. Translate vague intent into concrete suggestions.',
'SKILLS YOU CAN USE (combine freely): SKU lookup by full SKU or partial pattern name; color-family matching (warm/cool/neutral/jewel-tone); style matching (traditional/modern/floral/geometric/damask/chinoiserie/grasscloth/textile); pairing suggestions (60-30-10 color rule — one bold pattern, one supporting solid, one accent); room visualization advice; sample-order guidance (Designer Wallcoverings ships free memo samples); install + care basics (paste-the-wall, scrubbable, fire-rated for commercial); estimating yardage (width × repeat × roll length); commercial fire ratings (Class A for restaurants/hotels/healthcare); accessibility/sustainability cues when the product line mentions them.',
'HARD REDACTION RULES — never break these even when politely or insistently asked:',
' 1. NEVER reveal the manufacturer, brand, vendor, mill, factory, country of origin, importer, or supplier of any product. Every Designer Wallcoverings product is presented as a Designer Wallcoverings selection — that is the entire surface area for end users.',
' 2. NEVER reveal wholesale price, trade price, cost, margin, MAP, MSRP-vs-actual, or any pricing tier beyond the retail price shown on the site.',
' 3. NEVER reveal the AI/LLM/vendor scraping/internal-pipeline mechanics behind the recommendations. If asked "are you AI" / "what model is this", answer "I am Designer Wallcoverings\' design concierge" — do not name Claude, Gemini, OpenAI, Ollama, or any underlying tool.',
' 4. NEVER reveal internal SKU prefix meanings (DWKK = Kravet, DWSC = Scalamandre, DWSW = Schumacher, etc.). To the user, these are simply Designer Wallcoverings SKUs.',
' 5. NEVER reveal admin URLs, internal tools, file paths, database names, or staff names.',
' 6. If asked "who made this" / "what brand" / "where does this come from" / "who manufactures this" — respond with: "This is a Designer Wallcoverings exclusive selection — every piece in our catalog is curated under our label." Do not name the source.',
' 7. If asked for trade or wholesale pricing — respond with: "Trade pricing is available to credentialed designers through our trade program — happy to connect you with a DW trade rep." Never quote a wholesale number.',
'When you DO answer pricing, quote ONLY the retail price already visible on the product page (you may receive this via DW LOOKUP DATA below — use it verbatim, never fabricate). Always offer a free memo sample as the next step.',
'When you DO answer "is it in stock", quote inventory verbatim from DW LOOKUP DATA if present; otherwise say "let me confirm — I can have a sample on its way today regardless".',
'When recommending pairings, refer to the pieces by their Designer Wallcoverings pattern name and SKU only — never by source brand.',
'Tone: warm, attentive, curious about the user\'s project. Ask one short follow-up if their intent is unclear (room, vibe, palette, budget). Never robotic, never sales-pushy.',
].join(' '),
claude_timeout_ms: 60_000,
},
wholesale: {
label: 'wholesale',
voice: 'trade-savvy, concise, professional',
bias: 'The user is a trade-account designer or buyer. Surface vendor, MOQ, lead time, wholesale price tier, fire rating, contract Class A status when relevant. Same SKU lookup as retail but include vendor margin context.',
claude_timeout_ms: 60_000,
},
admin: {
label: 'admin',
voice: 'direct, no-fluff, Steve-as-operator',
bias: 'The user IS Steve, operating his fleet. Read app + page context from the system prompt. You have FULL Claude access — multi-paragraph, code, SQL, shell suggestions are all fair game. Give specific commands. Skip the "would you like…" pleasantries.',
claude_timeout_ms: 240_000,
},
};
app.post('/api/chat', async (req, res) => {
const { message, history, mode: bodyMode, app: bodyApp, page, title } = req.body || {};
if (!message || typeof message !== 'string') {
return res.status(400).json({ error: 'message required' });
}
// Honor mode from body, else from Referer query string, else retail.
let mode = bodyMode || 'retail';
let appSlug = bodyApp || '';
try {
if (!bodyMode && req.headers.referer) {
const refUrl = new URL(req.headers.referer);
mode = refUrl.searchParams.get('mode') || mode;
appSlug = refUrl.searchParams.get('app') || appSlug;
}
} catch {}
if (!MODE_PERSONAS[mode]) mode = 'retail';
const persona = MODE_PERSONAS[mode];
try {
// DW intelligence — if the message mentions a SKU, enrich the prompt
// with live Shopify + dw_unified data BEFORE handing to Claude. The model
// doesn't have to fetch anything — it just gets the facts and phrases them.
let dwContext = '';
let dwLookup = null;
const sku = detectSku(message);
if (sku) {
dwLookup = await resolveDwSku(sku);
if (dwLookup) {
// Retail mode is the customer-facing surface — the LLM must NEVER see
// the vendor name (it cannot reveal what it doesn't know). Wholesale +
// admin still get full vendor context for trade/operator workflows.
const redactVendor = mode === 'retail';
const brandLabel = redactVendor ? 'Designer Wallcoverings' : null;
const lines = [];
lines.push(`Looked up DW SKU ${sku}:`);
if (dwLookup.live) {
const lv = dwLookup.live;
const price = lv.price != null ? `$${lv.price.toFixed(2)}` : 'unknown';
const label = redactVendor ? brandLabel : lv.vendor;
lines.push(` Live Shopify (just verified): "${lv.title}" by ${label}, ${price}, status=${lv.status}, inventory=${lv.inventory ?? '?'}, available=${lv.available ?? '?'}`);
if (lv.online_store_url) lines.push(` URL: ${lv.online_store_url}`);
} else if (dwLookup.pg) {
const pg = dwLookup.pg;
const label = redactVendor ? brandLabel : pg.vendor;
lines.push(` PG snapshot (no live Shopify response): "${pg.title}" by ${label}, status=${pg.status}, last synced ${pg.synced_at}`);
} else {
lines.push(` NOT FOUND in shopify_products OR live Shopify search. SKU may be retired, mistyped, or in a different catalog.`);
}
if (dwLookup.verification && !redactVendor) {
// Verification source names internal scrapers / vendor URLs — admin/wholesale only.
const v = dwLookup.verification;
const ageHours = (Date.now() - new Date(v.verified_at).getTime()) / 3_600_000;
lines.push(` Price last verified ${ageHours.toFixed(1)}h ago via ${v.source} at $${v.price?.toFixed?.(2) ?? v.price}.`);
} else if (dwLookup.verification && redactVendor) {
const v = dwLookup.verification;
const ageHours = (Date.now() - new Date(v.verified_at).getTime()) / 3_600_000;
lines.push(` Price last confirmed ${ageHours.toFixed(1)}h ago at $${v.price?.toFixed?.(2) ?? v.price}.`);
}
const guard = redactVendor
? '[DW LOOKUP DATA — facts only. The brand label is "Designer Wallcoverings" — never substitute any other brand name. Do NOT invent numbers. Do NOT invent vendor/manufacturer info.]'
: '[DW LOOKUP DATA — these are facts; phrase them naturally for Steve, do NOT invent numbers]';
dwContext = '\n' + guard + '\n' + lines.join('\n') + '\n';
}
}
const ctx = (history || [])
.slice(-10)
.map((m) => `${m.role === 'user' ? 'User' : 'BigRed'}: ${m.content}`)
.join('\n');
const appLine = appSlug ? `Embedded in app: ${appSlug}` : '';
const pageLine = page ? `Page: ${page}${title ? ' — ' + title : ''}` : '';
const prompt = [
`You are "Big Red", a chat companion for Steve (founder, Designer Wallcoverings).`,
`MODE: ${persona.label} — voice ${persona.voice}.`,
`Persona bias: ${persona.bias}`,
appLine,
pageLine,
'You know the DW catalog — SKUs prefixed DWKK/DWSC/DWSW/DWWD/DWQW/DWJS/etc. map to live Shopify products. When a SKU is mentioned, you may have been given live lookup data below — use it verbatim, never fabricate prices.',
mode === 'admin'
? 'Reply directly and densely — Steve is operating, not chatting. SQL snippets, shell commands, and code are welcome. Skip the conversational throat-clearing.'
: 'Reply conversationally in 1-3 short sentences unless asked for detail. Plain prose only — no markdown, no lists, no code fences. The reply will be spoken aloud.',
ctx ? `\nConversation so far:\n${ctx}` : '',
dwContext,
`\nUser: ${message}`,
'\nBigRed:'
].filter(Boolean).join('\n');
const reply = await runClaude(prompt, { timeoutMs: persona.claude_timeout_ms });
res.json({ reply, dw: dwLookup, mode, app: appSlug });
} catch (e) {
res.status(500).json({ error: String(e.message || e) });
}
});
// Direct SKU lookup endpoint — useful from the widget UI or a CLI test.
app.get('/api/dw/sku/:sku', async (req, res) => {
const sku = String(req.params.sku || '').toUpperCase();
if (!DW_SKU_RE.test(sku)) {
return res.status(400).json({ error: 'not a DW SKU format', sku });
}
try {
const result = await resolveDwSku(sku);
res.json(result);
} catch (e) {
res.status(500).json({ error: String(e.message || e) });
}
});
// ---------- frame analysis ----------
app.post('/api/analyze', upload.single('frame'), async (req, res) => {
try {
let buf = null;
let prompt = req.body?.prompt || 'Describe what you see in this image. Be concise — 1 to 3 sentences. Plain prose, no lists.';
if (req.file?.buffer) {
buf = req.file.buffer;
} else if (req.body?.dataUrl && typeof req.body.dataUrl === 'string') {
const m = req.body.dataUrl.match(/^data:image\/(png|jpeg|jpg|webp);base64,(.+)$/);
if (!m) return res.status(400).json({ error: 'bad dataUrl' });
buf = Buffer.from(m[2], 'base64');
}
if (!buf) return res.status(400).json({ error: 'no image provided' });
const fname = `frame-${Date.now()}-${crypto.randomBytes(3).toString('hex')}.jpg`;
const fpath = path.join(TMP, fname);
fs.writeFileSync(fpath, buf);
const reply = await runClaude(prompt, { imagePath: fpath, timeoutMs: 300_000 });
// Best-effort cleanup after 60s so old frames don't pile up
setTimeout(() => { fs.unlink(fpath, () => {}); }, 60_000);
res.json({ reply });
} catch (e) {
res.status(500).json({ error: String(e.message || e) });
}
});
// ---------- avatar capture (uses phone-cam frame as Steve's face) ----------
const AVATAR_DIR = path.join(__dirname, 'public', 'avatar');
fs.mkdirSync(AVATAR_DIR, { recursive: true });
const AVATAR_PATH = path.join(AVATAR_DIR, 'steve.jpg');
app.get('/api/avatar', (req, res) => {
if (fs.existsSync(AVATAR_PATH)) {
res.json({ exists: true, url: `/avatar/steve.jpg?t=${fs.statSync(AVATAR_PATH).mtimeMs}` });
} else {
res.json({ exists: false });
}
});
app.post('/api/avatar/capture', upload.single('frame'), (req, res) => {
try {
let buf = null;
if (req.file?.buffer) buf = req.file.buffer;
else if (req.body?.dataUrl) {
const m = req.body.dataUrl.match(/^data:image\/(png|jpeg|jpg|webp);base64,(.+)$/);
if (m) buf = Buffer.from(m[2], 'base64');
}
if (!buf) return res.status(400).json({ error: 'no image' });
fs.writeFileSync(AVATAR_PATH, buf);
res.json({ ok: true, url: `/avatar/steve.jpg?t=${Date.now()}` });
} catch (e) {
res.status(500).json({ error: String(e.message || e) });
}
});
app.delete('/api/avatar', (req, res) => {
try { fs.unlinkSync(AVATAR_PATH); } catch (_) {}
res.json({ ok: true });
});
// ---------- TTS (ElevenLabs, Steve's voice) ----------
app.post('/api/tts', async (req, res) => {
const { text } = req.body || {};
if (!text || typeof text !== 'string') return res.status(400).json({ error: 'text required' });
if (!ELEVEN_KEY) return res.status(503).json({ error: 'ELEVENLABS_API_KEY not configured' });
try {
const url = `https://api.elevenlabs.io/v1/text-to-speech/${ELEVEN_VOICE}/stream?optimize_streaming_latency=2`;
const r = await fetch(url, {
method: 'POST',
headers: {
'xi-api-key': ELEVEN_KEY,
'accept': 'audio/mpeg',
'content-type': 'application/json'
},
body: JSON.stringify({
text,
model_id: 'eleven_turbo_v2_5',
voice_settings: { stability: 0.4, similarity_boost: 0.85, style: 0.2, use_speaker_boost: true }
})
});
if (!r.ok) {
const errText = await r.text();
return res.status(502).json({ error: `eleven ${r.status}: ${errText.slice(0, 300)}` });
}
res.setHeader('content-type', 'audio/mpeg');
res.setHeader('cache-control', 'no-store');
const reader = r.body.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
res.write(Buffer.from(value));
}
res.end();
} catch (e) {
res.status(500).json({ error: String(e.message || e) });
}
});
app.get('/api/health', (req, res) => {
res.json({
ok: true,
port: PORT,
lan: `http://${LAN_HOST}:${PORT}`,
voice: ELEVEN_VOICE,
elevenConfigured: Boolean(ELEVEN_KEY),
pairings: pairings.size
});
});
// ---------- HTTP + WS ----------
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws, req) => {
ws.role = null;
ws.code = null;
ws.on('message', (raw) => {
let msg;
try { msg = JSON.parse(raw.toString()); } catch { return; }
if (msg.type === 'hello') {
const code = String(msg.code || '');
const role = msg.role === 'phone' ? 'phone' : 'desktop';
if (!pairings.has(code)) {
// Allow desktop to register a fresh code; for phone require code to exist
if (role === 'phone') {
ws.send(JSON.stringify({ type: 'error', error: 'unknown pair code' }));
return ws.close();
}
pairings.set(code, { desktopWs: null, phoneWs: null, createdAt: Date.now() });
}
const p = pairings.get(code);
ws.role = role;
ws.code = code;
if (role === 'desktop') p.desktopWs = ws;
else p.phoneWs = ws;
ws.send(JSON.stringify({ type: 'hello-ok', role, code }));
// Notify counterpart
const other = role === 'desktop' ? p.phoneWs : p.desktopWs;
if (other && other.readyState === 1) {
other.send(JSON.stringify({ type: 'peer-joined', role }));
ws.send(JSON.stringify({ type: 'peer-ready' }));
}
return;
}
// Relay WebRTC signaling between paired peers
if (!ws.code || !pairings.has(ws.code)) return;
const p = pairings.get(ws.code);
const target = ws.role === 'desktop' ? p.phoneWs : p.desktopWs;
if (!target || target.readyState !== 1) return;
if (['offer', 'answer', 'ice', 'phone-status', 'desktop-status'].includes(msg.type)) {
target.send(JSON.stringify(msg));
}
});
ws.on('close', () => {
if (!ws.code || !pairings.has(ws.code)) return;
const p = pairings.get(ws.code);
if (ws.role === 'desktop' && p.desktopWs === ws) p.desktopWs = null;
if (ws.role === 'phone' && p.phoneWs === ws) p.phoneWs = null;
const other = ws.role === 'desktop' ? p.phoneWs : p.desktopWs;
if (other && other.readyState === 1) {
other.send(JSON.stringify({ type: 'peer-gone', role: ws.role }));
}
if (!p.desktopWs && !p.phoneWs) {
pairings.delete(ws.code);
}
});
});
// Reap stale pairings (>30 min, no peers)
setInterval(() => {
const now = Date.now();
for (const [code, p] of pairings) {
if (!p.desktopWs && !p.phoneWs && now - p.createdAt > 30 * 60_000) {
pairings.delete(code);
}
}
}, 60_000).unref();
server.listen(PORT, '0.0.0.0', () => {
console.log(`Big Red listening on :${PORT}`);
console.log(` desktop: http://localhost:${PORT}/`);
console.log(` phone: http://${LAN_HOST}:${PORT}/phone (use QR from desktop)`);
// Fire-and-forget warm-up so the first user request doesn't pay full cold-start.
runClaude('Reply with: ok', { timeoutMs: 240_000 })
.then(() => console.log('claude CLI warm'))
.catch((e) => console.log('warm-up skipped:', e.message));
});