← back to Build Debrief

src/server.js

276 lines

// build-debrief — Express server :9756
// Serves the SPA and exposes:
//   GET  /api/projects                     — full inventory + persisted state
//   GET  /api/project/:id                  — single project (id = base64(path))
//   POST /api/project/:id/notes            — save pros/cons/notes
//   POST /api/project/:id/verdict          — set verdict (keep/kill/archive/pivot)
//   POST /api/project/:id/priority         — set priority 0..10
//   POST /api/project/:id/chat             — append a turn; spawns local LLM
//   POST /api/project/:id/action           — execute action (open-iterm, abramstasks, gitify)
//   POST /api/refresh-inventory            — re-walk ~/Projects/
//   POST /api/auto-pros-cons/:id           — local-LLM-generate pros/cons

const express = require('express');
const compression = require('compression');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawn, exec } = require('child_process');

const HOME = os.homedir();
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const DATA_DIR = path.join(__dirname, '..', 'data');
const PROJECTS_JSON = path.join(DATA_DIR, 'projects.json');

const PORT = parseInt(process.env.PORT || '9756', 10);

const OLLAMA_URL = process.env.OLLAMA_URL || 'http://192.168.1.133:11434';
const OLLAMA_FALLBACK = 'http://localhost:11434';
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen3:14b';

const app = express();
app.use(compression());
app.use(express.json({ limit: '256kb' }));
app.use(express.static(PUBLIC_DIR, { etag: false, maxAge: 0 }));

// Per-request timeout — prevents Ollama hangs from wedging /healthz et al.
app.use((req, res, next) => {
  const ms = req.path.startsWith('/api/') && (req.path.includes('/chat') || req.path.includes('/auto-pros-cons'))
    ? 65000  // LLM endpoints get 65s (matches AbortSignal.timeout 60s)
    : 8000;  // everything else 8s
  res.setTimeout(ms, () => {
    if (!res.headersSent) res.status(504).json({ error: 'timeout', path: req.path });
    res.socket?.destroy();
  });
  next();
});

// ── persistence ─────────────────────────────────────────────────────────────
function loadDb() {
  try { return JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf8')); }
  catch { return { generated_at: null, total: 0, projects: [] }; }
}
let db = loadDb();

function saveDb() {
  const tmp = PROJECTS_JSON + '.tmp';
  fs.writeFileSync(tmp, JSON.stringify(db, null, 2));
  fs.renameSync(tmp, PROJECTS_JSON);
}

function idOf(p) { return Buffer.from(p.path).toString('base64url'); }
function findById(id) {
  const target = Buffer.from(id, 'base64url').toString('utf8');
  return db.projects.find(p => p.path === target);
}

// ── routes ──────────────────────────────────────────────────────────────────
app.get('/healthz', (_req, res) => res.json({ ok: true, total: db.total, generated_at: db.generated_at }));

app.get('/api/projects', (_req, res) => {
  const list = db.projects.map(p => ({ ...p, id: idOf(p) }));
  res.json({ generated_at: db.generated_at, total: db.total, projects: list });
});

app.get('/api/project/:id', (req, res) => {
  const p = findById(req.params.id);
  if (!p) return res.status(404).json({ error: 'not found' });
  res.json({ ...p, id: idOf(p) });
});

app.post('/api/project/:id/notes', (req, res) => {
  const p = findById(req.params.id);
  if (!p) return res.status(404).json({ error: 'not found' });
  if (typeof req.body.pros === 'string') p.pros = req.body.pros.slice(0, 4000);
  if (typeof req.body.cons === 'string') p.cons = req.body.cons.slice(0, 4000);
  if (typeof req.body.notes === 'string') p.notes = req.body.notes.slice(0, 4000);
  saveDb();
  res.json({ ok: true });
});

app.post('/api/project/:id/verdict', (req, res) => {
  const p = findById(req.params.id);
  if (!p) return res.status(404).json({ error: 'not found' });
  const v = req.body.verdict;
  if (!['keep', 'kill', 'archive', 'pivot', null].includes(v)) {
    return res.status(400).json({ error: 'bad verdict' });
  }
  p.verdict = v;
  p.verdict_at = new Date().toISOString();
  saveDb();
  res.json({ ok: true, verdict: v });
});

app.post('/api/project/:id/priority', (req, res) => {
  const p = findById(req.params.id);
  if (!p) return res.status(404).json({ error: 'not found' });
  const n = Math.max(0, Math.min(10, parseInt(req.body.priority, 10) || 0));
  p.priority = n;
  saveDb();
  res.json({ ok: true, priority: n });
});

// ── Claude chat per project (uses local Ollama; later: claude CLI shell-out) ─
async function ollamaChat(messages) {
  const body = JSON.stringify({ model: OLLAMA_MODEL, messages, stream: false });
  for (const url of [OLLAMA_URL, OLLAMA_FALLBACK]) {
    try {
      const r = await fetch(`${url}/api/chat`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body,
        signal: AbortSignal.timeout(60000),
      });
      if (r.ok) {
        const j = await r.json();
        return { ok: true, url, content: j.message?.content || '' };
      }
    } catch (e) { /* fall through */ }
  }
  return { ok: false, content: '' };
}

function chatSystem(project) {
  return `You are Steve's pragmatic build advisor for the project "${project.name}".
Project facts:
  - path: ${project.path}
  - first commit: ${project.first_at}
  - last commit:  ${project.last_at} (${project.days_since}d ago)
  - commits:      ${project.commit_count}
  - size:         ${project.size_mb} MB
  - last subject: ${project.last_subject}
  - pkg:          ${project.pkg_description || project.pkg_name || '—'}
  - readme:       ${(project.readme_snippet || '—').slice(0, 300)}
  - user pros:    ${project.pros || '—'}
  - user cons:    ${project.cons || '—'}
  - verdict so far: ${project.verdict || '—'}
Write tightly. No fluff. Recommend concrete next moves.
If Steve types /kill, /pivot, /archive, /boost, /commit, /run, /test, /split, propose the specific shell or git commands to execute.`;
}

app.post('/api/project/:id/chat', async (req, res) => {
  const p = findById(req.params.id);
  if (!p) return res.status(404).json({ error: 'not found' });
  const userMsg = (req.body.message || '').slice(0, 4000);
  if (!userMsg) return res.status(400).json({ error: 'empty message' });

  p.chat = p.chat || [];
  p.chat.push({ role: 'user', content: userMsg, at: new Date().toISOString() });

  const messages = [
    { role: 'system', content: chatSystem(p) },
    ...p.chat.slice(-10).map(t => ({ role: t.role, content: t.content })),
  ];

  const r = await ollamaChat(messages);
  if (!r.ok) {
    p.chat.push({ role: 'assistant', content: '(LLM unreachable. Try again or open a Claude tab via the Open-iTerm action.)', at: new Date().toISOString(), error: true });
    saveDb();
    return res.status(502).json({ error: 'llm offline', chat: p.chat });
  }
  p.chat.push({ role: 'assistant', content: r.content, at: new Date().toISOString() });
  saveDb();
  res.json({ ok: true, chat: p.chat });
});

app.post('/api/project/:id/auto-pros-cons', async (req, res) => {
  const p = findById(req.params.id);
  if (!p) return res.status(404).json({ error: 'not found' });
  const messages = [
    { role: 'system', content: 'You produce two short bulleted lists. Return strict JSON only: {"pros":["..."],"cons":["..."]}. 3-5 bullets each, max 12 words per bullet.' },
    { role: 'user', content: `Project "${p.name}" — facts:\n  size=${p.size_mb}MB, commits=${p.commit_count}, last=${p.days_since}d ago\n  desc: ${p.pkg_description || '—'}\n  readme: ${p.readme_snippet || '—'}\n\nGive me PROS (why this is a good idea) and CONS (why this is a bad idea).` },
  ];
  const r = await ollamaChat(messages);
  if (!r.ok) return res.status(502).json({ error: 'llm offline' });
  let parsed = { pros: [], cons: [] };
  try {
    const m = r.content.match(/\{[\s\S]*\}/);
    if (m) parsed = JSON.parse(m[0]);
  } catch {}
  p.pros = (parsed.pros || []).map(s => `• ${s}`).join('\n');
  p.cons = (parsed.cons || []).map(s => `• ${s}`).join('\n');
  saveDb();
  res.json({ ok: true, pros: p.pros, cons: p.cons });
});

// ── actions: open in iTerm, abramstasks fan-out, gitify ─────────────────────
function openInIterm(projectPath, opts = {}) {
  // Open a new iTerm tab cd'd into project; optionally run a command.
  const cmd = opts.cmd ? ` && ${opts.cmd}` : '';
  const cdShell = `cd ${JSON.stringify(projectPath)}${cmd}; exec $SHELL`;
  // Use the same `task` zsh fn Steve already has.
  const escaped = cdShell.replace(/'/g, `'\\''`);
  const zshCmd = `task ${JSON.stringify(opts.title || path.basename(projectPath))} '${escaped}'`;
  return new Promise((resolve, reject) => {
    exec(`/bin/zsh -ic ${JSON.stringify(zshCmd)}`, { timeout: 15000 }, (err, stdout, stderr) => {
      if (err) return reject(stderr || err.message);
      resolve({ ok: true, stderr });
    });
  });
}

app.post('/api/project/:id/action', async (req, res) => {
  const p = findById(req.params.id);
  if (!p) return res.status(404).json({ error: 'not found' });
  const action = req.body.action;
  const args = req.body.args || {};

  try {
    if (action === 'open-iterm') {
      await openInIterm(p.path, { title: p.name });
      return res.json({ ok: true });
    }
    if (action === 'open-claude') {
      const prompt = (args.prompt || `Resume work on ${p.name}. Project at ${p.path}.`).replace(/"/g, '\\"');
      await openInIterm(p.path, { title: p.name, cmd: `claude --model opus "${prompt}"` });
      return res.json({ ok: true });
    }
    if (action === 'gitify') {
      if (fs.existsSync(path.join(p.path, '.git'))) {
        return res.json({ ok: false, reason: 'already a git repo' });
      }
      await new Promise((resolve, reject) => {
        exec(`cd ${JSON.stringify(p.path)} && git init -q && git add -A && git -c user.email='steve@designerwallcoverings.com' -c user.name='Steve' commit -m 'initial scaffold' -q`,
          { timeout: 30000 }, (err) => err ? reject(err) : resolve());
      });
      return res.json({ ok: true });
    }
    if (action === 'reveal') {
      exec(`open ${JSON.stringify(p.path)}`);
      return res.json({ ok: true });
    }
    if (action === 'abramstasks') {
      const tasks = (args.tasks || []).filter(s => s && s.trim()).slice(0, 10);
      if (tasks.length === 0) return res.status(400).json({ error: 'no tasks' });
      const heredoc = tasks.join('\n');
      const cmd = `/bin/zsh -ic 'abramstasks ${JSON.stringify(p.name)} -' <<'EOF'\n${heredoc}\nEOF`;
      await new Promise((resolve, reject) => {
        exec(cmd, { timeout: 60000 }, (err, stdout, stderr) => err ? reject(stderr || err.message) : resolve());
      });
      return res.json({ ok: true, spawned: tasks.length });
    }
    return res.status(400).json({ error: 'unknown action' });
  } catch (e) {
    return res.status(500).json({ error: String(e) });
  }
});

app.post('/api/refresh-inventory', (_req, res) => {
  const child = spawn('node', [path.join(__dirname, '..', 'scripts', 'inventory.js')], {
    cwd: path.join(__dirname, '..'),
  });
  let out = '';
  child.stdout.on('data', d => out += d.toString());
  child.stderr.on('data', d => out += d.toString());
  child.on('close', code => {
    db = loadDb();
    res.json({ ok: code === 0, code, log: out });
  });
});

// ── boot ────────────────────────────────────────────────────────────────────
app.listen(PORT, '0.0.0.0', () => {
  console.log(`[build-debrief] http://0.0.0.0:${PORT}`);
  console.log(`  inventory: ${db.total} projects (generated ${db.generated_at})`);
});