// 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
}