From 2690e23b94c2d13f64f22564309ba9452bbf8444 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 5 Jan 2026 11:39:56 -0500 Subject: [PATCH 1/4] Skip welcome goals for highly engaged users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outbound planner was suggesting the "Welcome - What Brings You" goal to users who are clearly already engaged with the community (e.g., committee leaders, working group members). These users don't need to be asked what brings them here. Added eligibility check to skip intro goals for users who show any of these engagement indicators: - Committee leader status - Working group membership - Council membership - 10+ Slack messages in the last 30 days 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/skip-welcome-engaged-users.md | 6 ++++++ server/src/addie/services/outbound-planner.ts | 11 +++++++++++ 2 files changed, 17 insertions(+) create mode 100644 .changeset/skip-welcome-engaged-users.md diff --git a/.changeset/skip-welcome-engaged-users.md b/.changeset/skip-welcome-engaged-users.md new file mode 100644 index 0000000000..01554d803f --- /dev/null +++ b/.changeset/skip-welcome-engaged-users.md @@ -0,0 +1,6 @@ +--- +--- + +No protocol impact - internal behavior change only. + +Skip intro/welcome goals for highly engaged users (committee leaders, working group members, council members, active Slack users). diff --git a/server/src/addie/services/outbound-planner.ts b/server/src/addie/services/outbound-planner.ts index cdfcc79163..c5f4c738b5 100644 --- a/server/src/addie/services/outbound-planner.ts +++ b/server/src/addie/services/outbound-planner.ts @@ -142,6 +142,17 @@ export class OutboundPlanner { if (hasInsight) return false; // Already has this insight, skip goal } + // Skip intro/welcome goals for highly engaged users + // These users are clearly already part of the community - no need to ask "what brings you here?" + // Any of these indicators suggests the user is already engaged: + if (goal.category === 'information' && goal.success_insight_type === 'initial_interest') { + const caps = ctx.capabilities; + if (caps?.is_committee_leader) return false; + if (caps && caps.working_group_count > 0) return false; + if (caps && caps.council_count > 0) return false; + if (caps && caps.slack_message_count_30d >= 10) return false; + } + return true; } From 0611c574027e5d8ca9a6ab908d5d9000787cbc2f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 5 Jan 2026 11:29:11 -0800 Subject: [PATCH 2/4] Add passive note extraction from public channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two improvements to how Addie understands members: 1. Enriched LLM context in outbound planner: - Role in Community now prominently shows Committee Leader, Council Member, WG Member - Notes from channel conversations displayed separately for context - Removed duplicate committee leader from capabilities section 2. Passive note extraction from public channels: - New 'note' insight type for storing interesting tidbits as text - Watches all public channels Addie is in - Rate-limited queue (5s between LLM calls) to avoid API overload - Only extracts notable statements (interests, what they're building, opinions) - Stores as observations with medium confidence Example flow: Christina posts "I'm really interested in doing more with user data" → Extracted note: "Mentioned being interested in doing more with user data (in #wg-creative)" → Shows in planner context: "Notes from Channel Conversations: - Mentioned being interested..." 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- server/src/addie/services/outbound-planner.ts | 24 +- .../addie/services/passive-note-extractor.ts | 261 ++++++++++++++++++ .../db/migrations/141_note_insight_type.sql | 20 ++ server/src/slack/events.ts | 13 + 4 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 server/src/addie/services/passive-note-extractor.ts create mode 100644 server/src/db/migrations/141_note_insight_type.sql diff --git a/server/src/addie/services/outbound-planner.ts b/server/src/addie/services/outbound-planner.ts index c5f4c738b5..e861c1d6e2 100644 --- a/server/src/addie/services/outbound-planner.ts +++ b/server/src/addie/services/outbound-planner.ts @@ -373,7 +373,7 @@ export class OutboundPlanner { if (caps.has_team_members) capabilityLines.push('✓ Has team members'); else capabilityLines.push('✗ No team members added'); - if (caps.is_committee_leader) capabilityLines.push('✓ Committee leader'); + // Committee leader is now shown in Role section, not here if (caps.slack_message_count_30d > 0) { capabilityLines.push(`Activity: ${caps.slack_message_count_30d} Slack messages in last 30 days`); @@ -382,19 +382,35 @@ export class OutboundPlanner { } } + // Build role/position line + const roleLines: string[] = []; + if (caps?.is_committee_leader) roleLines.push('Committee Leader'); + if (caps && caps.council_count > 0) roleLines.push(`Council Member (${caps.council_count})`); + if (caps && caps.working_group_count > 0) roleLines.push(`WG Member (${caps.working_group_count})`); + const roleStr = roleLines.length > 0 ? roleLines.join(', ') : 'Community member'; + + // Filter notes from insights for separate display + const notes = ctx.user.insights.filter(i => i.type === 'note'); + const otherInsights = ctx.user.insights.filter(i => i.type !== 'note'); + const insightStr = otherInsights.length > 0 + ? otherInsights.map(i => `${i.type}: ${i.value} (${i.confidence})`).join('\n - ') + : 'Nothing yet'; + return `You are helping decide what capability or feature to introduce to a member of AgenticAdvertising.org. ## User Context - Name: ${ctx.user.display_name ?? 'Unknown'} -- Company: ${ctx.company?.name ?? 'Unknown'} (${ctx.company?.type ?? 'unknown type'}) -- Account Status: ${ctx.user.is_mapped ? 'Linked' : 'Not linked'} +- Company: ${ctx.company?.name ?? 'Unknown'} +- Role in Community: ${roleStr} +- Account Status: ${ctx.user.is_mapped ? 'Linked' : 'Not linked'}, ${ctx.user.is_member ? 'Paying member' : 'Not yet a member'} - Engagement Score: ${ctx.user.engagement_score}/100 ## What They've Done (Capabilities) ${capabilityLines.length > 0 ? capabilityLines.map(l => ` ${l}`).join('\n') : ' No capability data available'} ## What We Know (Insights) - - ${userInsights} + - ${insightStr} +${notes.length > 0 ? `\n## Notes from Channel Conversations\n${notes.map(n => ` - ${n.value}`).join('\n')}` : ''} ## Available Goals (pick ONE) ${goals.map((g, i) => `${i + 1}. **${g.name}** (${g.category}) diff --git a/server/src/addie/services/passive-note-extractor.ts b/server/src/addie/services/passive-note-extractor.ts new file mode 100644 index 0000000000..50373d7ede --- /dev/null +++ b/server/src/addie/services/passive-note-extractor.ts @@ -0,0 +1,261 @@ +/** + * Passive Note Extractor Service + * + * Watches public channels Addie is in and extracts interesting tidbits + * about what people say - stored as text notes, not structured insights. + * + * Key differences from insight-extractor.ts: + * - Passive: doesn't respond, just observes + * - Simpler: only extracts 'note' type insights + * - Rate-limited: uses a queue to avoid overwhelming the LLM API + * - Conservative: only extracts notable/interesting statements + */ + +import Anthropic from '@anthropic-ai/sdk'; +import { logger } from '../../logger.js'; +import { InsightsDatabase, type InsightConfidence } from '../../db/insights-db.js'; +import { ModelConfig } from '../../config/models.js'; +import { invalidateInsightsCache } from '../insights-cache.js'; + +const insightsDb = new InsightsDatabase(); + +// Rate limiting: process one message every 5 seconds +const RATE_LIMIT_MS = 5000; +const MIN_MESSAGE_LENGTH = 50; + +// Queue for rate-limited processing +interface QueueItem { + slackUserId: string; + workosUserId?: string; + channelId: string; + channelName?: string; + messageText: string; + messageTs: string; +} + +const queue: QueueItem[] = []; +let processing = false; + +/** + * Check if a message is worth analyzing for notes + */ +function shouldAnalyzeForNotes(text: string): boolean { + // Too short + if (text.length < MIN_MESSAGE_LENGTH) return false; + + // Mostly mentions or links + const mentionCount = (text.match(/<[@#!][^>]+>/g) || []).length; + if (mentionCount > 2) return false; + + // Pure questions to the group (not revealing anything about themselves) + if (/^(does anyone|has anyone|can someone|who knows|what is|how do|where can)/i.test(text.trim())) { + return false; + } + + // Just a reaction or short acknowledgment + if (/^(lol|haha|nice|great|thanks|agreed|yep|yes|no|ok|okay|cool|interesting)[!?.]*$/i.test(text.trim())) { + return false; + } + + return true; +} + +/** + * Build prompt for extracting a notable tidbit + */ +function buildNoteExtractionPrompt(message: string, channelName?: string): string { + return `Analyze this public Slack message from ${channelName ? `#${channelName}` : 'a channel'}. + +**Message:** +${message} + +**Task:** +Determine if this message reveals something notable about the person who wrote it - their interests, what they're working on, their opinions, or their goals. + +We're NOT looking for: +- Generic comments or reactions +- Questions to the group +- Casual chit-chat +- Technical troubleshooting + +We ARE looking for: +- "I'm really interested in X" +- "We're building Y at my company" +- "Our focus is on Z" +- "I think the future is..." +- Strong opinions or perspectives on industry topics + +If the message IS notable, summarize what it reveals about the person in ONE sentence. +If it's NOT notable, respond with null. + +Return ONLY valid JSON: +{"note": "Mentioned being interested in X" | null}`; +} + +/** + * Extract a note from a message using Claude + */ +async function extractNote(item: QueueItem): Promise { + const apiKey = process.env.ADDIE_ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + logger.warn('Passive note extractor: No API key configured'); + return null; + } + + try { + const client = new Anthropic({ apiKey }); + const prompt = buildNoteExtractionPrompt(item.messageText, item.channelName); + + const response = await client.messages.create({ + model: ModelConfig.fast, + max_tokens: 150, + messages: [{ role: 'user', content: prompt }], + }); + + const content = response.content[0]; + if (content.type !== 'text') return null; + + // Parse JSON response + const cleaned = content.text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim(); + const parsed = JSON.parse(cleaned); + + return parsed.note || null; + } catch (error) { + logger.warn({ error, slackUserId: item.slackUserId }, 'Passive note extraction failed'); + return null; + } +} + +/** + * Store a note as an insight + */ +async function storeNote( + slackUserId: string, + workosUserId: string | undefined, + note: string, + channelName: string | undefined, + messageTs: string +): Promise { + // Get the 'note' insight type ID + const noteType = await insightsDb.getInsightTypeByName('note'); + if (!noteType) { + logger.error('Note insight type not found - run migration 141_note_insight_type.sql'); + return; + } + + // Format the note with channel context + const formattedNote = channelName + ? `${note} (in #${channelName})` + : note; + + // Check for duplicate (same user, same basic note value) + const existingInsights = await insightsDb.getInsightsForUser(slackUserId); + const isDuplicate = existingInsights.some( + i => i.insight_type_name === 'note' && i.value.toLowerCase().includes(note.toLowerCase().slice(0, 30)) + ); + + if (isDuplicate) { + logger.debug({ slackUserId, note }, 'Skipping duplicate note'); + return; + } + + // Store the note + await insightsDb.addInsight({ + slack_user_id: slackUserId, + workos_user_id: workosUserId, + insight_type_id: noteType.id, + value: formattedNote, + confidence: 'medium' as InsightConfidence, // Passive observation = medium confidence + source_type: 'observation', + source_thread_id: undefined, + source_message_id: messageTs, + extracted_from: undefined, // Don't store the full message for privacy + }); + + // Invalidate cache for this user + invalidateInsightsCache(slackUserId); + + logger.info({ slackUserId, note: formattedNote }, 'Stored note from channel conversation'); +} + +/** + * Process the next item in the queue + */ +async function processNext(): Promise { + if (processing || queue.length === 0) return; + + processing = true; + const item = queue.shift()!; + + try { + const note = await extractNote(item); + if (note) { + await storeNote( + item.slackUserId, + item.workosUserId, + note, + item.channelName, + item.messageTs + ); + } + } catch (error) { + logger.warn({ error, item }, 'Error processing passive note extraction'); + } + + // Schedule next item after rate limit delay + setTimeout(() => { + processing = false; + processNext(); + }, RATE_LIMIT_MS); +} + +/** + * Queue a message for passive note extraction + * + * Call this from the message handler for public channel messages. + * The message will be processed asynchronously with rate limiting. + */ +export function queueForNoteExtraction(params: { + slackUserId: string; + workosUserId?: string; + channelId: string; + channelName?: string; + messageText: string; + messageTs: string; +}): void { + // Pre-filter before queueing + if (!shouldAnalyzeForNotes(params.messageText)) { + return; + } + + // Deduplicate: don't queue if same user+message already in queue + const isDuplicate = queue.some( + q => q.slackUserId === params.slackUserId && q.messageTs === params.messageTs + ); + if (isDuplicate) return; + + queue.push({ + slackUserId: params.slackUserId, + workosUserId: params.workosUserId, + channelId: params.channelId, + channelName: params.channelName, + messageText: params.messageText, + messageTs: params.messageTs, + }); + + logger.debug({ + slackUserId: params.slackUserId, + channelName: params.channelName, + queueLength: queue.length, + }, 'Queued message for passive note extraction'); + + // Start processing if not already running + processNext(); +} + +/** + * Get current queue length (for monitoring) + */ +export function getQueueLength(): number { + return queue.length; +} diff --git a/server/src/db/migrations/141_note_insight_type.sql b/server/src/db/migrations/141_note_insight_type.sql new file mode 100644 index 0000000000..19d6426078 --- /dev/null +++ b/server/src/db/migrations/141_note_insight_type.sql @@ -0,0 +1,20 @@ +-- Add 'note' insight type for storing interesting tidbits from channel conversations +-- These are free-form text observations, not structured data + +INSERT INTO member_insight_types (name, description, example_values, is_active, created_by) +VALUES ( + 'note', + 'Interesting tidbit or context about the person from channel conversations', + ARRAY[ + 'Mentioned being interested in doing more with user data', + 'Said they are building a buyer agent for programmatic', + 'Asked about measurement APIs for campaign attribution', + 'Shared that they are focused on sustainability initiatives' + ], + TRUE, + 'system' +) +ON CONFLICT (name) DO UPDATE SET + description = EXCLUDED.description, + example_values = EXCLUDED.example_values, + is_active = EXCLUDED.is_active; diff --git a/server/src/slack/events.ts b/server/src/slack/events.ts index 6aea9ded8e..f4d97d3bf4 100644 --- a/server/src/slack/events.ts +++ b/server/src/slack/events.ts @@ -24,6 +24,7 @@ import { type AppMentionEvent, type AssistantMessageEvent, } from '../addie/index.js'; +import { queueForNoteExtraction } from '../addie/services/passive-note-extractor.js'; const slackDb = new SlackDatabase(); const addieDb = new AddieDatabase(); @@ -410,6 +411,18 @@ export async function handleMessage(event: SlackMessageEvent): Promise { // Skip DMs (im) and private channels - only index public messages if (event.channel_type === 'channel' && event.text && event.text.length > 20) { await indexMessageForSearch(event); + + // Queue for passive note extraction (async, rate-limited) + // This extracts interesting tidbits from channel conversations + const channel = await getChannelInfo(event.channel); + queueForNoteExtraction({ + slackUserId: event.user, + workosUserId: mapping?.workos_user_id ?? undefined, + channelId: event.channel, + channelName: channel?.name, + messageText: event.text, + messageTs: event.ts, + }); } } catch (error) { logger.error({ error, userId: event.user }, 'Failed to record message activity'); From d60d752ee77f2d06e9fd638b1fe2e19f4789e5a0 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 5 Jan 2026 16:03:14 -0800 Subject: [PATCH 3/4] Fix duplicate migration version number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename 141_note_insight_type.sql to 142_note_insight_type.sql since 141_fix_feed_email_slugs.sql already exists on main. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- server/src/addie/services/passive-note-extractor.ts | 2 +- .../{141_note_insight_type.sql => 142_note_insight_type.sql} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename server/src/db/migrations/{141_note_insight_type.sql => 142_note_insight_type.sql} (100%) diff --git a/server/src/addie/services/passive-note-extractor.ts b/server/src/addie/services/passive-note-extractor.ts index 50373d7ede..22c1e3531a 100644 --- a/server/src/addie/services/passive-note-extractor.ts +++ b/server/src/addie/services/passive-note-extractor.ts @@ -139,7 +139,7 @@ async function storeNote( // Get the 'note' insight type ID const noteType = await insightsDb.getInsightTypeByName('note'); if (!noteType) { - logger.error('Note insight type not found - run migration 141_note_insight_type.sql'); + logger.error('Note insight type not found - run migration 142_note_insight_type.sql'); return; } diff --git a/server/src/db/migrations/141_note_insight_type.sql b/server/src/db/migrations/142_note_insight_type.sql similarity index 100% rename from server/src/db/migrations/141_note_insight_type.sql rename to server/src/db/migrations/142_note_insight_type.sql From f530fbd05ecad9e67e35dc2c2097ab968171dad1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 5 Jan 2026 16:07:03 -0800 Subject: [PATCH 4/4] Fix migration version conflict (142 -> 143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename note_insight_type migration to 143 since 142_member_search_analytics.sql was added to main. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- server/src/addie/services/passive-note-extractor.ts | 2 +- .../{142_note_insight_type.sql => 143_note_insight_type.sql} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename server/src/db/migrations/{142_note_insight_type.sql => 143_note_insight_type.sql} (100%) diff --git a/server/src/addie/services/passive-note-extractor.ts b/server/src/addie/services/passive-note-extractor.ts index 22c1e3531a..9471032e88 100644 --- a/server/src/addie/services/passive-note-extractor.ts +++ b/server/src/addie/services/passive-note-extractor.ts @@ -139,7 +139,7 @@ async function storeNote( // Get the 'note' insight type ID const noteType = await insightsDb.getInsightTypeByName('note'); if (!noteType) { - logger.error('Note insight type not found - run migration 142_note_insight_type.sql'); + logger.error('Note insight type not found - run migration 143_note_insight_type.sql'); return; } diff --git a/server/src/db/migrations/142_note_insight_type.sql b/server/src/db/migrations/143_note_insight_type.sql similarity index 100% rename from server/src/db/migrations/142_note_insight_type.sql rename to server/src/db/migrations/143_note_insight_type.sql