312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { isSafeOutboundMessage } from './outbound-message-safety.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;
|
|
/** 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 = 2;
|
|
export type ConversationPolicyMode = 'legacy' | 'r4r' | 'r4r-media' | 'manual';
|
|
export type HybridConversationPhase = 'rapport' | 'media-ask' | 'post-media-ask';
|
|
|
|
export type ConversationAutomationStatus =
|
|
| 'active'
|
|
| 'image-received'
|
|
| 'human-required'
|
|
| 'boundary'
|
|
| 'user-overridden';
|
|
|
|
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 {
|
|
if (state.automation.status !== 'active') return 0;
|
|
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 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,
|
|
sellerDetected: false,
|
|
askedForMedia: /\b(send|show|share)\b[^.\n]{0,40}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nudes?)\b/i.test(ownText),
|
|
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,
|
|
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`;
|
|
}
|
|
|
|
/** 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;
|
|
if (!isSafeOutboundMessage(trimmed)) return false;
|
|
const sentences = trimmed.match(/[^.!?]+[.!?]+/g) ?? [];
|
|
return sentences.length <= 3;
|
|
}
|
|
|
|
/** R4R automation may converse and flirt, but it must never solicit sexual media. */
|
|
export function isSafeR4RReply(message: string): boolean {
|
|
if (!isSafeConversationReply(message)) return false;
|
|
return !/\b(send|show|share|swap|trade|drop|give|dm)\b[^.!?\n]{0,60}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nude|nudes|content)\b/i.test(message)
|
|
&& !/\b(can|could|would|will) you\b[^.!?\n]{0,60}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nude|nudes)\b/i.test(message);
|
|
}
|
|
|
|
/** The hybrid policy gets one optional, non-explicit image request after rapport. */
|
|
export function isSafeHybridMediaAsk(message: string): boolean {
|
|
if (!isSafeConversationReply(message)) return false;
|
|
if (!/\b(pic|pics|photo|photos|selfie|selfies|video|videos)\b/i.test(message)) return false;
|
|
if (/\b(nude|nudes|naked|explicit|nsfw|sexy pic|sexual)\b/i.test(message)) return false;
|
|
return !/\b(must|prove|dare|owe|have to|need to)\b/i.test(message);
|
|
}
|
|
|
|
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<ConversationState>;
|
|
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,
|
|
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<ConversationAutomationState>;
|
|
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
|
|
: '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;
|
|
}
|