Skill: davidrudduck/slack-context-memory (version 2.0.0)

--- name: slack-context-memory description: Conversation summarization and context compaction for Slack channels. Reduces context window usage by 70-99% while preserving key information through semantic summaries. --- # Slack Context Memory Compress Slack conversation history into searchable summaries for context-efficient sessions. ## Problem Solved Clawdbot sessions lose context as conversation history grows. This skill: 1. **Detects conversation boundaries** in Slack message history 2. **Generates structured summaries** (TL;DR, decisions, topics, outcome) 3. **Stores summaries with embeddings** for semantic search 4. **Compacts context** - replace 1000s of messages with a few summaries 5. **Enables semantic retrieval** - find relevant past discussions ## Quick Start ```bash # Setup database schema cd /home/david/clawd/scripts/slack-context-memory node setup-schema.js # View compacted context for a channel node context-compactor.js C0ABGHA7CBE # Compare original vs compacted size node context-compactor.js C0ABGHA7CBE --compare # Search for relevant conversations node context-compactor.js --query "email newsletter filtering" ``` ## Token Savings | Channel | Original | Compacted | Savings | |---------|----------|-----------|---------| | #accounts (1000 msgs) | 112K tokens | 951 tokens | **99.2%** | | #homeassistant (50 msgs) | 3.1K tokens | 911 tokens | **70.8%** | ## Components ### Conversation Detection ```bash node detect-conversations.js <channel_id> node detect-conversations.js --all ``` ### Context Compaction ```bash node context-compactor.js <channel_id> --recent 20 node context-compactor.js <channel_id> --compare node context-compactor.js --query "search term" ``` ### Search ```bash node search-conversations.js semantic "query" node search-conversations.js text "query" node search-conversations.js recent --limit 10 ``` ## Requirements - PostgreSQL database with pgvector - Node.js 18+ - Slack message history in database ## Database Schema The `conversation_summaries` table stores: - `tldr` - 1-2 sentence summary - `full_summary` - Detailed summary - `key_decisions` - Array of decisions made - `topics` - Array of topics discussed - `outcome` - resolved/ongoing/needs-follow-up - `embedding` - Vector for semantic search (1024-dim) --- Built for Clawdbot 🦞 | 2026-01-28

path: context-compactor.js
content: #!/usr/bin/env node
/**
 * Context Compactor for Clawdbot Sessions
 * 
 * Replaces old messages with summaries to reduce context window usage.
 * Call this when loading session context to get a compacted version.
 * 
 * Usage:
 *   node context-compactor.js <channel_id> [--recent N] [--query "search term"]
 * 
 * Output: Compacted context ready for injection into session
 */

import pg from 'pg';

const { Client } = pg;

const DB_URL = process.env.SLACK_DB_URL;
if (!DB_URL) throw new Error('SLACK_DB_URL environment variable required');

/**
 * Get relevant summaries for a channel or query
 */
async function getRelevantSummaries(options = {}) {
  const client = new Client({ connectionString: DB_URL });
  await client.connect();

  try {
    let query, params;

    if (options.query) {
      // Search-based retrieval
      query = `
        SELECT 
          cs.channel_id,
          sc.name as channel_name,
          cs.start_ts,
          cs.end_ts,
          cs.message_count,
          cs.tldr,
          cs.full_summary,
          cs.key_decisions,
          cs.topics,
          cs.outcome,
          ts_rank(to_tsvector('english', cs.tldr || ' ' || cs.full_summary), 
                  plainto_tsquery('english', $1)) as relevance
        FROM conversation_summaries cs
        LEFT JOIN slack_channels sc ON cs.channel_id = sc.id
        WHERE to_tsvector('english', cs.tldr || ' ' || cs.full_summary) 
              @@ plainto_tsquery('english', $1)
        ORDER BY relevance DESC
        LIMIT $2
      `;
      params = [options.query, options.limit || 5];
    } else if (options.channelId) {
      // Channel-based retrieval
      query = `
        SELECT 
          cs.channel_id,
          sc.name as channel_name,
          cs.start_ts,
          cs.end_ts,
          cs.message_count,
          cs.tldr,
          cs.full_summary,
          cs.key_decisions,
          cs.topics,
          cs.outcome,
          1.0 as relevance
        FROM conversation_summaries cs
        LEFT JOIN slack_channels sc ON cs.channel_id = sc.id
        WHERE cs.channel_id = $1
        ORDER BY cs.start_ts DESC
        LIMIT $2
      `;
      params = [options.channelId, options.limit || 10];
    } else {
      // Recent across all channels
      query = `
        SELECT 
          cs.channel_id,
          sc.name as channel_name,
          cs.start_ts,
          cs.end_ts,
          cs.message_count,
          cs.tldr,
          cs.full_summary,
          cs.key_decisions,
          cs.topics,
          cs.outcome,
          1.0 as relevance
        FROM conversation_summaries cs
        LEFT JOIN slack_channels sc ON cs.channel_id = sc.id
        ORDER BY cs.end_ts DESC
        LIMIT $1
      `;
      params = [options.limit || 10];
    }

    const result = await client.query(query, params);
    return result.rows;

  } finally {
    await client.end();
  }
}

/**
 * Get recent messages from Slack database
 */
async function getRecentMessages(channelId, limit = 20) {
  const client = new Client({ connectionString: DB_URL });
  await client.connect();

  try {
    const result = await client.query(`
      SELECT 
        m.ts,
        m.user_id,
        su.name as user_name,
        m.text,
        m.timestamp
      FROM slack_messages m
      LEFT JOIN slack_users su ON m.user_id = su.id
      WHERE m.channel_id = $1
      ORDER BY m.timestamp DESC
      LIMIT $2
    `, [channelId, limit]);

    return result.rows.reverse(); // Chronological order
  } finally {
    await client.end();
  }
}

/**
 * Format summaries for context injection
 */
function formatSummariesForContext(summaries) {
  if (summaries.length === 0) {
    return '';
  }

  let output = '📚 **Previous Conversations (Summarized)**\n\n';

  for (const s of summaries) {
    const date = new Date(s.start_ts).toLocaleDateString();
    const channel = s.channel_name || s.channel_id;
    
    output += `**#${channel}** (${date}, ${s.message_count} messages)\n`;
    output += `${s.tldr}\n`;
    
    if (s.key_decisions && s.key_decisions.length > 0) {
      output += `Decisions: ${s.key_decisions.slice(0, 3).join('; ')}\n`;
    }
    
    if (s.outcome && s.outcome !== 'unknown') {
      output += `Status: ${s.outcome}\n`;
    }
    
    output += '\n';
  }

  return output;
}

/**
 * Format recent messages for context
 */
function formatRecentMessages(messages, channelName) {
  if (messages.length === 0) {
    return '';
  }

  let output = `\n💬 **Recent Messages** (#${channelName}, last ${messages.length})\n\n`;

  for (const m of messages) {
    const time = new Date(m.timestamp).toLocaleTimeString('en-US', { 
      hour: '2-digit', 
      minute: '2-digit' 
    });
    const user = m.user_name || m.user_id;
    const text = m.text?.substring(0, 200) || '';
    output += `[${time}] ${user}: ${text}\n`;
  }

  return output;
}

/**
 * Build compacted context for a session
 */
async function buildCompactedContext(options = {}) {
  const {
    channelId,
    query,
    recentMessageCount = 20,
    summaryLimit = 5,
    includeRecentMessages = true
  } = options;

  // Get relevant summaries
  const summaries = await getRelevantSummaries({
    channelId,
    query,
    limit: summaryLimit
  });

  // Get recent messages if requested
  let recentMessages = [];
  let channelName = channelId;
  
  if (includeRecentMessages && channelId) {
    recentMessages = await getRecentMessages(channelId, recentMessageCount);
    
    // Get channel name
    const client = new Client({ connectionString: DB_URL });
    await client.connect();
    const nameResult = await client.query(
      'SELECT name FROM slack_channels WHERE id = $1', 
      [channelId]
    );
    channelName = nameResult.rows[0]?.name || channelId;
    await client.end();
  }

  // Build compacted context
  const summaryContext = formatSummariesForContext(summaries);
  const recentContext = formatRecentMessages(recentMessages, channelName);

  // Calculate token estimates (rough: 1 token ≈ 4 chars)
  const summaryTokens = Math.ceil(summaryContext.length / 4);
  const recentTokens = Math.ceil(recentContext.length / 4);
  const totalTokens = summaryTokens + recentTokens;

  return {
    context: summaryContext + recentContext,
    stats: {
      summaryCount: summaries.length,
      recentMessageCount: recentMessages.length,
      estimatedTokens: {
        summaries: summaryTokens,
        recent: recentTokens,
        total: totalTokens
      }
    },
    summaries,
    recentMessages
  };
}

/**
 * Compare original vs compacted context size
 */
async function compareContextSizes(channelId) {
  const client = new Client({ connectionString: DB_URL });
  await client.connect();

  try {
    // Get all messages for channel
    const allMessages = await client.query(`
      SELECT text FROM slack_messages WHERE channel_id = $1
    `, [channelId]);

    const originalText = allMessages.rows.map(r => r.text).join('\n');
    const originalTokens = Math.ceil(originalText.length / 4);

    // Get compacted version
    const compacted = await buildCompactedContext({
      channelId,
      recentMessageCount: 20,
      summaryLimit: 5
    });

    const savings = originalTokens - compacted.stats.estimatedTokens.total;
    const savingsPercent = ((savings / originalTokens) * 100).toFixed(1);

    return {
      original: {
        messages: allMessages.rows.length,
        estimatedTokens: originalTokens
      },
      compacted: {
        summaries: compacted.stats.summaryCount,
        recentMessages: compacted.stats.recentMessageCount,
        estimatedTokens: compacted.stats.estimatedTokens.total
      },
      savings: {
        tokens: savings,
        percent: savingsPercent
      }
    };

  } finally {
    await client.end();
  }
}

// CLI
if (import.meta.url === `file://${process.argv[1]}`) {
  const args = process.argv.slice(2);
  const channelId = args[0];

  const options = {
    channelId,
    recentMessageCount: 20,
    summaryLimit: 5
  };

  // Parse options
  if (args.includes('--recent')) {
    const idx = args.indexOf('--recent');
    options.recentMessageCount = parseInt(args[idx + 1]) || 20;
  }

  if (args.includes('--query')) {
    const idx = args.indexOf('--query');
    options.query = args[idx + 1];
  }

  if (args.includes('--compare')) {
    // Compare mode
    if (!channelId) {
      console.error('Usage: node context-compactor.js <channel_id> --compare');
      process.exit(1);
    }

    console.log(`\n📊 Context Size Comparison for ${channelId}\n`);
    
    const comparison = await compareContextSizes(channelId);
    
    console.log('Original Context:');
    console.log(`  Messages: ${comparison.original.messages}`);
    console.log(`  Est. Tokens: ${comparison.original.estimatedTokens.toLocaleString()}`);
    
    console.log('\nCompacted Context:');
    console.log(`  Summaries: ${comparison.compacted.summaries}`);
    console.log(`  Recent Messages: ${comparison.compacted.recentMessages}`);
    console.log(`  Est. Tokens: ${comparison.compacted.estimatedTokens.toLocaleString()}`);
    
    console.log('\nSavings:');
    console.log(`  Tokens saved: ${comparison.savings.tokens.toLocaleString()}`);
    console.log(`  Reduction: ${comparison.savings.percent}%`);
    
    process.exit(0);
  }

  // Build compacted context
  const result = await buildCompactedContext(options);

  console.log(result.context);
  console.log('\n---');
  console.log(`📊 Stats: ${result.stats.summaryCount} summaries + ${result.stats.recentMessageCount} recent messages`);
  console.log(`📊 Est. tokens: ${result.stats.estimatedTokens.total.toLocaleString()}`);

  if (args.includes('--json')) {
    console.log('\n---JSON---');
    console.log(JSON.stringify(result, null, 2));
  }
}

export { 
  buildCompactedContext, 
  getRelevantSummaries, 
  getRecentMessages,
  compareContextSizes,
  formatSummariesForContext
};


path: detect-conversations.js
content: #!/usr/bin/env node
/**
 * Detect conversation boundaries in Slack message history
 * 
 * Segments messages into discrete conversations based on:
 * - Time gaps (>30 minutes = new conversation)
 * - Topic shifts (semantic distance via embeddings)
 * - Thread boundaries (explicit markers)
 * - Participant changes
 */

import pg from 'pg';

const { Client } = pg;

const DB_URL = process.env.SLACK_DB_URL;

const TIME_GAP_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
const MIN_MESSAGES_PER_CONVERSATION = 3;
const MAX_MESSAGES_PER_CONVERSATION = 100;

async function detectConversations(channelId, options = {}) {
  const client = new Client({ connectionString: DB_URL });
  await client.connect();

  try {
    // Fetch messages for channel, ordered by timestamp
    const query = `
      SELECT 
        ts,
        user_id,
        text,
        thread_ts,
        timestamp
      FROM slack_messages
      WHERE channel_id = $1
        ${options.since ? 'AND timestamp >= $2' : ''}
      ORDER BY timestamp ASC
    `;
    
    const params = options.since ? [channelId, options.since] : [channelId];
    const result = await client.query(query, params);
    
    if (result.rows.length === 0) {
      console.log(`No messages found in channel ${channelId}`);
      return [];
    }

    console.log(`Analyzing ${result.rows.length} messages in ${channelId}...`);

    // Segment into conversations
    const conversations = [];
    let currentConversation = null;
    let lastTimestamp = null;
    let lastParticipants = new Set();

    for (const msg of result.rows) {
      const msgTime = new Date(msg.timestamp);
      const timeSinceLastMsg = lastTimestamp 
        ? msgTime - lastTimestamp 
        : 0;

      // Start new conversation if:
      // 1. First message
      // 2. Time gap > threshold
      // 3. Current conversation is too long
      // 4. Thread boundary (thread_ts changes)
      const shouldStartNew = 
        !currentConversation ||
        timeSinceLastMsg > TIME_GAP_THRESHOLD_MS ||
        currentConversation.messages.length >= MAX_MESSAGES_PER_CONVERSATION ||
        (msg.thread_ts && msg.thread_ts !== currentConversation.thread_ts);

      if (shouldStartNew) {
        // Save previous conversation if it meets minimum requirements
        if (currentConversation && 
            currentConversation.messages.length >= MIN_MESSAGES_PER_CONVERSATION) {
          conversations.push(currentConversation);
        }

        // Start new conversation
        currentConversation = {
          channel_id: channelId,
          thread_ts: msg.thread_ts || null,
          start_ts: msgTime,
          end_ts: msgTime,
          messages: [],
          participants: new Set(),
          message_ids: []
        };
        lastParticipants = new Set();
      }

      // Add message to current conversation
      currentConversation.messages.push(msg);
      currentConversation.message_ids.push(msg.ts);
      currentConversation.participants.add(msg.user_id);
      currentConversation.end_ts = msgTime;
      lastParticipants.add(msg.user_id);
      lastTimestamp = msgTime;
    }

    // Save final conversation
    if (currentConversation && 
        currentConversation.messages.length >= MIN_MESSAGES_PER_CONVERSATION) {
      conversations.push(currentConversation);
    }

    // Convert Sets to Arrays for storage
    conversations.forEach(conv => {
      conv.participants = Array.from(conv.participants);
      conv.message_count = conv.messages.length;
    });

    console.log(`\nDetected ${conversations.length} conversations:`);
    conversations.forEach((conv, i) => {
      console.log(`  ${i+1}. ${conv.start_ts.toISOString()} → ${conv.end_ts.toISOString()}`);
      console.log(`     Messages: ${conv.message_count}, Participants: ${conv.participants.length}`);
    });

    return conversations;

  } finally {
    await client.end();
  }
}

async function detectAllChannels(options = {}) {
  const client = new Client({ connectionString: DB_URL });
  await client.connect();

  try {
    // Get all channels with messages
    const channelsResult = await client.query(`
      SELECT DISTINCT channel_id, COUNT(*) as msg_count
      FROM slack_messages
      ${options.since ? 'WHERE timestamp >= $1' : ''}
      GROUP BY channel_id
      ORDER BY msg_count DESC
    `, options.since ? [options.since] : []);

    console.log(`\nAnalyzing ${channelsResult.rows.length} channels...\n`);

    const allConversations = [];
    for (const { channel_id, msg_count } of channelsResult.rows) {
      console.log(`\n=== Channel: ${channel_id} (${msg_count} messages) ===`);
      const conversations = await detectConversations(channel_id, options);
      allConversations.push(...conversations);
    }

    return allConversations;

  } finally {
    await client.end();
  }
}

// CLI
if (import.meta.url === `file://${process.argv[1]}`) {
  const args = process.argv.slice(2);
  const channelId = args[0];
  
  const options = {};
  if (args.includes('--since')) {
    const sinceIdx = args.indexOf('--since');
    options.since = args[sinceIdx + 1];
  }

  if (!channelId || channelId === '--all') {
    detectAllChannels(options)
      .then(conversations => {
        console.log(`\n✅ Total conversations detected: ${conversations.length}`);
        process.exit(0);
      })
      .catch(err => {
        console.error('Error:', err);
        process.exit(1);
      });
  } else {
    detectConversations(channelId, options)
      .then(conversations => {
        console.log(`\n✅ Detected ${conversations.length} conversations`);
        // Output as JSON for piping to summarizer
        if (args.includes('--json')) {
          console.log(JSON.stringify(conversations, null, 2));
        }
        process.exit(0);
      })
      .catch(err => {
        console.error('Error:', err);
        process.exit(1);
      });
  }
}

export { detectConversations, detectAllChannels };


path: package.json
content: {
  "name": "slack-context-memory",
  "version": "1.0.0",
  "description": "Conversation summarization and context compaction for Slack. Reduces context window usage by 70-99%.",
  "type": "module",
  "keywords": [
    "clawdbot",
    "moltbot",
    "slack",
    "context",
    "memory",
    "summarization",
    "compaction"
  ],
  "author": "Monday (built with Opus)",
  "license": "MIT",
  "dependencies": {
    "pg": "^8.13.1"
  },
  "optionalDependencies": {
    "openai": "^4.77.0",
    "@anthropic-ai/sdk": "^0.71.2"
  },
  "engines": {
    "node": ">=18"
  }
}


path: schema.sql
content: -- Slack Context Memory: Conversation Summaries Schema
-- Stores compressed, searchable summaries of Slack conversations

CREATE TABLE IF NOT EXISTS conversation_summaries (
  id SERIAL PRIMARY KEY,
  
  -- Conversation identification
  channel_id TEXT NOT NULL,
  thread_ts TEXT,  -- NULL for channel-level conversations
  conversation_key TEXT GENERATED ALWAYS AS (
    channel_id || COALESCE('_' || thread_ts, '_channel')
  ) STORED,
  
  -- Time boundaries
  start_ts TIMESTAMPTZ NOT NULL,
  end_ts TIMESTAMPTZ NOT NULL,
  message_count INTEGER NOT NULL,
  
  -- Summary content
  tldr TEXT NOT NULL,  -- 1-2 sentence summary
  participants TEXT[] NOT NULL DEFAULT '{}',  -- user_ids
  key_decisions TEXT[] NOT NULL DEFAULT '{}',
  topics TEXT[] NOT NULL DEFAULT '{}',
  outcome TEXT,  -- resolved/ongoing/needs-follow-up/question
  
  -- Raw summary for detailed retrieval
  full_summary TEXT NOT NULL,
  
  -- Semantic search
  embedding VECTOR(1024),  -- BAAI/bge-m3 embeddings
  
  -- Message references
  message_ids TEXT[] NOT NULL DEFAULT '{}',  -- array of message ts values
  first_message_ts TEXT NOT NULL,
  last_message_ts TEXT NOT NULL,
  
  -- Metadata
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  
  -- Quality/confidence
  confidence_score FLOAT,  -- 0-1, how confident the summarization is
  
  UNIQUE(channel_id, thread_ts, start_ts)
);

-- Indexes for fast retrieval
CREATE INDEX IF NOT EXISTS idx_summaries_channel 
  ON conversation_summaries(channel_id);
  
CREATE INDEX IF NOT EXISTS idx_summaries_timerange 
  ON conversation_summaries(start_ts, end_ts);
  
CREATE INDEX IF NOT EXISTS idx_summaries_participants 
  ON conversation_summaries USING GIN(participants);
  
CREATE INDEX IF NOT EXISTS idx_summaries_topics 
  ON conversation_summaries USING GIN(topics);

-- Vector similarity search (requires pgvector)
CREATE INDEX IF NOT EXISTS idx_summaries_embedding 
  ON conversation_summaries 
  USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

-- Full-text search on summaries
CREATE INDEX IF NOT EXISTS idx_summaries_fts 
  ON conversation_summaries 
  USING GIN(to_tsvector('english', tldr || ' ' || full_summary));

-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_conversation_summary_timestamp()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = NOW();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER conversation_summary_updated
BEFORE UPDATE ON conversation_summaries
FOR EACH ROW
EXECUTE FUNCTION update_conversation_summary_timestamp();

-- View for human-readable summaries
CREATE OR REPLACE VIEW conversation_summaries_readable AS
SELECT 
  cs.id,
  sc.name as channel_name,
  cs.start_ts::date as date,
  to_char(cs.start_ts, 'HH24:MI') as start_time,
  to_char(cs.end_ts, 'HH24:MI') as end_time,
  cs.message_count,
  cs.tldr,
  array_length(cs.participants, 1) as participant_count,
  cs.outcome,
  cs.topics,
  cs.created_at
FROM conversation_summaries cs
LEFT JOIN slack_channels sc ON cs.channel_id = sc.id
ORDER BY cs.start_ts DESC;

COMMENT ON TABLE conversation_summaries IS 
  'Compressed, semantic-searchable summaries of Slack conversations for context compaction';
COMMENT ON COLUMN conversation_summaries.tldr IS 
  'Ultra-brief 1-2 sentence summary for quick scanning';
COMMENT ON COLUMN conversation_summaries.full_summary IS 
  'Detailed summary with key points, decisions, and context';
COMMENT ON COLUMN conversation_summaries.embedding IS 
  'Vector embedding (BAAI/bge-m3) for semantic similarity search';
COMMENT ON COLUMN conversation_summaries.confidence_score IS 
  'Quality score 0-1: how well the conversation segments and summarizes';


path: search-conversations.js
content: #!/usr/bin/env node
/**
 * Search conversation summaries semantically and via full-text
 * Returns relevant past conversations for context injection
 */

import pg from 'pg';

const { Client } = pg;

const DB_URL = process.env.SLACK_DB_URL;

const EMBEDDING_API = process.env.EMBEDDING_API_URL;
const EMBEDDING_API_KEY = process.env.EMBEDDING_API_KEY || 'dummy';

async function generateQueryEmbedding(query) {
  const response = await fetch(EMBEDDING_API, {
    method: 'POST',
    headers: { 
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${EMBEDDING_API_KEY}`
    },
    body: JSON.stringify({
      model: 'BAAI/bge-m3',
      input: query
    })
  });

  if (!response.ok) {
    throw new Error(`Embedding API error: ${response.statusText}`);
  }

  const data = await response.json();
  return data.data[0].embedding;
}

async function semanticSearch(query, options = {}) {
  const limit = options.limit || 10;
  const channelId = options.channelId;
  const minScore = options.minScore || 0.5;

  const client = new Client({ connectionString: DB_URL });
  await client.connect();

  try {
    // Generate query embedding
    const embedding = await generateQueryEmbedding(query);
    const embeddingStr = `[${embedding.join(',')}]`;

    // Semantic search with cosine similarity
    const searchQuery = `
      SELECT 
        cs.id,
        cs.channel_id,
        sc.name as channel_name,
        cs.start_ts,
        cs.end_ts,
        cs.message_count,
        cs.tldr,
        cs.full_summary,
        cs.participants,
        cs.key_decisions,
        cs.topics,
        cs.outcome,
        1 - (cs.embedding <=> $1::vector) AS similarity
      FROM conversation_summaries cs
      LEFT JOIN slack_channels sc ON cs.channel_id = sc.id
      WHERE cs.embedding IS NOT NULL
        ${channelId ? 'AND cs.channel_id = $3' : ''}
      ORDER BY cs.embedding <=> $1::vector
      LIMIT $2
    `;

    const params = channelId 
      ? [embeddingStr, limit, channelId]
      : [embeddingStr, limit];

    const result = await client.query(searchQuery, params);

    // Filter by minimum score
    const results = result.rows
      .filter(row => row.similarity >= minScore)
      .map(row => ({
        ...row,
        start_ts: row.start_ts.toISOString(),
        end_ts: row.end_ts.toISOString(),
        similarity: parseFloat(row.similarity.toFixed(3))
      }));

    return results;

  } finally {
    await client.end();
  }
}

async function fullTextSearch(query, options = {}) {
  const limit = options.limit || 10;
  const channelId = options.channelId;

  const client = new Client({ connectionString: DB_URL });
  await client.connect();

  try {
    const searchQuery = `
      SELECT 
        cs.id,
        cs.channel_id,
        sc.name as channel_name,
        cs.start_ts,
        cs.end_ts,
        cs.message_count,
        cs.tldr,
        cs.full_summary,
        cs.participants,
        cs.key_decisions,
        cs.topics,
        cs.outcome,
        ts_rank(
          to_tsvector('english', cs.tldr || ' ' || cs.full_summary),
          plainto_tsquery('english', $1)
        ) as rank
      FROM conversation_summaries cs
      LEFT JOIN slack_channels sc ON cs.channel_id = sc.id
      WHERE to_tsvector('english', cs.tldr || ' ' || cs.full_summary) 
            @@ plainto_tsquery('english', $1)
        ${channelId ? 'AND cs.channel_id = $3' : ''}
      ORDER BY rank DESC
      LIMIT $2
    `;

    const params = channelId 
      ? [query, limit, channelId]
      : [query, limit];

    const result = await client.query(searchQuery, params);

    return result.rows.map(row => ({
      ...row,
      start_ts: row.start_ts.toISOString(),
      end_ts: row.end_ts.toISOString(),
      rank: parseFloat(row.rank.toFixed(3))
    }));

  } finally {
    await client.end();
  }
}

async function searchByTopic(topic, options = {}) {
  const limit = options.limit || 10;
  const channelId = options.channelId;

  const client = new Client({ connectionString: DB_URL });
  await client.connect();

  try {
    const query = `
      SELECT 
        cs.id,
        cs.channel_id,
        sc.name as channel_name,
        cs.start_ts,
        cs.end_ts,
        cs.message_count,
        cs.tldr,
        cs.full_summary,
        cs.participants,
        cs.key_decisions,
        cs.topics,
        cs.outcome
      FROM conversation_summaries cs
      LEFT JOIN slack_channels sc ON cs.channel_id = sc.id
      WHERE $1 = ANY(cs.topics)
        ${channelId ? 'AND cs.channel_id = $3' : ''}
      ORDER BY cs.start_ts DESC
      LIMIT $2
    `;

    const params = channelId 
      ? [topic, limit, channelId]
      : [topic, limit];

    const result = await client.query(query, params);

    return result.rows.map(row => ({
      ...row,
      start_ts: row.start_ts.toISOString(),
      end_ts: row.end_ts.toISOString()
    }));

  } finally {
    await client.end();
  }
}

async function getRecentConversations(options = {}) {
  const limit = options.limit || 10;
  const channelId = options.channelId;
  const since = options.since; // ISO date string

  const client = new Client({ connectionString: DB_URL });
  await client.connect();

  try {
    let query = `
      SELECT 
        cs.id,
        cs.channel_id,
        sc.name as channel_name,
        cs.start_ts,
        cs.end_ts,
        cs.message_count,
        cs.tldr,
        cs.full_summary,
        cs.participants,
        cs.key_decisions,
        cs.topics,
        cs.outcome
      FROM conversation_summaries cs
      LEFT JOIN slack_channels sc ON cs.channel_id = sc.id
      WHERE 1=1
    `;

    const params = [];
    let paramCount = 0;

    if (channelId) {
      paramCount++;
      params.push(channelId);
      query += ` AND cs.channel_id = $${paramCount}`;
    }

    if (since) {
      paramCount++;
      params.push(since);
      query += ` AND cs.start_ts >= $${paramCount}`;
    }

    paramCount++;
    params.push(limit);
    query += ` ORDER BY cs.start_ts DESC LIMIT $${paramCount}`;

    const result = await client.query(query, params);

    return result.rows.map(row => ({
      ...row,
      start_ts: row.start_ts.toISOString(),
      end_ts: row.end_ts.toISOString()
    }));

  } finally {
    await client.end();
  }
}

function formatSearchResults(results) {
  if (results.length === 0) {
    return 'No relevant conversations found.';
  }

  return results.map((r, i) => {
    const date = new Date(r.start_ts).toLocaleDateString();
    const time = new Date(r.start_ts).toLocaleTimeString('en-US', { 
      hour: '2-digit', 
      minute: '2-digit' 
    });
    
    return `
${i + 1}. **${r.channel_name || r.channel_id}** (${date} ${time})
   ${r.tldr}
   ${r.topics.length > 0 ? `Topics: ${r.topics.join(', ')}` : ''}
   ${r.key_decisions.length > 0 ? `Decisions: ${r.key_decisions.join('; ')}` : ''}
   ${r.similarity ? `Relevance: ${(r.similarity * 100).toFixed(0)}%` : ''}
`.trim();
  }).join('\n\n');
}

// CLI
if (import.meta.url === `file://${process.argv[1]}`) {
  const args = process.argv.slice(2);
  const command = args[0];

  const options = {
    limit: 10,
    minScore: 0.5
  };

  if (args.includes('--channel')) {
    const idx = args.indexOf('--channel');
    options.channelId = args[idx + 1];
  }

  if (args.includes('--limit')) {
    const idx = args.indexOf('--limit');
    options.limit = parseInt(args[idx + 1]);
  }

  if (args.includes('--min-score')) {
    const idx = args.indexOf('--min-score');
    options.minScore = parseFloat(args[idx + 1]);
  }

  if (args.includes('--since')) {
    const idx = args.indexOf('--since');
    options.since = args[idx + 1];
  }

  async function run() {
    try {
      let results;

      if (command === 'semantic') {
        const query = args.slice(1).join(' ').replace(/--\w+\s+\S+/g, '').trim();
        if (!query) {
          console.error('Usage: search-conversations.js semantic "your query" [--channel ID] [--limit N]');
          process.exit(1);
        }
        console.log(`Semantic search: "${query}"\n`);
        results = await semanticSearch(query, options);
      } else if (command === 'text') {
        const query = args.slice(1).join(' ').replace(/--\w+\s+\S+/g, '').trim();
        if (!query) {
          console.error('Usage: search-conversations.js text "your query" [--channel ID] [--limit N]');
          process.exit(1);
        }
        console.log(`Full-text search: "${query}"\n`);
        results = await fullTextSearch(query, options);
      } else if (command === 'topic') {
        const topic = args[1];
        if (!topic) {
          console.error('Usage: search-conversations.js topic "topic-name" [--channel ID] [--limit N]');
          process.exit(1);
        }
        console.log(`Topic search: "${topic}"\n`);
        results = await searchByTopic(topic, options);
      } else if (command === 'recent') {
        console.log(`Recent conversations\n`);
        results = await getRecentConversations(options);
      } else {
        console.error(`Unknown command: ${command}`);
        console.error('Commands: semantic, text, topic, recent');
        process.exit(1);
      }

      console.log(formatSearchResults(results));

      if (args.includes('--json')) {
        console.log('\n--- JSON ---');
        console.log(JSON.stringify(results, null, 2));
      }

      process.exit(0);
    } catch (error) {
      console.error('Error:', error);
      process.exit(1);
    }
  }

  run();
}

export { 
  semanticSearch, 
  fullTextSearch, 
  searchByTopic, 
  getRecentConversations,
  formatSearchResults
};


path: setup-schema.js
content: #!/usr/bin/env node
/**
 * Setup database schema for conversation summaries
 */

import pg from 'pg';
import fs from 'fs';

const { Client } = pg;

const DB_URL = process.env.SLACK_DB_URL;

async function setupSchema() {
  const client = new Client({ connectionString: DB_URL });
  await client.connect();

  try {
    console.log('Setting up conversation_summaries schema...');
    
    const schema = fs.readFileSync('schema.sql', 'utf8');
    await client.query(schema);
    
    console.log('✅ Schema setup complete!');
    
    // Test the schema
    const result = await client.query(`
      SELECT table_name, column_name, data_type 
      FROM information_schema.columns 
      WHERE table_name = 'conversation_summaries'
      ORDER BY ordinal_position
    `);
    
    console.log(`\nCreated table with ${result.rows.length} columns:`);
    result.rows.forEach(row => {
      console.log(`  - ${row.column_name}: ${row.data_type}`);
    });
    
  } catch (error) {
    console.error('Error setting up schema:', error);
    throw error;
  } finally {
    await client.end();
  }
}

setupSchema()
  .then(() => process.exit(0))
  .catch(err => {
    console.error(err);
    process.exit(1);
  });


path: summarize-conversation.js
content: #!/usr/bin/env node
/**
 * Summarize Slack conversations using LLM
 * Generates structured summaries with embeddings for semantic search
 */

import pg from 'pg';
import Anthropic from '@anthropic-ai/sdk';

const { Client } = pg;

const DB_URL = process.env.SLACK_DB_URL;

const EMBEDDING_API = process.env.EMBEDDING_API_URL;
const EMBEDDING_API_KEY = process.env.EMBEDDING_API_KEY || 'dummy';
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;

const anthropic = new Anthropic({ [REDACTED_SECRET] });

async function generateEmbedding(text) {
  const response = await fetch(EMBEDDING_API, {
    method: 'POST',
    headers: { 
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${EMBEDDING_API_KEY}`
    },
    body: JSON.stringify({
      model: 'BAAI/bge-m3',
      input: text
    })
  });

  if (!response.ok) {
    throw new Error(`Embedding API error: ${response.statusText}`);
  }

  const data = await response.json();
  return data.data[0].embedding;
}

async function summarizeConversation(conversation) {
  // Build context from messages
  const messagesText = conversation.messages
    .map(msg => `[${msg.user_id}]: ${msg.text}`)
    .join('\n');

  const prompt = `Analyze this Slack conversation and provide a structured summary.

Conversation (${conversation.message_count} messages):
${messagesText}

Generate a JSON response with:
{
  "tldr": "1-2 sentence ultra-brief summary",
  "full_summary": "Detailed 2-3 paragraph summary with key points and context",
  "key_decisions": ["decision 1", "decision 2", ...],
  "topics": ["topic1", "topic2", ...],
  "outcome": "resolved|ongoing|needs-follow-up|question|discussion",
  "confidence_score": 0.0-1.0
}

Guidelines:
- tldr should be scannable and capture the essence
- full_summary should preserve important details and context
- key_decisions should capture concrete outcomes or agreements
- topics should be 2-4 word themes (e.g. "email integration", "database schema")
- outcome should reflect conversation status
- confidence_score: how well-defined/complete the conversation is

Return ONLY valid JSON, no markdown formatting.`;

  try {
    const response = await anthropic.messages.create({
      model: 'claude-opus-4',
      max_tokens: 2000,
      messages: [{
        role: 'user',
        content: prompt
      }]
    });

    const jsonText = response.content[0].text.trim();
    const summary = JSON.parse(jsonText);

    // Generate embedding for semantic search (tldr + full_summary)
    const embeddingText = `${summary.tldr}\n\n${summary.full_summary}`;
    const embedding = await generateEmbedding(embeddingText);

    return {
      ...summary,
      embedding,
      participants: conversation.participants,
      message_ids: conversation.message_ids,
      first_message_ts: conversation.messages[0].ts,
      last_message_ts: conversation.messages[conversation.messages.length - 1].ts
    };

  } catch (error) {
    console.error(`Failed to summarize conversation:`, error);
    
    // Fallback: simple heuristic summary
    return {
      tldr: `Discussion in ${conversation.channel_id} with ${conversation.participants.length} participants`,
      full_summary: `Conversation spanning ${conversation.message_count} messages from ${conversation.start_ts} to ${conversation.end_ts}`,
      key_decisions: [],
      topics: [],
      outcome: 'unknown',
      confidence_score: 0.3,
      embedding: null,
      participants: conversation.participants,
      message_ids: conversation.message_ids,
      first_message_ts: conversation.messages[0].ts,
      last_message_ts: conversation.messages[conversation.messages.length - 1].ts
    };
  }
}

async function storeSummary(conversation, summary) {
  const client = new Client({ connectionString: DB_URL });
  await client.connect();

  try {
    const embeddingStr = summary.embedding 
      ? `[${summary.embedding.join(',')}]`
      : null;

    const query = `
      INSERT INTO conversation_summaries (
        channel_id,
        thread_ts,
        start_ts,
        end_ts,
        message_count,
        tldr,
        participants,
        key_decisions,
        topics,
        outcome,
        full_summary,
        embedding,
        message_ids,
        first_message_ts,
        last_message_ts,
        confidence_score
      ) VALUES (
        $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
      )
      ON CONFLICT (channel_id, thread_ts, start_ts) 
      DO UPDATE SET
        end_ts = EXCLUDED.end_ts,
        message_count = EXCLUDED.message_count,
        tldr = EXCLUDED.tldr,
        participants = EXCLUDED.participants,
        key_decisions = EXCLUDED.key_decisions,
        topics = EXCLUDED.topics,
        outcome = EXCLUDED.outcome,
        full_summary = EXCLUDED.full_summary,
        embedding = EXCLUDED.embedding,
        message_ids = EXCLUDED.message_ids,
        last_message_ts = EXCLUDED.last_message_ts,
        confidence_score = EXCLUDED.confidence_score,
        updated_at = NOW()
      RETURNING id
    `;

    const result = await client.query(query, [
      conversation.channel_id,
      conversation.thread_ts,
      conversation.start_ts,
      conversation.end_ts,
      conversation.message_count,
      summary.tldr,
      summary.participants,
      summary.key_decisions,
      summary.topics,
      summary.outcome,
      summary.full_summary,
      embeddingStr,
      summary.message_ids,
      summary.first_message_ts,
      summary.last_message_ts,
      summary.confidence_score
    ]);

    console.log(`✅ Stored summary (ID: ${result.rows[0].id})`);
    return result.rows[0].id;

  } finally {
    await client.end();
  }
}

async function summarizeAndStore(conversation) {
  console.log(`\nSummarizing conversation: ${conversation.channel_id}`);
  console.log(`  Time: ${conversation.start_ts} → ${conversation.end_ts}`);
  console.log(`  Messages: ${conversation.message_count}`);
  
  const summary = await summarizeConversation(conversation);
  console.log(`  TL;DR: ${summary.tldr}`);
  
  const id = await storeSummary(conversation, summary);
  return { id, summary };
}

// CLI
if (import.meta.url === `file://${process.argv[1]}`) {
  const args = process.argv.slice(2);
  
  if (args[0] === '--stdin') {
    // Read conversations from stdin (JSON)
    let input = '';
    process.stdin.setEncoding('utf8');
    process.stdin.on('data', chunk => input += chunk);
    process.stdin.on('end', async () => {
      try {
        const conversations = JSON.parse(input);
        console.log(`Processing ${conversations.length} conversations...`);
        
        for (const conv of conversations) {
          await summarizeAndStore(conv);
        }
        
        console.log(`\n✅ Summarized and stored ${conversations.length} conversations`);
        process.exit(0);
      } catch (error) {
        console.error('Error:', error);
        process.exit(1);
      }
    });
  } else {
    console.error('Usage: node summarize-conversation.js --stdin < conversations.json');
    process.exit(1);
  }
}

export { summarizeConversation, storeSummary, summarizeAndStore };


path: summarize-openai.js
content: #!/usr/bin/env node
/**
 * Summarize Slack conversations using OpenAI GPT-5
 * Alternative to Claude for autonomous summarization
 */

import pg from 'pg';
import OpenAI from 'openai';

const { Client } = pg;

const DB_URL = process.env.SLACK_DB_URL;

const MODEL = process.env.SUMMARIZE_MODEL || 'gpt-5.1';

// OpenAI client - will use OPENAI_API_KEY from environment
const openai = new OpenAI();

async function summarizeConversation(conversation) {
  const messagesText = conversation.messages
    .slice(0, 50) // Limit for context
    .map(msg => `[${msg.user_id}]: ${msg.text}`)
    .join('\n');

  const prompt = `Analyze this Slack conversation and provide a structured summary.

Conversation (${conversation.message_count} messages, showing first 50):
${messagesText}

Generate a JSON response with:
{
  "tldr": "1-2 sentence ultra-brief summary",
  "full_summary": "Detailed 2-3 paragraph summary with key points",
  "key_decisions": ["decision 1", ...],
  "topics": ["topic1", "topic2", ...],
  "outcome": "resolved|ongoing|needs-follow-up|question|discussion",
  "confidence_score": 0.0-1.0
}

Return ONLY valid JSON, no markdown.`;

  try {
    const response = await openai.chat.completions.create({
      model: MODEL,
      messages: [{ role: 'user', content: prompt }],
      max_tokens: 2000,
      response_format: { type: 'json_object' }
    });

    const jsonText = response.choices[0].message.content.trim();
    return JSON.parse(jsonText);
  } catch (error) {
    console.error('Summarization error:', error.message);
    return {
      tldr: `Discussion with ${conversation.participants.length} participants`,
      full_summary: `Conversation spanning ${conversation.message_count} messages`,
      key_decisions: [],
      topics: [],
      outcome: 'unknown',
      confidence_score: 0.3
    };
  }
}

async function storeSummary(conversation, summary) {
  const client = new Client({ connectionString: DB_URL });
  await client.connect();

  try {
    await client.query(`
      INSERT INTO conversation_summaries (
        channel_id, thread_ts, start_ts, end_ts, message_count,
        tldr, participants, key_decisions, topics, outcome,
        full_summary, message_ids, first_message_ts, last_message_ts,
        confidence_score
      ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
      ON CONFLICT (channel_id, thread_ts, start_ts) DO UPDATE SET
        tldr = EXCLUDED.tldr,
        full_summary = EXCLUDED.full_summary,
        key_decisions = EXCLUDED.key_decisions,
        topics = EXCLUDED.topics,
        outcome = EXCLUDED.outcome,
        confidence_score = EXCLUDED.confidence_score,
        updated_at = NOW()
    `, [
      conversation.channel_id,
      conversation.thread_ts,
      conversation.start_ts,
      conversation.end_ts,
      conversation.message_count,
      summary.tldr,
      conversation.participants,
      summary.key_decisions || [],
      summary.topics || [],
      summary.outcome,
      summary.full_summary,
      conversation.message_ids,
      conversation.messages[0].ts,
      conversation.messages[conversation.messages.length - 1].ts,
      summary.confidence_score || 0.8
    ]);

    console.log(`✅ Stored: ${summary.tldr.substring(0, 60)}...`);
  } finally {
    await client.end();
  }
}

// CLI
if (import.meta.url === `file://${process.argv[1]}`) {
  console.log(`Using model: ${MODEL}`);
  
  // Read conversations from stdin
  let input = '';
  process.stdin.setEncoding('utf8');
  process.stdin.on('data', chunk => input += chunk);
  process.stdin.on('end', async () => {
    try {
      const conversations = JSON.parse(input);
      console.log(`Processing ${conversations.length} conversations...`);
      
      for (const conv of conversations) {
        console.log(`\nSummarizing: ${conv.channel_id} (${conv.message_count} messages)`);
        const summary = await summarizeConversation(conv);
        await storeSummary(conv, summary);
      }
      
      console.log(`\n✅ Done!`);
      process.exit(0);
    } catch (error) {
      console.error('Error:', error);
      process.exit(1);
    }
  });
}

export { summarizeConversation, storeSummary };


path: test-and-post.js
content: #!/usr/bin/env node
/**
 * Test pipeline: detect conversations, summarize, and post to Slack
 */

import pg from 'pg';
import Anthropic from '@anthropic-ai/sdk';

const { Client } = pg;

const DB_URL = process.env.SLACK_DB_URL;

const EMBEDDING_API = process.env.EMBEDDING_API_URL;
const EMBEDDING_API_KEY = process.env.EMBEDDING_API_KEY || 'dummy';

const anthropic = new Anthropic();

const TIME_GAP_THRESHOLD_MS = 30 * 60 * 1000;
const MIN_MESSAGES = 3;
const MAX_MESSAGES = 100;

async function detectConversations(channelId) {
  const client = new Client({ connectionString: DB_URL });
  await client.connect();

  try {
    const result = await client.query(`
      SELECT ts, user_id, text, thread_ts, timestamp
      FROM slack_messages
      WHERE channel_id = $1
      ORDER BY timestamp ASC
    `, [channelId]);

    if (result.rows.length < MIN_MESSAGES) {
      return [];
    }

    const conversations = [];
    let current = null;
    let lastTime = null;

    for (const msg of result.rows) {
      const msgTime = new Date(msg.timestamp);
      const gap = lastTime ? msgTime - lastTime : 0;

      const shouldStart = !current || 
        gap > TIME_GAP_THRESHOLD_MS || 
        current.messages.length >= MAX_MESSAGES ||
        (msg.thread_ts && msg.thread_ts !== current.thread_ts);

      if (shouldStart) {
        if (current && current.messages.length >= MIN_MESSAGES) {
          conversations.push(current);
        }
        current = {
          channel_id: channelId,
          thread_ts: msg.thread_ts || null,
          start_ts: msgTime,
          end_ts: msgTime,
          messages: [],
          participants: new Set(),
          message_ids: []
        };
      }

      current.messages.push(msg);
      current.message_ids.push(msg.ts);
      current.participants.add(msg.user_id);
      current.end_ts = msgTime;
      lastTime = msgTime;
    }

    if (current && current.messages.length >= MIN_MESSAGES) {
      conversations.push(current);
    }

    conversations.forEach(c => {
      c.participants = Array.from(c.participants);
      c.message_count = c.messages.length;
    });

    return conversations;
  } finally {
    await client.end();
  }
}

async function summarizeConversation(conversation) {
  const messagesText = conversation.messages
    .slice(0, 50) // Limit for context
    .map(msg => `[${msg.user_id}]: ${msg.text}`)
    .join('\n');

  const prompt = `Analyze this Slack conversation and provide a structured summary.

Conversation (${conversation.message_count} messages, showing first 50):
${messagesText}

Generate a JSON response with:
{
  "tldr": "1-2 sentence ultra-brief summary",
  "full_summary": "Detailed 2-3 paragraph summary with key points",
  "key_decisions": ["decision 1", ...],
  "topics": ["topic1", "topic2", ...],
  "outcome": "resolved|ongoing|needs-follow-up|question|discussion"
}

Return ONLY valid JSON.`;

  try {
    const response = await anthropic.messages.create({
      model: 'claude-sonnet-4-5-20250514',
      max_tokens: 2000,
      messages: [{ role: 'user', content: prompt }]
    });

    const jsonText = response.content[0].text.trim();
    // Extract JSON if wrapped in markdown
    const jsonMatch = jsonText.match(/\{[\s\S]*\}/);
    if (jsonMatch) {
      return JSON.parse(jsonMatch[0]);
    }
    return JSON.parse(jsonText);
  } catch (error) {
    console.error('Summarization error:', error.message);
    return {
      tldr: `Discussion with ${conversation.participants.length} participants`,
      full_summary: `Conversation spanning ${conversation.message_count} messages`,
      key_decisions: [],
      topics: [],
      outcome: 'unknown'
    };
  }
}

async function generateEmbedding(text) {
  try {
    const response = await fetch(EMBEDDING_API, {
      method: 'POST',
      headers: { 
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${EMBEDDING_API_KEY}`
      },
      body: JSON.stringify({ model: 'BAAI/bge-m3', input: text })
    });
    if (!response.ok) return null;
    const data = await response.json();
    return data.data[0].embedding;
  } catch {
    return null;
  }
}

async function storeSummary(conversation, summary) {
  const client = new Client({ connectionString: DB_URL });
  await client.connect();

  try {
    const embeddingText = `${summary.tldr}\n\n${summary.full_summary}`;
    const embedding = await generateEmbedding(embeddingText);
    const embeddingStr = embedding ? `[${embedding.join(',')}]` : null;

    await client.query(`
      INSERT INTO conversation_summaries (
        channel_id, thread_ts, start_ts, end_ts, message_count,
        tldr, participants, key_decisions, topics, outcome,
        full_summary, embedding, message_ids, first_message_ts, last_message_ts,
        confidence_score
      ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
      ON CONFLICT (channel_id, thread_ts, start_ts) DO UPDATE SET
        tldr = EXCLUDED.tldr,
        full_summary = EXCLUDED.full_summary,
        key_decisions = EXCLUDED.key_decisions,
        topics = EXCLUDED.topics,
        outcome = EXCLUDED.outcome,
        embedding = EXCLUDED.embedding,
        updated_at = NOW()
    `, [
      conversation.channel_id,
      conversation.thread_ts,
      conversation.start_ts,
      conversation.end_ts,
      conversation.message_count,
      summary.tldr,
      conversation.participants,
      summary.key_decisions || [],
      summary.topics || [],
      summary.outcome,
      summary.full_summary,
      embeddingStr,
      conversation.message_ids,
      conversation.messages[0].ts,
      conversation.messages[conversation.messages.length - 1].ts,
      0.8
    ]);
  } finally {
    await client.end();
  }
}

function formatSummaryForSlack(channelName, conversations, summaries) {
  let output = `📊 **Context Memory Test: #${channelName}**\n\n`;
  output += `Found **${conversations.length}** conversation(s) to summarize:\n\n`;

  for (let i = 0; i < summaries.length; i++) {
    const conv = conversations[i];
    const sum = summaries[i];
    const date = conv.start_ts.toLocaleDateString();
    const time = conv.start_ts.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });

    output += `---\n`;
    output += `**Conversation ${i + 1}** (${date} ${time}, ${conv.message_count} messages)\n\n`;
    output += `**TL;DR:** ${sum.tldr}\n\n`;
    
    if (sum.topics && sum.topics.length > 0) {
      output += `**Topics:** ${sum.topics.join(', ')}\n\n`;
    }
    
    if (sum.key_decisions && sum.key_decisions.length > 0) {
      output += `**Key decisions:**\n\n`;
      for (const decision of sum.key_decisions) {
        output += `• ${decision}\n`;
      }
      output += '\n';
    }
    
    output += `**Outcome:** ${sum.outcome}\n\n`;
  }

  output += `---\n✅ Summaries stored in database with embeddings for semantic search.`;
  return output;
}

// Main
const channelId = process.argv[2];
if (!channelId) {
  console.error('Usage: node test-and-post.js <channel_id>');
  process.exit(1);
}

console.log(`\n🔍 Processing channel: ${channelId}`);

// Get channel name
const client = new Client({ connectionString: DB_URL });
await client.connect();
const channelResult = await client.query('SELECT name FROM slack_channels WHERE id = $1', [channelId]);
const channelName = channelResult.rows[0]?.name || channelId;
await client.end();

console.log(`📌 Channel: #${channelName}`);

// Detect conversations
console.log('🔄 Detecting conversations...');
const conversations = await detectConversations(channelId);
console.log(`   Found ${conversations.length} conversation(s)`);

if (conversations.length === 0) {
  console.log('❌ No conversations to summarize');
  process.exit(0);
}

// Summarize each
console.log('🧠 Generating summaries...');
const summaries = [];
for (let i = 0; i < conversations.length; i++) {
  console.log(`   Summarizing ${i + 1}/${conversations.length}...`);
  const summary = await summarizeConversation(conversations[i]);
  summaries.push(summary);
  
  // Store in DB
  await storeSummary(conversations[i], summary);
  console.log(`   ✅ Stored: ${summary.tldr.substring(0, 60)}...`);
}

// Format for Slack
const slackMessage = formatSummaryForSlack(channelName, conversations, summaries);
console.log('\n📝 Slack message:\n');
console.log(slackMessage);

// Output the formatted message for posting
console.log('\n---JSON_OUTPUT---');
console.log(JSON.stringify({
  channelId,
  channelName,
  conversationCount: conversations.length,
  message: slackMessage
}));


Security scan verdicts — ClawScan: suspicious | static analysis:  | VirusTotal:  | SkillSpector:  ()