/** * Unified conversation log — the columns, outbox and migration that make * `chat.db` the machine's superset of every conversation surface * (specs/unified-conversations-cloud-store.md §A.4). * * Every message row carries first-class copies of what used to be hidden in * `metadata` JSON: * * - `source` — which surface it belongs to (slack, crewly-chat, cloud-talk, …) * - `direction` — `in` (to an agent), `out` (from an agent), `internal` * - `sender_kind` — `owner`, `agent`, `human` (someone else), `system` * - `agent_session` — the agent the message is to/from (denormalised) * - `ext_ref` — JSON of external ids (Slack workspace/channel/ts/thread, …) * - `cloud_sync` — 1 when the row may leave the machine, 0 when not (owner * decision O3: other people's chatter in shared Slack * channels stays local) * * A trigger on `chat_messages` fills `cloud_outbox` for every syncable insert, * so any writer — present or future, through `recordTurn` or not — produces * an upload row. * * @module services/chat-v2/sqlite/unified-log */ import { CONVERSATION_LOG_CONSTANTS } from '../../../constants.js'; import type { ChatChannelType, ChatSenderType } from '../types.js'; import type { ChatDatabase } from './chat-db.js'; /** Which surface a message belongs to. */ export type ConversationSource = (typeof CONVERSATION_LOG_CONSTANTS.SOURCES)[number]; /** Whether a message went to an agent, came from one, or neither. */ export type ConversationDirection = (typeof CONVERSATION_LOG_CONSTANTS.DIRECTIONS)[number]; /** Who wrote a message. */ export type ConversationSenderKind = (typeof CONVERSATION_LOG_CONSTANTS.SENDER_KINDS)[number]; /** External ids kept per message (only the fields that apply are present). */ export interface ConversationExtRef { slackTeamId?: string; slackChannelId?: string; /** Slack message ts */ ts?: string; /** Slack thread root ts */ threadTs?: string; slackUserId?: string; telegramChatId?: string; telegramMessageId?: string; gchatSpace?: string; gchatThread?: string; gchatMessage?: string; whatsappChatId?: string; } /** The derived columns written with every insert. */ export interface UnifiedColumns { source: ConversationSource; direction: ConversationDirection; senderKind: ConversationSenderKind; agentSession: string | null; extRef: ConversationExtRef | null; /** 1 = may be uploaded to Cloud, 0 = stays on this machine. */ cloudSync: 0 | 1; } /** Who the owner is on the surfaces that carry other people too. */ export interface OwnerIdentity { /** The owner's Slack user id (the person who installed the app), when known. */ slackUserId?: string | null; /** The Slack workspace this machine serves, used when a row carries no team id. */ slackTeamId?: string | null; } /** Inputs of {@link deriveUnifiedColumns}. */ export interface DeriveUnifiedColumnsInput { senderType: ChatSenderType; senderId: string; /** Parsed metadata (may be empty). */ metadata: Record; /** Mentioned agent sessions / member ids. */ mentions: readonly string[]; channel: { id: string; type: ChatChannelType; agentSession: string; }; /** * `source` column of the channel's most recent `user` row. An agent's reply * belongs to the surface it answers (spec §A.4: reply-tool takes the * inbound channel's source). */ latestInboundSource?: string | null; owner?: OwnerIdentity | null; } /** * Whether a value is one of the {@link ConversationSource} values. * * @param value - Candidate * @returns True for a known source */ export declare function isConversationSource(value: unknown): value is ConversationSource; /** * The surface a channel id names, for legacy rows whose tag is missing or * wrong (WhatsApp rows were tagged `slack` before G2). * * @param channelId - chat-v2 channel id * @returns The surface, or null when the id says nothing */ export declare function sourceFromChannelId(channelId: string): ConversationSource | null; /** * Derive the unified-log columns for one message. * * Pure: every input is passed in, so the same function serves live inserts * and the legacy-row backfill. * * O3 (owner decision 2026-09-28): in a shared Slack channel (a `huddle` or * `channel` room), only messages involving this account's agents may leave * the machine — the owner's own messages, any local agent's message, and a * message that @-mentions one of our agents. Anyone else's chatter is * `cloud_sync = 0`. * * @param input - Row and channel facts * @returns The derived columns * * @example * ```typescript * deriveUnifiedColumns({ * senderType: 'user', senderId: 'Maya', mentions: [], * metadata: { source: 'slack', slackUserId: 'U2' }, * channel: { id: 'huddle-1', type: 'huddle', agentSession: '' }, * owner: { slackUserId: 'U1' }, * }).cloudSync; // 0 — someone else's chatter * ``` */ export declare function deriveUnifiedColumns(input: DeriveUnifiedColumnsInput): UnifiedColumns; /** * Parse a stored metadata / mentions JSON column without throwing. * * @param raw - Column value * @returns Parsed object (metadata) — `{}` when missing or malformed */ export declare function parseMetadataColumn(raw: string | null | undefined): Record; /** * Parse a stored mentions JSON column without throwing. * * @param raw - Column value * @returns String entries, `[]` when missing or malformed */ export declare function parseMentionsColumn(raw: string | null | undefined): string[]; /** Columns added to `chat_messages` (name → ADD COLUMN clause). */ export declare const UNIFIED_LOG_MESSAGE_COLUMNS: ReadonlyArray<{ name: string; addClause: string; }>; /** * Outbox, sync state, per-agent index and outbox triggers. Idempotent. * * The triggers skip rows with `cloud_sync = 0` (O3). An update enqueues only * when user-visible content changes or a row becomes syncable (owner * reclassification); metadata-only bookkeeping does not. */ export declare const UNIFIED_LOG_SQL = "\nCREATE INDEX IF NOT EXISTS ix_messages_agent_created\n ON chat_messages(agent_session, created_at DESC);\n\n-- Backfill pages history in created_at order (spec \u00A7B.4).\nCREATE INDEX IF NOT EXISTS ix_messages_created\n ON chat_messages(created_at);\n\nCREATE TABLE IF NOT EXISTS cloud_outbox (\n seq INTEGER PRIMARY KEY AUTOINCREMENT,\n message_id TEXT NOT NULL,\n op TEXT NOT NULL CHECK(op IN ('upsert','delete')),\n enqueued_at INTEGER NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS cloud_sync_state (\n k TEXT PRIMARY KEY,\n v TEXT\n);\n\nCREATE TRIGGER IF NOT EXISTS trg_outbox_ins AFTER INSERT ON chat_messages\n WHEN COALESCE(NEW.cloud_sync, 1) = 1\n BEGIN\n INSERT INTO cloud_outbox(message_id, op, enqueued_at) VALUES (NEW.id, 'upsert', NEW.created_at);\n END;\n\nCREATE TRIGGER IF NOT EXISTS trg_outbox_upd AFTER UPDATE OF content, cloud_sync ON chat_messages\n WHEN COALESCE(NEW.cloud_sync, 1) = 1\n AND (NEW.content IS NOT OLD.content OR COALESCE(OLD.cloud_sync, 1) <> 1)\n BEGIN\n INSERT INTO cloud_outbox(message_id, op, enqueued_at)\n VALUES (NEW.id, 'upsert', CAST(strftime('%s','now') AS INTEGER) * 1000);\n END;\n\nCREATE TRIGGER IF NOT EXISTS trg_outbox_del AFTER DELETE ON chat_messages\n WHEN COALESCE(OLD.cloud_sync, 1) = 1\n BEGIN\n INSERT INTO cloud_outbox(message_id, op, enqueued_at)\n VALUES (OLD.id, 'delete', CAST(strftime('%s','now') AS INTEGER) * 1000);\n END;\n"; /** Report of {@link applyUnifiedLogUpgrades}. */ export interface UnifiedLogUpgradeReport { /** Columns added to `chat_messages` on this run. */ columnsAdded: string[]; /** Legacy rows given unified-log columns on this run. */ rowsBackfilled: number; } /** * Bring `chat.db` up to the unified-log schema: add the columns, create the * outbox / state tables, index and triggers, then backfill rows that have no * `source` yet (every row written before this migration). * * Backfilled rows are NOT put in the outbox — the first-sign-in backfill * (spec §B.4) uploads history within the plan's window. * * Safe on every boot: each step is a no-op once applied. * * @param db - The chat database handle * @returns What changed */ export declare function applyUnifiedLogUpgrades(db: ChatDatabase): UnifiedLogUpgradeReport; /** * Give legacy rows (`source IS NULL`) their unified-log columns, best effort. * * Runs in pages of {@link CONVERSATION_LOG_CONSTANTS.BACKFILL_BATCH_SIZE} * rows ordered by channel and seq, carrying each channel's latest inbound * source forward so agent replies inherit the surface they answered. The * owner's Slack id is unknown at boot, so shared-channel Slack rows land as * `human`; {@link reclassifyOwnerSlackRows} upgrades them once it is known. * * Nothing is enqueued: the update trigger fires only when `content` changes * or `cloud_sync` rises from a non-1 value, and legacy rows have * `cloud_sync IS NULL`, which the trigger already treats as 1. * * @param db - The chat database handle * @returns Rows updated */ export declare function backfillUnifiedColumns(db: ChatDatabase): number; /** * Once the owner's Slack user id is known, mark their Slack rows as the * owner's and — in shared channels — syncable. Rows recorded before the id * was available (legacy backfill, early boot) were conservatively `human`. * * Idempotent; the `cloud_sync` 0 → 1 flip enqueues those rows for upload * through the update trigger. * * @param db - The chat database handle * @param ownerSlackUserId - The owner's Slack user id * @returns Rows reclassified */ export declare function reclassifyOwnerSlackRows(db: ChatDatabase, ownerSlackUserId: string): number; //# sourceMappingURL=unified-log.d.ts.map