Skip to content

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

platform/apps/app/src/lib/chat/conversation.ts
/**
* 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}...`;
}