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