Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/skip-welcome-engaged-users.md
Original file line number Diff line number Diff line change
@@ -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).
35 changes: 31 additions & 4 deletions server/src/addie/services/outbound-planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -362,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`);
Expand All @@ -371,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})
Expand Down
261 changes: 261 additions & 0 deletions server/src/addie/services/passive-note-extractor.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<void> {
// Get the 'note' insight type ID
const noteType = await insightsDb.getInsightTypeByName('note');
if (!noteType) {
logger.error('Note insight type not found - run migration 143_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<void> {
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;
}
20 changes: 20 additions & 0 deletions server/src/db/migrations/143_note_insight_type.sql
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions server/src/slack/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -410,6 +411,18 @@ export async function handleMessage(event: SlackMessageEvent): Promise<void> {
// 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');
Expand Down