← back to Slack Idea Board

server.js

350 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;
// Steve's ask (2026-08-30): pull the EXACT posts from the #claude-to-steve channel specifically.
// Default to that one channel; override with IDEA_CHANNELS="id1,id2" to widen (e.g. add claude-chat).
const CHANNELS = (process.env.IDEA_CHANNELS || 'C09SW7VQ0RK').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 || 'qwen3:14b'; // hermes3:8b was never installed here; qwen3:14b is the fleet-standard local model
// 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, 1200); // show the full unfurl, not a stub
  const raw = (m.text || '').replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
  return { title, text, note: raw };
}
// The EXACT post text, verbatim — Slack link markup resolved to readable form and HTML
// entities decoded, but the words left as Steve typed/pasted them. Powers column 1.
function exactText(m) {
  let t = m.text || '';
  t = t.replace(/<(https?:\/\/[^>|]+)\|([^>]+)>/g, '$2')  // <url|label> -> label
       .replace(/<(https?:\/\/[^>|]+)>/g, '$1')           // <url> -> url
       .replace(/<@([A-Z0-9]+)>/g, '@$1')
       .replace(/<#[A-Z0-9]+\|([^>]+)>/g, '#$1');
  t = t.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
  return t.trim();
}

async function ollamaEnrich(idea) {
  const prompt = `${BUILD_CONTEXT}

${AA_CONTEXT}

Below is the EXACT post Steve dropped in his #claude-to-steve idea channel (usually an X/Twitter
post, GitHub repo, or tool he wants assessed as build inspiration). Judge it on exactly two things:
(1) is it a GOOD BUILD — worth building for Steve, yes or no and why; and
(2) WHAT is it good for — which of Steve's builds / Agent Abrams projects it best powers or slots into.

The exact post: ${idea.post_text || idea.note || '(none)'}
URL: ${idea.url || '(none)'}
Link title: ${idea.title || '(none)'}
Link description: ${idea.text || '(none)'}

Respond with ONLY a JSON object, no prose:
{
  "about": "<1-2 sentence plain-English summary of what this post/link IS>",
  "good_build": "<Yes | Maybe | No>",
  "good_build_reason": "<1-2 sentences: is it worth BUILDING for Steve, and why / why not>",
  "good_for": "<1-2 sentences naming the CONCRETE builds or Agent Abrams projects this is good for — name at least one real target (a project, a skill, an agent, or an existing ~/Projects build), or say 'nothing specific' if it truly fits nothing>",
  "good_for_target": "<the single BEST build/project/skill/agent name it fits, or — if none>",
  "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);
  // Local models sometimes return an array/object for a field (e.g. good_for as a list) — coerce
  // everything to a clean string so the front-end (which esc()s strings) never chokes.
  const str = v => Array.isArray(v) ? v.filter(Boolean).join('; ') : (v == null ? '' : String(v));
  const verdict = v => { v = str(v).trim(); return /^y/i.test(v) ? 'Yes' : /^n/i.test(v) ? 'No' : 'Maybe'; };
  return {
    about: str(parsed.about) || idea.title || '',
    good_build: verdict(parsed.good_build),
    good_build_reason: str(parsed.good_build_reason),
    good_for: str(parsed.good_for),
    good_for_target: str(parsed.good_for_target) || '—',
    next_step: str(parsed.next_step) || 'Skip',
    next_detail: str(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) {
      // EXACT-POSTS mode (Steve's ask): show every real post in the channel. Drop only Slack
      // system noise (channel join/leave/topic events); a bot/Claude message or a text-only
      // post with no link is now KEPT, not filtered out.
      if (m.subtype && !['bot_message', 'thread_broadcast', 'me_message'].includes(m.subtype)) continue;
      const url = firstUrl(m);
      const post_text = exactText(m);
      if (!post_text && !url) continue; // nothing to render
      const about = ideaAbout(m);
      ideas.push({ id: cid + ':' + m.ts, ts: m.ts, channel: CHAN_NAME[cid] || cid, url, post_text,
        author: m.user === STEVE ? 'Steve' : (m.bot_id ? 'Claude' : (m.user || 'member')), ...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.
  // Text-only posts (no url) can't collide on url, so they key on their own id.
  const seen = new Set(), unique = [];
  for (const i of ideas) { const k = i.url ? canon(i.url) : 'txt:' + i.id; 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   || '2000',  10); // same idea can't relaunch within 2s
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|| '20',    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 || '', good_build: '?', good_build_reason: 'AI enrich failed: ' + e.message, good_for: '', good_for_target: '—', 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 good_build dimension so the backlog backfills too.
    const todo = ideas.filter(i => !CACHE[i.id] || CACHE[i.id].good_build === 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].good_build === 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 as a new TAB inside ONE persistent "Idea Builds" iTerm2 window.
// Every build click adds a tab to the same window instead of spawning a fresh window each time
// (Steve, 2026-09-04). Prompt is passed via a temp file so no shell/AppleScript escaping is needed
// and nothing from the client is interpolated into a command.
//
// Window identity across independent osascript runs: iTerm2's window `id` is a stable integer for
// the window's lifetime, but each spawn() is its own process with no shared state — so we persist
// that id to a marker file. Each launch reads it, looks for a live window with that id, and either
// creates a tab in it (found) or creates a new window and records its id (missing / first launch).
const WIN_MARKER = path.join(os.tmpdir(), 'idea-build-window-id');
// Serialize launches: two rapid build clicks could both read a STALE WIN_MARKER before
// either wrote its new window id back, so each would spawn its own window. Chain the calls
// so every launch fully completes (and persists the window id) before the next reads it —
// the second click then reuses the same "Idea Builds" window as an extra tab.
let LAUNCH_LOCK = Promise.resolve();
function launchClaude(prompt, cwd) {
  const run = () => launchClaudeImpl(prompt, cwd);
  const result = LAUNCH_LOCK.then(run, run);
  LAUNCH_LOCK = result.then(() => {}, () => {}); // keep the chain alive past a rejection
  return result;
}
function launchClaudeImpl(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 });
  // Read the last window id. Inject as a BARE integer (not a quoted string) so the AppleScript
  // `id of w is savedId` compares int-to-int; anything non-numeric falls back to `missing value`.
  let savedId = '';
  try { savedId = fs.readFileSync(WIN_MARKER, 'utf8').trim(); } catch {}
  const idExpr = /^\d+$/.test(savedId) ? savedId : 'missing value';
  const osa = `tell application "iTerm2"
    activate
    set savedId to ${idExpr}
    set targetWin to missing value
    if savedId is not missing value then
      repeat with w in windows
        try
          if (id of w) is savedId then set targetWin to w
        end try
      end repeat
    end if
    if targetWin is missing value then
      set targetWin to (create window with default profile)
    else
      tell targetWin to create tab with default profile
    end if
    tell current session of current tab of targetWin to write text "bash ${launcher}"
    return (id of targetWin) as string
  end tell`;
  return new Promise((resolve) => {
    const p = spawn('osascript', ['-e', osa]);
    let out = '', err = '';
    p.stdout.on('data', d => out += d);
    p.stderr.on('data', d => err += d);
    p.on('close', code => {
      const winId = out.trim();
      if (code === 0 && /^\d+$/.test(winId)) { try { fs.writeFileSync(WIN_MARKER, winId, 'utf8'); } catch {} }
      resolve({ ok: code === 0, code, err: err.trim(), promptFile, winId });
    });
  });
}

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 }));
      }
      // Guard 0 — spawn is LOCALHOST-ONLY. The board is exposed to the internet via a Cloudflare
      // tunnel (basic-auth only), so a real `claude --model opus` terminal spawn on this Mac must
      // never be triggerable from the public URL. Preview/copy still works everywhere; only the
      // actual spawn is gated. The tunnel is identified by the proxy headers Cloudflare injects.
      const viaProxy = !!(req.headers['cf-ray'] || req.headers['x-forwarded-for'] || req.headers['x-forwarded-host']);
      if (viaProxy) {
        res.writeHead(403, { 'Content-Type': 'application/json' });
        return res.end(JSON.stringify({ launched: false, localOnly: true, reason: 'spawn is localhost-only — open http://localhost:9820 on the Mac to launch', step }));
      }
      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(); });