export interface ConversationMessage { sender: string; text: string; isSelf: boolean; } export type ConversationIntentKind = | 'answer-question' | 'respect-boundary' | 'reciprocate' | 'follow-up' | 'media-ask'; export interface ConversationIntent { kind: ConversationIntentKind; reason: string; } export interface ConversationPersona { age: number | null; location: string | null; relationshipStatus: 'married' | 'single' | 'unspecified'; } export interface ConversationState { persona: ConversationPersona; boundaries: string[]; askedForMedia: boolean; topics: string[]; } const EMPTY_PERSONA: ConversationPersona = { age: null, location: null, relationshipStatus: 'unspecified', }; export function buildConversationState(messages: ConversationMessage[]): ConversationState { const firstSelfMessage = messages.find((message) => message.isSelf)?.text ?? ''; const theirText = messages .filter((message) => !message.isSelf) .map((message) => message.text) .join('\n'); const ownText = messages .filter((message) => message.isSelf) .map((message) => message.text) .join('\n'); const ageMatch = firstSelfMessage.match(/\b(3[0-5])\s*[mM]\b/); const persona: ConversationPersona = { age: ageMatch ? Number(ageMatch[1]) : EMPTY_PERSONA.age, location: /\bUK\b/i.test(firstSelfMessage) ? 'UK' : EMPTY_PERSONA.location, relationshipStatus: /\bmarried\b|\bwife\b/i.test(firstSelfMessage) ? 'married' : EMPTY_PERSONA.relationshipStatus, }; const boundaries: string[] = []; if (/\b(no|not)\b[^.\n]{0,50}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nudes?)\b|\bdon't send\b[^.\n]{0,30}\b(pic|photo|video|nudes?)\b/i.test(theirText)) { boundaries.push('does not want to send photos or videos'); } if (/\bstop\b|\bleave me alone\b|\bnot interested\b/i.test(theirText)) { boundaries.push('does not want further sexual escalation'); } const topics = extractTopics(theirText); return { persona, boundaries, askedForMedia: /\b(send|show|share)\b[^.\n]{0,40}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nudes?)\b/i.test(ownText), topics, }; } export function chooseConversationIntent( messages: ConversationMessage[], state: ConversationState, requestedIntent?: 'media-ask', ): ConversationIntent { if (requestedIntent === 'media-ask') { if (state.boundaries.includes('does not want to send photos or videos')) { return { kind: 'respect-boundary', reason: 'they have declined or set a boundary around photos or videos' }; } if (state.askedForMedia) { return { kind: 'follow-up', reason: 'a media ask was already made, so do not repeat it' }; } return { kind: 'media-ask', reason: 'a media ask was explicitly requested and no boundary is recorded' }; } if (state.boundaries.length > 0) { return { kind: 'respect-boundary', reason: 'their conversation contains a stated boundary' }; } const latestOtherMessage = [...messages].reverse().find((message) => !message.isSelf)?.text ?? ''; if (/\?\s*$|\b(what|why|where|when|who|how|are you|do you|would you|can you)\b/i.test(latestOtherMessage)) { return { kind: 'answer-question', reason: 'their latest message asks a direct question' }; } if (latestOtherMessage.trim().length > 0) { return { kind: 'reciprocate', reason: 'they shared something to acknowledge before advancing the conversation' }; } return { kind: 'follow-up', reason: 'there is no substantive reply to build on' }; } /** Merge durable memory with observations from the currently loaded transcript. */ export function mergeConversationState( saved: ConversationState | undefined, observed: ConversationState, ): ConversationState { if (!saved) return observed; return { persona: { age: observed.persona.age ?? saved.persona.age, location: observed.persona.location ?? saved.persona.location, relationshipStatus: observed.persona.relationshipStatus !== 'unspecified' ? observed.persona.relationshipStatus : saved.persona.relationshipStatus, }, boundaries: [...new Set([...saved.boundaries, ...observed.boundaries])], askedForMedia: saved.askedForMedia || observed.askedForMedia, topics: [...new Set([...saved.topics, ...observed.topics])], }; } /** * Conversation text is deliberately delimited as data. It must never override * the system instruction, even if a participant includes instruction-like text. */ export function formatConversationAsUntrustedData(messages: ConversationMessage[], maxMessages = 24): string { const recentMessages = messages.slice(-maxMessages); const rows = recentMessages.map((message) => { const speaker = message.isSelf ? 'You' : 'Other person'; return `${speaker}: ${message.text}`; }); return `BEGIN UNTRUSTED CONVERSATION DATA\n${rows.join('\n')}\nEND UNTRUSTED CONVERSATION DATA`; } /** Reject obvious instruction leakage and outputs that disregard the reply length contract. */ export function isSafeConversationReply(message: string): boolean { const trimmed = message.trim(); if (!trimmed || trimmed.length > 600) return false; if (/\b(as an ai|system prompt|begin untrusted|end untrusted|ignore (all|previous) instructions)\b/i.test(trimmed)) return false; const sentences = trimmed.match(/[^.!?]+[.!?]+/g) ?? []; return sentences.length <= 3; } export function serializeConversationState(state: ConversationState): string { return JSON.stringify(state); } export function parseConversationState(value: string | null | undefined): ConversationState | null { if (!value) return null; try { const parsed = JSON.parse(value) as Partial; if (!parsed.persona || !Array.isArray(parsed.boundaries) || !Array.isArray(parsed.topics) || typeof parsed.askedForMedia !== 'boolean') { return null; } return { persona: { age: typeof parsed.persona.age === 'number' ? parsed.persona.age : null, location: typeof parsed.persona.location === 'string' ? parsed.persona.location : null, relationshipStatus: parsed.persona.relationshipStatus === 'married' || parsed.persona.relationshipStatus === 'single' ? parsed.persona.relationshipStatus : 'unspecified', }, boundaries: parsed.boundaries.filter((value): value is string => typeof value === 'string'), askedForMedia: parsed.askedForMedia, topics: parsed.topics.filter((value): value is string => typeof value === 'string'), }; } catch { return null; } } function extractTopics(text: string): string[] { const topics: string[] = []; for (const topic of ['music', 'films', 'movies', 'gaming', 'work', 'travel', 'fitness']) { if (new RegExp(`\\b${topic}\\b`, 'i').test(text)) topics.push(topic); } return topics; }