OverviewFilesCommitsIssuesDocsReleasesPeers

hypercore-kb / hyper-social / hyper-daemon / daemons / summarize.js

301 lines · javascript ref:
// Summarize daemon — extractive conversation summarizer
// Collects chat messages and produces summaries without LLM

// Common English stop words to filter from topic extraction
const STOP_WORDS = new Set([
  'the', 'be', 'to', 'of', 'and', 'a', 'in', 'that', 'have', 'i',
  'it', 'for', 'not', 'on', 'with', 'he', 'as', 'you', 'do', 'at',
  'this', 'but', 'his', 'by', 'from', 'they', 'we', 'say', 'her',
  'she', 'or', 'an', 'will', 'my', 'one', 'all', 'would', 'there',
  'their', 'what', 'so', 'up', 'out', 'if', 'about', 'who', 'get',
  'which', 'go', 'me', 'when', 'make', 'can', 'like', 'time', 'no',
  'just', 'him', 'know', 'take', 'people', 'into', 'year', 'your',
  'good', 'some', 'could', 'them', 'see', 'other', 'than', 'then',
  'now', 'look', 'only', 'come', 'its', 'over', 'think', 'also',
  'back', 'after', 'use', 'two', 'how', 'our', 'work', 'first',
  'well', 'way', 'even', 'new', 'want', 'because', 'any', 'these',
  'give', 'day', 'most', 'us', 'is', 'am', 'are', 'was', 'were',
  'been', 'being', 'has', 'had', 'having', 'does', 'did', 'doing',
  'should', 'more', 'very', 'too', 'here', 'where', 'why', 'yes',
  'yeah', 'ok', 'okay', 'sure', 'right', 'oh', 'hi', 'hey', 'lol',
  'haha', 'thanks', 'thank', 'im', 'dont', 'thats', 'gonna', 'got',
  'really', 'thing', 'things', 'much', 'still', 'let', 'need',
  'been', 'going', 'did', 'made', 'said', 'each', 'tell', 'does',
  'set', 'three', 'own', 'point', 'end', 'while', 'same'
])

// Tokenize text into lowercase words, stripping punctuation
function tokenize (text) {
  if (!text) return []
  return text
    .toLowerCase()
    .replace(/https?:\/\/\S+/g, '') // strip URLs
    .replace(/[^a-z0-9\s'-]/g, ' ')
    .split(/\s+/)
    .filter(w => w.length > 2 && !STOP_WORDS.has(w))
}

// Extract most frequent non-stopword terms (3+ occurrences by default)
function extractTopics (messages, opts = {}) {
  const minCount = opts.minCount || 3
  const maxTopics = opts.maxTopics || 10
  const freq = new Map()

  for (const msg of messages) {
    const words = tokenize(msg.text)
    // Count unique words per message to avoid spam-inflation
    const unique = new Set(words)
    for (const word of unique) {
      freq.set(word, (freq.get(word) || 0) + 1)
    }
  }

  // Filter by minimum count, sort by frequency descending
  return Array.from(freq.entries())
    .filter(([, count]) => count >= minCount)
    .sort((a, b) => b[1] - a[1])
    .slice(0, maxTopics)
    .map(([word, count]) => ({ word, count }))
}

// Identify key messages: questions, links, or high-engagement content
function extractKeyMessages (messages, opts = {}) {
  const maxKey = opts.maxKeyMessages || 5
  const scored = []

  for (const msg of messages) {
    const text = msg.text || ''
    let score = 0

    // Questions get high priority
    if (text.includes('?')) score += 3

    // Links are informational
    if (/https?:\/\/\S+/.test(text)) score += 2

    // Code blocks indicate technical discussion
    if (text.includes('```') || text.includes('`')) score += 1

    // Longer messages tend to be more substantive
    if (text.length > 100) score += 1
    if (text.length > 200) score += 1

    // Exclamation marks indicate emphasis
    if (text.includes('!')) score += 1

    if (score > 0) {
      scored.push({ msg, score })
    }
  }

  return scored
    .sort((a, b) => b.score - a.score)
    .slice(0, maxKey)
    .map(s => s.msg)
}

// Get unique participants
function extractParticipants (messages) {
  const participants = new Map()

  for (const msg of messages) {
    const author = msg.author || 'unknown'
    participants.set(author, (participants.get(author) || 0) + 1)
  }

  return Array.from(participants.entries())
    .sort((a, b) => b[1] - a[1])
    .map(([name, count]) => ({ name, count }))
}

// Calculate time range of the conversation
function getTimeRange (messages) {
  if (!messages.length) return { start: 0, end: 0, durationMs: 0 }

  const timestamps = messages
    .map(m => m.timestamp || 0)
    .filter(t => t > 0)

  if (!timestamps.length) return { start: 0, end: 0, durationMs: 0 }

  const start = Math.min(...timestamps)
  const end = Math.max(...timestamps)

  return {
    start,
    end,
    durationMs: end - start
  }
}

// Format duration in human-readable form
function formatDuration (ms) {
  if (ms < 60000) return 'less than a minute'
  const minutes = Math.floor(ms / 60000)
  if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''}`
  const hours = Math.floor(minutes / 60)
  const remaining = minutes % 60
  if (hours < 24) {
    if (remaining === 0) return `${hours} hour${hours !== 1 ? 's' : ''}`
    return `${hours}h ${remaining}m`
  }
  const days = Math.floor(hours / 24)
  return `${days} day${days !== 1 ? 's' : ''}`
}

// Build the markdown summary
function formatSummary (participants, topics, keyMessages, timeRange, messageCount) {
  const lines = []

  lines.push(`## Conversation Summary`)
  lines.push('')

  // Stats line
  const parts = [`${messageCount} messages`]
  if (participants.length > 0) {
    parts.push(`${participants.length} participant${participants.length !== 1 ? 's' : ''}`)
  }
  if (timeRange.durationMs > 0) {
    parts.push(`over ${formatDuration(timeRange.durationMs)}`)
  }
  lines.push(parts.join(', '))
  lines.push('')

  // Participants
  if (participants.length > 0) {
    lines.push('### Participants')
    for (const p of participants) {
      lines.push(`- **${p.name}** (${p.count} message${p.count !== 1 ? 's' : ''})`)
    }
    lines.push('')
  }

  // Topics
  if (topics.length > 0) {
    lines.push('### Topics')
    for (const t of topics) {
      lines.push(`- ${t.word} (${t.count}x)`)
    }
    lines.push('')
  }

  // Key messages
  if (keyMessages.length > 0) {
    lines.push('### Key Messages')
    for (const msg of keyMessages) {
      const author = msg.author || 'unknown'
      const text = (msg.text || '').slice(0, 150)
      const ellipsis = (msg.text || '').length > 150 ? '...' : ''
      lines.push(`- **${author}**: ${text}${ellipsis}`)
    }
    lines.push('')
  }

  return lines.join('\n')
}

const summarize = {
  name: 'summarize',
  description: 'Auto-summarize conversations into digestible format',

  defaultConfig: {
    windowSize: 50,
    minMessages: 10,
    includeParticipants: true,
    includeTopics: true,
    topicMinCount: 3,
    maxTopics: 10,
    maxKeyMessages: 5
  },

  // Process a batch of messages and produce a summary
  // messages: [{ text, author, timestamp, type }]
  // config: { windowSize, minMessages, ... }
  // Returns: { summary, participants, topics, keyMessages, messageCount, timeRange }
  process (messages, config) {
    const cfg = { ...summarize.defaultConfig, ...(config || {}) }

    if (!messages || !messages.length) {
      return {
        summary: '',
        participants: [],
        topics: [],
        keyMessages: [],
        messageCount: 0,
        timeRange: { start: 0, end: 0, durationMs: 0 }
      }
    }

    // Enforce minimum threshold
    if (messages.length < cfg.minMessages) {
      return {
        summary: '',
        participants: extractParticipants(messages),
        topics: [],
        keyMessages: [],
        messageCount: messages.length,
        timeRange: getTimeRange(messages),
        belowThreshold: true
      }
    }

    // Window: take only the last N messages
    const window = messages.length > cfg.windowSize
      ? messages.slice(messages.length - cfg.windowSize)
      : messages

    const participants = cfg.includeParticipants
      ? extractParticipants(window)
      : []

    const topics = cfg.includeTopics
      ? extractTopics(window, { minCount: cfg.topicMinCount, maxTopics: cfg.maxTopics })
      : []

    const keyMessages = extractKeyMessages(window, { maxKeyMessages: cfg.maxKeyMessages })
    const timeRange = getTimeRange(window)
    const summary = formatSummary(participants, topics, keyMessages, timeRange, window.length)

    return {
      summary,
      participants,
      topics,
      keyMessages,
      messageCount: window.length,
      timeRange
    }
  },

  // Generate a daemon script for the DaemonRuntime
  // Collects messages in a buffer and summarizes at threshold
  toDaemon (config, opts = {}) {
    const targetRoom = opts.targetRoom

    return {
      trigger: {
        type: 'message',
        filter: {}
      },
      actions: [{
        type: 'log',
        message: 'Message buffered for summary'
      }],
      enabled: true,
      _targetRoom: targetRoom,
      _threshold: (config && config.windowSize) || 50
    }
  }
}

export {
  summarize,
  extractTopics,
  extractKeyMessages,
  extractParticipants,
  getTimeRange,
  formatSummary,
  formatDuration,
  tokenize,
  STOP_WORDS
}