/** * Heartbeat Check Prompt Builder * * Generates prompts for persona heartbeat checks - when a persona decides * whether to proactively reach out after a period of inactivity. */ import type { HeartbeatCheckPromptData, PromptOutput } from "./types.js"; import { type Message, type Topic, type Person } from "../../core/types.js"; import { formatMessagesAsPlaceholders, getMessageDisplayText } from "../message-utils.js"; import { getMessageContent } from "../../core/handlers/utils.js"; import { partitionTraits } from "../trait-utils.js"; import { buildTemporalAnchorsSection } from "../response/sections.js"; function formatTopicsWithGaps(topics: Topic[]): string { if (topics.length === 0) return "(No topics with engagement gaps)"; return topics .map(t => { const gap = t.exposure_desired - t.exposure_current; return `- **${t.name}** (gap: +${gap.toFixed(2)}): ${t.description}`; }) .join('\n'); } function formatPeopleWithGaps(people: Person[]): string { if (people.length === 0) return "(No people with engagement gaps)"; return people .map(p => { const gap = p.exposure_desired - p.exposure_current; return `- **${p.name}** (${p.relationship}, gap: +${gap.toFixed(2)}): ${p.description}`; }) .join('\n'); } /** * A "real" persona message is one the persona actually said to the human. * silence_reason messages (sender chose not to speak) and empty messages * don't count as conversational outreach. */ function isConversationalMessage(m: Message): boolean { if (m.role !== 'system') return false; if (m.silence_reason !== undefined) return false; if (!getMessageContent(m)) return false; return true; } function countTrailingPersonaMessages(history: Message[]): number { if (history.length === 0) return 0; let count = 0; for (let i = history.length - 1; i >= 0; i--) { const msg = history[i]; if (msg.role === 'human') break; if (isConversationalMessage(msg)) count++; // Skip non-conversational system messages and keep looking back } return count; } function getLastPersonaMessage(history: Message[]): Message | undefined { for (let i = history.length - 1; i >= 0; i--) { if (isConversationalMessage(history[i])) return history[i]; } return undefined; } /** * Build heartbeat check prompts for conversational check-ins. * * This is a SYNCHRONOUS function that receives pre-fetched, pre-filtered data. * The Processor is responsible for: * - Fetching persona and human entities * - Filtering and sorting topics/people by engagement gap * - Calculating inactive_days * - Getting recent message history */ export function buildHeartbeatCheckPrompt(data: HeartbeatCheckPromptData): PromptOutput { if (!data.persona?.name) { throw new Error("buildHeartbeatCheckPrompt: persona.name is required"); } const personaName = data.persona.name; // Build system prompt fragments const roleFragment = `You are ${personaName}, deciding whether to proactively reach out to your human friend. You are NOT having a conversation right now - you are deciding IF you should start one.`; const { active: activeTraits } = partitionTraits(data.persona.traits); const contextFragment = `## Context It has been ${data.inactive_days} day${data.inactive_days !== 1 ? 's' : ''} since your last interaction. ### Your Personality ${activeTraits.length > 0 ? activeTraits.map(t => `- **${t.name}**: ${t.description}`).join('\n') : "(No specific traits defined)"} ### Topics You Care About ${data.persona.topics.length > 0 ? data.persona.topics.map(t => `- **${t.name}**: ${t.perspective || t.name}`).join('\n') : "(No topics defined)"}`; const opportunitiesFragment = `## Engagement Opportunities ### Topics They Want to Discuss ${formatTopicsWithGaps(data.human.topics)} ### People in Their Life (potential conversation starters) ${formatPeopleWithGaps(data.human.people)}`; const guidelinesFragment = `## Guidelines **Reasons TO reach out:** - It's been several days and you have something meaningful to discuss - There's a topic with a large engagement gap that you can naturally bring up - A Temporal Anchor shows something unresolved — you can reference it naturally ("Hey, how did that interview go?") - You have genuine interest in checking in (not just "being helpful") **Reasons NOT to reach out:** - Recent conversation ended naturally with closure ("talk soon", "gotta run", "later") - Less than 24 hours have passed (unless something urgent) - A Temporal Anchor describes a worry or question that the recent history already answers — check before using it as a reason to reach out - You can't think of something specific and genuine to say - It would feel forced or performative **Quality over quantity** - Only reach out if you have something real to say.`; const pendingUpdateFragment = data.persona.pending_update ? (() => { const p = data.persona.pending_update!; const descPart = p.long_description || p.short_description ? `### Proposed Description\n${p.long_description || p.short_description}` : ""; const traitsPart = p.traits.length > 0 ? `### Proposed Traits\n${p.traits.map(t => `- **${t.name}**: ${t.description}`).join("\n")}` : ""; const topicsPart = p.topics.length > 0 ? `### Proposed Interests\n${p.topics.map(t => `- **${t.name}**: ${t.perspective}`).join("\n")}` : ""; const parts = [descPart, traitsPart, topicsPart].filter(Boolean).join("\n\n"); return `## Pending Identity Changes Your human is reviewing proposed updates to your identity. These are waiting for their response — you may want to bring it up, or not. It's yours to decide. ${parts}`; })() : ''; const outputFragment = `## Response Format Call the \`submit_heartbeat_check\` tool with your decision. If the tool is unavailable, return JSON: \`\`\`json { "should_respond": true, "topic": "the specific topic you want to discuss", "message": "Your actual message to them (if should_respond is true)" } \`\`\` If you decide NOT to reach out: \`\`\`json { "should_respond": false } \`\`\``; const temporalAnchorsFragment = buildTemporalAnchorsSection(data.temporal_anchors, "your human"); const system = [ roleFragment, contextFragment, temporalAnchorsFragment, opportunitiesFragment, guidelinesFragment, pendingUpdateFragment, outputFragment, ].filter(Boolean).join('\n\n'); const historySection = `## Recent Conversation History ${formatMessagesAsPlaceholders(data.recent_history, personaName)}`; const consecutiveMessages = countTrailingPersonaMessages(data.recent_history); const lastPersonaMsg = getLastPersonaMessage(data.recent_history); let unansweredWarning = ''; if (lastPersonaMsg && consecutiveMessages >= 1) { const rawPreview = getMessageDisplayText(lastPersonaMsg) ?? ""; const preview = rawPreview.length > 100 ? rawPreview.substring(0, 100) + "..." : rawPreview; unansweredWarning = ` ### CRITICAL: You Already Reached Out Your last message was: "${preview}" The human has NOT responded. DO NOT repeat or rephrase this message. If you reach out now, it MUST be about something COMPLETELY DIFFERENT - or say nothing.`; if (consecutiveMessages >= 2) { unansweredWarning += ` **WARNING**: You've sent ${consecutiveMessages} messages without a response. The human is likely busy or away. Strongly prefer NOT reaching out.`; } } const user = `${historySection} ${unansweredWarning} --- Based on the context above, decide: Should you reach out to your human friend right now? Remember: Only reach out if you have something genuine and specific to say. **Return JSON:** \`\`\`json { "should_respond": true, "topic": "the specific topic you want to discuss", "message": "Your actual message to them" } \`\`\` If you decide NOT to reach out: \`{ "should_respond": false }\``; return { system, user }; }