diff --git a/examples/focus-group/README.md b/examples/focus-group/README.md new file mode 100644 index 0000000..1e9763b --- /dev/null +++ b/examples/focus-group/README.md @@ -0,0 +1,146 @@ +# Political Focus Group Simulator + +Simulate a political focus group with persistent AI personas to test candidate messaging. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ FOCUS GROUP │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ │ +│ │ CANDIDATE │ ──────────► Presents position │ +│ │ │ ◄────────── Asks follow-up questions │ +│ └──────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ VOTER 1 │ │ VOTER 2 │ ... (expandable) │ +│ │ (Maria) │ │ (James) │ │ +│ │ Independent │ │ Republican │ │ +│ └──────────────┘ └──────────────┘ │ +│ │ │ │ +│ └────────┬─────────┘ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ ANALYST │ Observes and provides insights │ +│ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Quick Start + +```bash +cd examples/focus-group + +# Run on a specific topic +bun cli.ts "healthcare reform" + +# Interactive mode (enter multiple topics) +bun cli.ts + +# Check status +bun cli.ts --status + +# Reset all agents +bun cli.ts --reset +``` + +## Flow + +1. **Candidate presents** - Takes a topic and presents a specific position +2. **Voters react** - Each voter responds based on their persona +3. **Candidate follows up** - Asks a probing question based on reactions +4. **Voters respond** - Answer the follow-up question +5. **Analyst summarizes** - Provides insights and recommendations + +## Default Personas + +### Maria (Independent) +- 34 years old, Phoenix, AZ +- Nurse, mother of two +- Top issues: healthcare costs, education, immigration +- Weak party identification + +### James (Republican) +- 58 years old, rural Ohio +- Former auto worker, small business owner +- Top issues: economy, manufacturing jobs, government spending +- Moderate party identification, skeptical of both parties + +## Customizing Personas + +Edit `agents.ts` and modify `SAMPLE_PERSONAS`: + +```typescript +export const SAMPLE_PERSONAS: VoterPersona[] = [ + { + name: 'Sarah', + age: 28, + location: 'Austin, TX', + party: 'Democrat', + leaningStrength: 'moderate', + topIssues: ['climate change', 'student debt', 'housing costs'], + background: 'Software engineer, renting, worried about buying a home.', + }, + // Add more personas... +]; +``` + +## Key Letta Features Demonstrated + +1. **Agent Persistence** - All agents remember previous sessions +2. **Multi-Agent Coordination** - Candidate, voters, and analyst interact +3. **Persona Memory** - Each voter maintains their identity in memory blocks +4. **Streaming Responses** - See responses as they're generated + +## Expanding to More Voters + +The architecture supports any number of voters. To add more: + +1. Add personas to `SAMPLE_PERSONAS` in `agents.ts` +2. The `FocusGroup` class automatically creates agents for each persona +3. Each new voter gets their own persistent agent + +## Example Session + +``` +$ bun cli.ts "raising the minimum wage" + +═══ CANDIDATE PRESENTS ═══ +Candidate: I support raising the minimum wage to $15/hour over three years. +This gives businesses time to adjust while ensuring workers can afford basic +necessities. No one working full-time should live in poverty. + +═══ VOTER REACTIONS ═══ +Maria: That really resonates with me. As a nurse, I see patients who can't +afford medications because they're working two jobs just to pay rent. $15 +feels like a starting point for dignity. + +James: I'm torn. My employees deserve better pay, but I'm already struggling +with costs. Three years might help, but I worry about the businesses that +can't absorb it. What happens to them? + +═══ FOLLOW-UP QUESTION ═══ +Candidate: James, if there were tax credits or support for small businesses +during the transition, would that change how you feel about it? + +═══ FOLLOW-UP RESPONSES ═══ +Maria: That actually makes me more confident. If we're supporting workers +AND small businesses, that's the kind of balanced approach I can get behind. + +James: Tax credits would help. I'm still skeptical, but at least you're +thinking about people like me. Most politicians forget we exist. + +═══ ANALYST INSIGHTS ═══ +Analyst: Key finding: the "three year transition" message softened James's +opposition. Maria is already supportive but responds to fairness framing. +James opened up when small business concerns were acknowledged directly - +quote: "at least you're thinking about people like me." + +Recommendation: Lead with the transition timeline and pair minimum wage +with small business support. This creates permission for moderate +Republicans to consider the position. +``` diff --git a/examples/focus-group/agents.ts b/examples/focus-group/agents.ts new file mode 100644 index 0000000..500f577 --- /dev/null +++ b/examples/focus-group/agents.ts @@ -0,0 +1,196 @@ +/** + * Focus Group Agents + * + * Creates and manages the three types of agents: + * 1. Candidate - presents positions, asks follow-ups + * 2. Voters - respond based on their persona + * 3. Analyst - provides focus group analysis + */ + +import { createSession, resumeSession, type Session } from '../../src/index.js'; +import { VoterPersona, CONFIG } from './types.js'; + +// ============================================================================ +// CANDIDATE AGENT +// ============================================================================ + +const CANDIDATE_PROMPT = `You are a political candidate presenting your positions to a focus group. + +Your role: +- Present clear, specific policy positions when asked +- Listen to voter feedback and ask thoughtful follow-up questions +- Stay focused on understanding voter concerns +- Be authentic but strategic + +When presenting a position: +- Be specific about what you would do +- Explain the reasoning briefly +- Keep it to 2-3 sentences + +When asking follow-ups: +- Probe deeper into concerns raised +- Ask about trade-offs they'd accept +- Keep questions focused and open-ended`; + +export async function createCandidateAgent(): Promise { + return createSession({ + model: CONFIG.model, + systemPrompt: CANDIDATE_PROMPT, + memory: [ + { + label: 'positions', + value: '# My Positions\n\n(Positions I\'ve presented)', + description: 'Policy positions I have presented to focus groups', + }, + { + label: 'voter-insights', + value: '# Voter Insights\n\n(What I\'ve learned about voter concerns)', + description: 'Insights gathered from voter feedback', + }, + ], + permissionMode: 'bypassPermissions', + }); +} + +export async function resumeCandidateAgent(agentId: string): Promise { + return resumeSession(agentId, { + model: CONFIG.model, + permissionMode: 'bypassPermissions', + }); +} + +// ============================================================================ +// VOTER AGENT +// ============================================================================ + +function buildVoterPrompt(persona: VoterPersona): string { + const partyDesc = persona.leaningStrength === 'strong' + ? `strongly identifies as ${persona.party}` + : persona.leaningStrength === 'moderate' + ? `leans ${persona.party}` + : `weakly identifies as ${persona.party}`; + + return `You are ${persona.name}, a ${persona.age}-year-old voter from ${persona.location}. + +YOUR IDENTITY: +- You ${partyDesc} +- Your top issues: ${persona.topIssues.join(', ')} +- Background: ${persona.background} + +YOUR ROLE IN THIS FOCUS GROUP: +- React authentically to political positions based on your persona +- Share how positions make you FEEL, not just what you think +- Be specific about what resonates or concerns you +- You can be persuaded but stay true to your core values + +RESPONSE STYLE: +- Speak naturally, as yourself (first person) +- Keep responses to 2-4 sentences +- Show emotional reactions when appropriate +- Reference your personal situation when relevant`; +} + +export async function createVoterAgent(persona: VoterPersona): Promise { + return createSession({ + model: CONFIG.model, + systemPrompt: buildVoterPrompt(persona), + memory: [ + { + label: 'my-identity', + value: `# Who I Am + +Name: ${persona.name} +Age: ${persona.age} +Location: ${persona.location} +Party: ${persona.party} (${persona.leaningStrength}) +Top Issues: ${persona.topIssues.join(', ')} +Background: ${persona.background}`, + description: 'My demographic information and political identity', + }, + { + label: 'my-reactions', + value: '# My Reactions\n\n(How I\'ve felt about positions presented)', + description: 'My emotional reactions to political positions', + }, + ], + permissionMode: 'bypassPermissions', + }); +} + +export async function resumeVoterAgent(agentId: string): Promise { + return resumeSession(agentId, { + model: CONFIG.model, + permissionMode: 'bypassPermissions', + }); +} + +// ============================================================================ +// ANALYST AGENT +// ============================================================================ + +const ANALYST_PROMPT = `You are a focus group analyst observing voter reactions to political messaging. + +Your role: +- Observe voter responses and identify patterns +- Note which messages resonate and which fall flat +- Identify persuadable voters and potential wedge issues +- Provide actionable insights for the candidate + +Analysis style: +- Be specific and cite voter quotes +- Identify emotional triggers +- Note differences between voter segments +- Keep analysis concise but substantive (4-6 sentences) +- End with 1-2 tactical recommendations`; + +export async function createAnalystAgent(): Promise { + return createSession({ + model: CONFIG.model, + systemPrompt: ANALYST_PROMPT, + memory: [ + { + label: 'session-notes', + value: '# Focus Group Notes\n\n(Observations from sessions)', + description: 'Running notes on voter reactions and patterns', + }, + { + label: 'recommendations', + value: '# Strategic Recommendations\n\n(Messaging recommendations)', + description: 'Tactical recommendations based on focus group insights', + }, + ], + permissionMode: 'bypassPermissions', + }); +} + +export async function resumeAnalystAgent(agentId: string): Promise { + return resumeSession(agentId, { + model: CONFIG.model, + permissionMode: 'bypassPermissions', + }); +} + +// ============================================================================ +// SAMPLE PERSONAS +// ============================================================================ + +export const SAMPLE_PERSONAS: VoterPersona[] = [ + { + name: 'Maria', + age: 34, + location: 'Phoenix, AZ', + party: 'Independent', + leaningStrength: 'weak', + topIssues: ['healthcare costs', 'education', 'immigration'], + background: 'Nurse and mother of two. Worried about affording childcare and her kids\' future.', + }, + { + name: 'James', + age: 58, + location: 'Rural Ohio', + party: 'Republican', + leaningStrength: 'moderate', + topIssues: ['economy', 'manufacturing jobs', 'government spending'], + background: 'Former auto worker, now runs a small business. Skeptical of both parties.', + }, +]; diff --git a/examples/focus-group/cli.ts b/examples/focus-group/cli.ts new file mode 100755 index 0000000..990ee3a --- /dev/null +++ b/examples/focus-group/cli.ts @@ -0,0 +1,147 @@ +#!/usr/bin/env bun + +/** + * Focus Group CLI + * + * Simulate a political focus group with AI personas. + * + * Usage: + * bun cli.ts "healthcare" # Run focus group on healthcare + * bun cli.ts # Interactive mode + * bun cli.ts --status # Show agent status + * bun cli.ts --reset # Reset all agents + * + * The focus group includes: + * - A candidate who presents positions + * - Voters with distinct personas who react + * - An analyst who provides insights + * + * Agents persist across sessions, so they remember previous discussions. + */ + +import { parseArgs } from 'node:util'; +import { + FocusGroup, + loadState, + resetFocusGroup, + showStatus, +} from './focus-group.js'; + +async function main() { + const { values, positionals } = parseArgs({ + args: process.argv.slice(2), + options: { + status: { type: 'boolean', default: false }, + reset: { type: 'boolean', default: false }, + help: { type: 'boolean', short: 'h', default: false }, + }, + allowPositionals: true, + }); + + if (values.help) { + printHelp(); + return; + } + + if (values.reset) { + await resetFocusGroup(); + return; + } + + if (values.status) { + await showStatus(); + return; + } + + // Load state and create focus group + const state = await loadState(); + const focusGroup = new FocusGroup(state); + + try { + await focusGroup.initialize(); + + if (positionals.length > 0) { + // Run on specified topic + const topic = positionals.join(' '); + await focusGroup.runRound(topic); + } else { + // Interactive mode + await interactiveMode(focusGroup); + } + } finally { + focusGroup.close(); + } +} + +async function interactiveMode(focusGroup: FocusGroup): Promise { + const readline = await import('node:readline'); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const ask = (prompt: string): Promise => { + return new Promise((resolve) => { + rl.question(prompt, (answer) => resolve(answer)); + }); + }; + + console.log('\n📊 Focus Group - Interactive Mode'); + console.log('Enter topics to test, or "quit" to exit.\n'); + + while (true) { + const topic = await ask('\x1b[1mTopic:\x1b[0m '); + + if (topic.toLowerCase() === 'quit' || topic.toLowerCase() === 'exit') { + break; + } + + if (!topic.trim()) continue; + + await focusGroup.runRound(topic); + console.log('\n' + '─'.repeat(60) + '\n'); + } + + rl.close(); +} + +function printHelp() { + console.log(` +📊 Focus Group Simulator + +Test political messaging against AI voter personas. + +USAGE: + bun cli.ts [topic] Run focus group on a topic + bun cli.ts Interactive mode + bun cli.ts --status Show agent status + bun cli.ts --reset Reset all agents + bun cli.ts -h, --help Show this help + +EXAMPLES: + bun cli.ts "healthcare reform" + bun cli.ts "immigration policy" + bun cli.ts "tax cuts vs government services" + +HOW IT WORKS: + 1. Candidate presents a position on the topic + 2. Voters (Maria & James) react based on their personas + 3. Candidate asks a follow-up question + 4. Voters respond to the follow-up + 5. Analyst provides insights and recommendations + +VOTER PERSONAS: + Maria - 34yo Independent from Phoenix, AZ + Nurse, mom of two. Cares about healthcare, education. + + James - 58yo moderate Republican from rural Ohio + Former auto worker, small business owner. Skeptical. + +PERSISTENCE: + Agents remember previous sessions. Run multiple topics to see + how insights accumulate over time. +`); +} + +main().catch(console.error); diff --git a/examples/focus-group/focus-group.ts b/examples/focus-group/focus-group.ts new file mode 100644 index 0000000..dd86d7d --- /dev/null +++ b/examples/focus-group/focus-group.ts @@ -0,0 +1,329 @@ +/** + * Focus Group Session + * + * Orchestrates the interaction between candidate, voters, and analyst. + * + * Flow: + * 1. Candidate presents a position + * 2. Each voter responds + * 3. Candidate asks a follow-up question + * 4. Each voter responds to the follow-up + * 5. Analyst provides summary and insights + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Session } from '../../src/index.js'; +import { + FocusGroupState, + VoterPersona, + FocusGroupRound, +} from './types.js'; +import { + createCandidateAgent, + resumeCandidateAgent, + createVoterAgent, + resumeVoterAgent, + createAnalystAgent, + resumeAnalystAgent, + SAMPLE_PERSONAS, +} from './agents.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const STATE_FILE = join(__dirname, 'state.json'); + +// ANSI colors for terminal output +const C = { + reset: '\x1b[0m', + dim: '\x1b[2m', + candidate: '\x1b[36m', // cyan + voter1: '\x1b[33m', // yellow + voter2: '\x1b[35m', // magenta + analyst: '\x1b[32m', // green + header: '\x1b[1m', // bold +}; + +// ============================================================================ +// STATE MANAGEMENT +// ============================================================================ + +export async function loadState(): Promise { + if (existsSync(STATE_FILE)) { + return JSON.parse(await readFile(STATE_FILE, 'utf-8')); + } + return { + candidateAgentId: null, + voterAgentIds: {}, + analystAgentId: null, + sessionCount: 0, + }; +} + +export async function saveState(state: FocusGroupState): Promise { + await writeFile(STATE_FILE, JSON.stringify(state, null, 2)); +} + +// ============================================================================ +// AGENT COMMUNICATION +// ============================================================================ + +/** + * Send a message to an agent and collect the full response + */ +async function chat(session: Session, message: string): Promise { + await session.send(message); + + let response = ''; + for await (const msg of session.stream()) { + if (msg.type === 'assistant') { + response += msg.content; + } + } + return response.trim(); +} + +/** + * Send a message and stream the response to console + */ +async function chatWithOutput( + session: Session, + message: string, + color: string, + label: string +): Promise { + process.stdout.write(`\n${C.header}${label}:${C.reset} `); + + await session.send(message); + + let response = ''; + for await (const msg of session.stream()) { + if (msg.type === 'assistant') { + response += msg.content; + process.stdout.write(`${color}${msg.content}${C.reset}`); + } + } + console.log(); // newline + return response.trim(); +} + +// ============================================================================ +// FOCUS GROUP SESSION +// ============================================================================ + +export class FocusGroup { + private state: FocusGroupState; + private candidate: Session | null = null; + private voters: Map = new Map(); + private analyst: Session | null = null; + private personas: VoterPersona[]; + + constructor(state: FocusGroupState, personas: VoterPersona[] = SAMPLE_PERSONAS) { + this.state = state; + this.personas = personas; + } + + /** + * Initialize all agents (create new or resume existing) + */ + async initialize(): Promise { + console.log(`${C.dim}Initializing focus group...${C.reset}\n`); + + // Candidate + if (this.state.candidateAgentId) { + console.log(`${C.dim}Resuming candidate agent...${C.reset}`); + this.candidate = await resumeCandidateAgent(this.state.candidateAgentId); + } else { + console.log(`${C.dim}Creating candidate agent...${C.reset}`); + this.candidate = await createCandidateAgent(); + } + + // Voters + for (const persona of this.personas) { + const existingId = this.state.voterAgentIds[persona.name]; + if (existingId) { + console.log(`${C.dim}Resuming voter: ${persona.name}...${C.reset}`); + this.voters.set(persona.name, await resumeVoterAgent(existingId)); + } else { + console.log(`${C.dim}Creating voter: ${persona.name}...${C.reset}`); + this.voters.set(persona.name, await createVoterAgent(persona)); + } + } + + // Analyst + if (this.state.analystAgentId) { + console.log(`${C.dim}Resuming analyst agent...${C.reset}`); + this.analyst = await resumeAnalystAgent(this.state.analystAgentId); + } else { + console.log(`${C.dim}Creating analyst agent...${C.reset}`); + this.analyst = await createAnalystAgent(); + } + + console.log(`${C.dim}All agents ready.${C.reset}\n`); + } + + /** + * Run a focus group round on a topic + */ + async runRound(topic: string): Promise { + const round: FocusGroupRound = { + position: '', + voterResponses: [], + }; + + // Step 1: Candidate presents position + console.log(`${C.header}═══ CANDIDATE PRESENTS ═══${C.reset}`); + round.position = await chatWithOutput( + this.candidate!, + `Present your position on: ${topic}\n\nBe specific about what you would do. Keep it to 2-3 sentences.`, + C.candidate, + 'Candidate' + ); + + // Step 2: Voters respond + console.log(`\n${C.header}═══ VOTER REACTIONS ═══${C.reset}`); + const voterColors = [C.voter1, C.voter2]; + let i = 0; + for (const [name, voter] of this.voters) { + const reaction = await chatWithOutput( + voter, + `A political candidate just said:\n\n"${round.position}"\n\nHow does this make you feel? React as yourself.`, + voterColors[i % voterColors.length], + name + ); + round.voterResponses.push({ voterName: name, reaction }); + i++; + } + + // Step 3: Candidate asks follow-up + console.log(`\n${C.header}═══ FOLLOW-UP QUESTION ═══${C.reset}`); + const voterSummary = round.voterResponses + .map(r => `${r.voterName}: "${r.reaction}"`) + .join('\n\n'); + + round.followUpQuestion = await chatWithOutput( + this.candidate!, + `Here's how the voters reacted to your position:\n\n${voterSummary}\n\nAsk ONE follow-up question to dig deeper into their concerns.`, + C.candidate, + 'Candidate' + ); + + // Step 4: Voters respond to follow-up + console.log(`\n${C.header}═══ FOLLOW-UP RESPONSES ═══${C.reset}`); + round.followUpResponses = []; + i = 0; + for (const [name, voter] of this.voters) { + const reaction = await chatWithOutput( + voter, + `The candidate asks: "${round.followUpQuestion}"\n\nAnswer honestly based on your perspective.`, + voterColors[i % voterColors.length], + name + ); + round.followUpResponses.push({ voterName: name, reaction }); + i++; + } + + // Step 5: Analyst provides insights + console.log(`\n${C.header}═══ ANALYST INSIGHTS ═══${C.reset}`); + const fullTranscript = ` +TOPIC: ${topic} + +CANDIDATE'S POSITION: +"${round.position}" + +INITIAL REACTIONS: +${round.voterResponses.map(r => `${r.voterName}: "${r.reaction}"`).join('\n')} + +FOLLOW-UP QUESTION: +"${round.followUpQuestion}" + +FOLLOW-UP RESPONSES: +${round.followUpResponses.map(r => `${r.voterName}: "${r.reaction}"`).join('\n')} +`; + + round.analysis = await chatWithOutput( + this.analyst!, + `Analyze this focus group exchange:\n${fullTranscript}\n\nProvide insights on what worked, what didn't, and recommendations.`, + C.analyst, + 'Analyst' + ); + + // Save agent IDs after first interaction + await this.saveAgentIds(); + + return round; + } + + /** + * Save agent IDs to state + */ + private async saveAgentIds(): Promise { + let needsSave = false; + + if (!this.state.candidateAgentId && this.candidate?.agentId) { + this.state.candidateAgentId = this.candidate.agentId; + needsSave = true; + } + + for (const [name, voter] of this.voters) { + if (!this.state.voterAgentIds[name] && voter.agentId) { + this.state.voterAgentIds[name] = voter.agentId; + needsSave = true; + } + } + + if (!this.state.analystAgentId && this.analyst?.agentId) { + this.state.analystAgentId = this.analyst.agentId; + needsSave = true; + } + + if (needsSave) { + this.state.sessionCount++; + await saveState(this.state); + console.log(`\n${C.dim}[Agents saved - session #${this.state.sessionCount}]${C.reset}`); + } + } + + /** + * Close all sessions + */ + close(): void { + this.candidate?.close(); + for (const voter of this.voters.values()) { + voter.close(); + } + this.analyst?.close(); + } +} + +/** + * Reset focus group state + */ +export async function resetFocusGroup(): Promise { + if (existsSync(STATE_FILE)) { + const { unlink } = await import('node:fs/promises'); + await unlink(STATE_FILE); + } + console.log('Focus group reset. All agents forgotten.'); +} + +/** + * Show current status + */ +export async function showStatus(): Promise { + const state = await loadState(); + console.log('\n📊 Focus Group Status\n'); + console.log(`Sessions: ${state.sessionCount}`); + console.log(`Candidate: ${state.candidateAgentId || '(not created)'}`); + console.log(`Analyst: ${state.analystAgentId || '(not created)'}`); + console.log(`Voters:`); + for (const [name, id] of Object.entries(state.voterAgentIds)) { + console.log(` - ${name}: ${id}`); + } + if (Object.keys(state.voterAgentIds).length === 0) { + console.log(' (none created)'); + } + console.log(); +} diff --git a/examples/focus-group/types.ts b/examples/focus-group/types.ts new file mode 100644 index 0000000..6c19411 --- /dev/null +++ b/examples/focus-group/types.ts @@ -0,0 +1,47 @@ +/** + * Focus Group Types + * + * This demo simulates a political focus group where: + * - A candidate presents positions to voters + * - Voters (with distinct personas) respond with their reactions + * - An analyst observes and provides insights + */ + +// Voter persona definition - describes who the voter is +export interface VoterPersona { + name: string; + age: number; + location: string; + party: 'Democrat' | 'Republican' | 'Independent'; + leaningStrength: 'strong' | 'moderate' | 'weak'; + topIssues: string[]; // What they care about most + background: string; // Brief description +} + +// State persisted to disk +export interface FocusGroupState { + candidateAgentId: string | null; + voterAgentIds: Record; // persona name -> agent ID + analystAgentId: string | null; + sessionCount: number; +} + +// A single exchange in the focus group +export interface FocusGroupRound { + position: string; // What the candidate presented + voterResponses: { + voterName: string; + reaction: string; + }[]; + followUpQuestion?: string; // Candidate's follow-up + followUpResponses?: { + voterName: string; + reaction: string; + }[]; + analysis?: string; // Analyst's summary +} + +// Configuration +export const CONFIG = { + model: 'haiku', // Fast and cheap for demos +};