import { createHash } from 'node:crypto'; import type Database from 'better-sqlite3'; export const CONVERSATION_EVENT_ACTORS = ['recipient', 'ai', 'human', 'system', 'unknown'] as const; export type ConversationEventActor = (typeof CONVERSATION_EVENT_ACTORS)[number]; export const CONVERSATION_EVENT_KINDS = [ 'inbound-text', 'inbound-media', 'opener', 'automation-reply', 'manual-text', 'automated-library-media', 'manual-library-media', 'library-reciprocal', 'unknown-outbound', ] as const; export type ConversationEventKind = (typeof CONVERSATION_EVENT_KINDS)[number]; export type AttributionConfidence = 'exact' | 'fingerprint' | 'inferred'; export interface ConversationEventRecord { accountId: string; conversationId: string; eventId: string; eventTimestamp: number; direction: 'inbound' | 'outbound'; actor: ConversationEventActor; eventKind: ConversationEventKind; textSha256: string | null; sourceReference: string | null; attributionConfidence: AttributionConfidence; observedAt: number; } export interface UpsertConversationEvent { conversationId: string; eventId: string; eventTimestamp: number; direction: 'inbound' | 'outbound'; actor: ConversationEventActor; eventKind: ConversationEventKind; text?: string | null; sourceReference?: string | null; attributionConfidence: AttributionConfidence; observedAt?: number; } /** Account-scoped, text-minimising provenance for Matrix conversation events. */ export class ConversationEventRepository { constructor( private readonly db: Database.Database, private readonly accountId: string, ) {} upsert(input: UpsertConversationEvent): ConversationEventRecord { const observedAt = input.observedAt ?? Date.now(); const textSha256 = input.text ? conversationTextSha256(input.text) : null; this.db.prepare(` INSERT INTO conversation_events ( account_id, conversation_id, event_id, event_timestamp, direction, actor, event_kind, text_sha256, source_reference, attribution_confidence, observed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(account_id, conversation_id, event_id) DO UPDATE SET event_timestamp = excluded.event_timestamp, direction = excluded.direction, actor = CASE WHEN conversation_events.attribution_confidence = 'exact' THEN conversation_events.actor WHEN excluded.attribution_confidence = 'exact' THEN excluded.actor WHEN conversation_events.attribution_confidence = 'fingerprint' THEN conversation_events.actor ELSE excluded.actor END, event_kind = CASE WHEN conversation_events.attribution_confidence = 'exact' THEN conversation_events.event_kind WHEN excluded.attribution_confidence = 'exact' THEN excluded.event_kind WHEN conversation_events.attribution_confidence = 'fingerprint' THEN conversation_events.event_kind ELSE excluded.event_kind END, text_sha256 = COALESCE(excluded.text_sha256, conversation_events.text_sha256), source_reference = COALESCE(excluded.source_reference, conversation_events.source_reference), attribution_confidence = CASE WHEN conversation_events.attribution_confidence = 'exact' OR excluded.attribution_confidence = 'exact' THEN 'exact' WHEN conversation_events.attribution_confidence = 'fingerprint' OR excluded.attribution_confidence = 'fingerprint' THEN 'fingerprint' ELSE 'inferred' END, observed_at = MAX(conversation_events.observed_at, excluded.observed_at) `).run( this.accountId, input.conversationId, input.eventId, input.eventTimestamp, input.direction, input.actor, input.eventKind, textSha256, input.sourceReference ?? null, input.attributionConfidence, observedAt, ); return this.get(input.conversationId, input.eventId)!; } get(conversationId: string, eventId: string): ConversationEventRecord | undefined { const row = this.db.prepare(` SELECT * FROM conversation_events WHERE account_id = ? AND conversation_id = ? AND event_id = ? `).get(this.accountId, conversationId, eventId) as EventRow | undefined; return row ? mapRow(row) : undefined; } listConversation(conversationId: string): ConversationEventRecord[] { return (this.db.prepare(` SELECT * FROM conversation_events WHERE account_id = ? AND conversation_id = ? ORDER BY event_timestamp ASC, event_id ASC `).all(this.accountId, conversationId) as EventRow[]).map(mapRow); } countByConfidence(): Record { const result: Record = { exact: 0, fingerprint: 0, inferred: 0 }; const rows = this.db.prepare(` SELECT attribution_confidence, COUNT(*) AS total FROM conversation_events WHERE account_id = ? GROUP BY attribution_confidence `).all(this.accountId) as Array<{ attribution_confidence: AttributionConfidence; total: number }>; for (const row of rows) result[row.attribution_confidence] = row.total; return result; } } interface EventRow { account_id: string; conversation_id: string; event_id: string; event_timestamp: number; direction: 'inbound' | 'outbound'; actor: ConversationEventActor; event_kind: ConversationEventKind; text_sha256: string | null; source_reference: string | null; attribution_confidence: AttributionConfidence; observed_at: number; } function mapRow(row: EventRow): ConversationEventRecord { return { accountId: row.account_id, conversationId: row.conversation_id, eventId: row.event_id, eventTimestamp: row.event_timestamp, direction: row.direction, actor: row.actor, eventKind: row.event_kind, textSha256: row.text_sha256, sourceReference: row.source_reference, attributionConfidence: row.attribution_confidence, observedAt: row.observed_at, }; } export function conversationTextSha256(text: string): string { return createHash('sha256').update(text.trim()).digest('hex'); }