← back to Slack Idea Board
server.js
272 lines
// slack-idea-board — polls Steve's Slack idea feed(s) and renders a 4-column
// triage board: [1] link + what the idea is · [2] good for your builds? why ·
// [3] good for an Agent Abrams build / project? · [4] next step (Build new
// project / Add new skill with repo / Create agent).
// AI enrichment is LOCAL Ollama ($0). Zero external deps (Node built-in http).
const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawn } = require('child_process');
const DIR = __dirname;
// Where "Build new project" sessions are launched from — the Claude web-dev accelerator.
const ACCEL_DIR = process.env.ACCEL_DIR || path.join(os.homedir(), 'Projects', 'claude-webdev-accelerator');
const ENV = loadEnv('/Users/macstudio3/Projects/slack-to-steve/.env'); // reuse the bot token + channels
const TOKEN = ENV.SLACK_BOT_TOKEN;
const CHANNELS = (ENV.SLACK_CHANNEL_IDS || ENV.SLACK_CHANNEL_ID || '').split(',').map(s => s.trim()).filter(Boolean);
const STEVE = ENV.STEVE_USER_ID;
const PORT = parseInt(process.env.PORT || '9820', 10);
const OLLAMA = process.env.OLLAMA_URL || 'http://localhost:11434';
const MODEL = process.env.OLLAMA_MODEL || 'hermes3:8b';
// Basic auth — internal Slack-feed board, gated by default at ideas.agentabrams.com
// like every other internal DW app (unified admin/DW2024!). Override the credential
// with BASIC_AUTH="user:pass"; set BASIC_AUTH="" to make it public again (Steve's call).
const BASIC_AUTH = process.env.BASIC_AUTH === undefined ? 'admin:DW2024!' : process.env.BASIC_AUTH; // gated-by-default (Steve 2026-07-25, was public since 07-23)
function authed(req) {
if (!BASIC_AUTH) return true; // auth disabled
const h = req.headers['authorization'] || '';
const m = h.match(/^Basic\s+(.+)$/i);
if (!m) return false;
let dec = ''; try { dec = Buffer.from(m[1], 'base64').toString('utf8'); } catch { return false; }
if (dec === 'dbrown:dust1989') return true; // second admin user (dbrown)
return dec === BASIC_AUTH;
}
const CACHE_FILE = path.join(DIR, 'data', 'enrich.json');
const CHAN_NAME = { 'C09SW7VQ0RK': 'claude-to-steve', 'C0BGQ2QLR35': 'claude-chat' };
const BUILD_CONTEXT = `Steve builds: AI agents & Claude Code skills (vendor scrapers, automation), Designer Wallcoverings e-commerce (Shopify, catalog pipelines, dashboards), web apps, marketplaces, and LOCAL-AI pipelines (Ollama/exo). He drops links (X/Twitter posts, GitHub repos, tools, demos about AI agents, coding, automation, design) as build inspiration.`;
// Context for the second fit dimension: does the idea suit an "Agent Abrams" build,
// or slot into one of Steve's existing projects?
const AA_CONTEXT = `"Agent Abrams" is Steve's public AI-agent brand + fleet at agentabrams.com — e.g. the AbramsEgo agent command center, builds.agentabrams.com (fleet dashboard + nightly recap films), the CNCP command center, and public directory sites (lawyers / doctors / animals / costa-rica / LA-eats / commercial real estate). "Any projects" means any of Steve's existing ~/Projects builds — DW Shopify + vendor scrapers, catalog viewers, marketing tools, dashboards, and monitoring canaries. When assessing this dimension, name ONE concrete Agent Abrams build or existing project the idea could power or slot into.`;
function loadEnv(p) { const o = {}; try { for (const l of fs.readFileSync(p, 'utf8').split('\n')) { const m = l.match(/^([A-Z0-9_]+)=(.*)$/); if (m) o[m[1]] = m[2].replace(/^['"]|['"]$/g, ''); } } catch {} return o; }
let CACHE = {}; try { CACHE = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } catch {}
function saveCache() { try { fs.writeFileSync(CACHE_FILE, JSON.stringify(CACHE, null, 2)); } catch {} }
async function slack(method, params = {}) {
const r = await fetch('https://slack.com/api/' + method + '?' + new URLSearchParams(params), { headers: { Authorization: 'Bearer ' + TOKEN } });
return r.json();
}
const URL_RE = /<(https?:\/\/[^>|\s]+)(?:\|[^>]*)?>|(?<![<|])(https?:\/\/[^\s|>]+)/;
function firstUrl(m) {
const mt = (m.text || '').match(URL_RE); if (mt) return mt[1] || mt[2];
for (const a of m.attachments || []) if (a.title_link || a.original_url) return a.title_link || a.original_url;
for (const f of m.files || []) if (f.url_private) return f.url_private;
return null;
}
function ideaAbout(m) {
const a = (m.attachments || [])[0] || {};
const title = a.title || '';
const text = (a.text || '').replace(/\s+/g, ' ').slice(0, 400);
const raw = (m.text || '').replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
return { title, text, note: raw };
}
async function ollamaEnrich(idea) {
const prompt = `${BUILD_CONTEXT}
${AA_CONTEXT}
A new item was posted to Steve's idea feed. Assess it FOR HIS BUILDS, and separately for whether it fits an AGENT ABRAMS build or any existing project.
URL: ${idea.url}
Link title: ${idea.title || '(none)'}
Link description: ${idea.text || '(none)'}
Steve's note: ${idea.note || '(none)'}
Respond with ONLY a JSON object, no prose:
{
"about": "<1-2 sentence plain-English summary of what this idea/link IS>",
"fit": "<Yes | Maybe | No>",
"fit_reason": "<1-2 sentences: is it good for Steve's builds and why / why not>",
"aa_fit": "<Yes | Maybe | No — is it good for an Agent Abrams build or any existing project?>",
"aa_target": "<name of ONE Agent Abrams build or existing project it best fits, or — if none>",
"aa_reason": "<1 sentence: which agentabrams.com build or existing project this could power, and how>",
"next_step": "<exactly one of: Build new project | Add new skill with repo | Create agent | Skip>",
"next_detail": "<1 sentence: concrete suggestion — e.g. a project/skill/agent name and what it does>"
}`;
const r = await fetch(OLLAMA + '/api/generate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: MODEL, prompt, stream: false, format: 'json', options: { temperature: 0.3 } }),
});
const j = await r.json();
const parsed = JSON.parse(j.response);
return {
about: parsed.about || idea.title || '', fit: parsed.fit || 'Maybe',
fit_reason: parsed.fit_reason || '',
aa_fit: parsed.aa_fit || 'Maybe', aa_target: parsed.aa_target || '—',
aa_reason: parsed.aa_reason || '',
next_step: parsed.next_step || 'Skip',
next_detail: parsed.next_detail || '',
};
}
async function collectIdeas() {
const ideas = [];
for (const cid of CHANNELS) {
let cursor = '', msgs = [];
for (let page = 0; page < 20; page++) { // paginate ALL history (up to 20×200)
const h = await slack('conversations.history', { channel: cid, limit: '200', cursor });
if (!h.ok) break;
msgs.push(...(h.messages || []));
cursor = h.response_metadata && h.response_metadata.next_cursor;
if (!cursor) break;
}
for (const m of msgs) {
if (m.subtype || m.bot_id) continue;
if (STEVE && m.user !== STEVE) continue;
const url = firstUrl(m); if (!url) continue;
const about = ideaAbout(m);
ideas.push({ id: cid + ':' + m.ts, ts: m.ts, channel: CHAN_NAME[cid] || cid, url, ...about,
when: new Date(parseFloat(m.ts) * 1000).toISOString() });
}
}
ideas.sort((a, b) => parseFloat(b.ts) - parseFloat(a.ts));
// DEDUPE by canonical URL (strip tracking params, www, trailing slash) — keep newest.
const seen = new Set(), unique = [];
for (const i of ideas) { const k = canon(i.url); if (seen.has(k)) continue; seen.add(k); unique.push(i); }
return unique;
}
function canon(u) {
try {
const x = new URL(u); x.hash = '';
for (const p of [...x.searchParams.keys()]) if (/^(fbclid|utm_|s|t|si|feature|ref|ref_src|ref_url|mibextid)$/i.test(p)) x.searchParams.delete(p);
return (x.hostname.replace(/^www\./, '') + x.pathname.replace(/\/$/, '') + (x.search || '')).toLowerCase();
} catch { return (u || '').toLowerCase(); }
}
// --- /api/build spawn guards ---------------------------------------------------------------
// A real /api/build call opens an iTerm2 tab running `claude --model opus`. That side effect is
// irreversible and unbounded, so it MUST be storm-proof: on 2026-07-23 a single `/5x` verification
// run (a "click every control" pass across 3 sweeps × real browsers) clicked ~34 spawn buttons per
// sweep and opened hundreds of terminals. Three independent guards now stand between a click and a
// spawn: (1) the server requires an explicit human `confirm:true` — a plain/automated POST only gets
// a preview, never a spawn; (2) a per-(id|step) cooldown dedupes rapid repeats; (3) a global
// rolling-window cap is a hard circuit breaker against any looping caller.
const BUILD_INFLIGHT = new Set(); // (id|step) keys currently spawning
const BUILD_LAST = new Map(); // (id|step) -> last real-launch epoch-ms (cooldown)
let BUILD_TIMES = []; // recent real-launch timestamps (rolling window)
const BUILD_COOLDOWN_MS = parseInt(process.env.BUILD_COOLDOWN_MS || '5000', 10); // same idea can't relaunch within 5s
const BUILD_WINDOW_MS = parseInt(process.env.BUILD_WINDOW_MS || '30000', 10); // rolling window size
const BUILD_MAX_PER_WINDOW= parseInt(process.env.BUILD_MAX_PER_WINDOW|| '5', 10); // max real spawns per window
let ENRICH_BUSY = false;
const CONCURRENCY = parseInt(process.env.ENRICH_CONCURRENCY || '4', 10); // parallel local-model calls
async function enrichOne(idea) {
try { CACHE[idea.id] = { ...await ollamaEnrich(idea), enriched_at: new Date().toISOString() }; }
catch (e) { CACHE[idea.id] = { about: idea.title || '', fit: '?', fit_reason: 'AI enrich failed: ' + e.message, aa_fit: '?', aa_target: '—', aa_reason: '', next_step: 'Skip', next_detail: '', error: true }; }
}
async function enrichNew(ideas, cap = 24) {
if (ENRICH_BUSY) return; ENRICH_BUSY = true;
try {
// Re-enrich records that predate the aa_fit dimension so the backlog backfills too.
const todo = ideas.filter(i => !CACHE[i.id] || CACHE[i.id].aa_fit === undefined).slice(0, cap);
for (let i = 0; i < todo.length; i += CONCURRENCY) {
await Promise.all(todo.slice(i, i + CONCURRENCY).map(enrichOne));
saveCache();
}
} finally { ENRICH_BUSY = false; }
}
// Background drainer: keep enriching the backlog independent of page polls.
async function drain() {
try { const ideas = await collectIdeas(); if (ideas.some(i => !CACHE[i.id] || CACHE[i.id].aa_fit === undefined)) await enrichNew(ideas, 24); } catch {}
setTimeout(drain, 2000);
}
// Reconstruct a SAFE Claude kickoff prompt server-side (never trust a client-supplied command).
function buildPrompt(idea, step) {
const from = idea && idea.title ? `${idea.title} — ${idea.url || ''}` : (idea ? (idea.url || '') : '');
const d = (idea && (idea.next_detail || idea.about || idea.title)) || 'this idea';
if (/skill/i.test(step)) return `/skill-creator Create a new skill with its own git repo: ${d}` + (from ? ` (from idea: ${from})` : '');
if (/agent/i.test(step)) return `Create a new agent: ${d}` + (from ? ` (from idea: ${from})` : '');
// Default = Build new project → route through the web-dev accelerator so it inherits the
// combined skills + agent playbook for rapidly prototyping & launching a high-value client build.
return [
`Use the Claude web-dev accelerator in this directory (see ACCELERATOR.md) to rapidly prototype and launch a new project.`,
from ? `Seed idea: ${from}.` : '',
`What to build: ${d}.`,
`Follow the accelerator playbook — pick the matching skills/agents, scaffold + gitify, stand up a local viewer, and stop for my go before anything is deployed or made public.`,
].filter(Boolean).join(' ');
}
// Launch a real Claude Code session in a new iTerm2 tab. Prompt is passed via a temp file so no
// shell/AppleScript escaping is needed and nothing from the client is interpolated into a command.
function launchClaude(prompt, cwd) {
const stamp = Date.now() + '-' + Math.random().toString(36).slice(2, 8);
const promptFile = path.join(os.tmpdir(), `idea-build-${stamp}.txt`);
const launcher = path.join(os.tmpdir(), `idea-build-${stamp}.sh`);
fs.writeFileSync(promptFile, prompt, 'utf8');
const dir = fs.existsSync(cwd) ? cwd : os.homedir();
fs.writeFileSync(launcher,
`#!/bin/bash\ncd ${JSON.stringify(dir)} || exit 1\nclaude --model opus "$(cat ${JSON.stringify(promptFile)})"\n`, { mode: 0o755 });
const osa = `tell application "iTerm2"
activate
tell current window to create tab with default profile
tell current session of current window to write text "bash ${launcher}"
end tell`;
return new Promise((resolve) => {
const p = spawn('osascript', ['-e', osa]);
let err = '';
p.stderr.on('data', d => err += d);
p.on('close', code => resolve({ ok: code === 0, code, err: err.trim(), promptFile }));
});
}
const server = http.createServer(async (req, res) => {
try {
if (!authed(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Idea Board"' }); return res.end('auth required'); }
if (req.url === '/favicon.ico' || req.url === '/apple-touch-icon.png') { res.writeHead(204); return res.end(); } // no icon → 204 (not a 404 console error)
if (req.method === 'POST' && req.url === '/api/build') {
let body = ''; for await (const c of req) { body += c; if (body.length > 1e5) break; }
let j = {}; try { j = JSON.parse(body || '{}'); } catch {}
const step = String(j.step || 'Build new project');
let idea = null;
if (j.id) { try { idea = (await collectIdeas()).find(i => i.id === j.id) || null; } catch {} }
const prompt = buildPrompt(idea, step);
const cwd = /skill|agent/i.test(step) ? os.homedir() : ACCEL_DIR;
// Guard 1 — NEVER spawn without an explicit human confirm. dryRun or a request that hasn't set
// confirm:true returns a preview only. This alone defeats any automated "click every control"
// pass (CTA/3x/5x), which POSTs without confirm and so can no longer open a single terminal.
if (j.dryRun || !j.confirm) {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ launched: false, dryRun: !!j.dryRun, needsConfirm: !j.dryRun, step, cwd, prompt }));
}
const key = (j.id || '') + '|' + step;
const now = Date.now();
// Guard 2 — per-idea in-flight + cooldown dedupe: drop a repeat of the same idea within the cooldown.
if (BUILD_INFLIGHT.has(key) || (BUILD_LAST.get(key) && now - BUILD_LAST.get(key) < BUILD_COOLDOWN_MS)) {
res.writeHead(429, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ launched: false, throttled: true, reason: 'duplicate or within cooldown', step }));
}
// Guard 3 — global rolling-window circuit breaker: cap total real spawns regardless of caller.
BUILD_TIMES = BUILD_TIMES.filter(t => now - t < BUILD_WINDOW_MS);
if (BUILD_TIMES.length >= BUILD_MAX_PER_WINDOW) {
res.writeHead(429, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ launched: false, throttled: true, reason: `rate limit: max ${BUILD_MAX_PER_WINDOW} launches / ${BUILD_WINDOW_MS / 1000}s`, step }));
}
BUILD_INFLIGHT.add(key); BUILD_TIMES.push(now);
let r;
try { r = await launchClaude(prompt, cwd); }
finally { BUILD_INFLIGHT.delete(key); BUILD_LAST.set(key, Date.now()); }
res.writeHead(r.ok ? 200 : 500, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ launched: r.ok, step, cwd, prompt, error: r.ok ? undefined : (r.err || 'osascript failed') }));
}
if (req.url.startsWith('/api/ideas')) {
const ideas = await collectIdeas();
enrichNew(ideas); // fire-and-forget; cached items return immediately
const out = ideas.map(i => ({ ...i, ...(CACHE[i.id] || { pending: true }) }));
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ count: out.length, pending: out.filter(x => x.pending).length, ideas: out, model: MODEL }));
}
const file = req.url === '/' || req.url === '' ? 'index.html' : req.url.split('?')[0].replace(/^\//, '');
const fp = path.join(DIR, 'public', path.basename(file));
if (fs.existsSync(fp)) {
res.writeHead(200, { 'Content-Type': file.endsWith('.html') ? 'text/html' : 'text/plain' });
return res.end(fs.readFileSync(fp));
}
res.writeHead(404); res.end('not found');
} catch (e) { res.writeHead(500); res.end(String(e.message)); }
});
server.listen(PORT, () => { console.log(`[slack-idea-board] http://localhost:${PORT} channels=${CHANNELS.join(',')} model=${MODEL}`); drain(); });