[object Object]

← back to Slack Idea Board

slack-idea-board: 3-column Slack-feed idea triage (link/about · fit+why · next step), local Ollama enrichment

edba999362eb490908ec720f6ccd838506ae4393 · 2026-07-13 00:35:57 -0700 · Steve Abrams

Files touched

Diff

commit edba999362eb490908ec720f6ccd838506ae4393
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 13 00:35:57 2026 -0700

    slack-idea-board: 3-column Slack-feed idea triage (link/about · fit+why · next step), local Ollama enrichment
---
 .gitignore        |   4 ++
 public/index.html |  93 ++++++++++++++++++++++++++++++++++++++++
 server.js         | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 223 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e05ae32
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+data/
+*.log
+.DS_Store
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..f57c194
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Idea Board · Slack feed</title>
+<style>
+  :root{--bg:#0f1117;--panel:#161922;--line:#262b38;--text:#e6e8ee;--dim:#9aa3b2;--gold:#c9a227;
+    --yes:#2ecc71;--maybe:#e0b000;--no:#e05a5a;--chip:#1d2230;}
+  *{box-sizing:border-box}
+  body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
+  header{position:sticky;top:0;z-index:5;background:linear-gradient(#0f1117,#0f1117ee);border-bottom:1px solid var(--line);
+    padding:14px 20px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
+  h1{font-size:16px;margin:0;letter-spacing:.3px}
+  h1 b{color:var(--gold)}
+  .meta{color:var(--dim);font-size:12px}
+  .spacer{flex:1}
+  .live{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)}
+  .dot{width:8px;height:8px;border-radius:50%;background:var(--yes);box-shadow:0 0 0 0 var(--yes);animation:p 2s infinite}
+  @keyframes p{0%{box-shadow:0 0 0 0 rgba(46,204,113,.5)}70%{box-shadow:0 0 0 7px rgba(46,204,113,0)}100%{box-shadow:0 0 0 0 rgba(46,204,113,0)}}
+  table{width:100%;border-collapse:collapse}
+  thead th{position:sticky;top:57px;background:#12151d;text-align:left;font-size:11px;letter-spacing:.08em;
+    text-transform:uppercase;color:var(--dim);padding:10px 16px;border-bottom:1px solid var(--line)}
+  tbody td{padding:14px 16px;border-bottom:1px solid var(--line);vertical-align:top}
+  tbody tr:hover{background:#12151d}
+  .col1{width:38%} .col2{width:30%} .col3{width:32%}
+  a.link{color:#7db4ff;text-decoration:none;font-weight:600;word-break:break-word}
+  a.link:hover{text-decoration:underline}
+  .about{color:var(--text);margin-top:5px}
+  .note{color:var(--dim);font-style:italic;margin-top:4px;font-size:12.5px}
+  .when{color:var(--dim);font-size:11px;margin-top:6px}
+  .chan{display:inline-block;font-size:10px;color:var(--dim);border:1px solid var(--line);border-radius:999px;padding:1px 7px;margin-left:6px}
+  .fit{display:inline-block;font-weight:700;border-radius:6px;padding:2px 9px;font-size:12px}
+  .fit.Yes{background:rgba(46,204,113,.15);color:var(--yes)} .fit.Maybe{background:rgba(224,176,0,.15);color:var(--maybe)}
+  .fit.No{background:rgba(224,90,90,.15);color:var(--no)} .fit\.\?{background:var(--chip);color:var(--dim)}
+  .reason{margin-top:6px;color:var(--text)}
+  .step{display:inline-block;border-radius:6px;padding:3px 10px;font-size:12px;font-weight:600;border:1px solid var(--line);background:var(--chip)}
+  .step.build{border-color:#3b6fd0;color:#8fb4ff} .step.skill{border-color:#8a5cd0;color:#c3a6ff}
+  .step.agent{border-color:#c9a227;color:#e6c65a} .step.skip{color:var(--dim)}
+  .detail{margin-top:6px;color:var(--dim)}
+  .pending{color:var(--dim);font-style:italic}
+  .empty{padding:40px;text-align:center;color:var(--dim)}
+</style>
+</head>
+<body>
+<header>
+  <h1><b>Idea Board</b> — Slack feed triage</h1>
+  <span class="meta" id="stat">loading…</span>
+  <span class="spacer"></span>
+  <span class="live"><span class="dot"></span> live · auto-refresh 20s</span>
+</header>
+<table>
+  <thead><tr>
+    <th class="col1">1 · Link &amp; what the idea is</th>
+    <th class="col2">2 · Good for your builds? Why</th>
+    <th class="col3">3 · Next step</th>
+  </tr></thead>
+  <tbody id="rows"><tr><td colspan="3" class="empty">Loading your feed…</td></tr></tbody>
+</table>
+<script>
+const stepClass = s => /build new project/i.test(s)?'build':/skill/i.test(s)?'skill':/agent/i.test(s)?'agent':'skip';
+const esc = s => (s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+function fmt(iso){try{return new Date(iso).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}catch{return iso}}
+async function load(){
+  try{
+    const r = await fetch('/api/ideas'); const d = await r.json();
+    document.getElementById('stat').textContent =
+      `${d.count} ideas · ${d.pending} analyzing · model ${d.model}`;
+    const rows = document.getElementById('rows');
+    if(!d.ideas.length){ rows.innerHTML='<tr><td colspan=3 class=empty>No links posted yet. Drop an x.com/GitHub link in #claude-to-steve or #claude-chat.</td></tr>'; return; }
+    rows.innerHTML = d.ideas.map(i=>{
+      const host = (()=>{try{return new URL(i.url).hostname.replace('www.','')}catch{return i.url}})();
+      const c2 = i.pending ? '<span class="pending">analyzing…</span>'
+        : `<span class="fit ${i.fit}">${esc(i.fit)}</span><div class="reason">${esc(i.fit_reason)}</div>`;
+      const c3 = i.pending ? '<span class="pending">…</span>'
+        : `<span class="step ${stepClass(i.next_step)}">${esc(i.next_step)}</span><div class="detail">${esc(i.next_detail)}</div>`;
+      return `<tr>
+        <td class="col1">
+          <a class="link" href="${esc(i.url)}" target="_blank" rel="noopener noreferrer">${esc(i.title||host)} ↗</a>
+          <span class="chan">${esc(i.channel)}</span>
+          <div class="about">${esc(i.about||i.text||'')}</div>
+          ${i.note?`<div class="note">“${esc(i.note.slice(0,140))}”</div>`:''}
+          <div class="when">🕓 ${fmt(i.when)}</div>
+        </td>
+        <td class="col2">${c2}</td>
+        <td class="col3">${c3}</td>
+      </tr>`;
+    }).join('');
+  }catch(e){ document.getElementById('stat').textContent='error: '+e.message; }
+}
+load(); setInterval(load, 20000);
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..7f45c0c
--- /dev/null
+++ b/server.js
@@ -0,0 +1,126 @@
+// slack-idea-board — polls Steve's Slack idea feed(s) and renders a 3-column
+// triage board: [1] link + what the idea is · [2] good for your builds? why ·
+// [3] 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 DIR = __dirname;
+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';
+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.`;
+
+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}
+
+A new item was posted to Steve's idea feed. Assess it FOR HIS BUILDS.
+
+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>",
+  "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 || '', next_step: parsed.next_step || 'Skip',
+    next_detail: parsed.next_detail || '',
+  };
+}
+
+async function collectIdeas() {
+  const ideas = [];
+  for (const cid of CHANNELS) {
+    const h = await slack('conversations.history', { channel: cid, limit: '200' });
+    if (!h.ok) continue;
+    for (const m of h.messages || []) {
+      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));
+  return ideas;
+}
+
+let ENRICH_BUSY = false;
+async function enrichNew(ideas, cap = 6) {
+  if (ENRICH_BUSY) return; ENRICH_BUSY = true;
+  let done = 0;
+  try {
+    for (const idea of ideas) {
+      if (CACHE[idea.id]) continue;
+      if (done >= cap) break;
+      try { CACHE[idea.id] = { ...await ollamaEnrich(idea), enriched_at: new Date().toISOString() }; done++; saveCache(); }
+      catch (e) { CACHE[idea.id] = { about: idea.title || '', fit: '?', fit_reason: 'AI enrich failed: ' + e.message, next_step: 'Skip', next_detail: '', error: true }; saveCache(); }
+    }
+  } finally { ENRICH_BUSY = false; }
+}
+
+const server = http.createServer(async (req, res) => {
+  try {
+    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}`));

(oldest)  ·  back to Slack Idea Board  ·  idea-board: dedupe by URL + full pagination + Actionable/Yes 8231daf →