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