Conversation history + lazy compaction
Source: platform/apps/app/src/lib/chat/conversation.ts · rendered from main on every deploy — edit in the repo, not here
/** * Conversation lifecycle helpers (ADR-0017). * * Handles conversation creation, message persistence, history loading, * compaction, and access control. */
import type { Database } from '@puccha/db';import { conversations, messages } from '@puccha/db';import type { ConversationSource, ConversationVisibility, Role, TenantContext,} from '@puccha/types';import { and, desc, eq, isNull, lt } from 'drizzle-orm';import { nanoid } from 'nanoid';import { log } from '../log.js';
// ── Conversation CRUD ──────────────────────────────────────────────────
export async function createConversation( db: Database, tenant: TenantContext, opts: { title?: string; source: ConversationSource; locale?: string; metadata?: Record<string, unknown>; /** ADR-0039 Stage 3. Optional during the migration window — when omitted, * the row is left with workspace_id NULL (caller's responsibility to * pass it). All known callers in apps/app/src/routes/api/chat now * thread `locals.workspace?.id` here. */ workspaceId?: string | null; /** Override the default visibility. Channel conversations have no human * creator, so they pass `'team'` to stay visible to agents. */ visibility?: ConversationVisibility; },) { const now = new Date(); const id = `conv_${nanoid(16)}`;
// Auto-assign (ADR-0049): evaluate rules BEFORE the insert so the // row lands with assignedAgentId already set. Rules are convenience // — a failed lookup leaves the conversation unassigned, which is the // safe default and matches the pre-rules behaviour. const { evaluateAssignRules } = await import('./assign-rules.js'); const autoAssign = await evaluateAssignRules(db, tenant.id, { source: opts.source });
await db.insert(conversations).values({ id, tenantId: tenant.id, workspaceId: opts.workspaceId ?? null, userId: tenant.userId, title: opts.title ?? null, source: opts.source, locale: opts.locale ?? 'th', metadata: opts.metadata ? JSON.stringify(opts.metadata) : null, assignedAgentId: autoAssign, // ADR-0135. Tenants on the shared support inbox default to `team` // visibility so every agent sees the queue; everyone else keeps // ADR-0017 private-by-default. canAccessConversation() already grants // `team` to any member, so no further wiring is needed. visibility: opts.visibility ?? (tenant.sharedSupportInbox ? 'team' : 'private'), createdAt: now, updatedAt: now, });
return { id, createdAt: now };}
export async function getConversation( db: Database, tenantId: string, conversationId: string, workspaceId?: string,) { const where = workspaceId ? and( eq(conversations.tenantId, tenantId), eq(conversations.workspaceId, workspaceId), eq(conversations.id, conversationId), ) : and(eq(conversations.tenantId, tenantId), eq(conversations.id, conversationId)); return db.select().from(conversations).where(where).get();}
export async function listConversations( db: Database, tenantId: string, opts: { userId?: string; status?: string; limit?: number; cursor?: string; workspaceId?: string; },) { const conditions = [eq(conversations.tenantId, tenantId), isNull(conversations.deletedAt)];
if (opts.workspaceId) conditions.push(eq(conversations.workspaceId, opts.workspaceId)); if (opts.userId) conditions.push(eq(conversations.userId, opts.userId)); if (opts.status) conditions.push(eq(conversations.status, opts.status as 'active')); if (opts.cursor) conditions.push(lt(conversations.id, opts.cursor));
return db .select() .from(conversations) .where(and(...conditions)) .orderBy(desc(conversations.updatedAt)) .limit(opts.limit ?? 20);}
export async function softDeleteConversation( db: Database, tenantId: string, conversationId: string,) { await db .update(conversations) .set({ status: 'deleted', deletedAt: new Date() }) .where(and(eq(conversations.tenantId, tenantId), eq(conversations.id, conversationId)));}
export async function updateConversationVisibility( db: Database, tenantId: string, conversationId: string, visibility: ConversationVisibility, sharedWith?: string[],) { await db .update(conversations) .set({ visibility, sharedWith: sharedWith ? JSON.stringify(sharedWith) : null, updatedAt: new Date(), }) .where(and(eq(conversations.tenantId, tenantId), eq(conversations.id, conversationId)));}
/** ADR-0169. Persist the guided-flow state bag for a conversation. Overwrites the * whole `metadata` column — callers pass the merged bag (flow/state.writeFlowState * preserves any sibling keys). Does not touch `updatedAt` (the turn's messages * already do). */export async function updateConversationMetadata( db: Database, tenantId: string, conversationId: string, metadata: string,) { await db .update(conversations) .set({ metadata }) .where(and(eq(conversations.tenantId, tenantId), eq(conversations.id, conversationId)));}
// ── Messages ───────────────────────────────────────────────────────────
export async function addMessage( db: Database, tenantId: string, conversationId: string, msg: { role: 'user' | 'assistant' | 'system'; content: string; sourcesJson?: string; chunkIdsJson?: string; rewrittenQuery?: string; tokensIn?: number; tokensOut?: number; latencyMs?: number; /** ADR-0043: pre-stringified JSON of the attachments array, or null. */ attachmentsJson?: string | null; /** ADR-0069: captured idk-gap outcome for the /knowledge-gaps queue * ('out_of_scope' | 'rag_no_sources' | 'rag_miss'), or null/undefined * for non-gap turns (substantive answers, cache hits, canned replies). */ gapOutcome?: string | null; /** ADR-0165: group user-message asker attribution (channel user id + name), * for click-to-identify. Null for 1:1 / non-group messages. */ authorExternalId?: string | null; authorName?: string | null; /** ADR-0205: which persona / exact system prompt (16-hex SHA-256 prefix) * produced an assistant message. Hash is null when no model ran * (canned reply, refusal, cache hit) — the persona still applies. */ personaId?: string | null; promptHash?: string | null; },) { const id = `msg_${nanoid(16)}`; const now = new Date(); await db.insert(messages).values({ id, conversationId, tenantId, role: msg.role, content: msg.content, sourcesJson: msg.sourcesJson ?? null, chunkIdsJson: msg.chunkIdsJson ?? null, rewrittenQuery: msg.rewrittenQuery ?? null, tokensIn: msg.tokensIn ?? null, tokensOut: msg.tokensOut ?? null, latencyMs: msg.latencyMs ?? null, feedback: null, feedbackText: null, attachmentsJson: msg.attachmentsJson ?? null, gapOutcome: msg.gapOutcome ?? null, authorExternalId: msg.authorExternalId ?? null, authorName: msg.authorName ?? null, personaId: msg.personaId ?? null, promptHash: msg.promptHash ?? null, createdAt: now, });
// Bump conversation's updatedAt on every insert; ADR-0068 also bumps // lastVisitorMessageAt when the writer is the visitor — that field is // what the "Latest customer" inbox sort orders on, and it's the only // signal that distinguishes "customer is waiting" from "agent just // touched this". const convUpdates: { updatedAt: Date; lastVisitorMessageAt?: Date } = { updatedAt: now }; if (msg.role === 'user') convUpdates.lastVisitorMessageAt = now; await db.update(conversations).set(convUpdates).where(eq(conversations.id, conversationId));
return { id };}
export async function getMessages( db: Database, tenantId: string, conversationId: string, limit = 50,) { // Latest N in chronological order. DESC + reverse (not ASC + limit) so a // conversation longer than `limit` returns its most RECENT messages — an // ASC + .limit() here would silently drop recent activity on long threads // (CLAUDE.md time-series .limit() bug pattern). Only the export uses this. const rows = await db .select() .from(messages) .where(and(eq(messages.tenantId, tenantId), eq(messages.conversationId, conversationId))) .orderBy(desc(messages.createdAt)) .limit(limit); return rows.reverse();}
export async function updateMessageFeedback( db: Database, tenantId: string, messageId: string, feedback: 'up' | 'down', feedbackText?: string,) { await db .update(messages) .set({ feedback, feedbackText: feedbackText ?? null }) .where(and(eq(messages.tenantId, tenantId), eq(messages.id, messageId)));}
// ── History for LLM context ────────────────────────────────────────────
export interface HistoryMessage { role: 'user' | 'assistant'; content: string;}
/** * Load conversation history for LLM context. * Applies compaction for long conversations (ADR-0017 §3d). * * Returns messages suitable for passing to query rewrite and LLM prompt. * Token budget: 4000 tokens for history (~10 messages). */export async function loadHistoryForLLM( db: Database, tenantId: string, conversationId: string,): Promise<{ history: HistoryMessage[]; summary: string | null }> { const allMessages = await db .select({ role: messages.role, content: messages.content }) .from(messages) .where(and(eq(messages.tenantId, tenantId), eq(messages.conversationId, conversationId))) .orderBy(messages.createdAt);
// Filter to user/assistant messages only (skip system) const chatMessages = allMessages.filter( (m): m is { role: 'user' | 'assistant'; content: string } => m.role === 'user' || m.role === 'assistant', );
// Load conversation summary if exists const conv = await db .select({ summary: conversations.summary }) .from(conversations) .where(and(eq(conversations.tenantId, tenantId), eq(conversations.id, conversationId))) .get();
if (chatMessages.length <= 10) { // Short conversation — return verbatim return { history: chatMessages.map((m) => ({ role: m.role, content: m.content })), summary: null, }; }
// Long conversation — use summary + last 10 messages const recent = chatMessages.slice(-10); const existingSummary = conv?.summary ?? null;
// If no summary exists yet but we have >10 messages, the caller should // trigger compaction asynchronously. We return what we have now. return { history: recent.map((m) => ({ role: m.role, content: m.content })), summary: existingSummary, };}
/** * Lazy compaction: summarize older messages via LLM and store on the conversation. * Called asynchronously after loadHistoryForLLM detects >10 messages with no summary. * * ADR-0017 §3d: * Turns 1-10: verbatim * Turns 11-20: summarize 1-8 into ~200 tokens, keep 9-20 verbatim * Turns 21+: summarize 1-18 into ~300 tokens, keep last 10 verbatim */export async function compactConversationHistory( db: Database, tenantId: string, conversationId: string, apiKey: string,): Promise<void> { const allMessages = await db .select({ id: messages.id, role: messages.role, content: messages.content }) .from(messages) .where(and(eq(messages.tenantId, tenantId), eq(messages.conversationId, conversationId))) .orderBy(messages.createdAt);
const chatMessages = allMessages.filter((m) => m.role === 'user' || m.role === 'assistant');
if (chatMessages.length <= 10) return; // Nothing to compact
// Check if summary already covers enough messages const conv = await db .select({ summarizedThrough: conversations.summarizedThrough }) .from(conversations) .where(and(eq(conversations.tenantId, tenantId), eq(conversations.id, conversationId))) .get();
const toSummarize = chatMessages.slice(0, -10); // Everything except last 10 const lastSummarizedId = toSummarize[toSummarize.length - 1]?.id;
// Skip if we've already summarized through this message if (conv?.summarizedThrough === lastSummarizedId) return;
// Build text to summarize (truncate each message to control token budget) const summaryInput = toSummarize .map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.slice(0, 200)}`) .join('\n');
const maxTokens = toSummarize.length > 16 ? 300 : 200;
try { const { createLlmProvider } = await import('./llm-provider.js'); const { generateText } = await import('ai');
// Background summary — not gateway-routed yet (ADR-0116 follow-up). const { chatModel } = createLlmProvider(apiKey);
const { text: summary } = await generateText({ model: chatModel, system: 'Summarize this conversation concisely in the same language. Focus on key topics discussed and conclusions reached. Output only the summary.', prompt: summaryInput, maxOutputTokens: maxTokens, temperature: 0, });
await db .update(conversations) .set({ summary: summary.trim(), summarizedThrough: lastSummarizedId, updatedAt: new Date(), }) .where(and(eq(conversations.tenantId, tenantId), eq(conversations.id, conversationId))); } catch (err) { // Compaction failed — non-critical, will retry on next long conversation load. // Log it though: this path is fire-and-forget, so without a record a // persistent failure (e.g. the provider bug that silently broke it) stays // invisible until someone notices summaries never populate. log.error('chat.compaction_failed', { tenantId, conversationId, error: String(err) }); }}
// ── Access control ─────────────────────────────────────────────────────
/** * Check if a user can access a conversation (ADR-0017 Q2). * Private by default — only creator, shared-with users, and admin/owner can access. */export function canAccessConversation( conversation: { userId: string | null; visibility: string; sharedWith: string | null }, userId: string | null, role: Role | null,): boolean { // Admin/owner can always access (audit obligation) if (role === 'admin' || role === 'owner') return true;
// Creator can always access if (userId && conversation.userId === userId) return true;
// Shared conversations if (conversation.visibility === 'shared' && userId && conversation.sharedWith) { const shared: string[] = JSON.parse(conversation.sharedWith); if (shared.includes(userId)) return true; }
// Team visibility — any member if (conversation.visibility === 'team' && role !== null) return true;
return false;}
/** * Check if a user can delete a conversation. * Only creator or admin/owner. */export function canDeleteConversation( conversation: { userId: string | null }, userId: string | null, role: Role | null,): boolean { if (role === 'admin' || role === 'owner') return true; if (userId && conversation.userId === userId) return true; return false;}
// ── Auto-title ─────────────────────────────────────────────────────────
/** Generate a short title from the first user query. */export function generateTitle(query: string): string { // Truncate to ~50 chars, breaking at word boundary if (query.length <= 50) return query; const truncated = query.slice(0, 50); const lastSpace = truncated.lastIndexOf(' '); return `${lastSpace > 20 ? truncated.slice(0, lastSpace) : truncated}...`;}