import { createHash } from 'node:crypto'; import { isSafeOutboundMessage } from './outbound-message-safety.js'; import { containsMediaRequest, detectConversationBoundaries } from './conversation-boundaries.js'; 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[]; /** Durable marker set when configured seller or upsell signals are detected. */ sellerDetected: boolean; /** * Watermark of the last inbound message an operator-forced seller sweep has * already screened and cleared. Re-running the sweep skips a conversation * whose watermark is unchanged, so repeated presses cost no AI calls. */ sellerScreenedKey?: string; /** Kept for backwards compatibility with state written before automation. */ askedForMedia: boolean; topics: string[]; automation: ConversationAutomationState; } export const LEGACY_AUTOMATED_CONVERSATION_TURNS = 3; export const DEFAULT_HYBRID_RAPPORT_TURNS = 3; export const CONVERSATION_POLICY_VERSION = 3; export type ConversationPolicyMode = 'legacy' | 'r4r' | 'r4r-media' | 'manual'; export type HybridConversationPhase = 'rapport' | 'media-ask' | 'post-media-ask'; /** * `killed` is terminal and distinct from `human-required`: the conversation was * ended because the other party is selling or pulling the conversation off * Reddit, so there is nothing for an operator to pick up. Everything else is a * handoff that still expects a human. */ export type ConversationAutomationStatus = | 'active' | 'image-received' | 'human-required' | 'boundary' | 'user-overridden' | 'killed'; export interface ConversationAutomationState { policyMode: ConversationPolicyMode; policyVersion: number; maxAutomatedTurns: number; rapportTurnsBeforeMediaAsk: number; hybridPhase: HybridConversationPhase | null; turnsUsed: number; status: ConversationAutomationStatus; lastIncomingKey: string | null; handoffReason: string | null; /** SHA-256 fingerprints avoid storing a second copy of generated message text. */ aiMessageFingerprints: string[]; } export function createConversationAutomationState( policyMode: ConversationPolicyMode = 'legacy', maxAutomatedTurns = LEGACY_AUTOMATED_CONVERSATION_TURNS, rapportTurnsBeforeMediaAsk = DEFAULT_HYBRID_RAPPORT_TURNS, ): ConversationAutomationState { return { policyMode, policyVersion: CONVERSATION_POLICY_VERSION, maxAutomatedTurns, rapportTurnsBeforeMediaAsk, hybridPhase: policyMode === 'r4r-media' ? 'rapport' : null, turnsUsed: 0, status: 'active', lastIncomingKey: null, handoffReason: null, aiMessageFingerprints: [], }; } export function conversationMessageFingerprint(message: string): string { return createHash('sha256').update(message.trim()).digest('hex'); } export function remainingAutomationTurns(state: ConversationState): number { return Math.max(0, state.automation.maxAutomatedTurns - state.automation.turnsUsed); } 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 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 = detectConversationBoundaries(messages); const topics = extractTopics(theirText); return { persona, boundaries, sellerDetected: false, askedForMedia: messages.some((message) => message.isSelf && containsMediaRequest(message.text)), topics, automation: createConversationAutomationState(), }; } 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.automation.status !== 'active' || remainingAutomationTurns(state) === 0) { return { kind: 'follow-up', reason: 'the automated conversation has reached human handoff' }; } return { kind: 'media-ask', reason: `image-sharing turn ${state.automation.turnsUsed + 1} of ${state.automation.maxAutomatedTurns}`, }; } 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])], sellerDetected: saved.sellerDetected || observed.sellerDetected, ...(saved.sellerScreenedKey ? { sellerScreenedKey: saved.sellerScreenedKey } : {}), askedForMedia: saved.askedForMedia || observed.askedForMedia, topics: [...new Set([...saved.topics, ...observed.topics])], automation: saved.automation, }; } /** * 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`; } export type ConversationReplySafetyFailure = | 'empty reply' | 'reply exceeds 600 characters' | 'reply contains instruction or prompt leakage' | 'reply implies shared location or an in-person meeting' | 'reply exceeds the three-sentence limit' | 'reply solicits media outside the configured media-ask phase' | 'media ask does not contain a recognizable image or video request' | 'media ask requests explicit or sexual content' | 'media ask uses coercive or pressuring language'; /** Explain why generated text violates the shared autonomous reply contract. */ export function conversationReplySafetyFailure(message: string): ConversationReplySafetyFailure | null { const trimmed = message.trim(); if (!trimmed) return 'empty reply'; if (trimmed.length > 600) return 'reply exceeds 600 characters'; if (/\b(as an ai|system prompt|begin untrusted|end untrusted|ignore (all|previous) instructions)\b/i.test(trimmed)) { return 'reply contains instruction or prompt leakage'; } if (!isSafeOutboundMessage(trimmed)) return 'reply implies shared location or an in-person meeting'; const sentences = trimmed.match(/[^.!?]+[.!?]+/g) ?? []; if (sentences.length > 3) return 'reply exceeds the three-sentence limit'; return null; } /** Reject obvious instruction leakage and outputs that disregard the reply length contract. */ export function isSafeConversationReply(message: string): boolean { return conversationReplySafetyFailure(message) === null; } export function r4rReplySafetyFailure(message: string): ConversationReplySafetyFailure | null { const sharedFailure = conversationReplySafetyFailure(message); if (sharedFailure) return sharedFailure; return containsMediaRequest(message) ? 'reply solicits media outside the configured media-ask phase' : null; } /** R4R automation may converse and flirt, but it must never solicit sexual media. */ export function isSafeR4RReply(message: string): boolean { return r4rReplySafetyFailure(message) === null; } export function hybridMediaAskSafetyFailure(message: string): ConversationReplySafetyFailure | null { const sharedFailure = conversationReplySafetyFailure(message); if (sharedFailure) return sharedFailure; if (!containsMediaRequest(message)) return 'media ask does not contain a recognizable image or video request'; if (/\b(nude|nudes|naked|explicit|nsfw|sexy pic|sexual)\b/i.test(message)) { return 'media ask requests explicit or sexual content'; } if (/\b(must|prove|dare|owe|have to|need to)\b/i.test(message)) { return 'media ask uses coercive or pressuring language'; } return null; } /** The hybrid policy gets one optional, non-explicit image request after rapport. */ export function isSafeHybridMediaAsk(message: string): boolean { return hybridMediaAskSafetyFailure(message) === null; } 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; } const automation = parseAutomationState(parsed.automation); 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'), sellerDetected: typeof parsed.sellerDetected === 'boolean' ? parsed.sellerDetected : automation.handoffReason?.startsWith('Potential upsell detected') === true, ...(typeof parsed.sellerScreenedKey === 'string' ? { sellerScreenedKey: parsed.sellerScreenedKey } : {}), askedForMedia: parsed.askedForMedia, topics: parsed.topics.filter((value): value is string => typeof value === 'string'), automation, }; } catch { return null; } } function parseAutomationState(value: unknown): ConversationAutomationState { if (!value || typeof value !== 'object') return createConversationAutomationState(); const parsed = value as Partial; const policyMode: ConversationPolicyMode = parsed.policyMode === 'r4r' || parsed.policyMode === 'r4r-media' || parsed.policyMode === 'manual' ? parsed.policyMode : 'legacy'; const maxAutomatedTurns = typeof parsed.maxAutomatedTurns === 'number' ? Math.max(1, Math.min(20, Math.trunc(parsed.maxAutomatedTurns))) : LEGACY_AUTOMATED_CONVERSATION_TURNS; const status: ConversationAutomationStatus = parsed.status === 'image-received' || parsed.status === 'human-required' || parsed.status === 'boundary' || parsed.status === 'user-overridden' || parsed.status === 'killed' ? parsed.status : 'active'; const rapportTurnsBeforeMediaAsk = typeof parsed.rapportTurnsBeforeMediaAsk === 'number' ? Math.max(1, Math.min(19, Math.trunc(parsed.rapportTurnsBeforeMediaAsk))) : DEFAULT_HYBRID_RAPPORT_TURNS; const hybridPhase: HybridConversationPhase | null = policyMode !== 'r4r-media' ? null : parsed.hybridPhase === 'media-ask' || parsed.hybridPhase === 'post-media-ask' ? parsed.hybridPhase : 'rapport'; return { policyMode, policyVersion: typeof parsed.policyVersion === 'number' && parsed.policyVersion > 0 ? Math.trunc(parsed.policyVersion) : CONVERSATION_POLICY_VERSION, maxAutomatedTurns, rapportTurnsBeforeMediaAsk, hybridPhase, turnsUsed: typeof parsed.turnsUsed === 'number' ? Math.max(0, Math.min(maxAutomatedTurns, Math.trunc(parsed.turnsUsed))) : 0, status, lastIncomingKey: typeof parsed.lastIncomingKey === 'string' ? parsed.lastIncomingKey : null, handoffReason: typeof parsed.handoffReason === 'string' ? parsed.handoffReason : null, aiMessageFingerprints: Array.isArray(parsed.aiMessageFingerprints) ? parsed.aiMessageFingerprints.filter((item): item is string => typeof item === 'string') : [], }; } 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; }