/** * Shared conversation title generation service. * * Provides a single reusable primitive for generating and persisting * conversation titles across all creation paths. Enforces a safe * overwrite policy: only replaceable placeholder/system titles are * overwritten, never user-provided custom titles. */ import { createTimeout, extractAllText, extractToolUse, getConfiguredProvider, userMessage as buildUserMessage, } from "../providers/provider-send-message.js"; import type { Provider, ToolDefinition } from "../providers/types.js"; import { publishConversationTitleChanged } from "../runtime/sync/resource-sync-events.js"; import { getLogger } from "../util/logger.js"; import { Mutex } from "../util/mutex.js"; import { normalizeTitle, stripThinkingTags, truncateTitle, } from "../util/short-title.js"; import { getConversation, getMessages, type MessageRow, updateConversationTitle, } from "./conversation-crud.js"; const log = getLogger("conversation-title-service"); // ── Types ──────────────────────────────────────────────────────────── export type TitleOrigin = | "runtime_api" | "channel_inbound" | "voice_outbound" | "voice_inbound" | "guardian_request" | "schedule" | "task" | "watcher" | "subagent" | "sequence" | "heartbeat" | "filing" | "local" | "task_submit" | "memory_consolidation" | "memory_retrospective" | "misc"; export interface TitleContext { origin: TitleOrigin; conversationKey?: string; sourceChannel?: string; assistantId?: string; externalChatId?: string; displayName?: string; username?: string; triggerTextSnippet?: string; systemHint?: string; metadataHints?: string[]; uxBrief?: string; } // ── Placeholder / loading state ────────────────────────────────────── export const GENERATING_TITLE = "Generating title..."; const UNTITLED_FALLBACK = "Untitled Conversation"; // ── `conversations.isAutoTitle` values ─────────────────────────────── // // Most readers treat the column as a boolean ("not user-set"); the two // distinct non-zero values let the title pipeline tell HOW a title was set. /** Title was generated by an LLM from conversation content. */ export const AUTO_TITLE_LLM = 1; /** * Title was derived deterministically from bootstrap context — no LLM call, * no user input. Unlike LLM titles, these stay replaceable: the first * genuine user message in the conversation upgrades them to an LLM title * (see `canReplaceTitle`). */ export const AUTO_TITLE_DETERMINISTIC = 2; // ── Replaceability check ───────────────────────────────────────────── const REPLACEABLE_PATTERNS = [ /^Runtime:\s/, /^New Conversation$/, /^Untitled$/, /^Untitled Conversation$/, /^Generating title\.\.\.$/, ]; /** * Check whether a title is a system-generated placeholder that can be * safely overwritten by auto-generated titles. Returns `false` for * user-provided custom titles. */ export function isReplaceableTitle(title: string | null): boolean { if (title == null || title.trim() === "") { return true; } return REPLACEABLE_PATTERNS.some((pattern) => pattern.test(title)); } /** * Whether auto-generation may overwrite the conversation's current title. * True for placeholder/system titles and for deterministic bootstrap titles * (`AUTO_TITLE_DETERMINISTIC`); false for user-set custom titles and titles * already generated by an LLM. */ function canReplaceTitle(conversation: { title: string | null; isAutoTitle: number; }): boolean { return ( isReplaceableTitle(conversation.title) || conversation.isAutoTitle === AUTO_TITLE_DETERMINISTIC ); } /** * Derive a deterministic title from bootstrap context without an LLM call. * Used for background/system conversations (heartbeat runs, scheduled jobs, * subagents, retrospectives) where an LLM-generated title is not worth the * tokens — the bootstrap `systemHint` already names the work. Persist it * with `AUTO_TITLE_DETERMINISTIC` so a later genuine user message can still * upgrade it to an LLM title. */ export function deriveDeterministicTitle(context: TitleContext): string { const base = deriveFallbackTitle(context) ?? UNTITLED_FALLBACK; return truncateTitle(base.replace(/\s+/g, " ").trim()); } /** * Apply a deterministic title to a conversation, but only while its current * title is still a replaceable placeholder — never clobbering a user or LLM * title. For conversations that will never run an agent turn (so neither the * `user-prompt-submit` nor `stop` title hook ever fires), e.g. a floor-denied * inbound whose only content is a guardian access-request card: without this * the sidebar shows a permanent "Generating title…". Persisted as * `AUTO_TITLE_DETERMINISTIC` so a later genuine turn can still upgrade it, and * broadcast so connected clients converge. Returns whether the title changed. */ export function applyDeterministicTitleIfReplaceable( conversationId: string, title: string, ): boolean { const conversation = getConversation(conversationId); if (conversation && !isReplaceableTitle(conversation.title)) { return false; } const cleaned = truncateTitle(title.replace(/\s+/g, " ").trim()) || UNTITLED_FALLBACK; updateConversationTitle(conversationId, cleaned, AUTO_TITLE_DETERMINISTIC); publishConversationTitleChanged(conversationId, cleaned); return true; } // ── Title generation ───────────────────────────────────────────────── export interface GenerateTitleParams { conversationId: string; /** Provider to use for LLM call. Falls back to getConfiguredProvider(). */ provider?: Provider; /** Context about how/where the conversation was created. */ context?: TitleContext; /** User message text (first turn). */ userMessage?: string; /** Assistant response text (first turn). */ assistantResponse?: string; /** Abort signal. */ signal?: AbortSignal; } /** * Generate a conversation title via LLM and persist it, but only if the * current title is still replaceable (safe overwrite policy). */ export async function generateAndPersistConversationTitle( params: GenerateTitleParams, ): Promise<{ title: string; updated: boolean }> { const { conversationId, context, userMessage, assistantResponse, signal } = params; // Check current title is replaceable const conversation = getConversation(conversationId); if (conversation && !canReplaceTitle(conversation)) { return { title: conversation.title!, updated: false }; } const provider = params.provider ?? (await getConfiguredProvider("conversationTitle")); if (!provider) { // No provider available — fall back to context-derived title or untitled. // Deterministic, so keep it upgradeable by a later generation pass. const fallback = deriveFallbackTitle(context) ?? UNTITLED_FALLBACK; updateConversationTitle(conversationId, fallback, AUTO_TITLE_DETERMINISTIC); publishConversationTitleChanged(conversationId, fallback); logRetryableFallback(params, "no_provider"); return { title: fallback, updated: true }; } const prompt = buildTitlePrompt(context, userMessage, assistantResponse); const title = await generateTitleViaLLM( provider, prompt, conversationId, signal, ); if (title) { // Re-check replaceability before persisting (race guard) const current = getConversation(conversationId); if (current && !canReplaceTitle(current)) { return { title: current.title!, updated: false }; } updateConversationTitle(conversationId, title, AUTO_TITLE_LLM); publishConversationTitleChanged(conversationId, title); log.info({ conversationId, title }, "Auto-generated conversation title"); return { title, updated: true }; } // No text in response — use fallback // Re-check replaceability before persisting (race guard — same as the // text-response path above). A concurrent custom rename may have landed // while the LLM request was in-flight; writing unconditionally would // clobber the user's intent. const currentForFallback = getConversation(conversationId); if (currentForFallback && !canReplaceTitle(currentForFallback)) { return { title: currentForFallback.title!, updated: false }; } const fallback = deriveFallbackTitle(context) ?? UNTITLED_FALLBACK; updateConversationTitle(conversationId, fallback, AUTO_TITLE_DETERMINISTIC); publishConversationTitleChanged(conversationId, fallback); logRetryableFallback(params, "empty_output"); return { title: fallback, updated: true }; } // ── Serial title-generation queue ──────────────────────────────────── /** * Each title generation makes an LLM call. Without serialization, burst * conversation creation (e.g. 5 new chats in quick succession) fires N * concurrent requests that can hit provider rate limits or contend for * API capacity, causing later calls to time out and fall back to * "Untitled Conversation". * * A serial queue ensures at most one title-generation LLM call is * in-flight at a time. Each call is lightweight (~1–3 s for a ≤5-word * title), so the added serial latency is modest and invisible to the * user (the UI shows "Generating title…" as a placeholder during the * wait). Both initial generation and second-pass regeneration share * this queue since they hit the same provider. */ export const titleMutex = new Mutex(); /** * Fire-and-forget wrapper for title generation. Failures are logged * but do not propagate. On failure, replaces loading placeholder with * a retryable fallback title so loading state is never permanent. * * Calls are serialized via {@link titleMutex} so burst conversation * creation does not overwhelm the LLM provider. */ export function queueGenerateConversationTitle( params: GenerateTitleParams, ): void { void titleMutex .withLock(async () => { await generateAndPersistConversationTitle(params); }) .catch((err) => { log.warn( retryableFallbackLogFields(params, "generation_error", err), "Conversation title generation used retryable fallback", ); // Replace loading placeholder with a retryable fallback. try { const conversation = getConversation(params.conversationId); if (conversation && conversation.title === GENERATING_TITLE) { const fallback = deriveFallbackTitle(params.context) ?? UNTITLED_FALLBACK; updateConversationTitle( params.conversationId, fallback, AUTO_TITLE_DETERMINISTIC, ); publishConversationTitleChanged(params.conversationId, fallback); } } catch { // Best-effort } }); } // ── Title regeneration (second pass) ───────────────────────────────── export interface RegenerateTitleParams { conversationId: string; provider?: Provider; signal?: AbortSignal; /** * Limit regeneration to placeholder or deterministic titles. Used for retrying * failed initial generation without racing against a successful initial title. */ onlyIfReplaceable?: boolean; } /** * Re-generate a conversation title using the last 3 stored messages. * Only fires when the current title was auto-generated (isAutoTitle = 1). * Skips if the user has manually renamed the conversation. */ export async function regenerateConversationTitle( params: RegenerateTitleParams, ): Promise<{ title: string; updated: boolean }> { const { conversationId, onlyIfReplaceable, signal } = params; const conversation = getConversation(conversationId); if (!conversation || !conversation.isAutoTitle) { return { title: conversation?.title ?? UNTITLED_FALLBACK, updated: false }; } if (onlyIfReplaceable && !canReplaceTitle(conversation)) { return { title: conversation.title ?? UNTITLED_FALLBACK, updated: false }; } const provider = params.provider ?? (await getConfiguredProvider("conversationTitle")); if (!provider) { return { title: conversation.title ?? UNTITLED_FALLBACK, updated: false }; } const allMessages = getMessages(conversationId); const recentMessages = allMessages.slice(-3); if (recentMessages.length === 0) { return { title: conversation.title ?? UNTITLED_FALLBACK, updated: false }; } const prompt = buildRegenerationPrompt(recentMessages); // Skip the LLM call if no messages yielded extractable text — the prompt // would be just the "Recent messages:" header, and the model tends to // fabricate a meta-title about the emptiness rather than decline. if (!/\n(?:User|Assistant): /.test(prompt)) { return { title: conversation.title ?? UNTITLED_FALLBACK, updated: false }; } const title = await generateTitleViaLLM( provider, prompt, conversationId, signal, ); if (title) { // Re-check isAutoTitle before persisting (race guard against manual rename) const current = getConversation(conversationId); if ( !current || !current.isAutoTitle || (onlyIfReplaceable && !canReplaceTitle(current)) ) { return { title: current?.title ?? UNTITLED_FALLBACK, updated: false }; } updateConversationTitle(conversationId, title, AUTO_TITLE_LLM); publishConversationTitleChanged(conversationId, title); log.info( { conversationId, title }, "Re-generated conversation title (second pass)", ); return { title, updated: true }; } return { title: conversation.title ?? UNTITLED_FALLBACK, updated: false }; } /** * Fire-and-forget wrapper for title regeneration. * * Serialized via the same {@link titleMutex} as initial generation. */ export function queueRegenerateConversationTitle( params: RegenerateTitleParams, ): void { void titleMutex .withLock(async () => { await regenerateConversationTitle(params); }) .catch((err) => { log.warn( { err, conversationId: params.conversationId }, "Failed to regenerate conversation title (non-fatal)", ); }); } // ── Internal helpers ───────────────────────────────────────────────── /** * Dedicated system prompt for title generation. Replaces the default * assistant system prompt that btw-sidechain would otherwise inject, * which caused the model to respond to the conversation content instead * of titling it. */ function buildTitleSystemPrompt(): string { return [ "You generate ultra-concise conversation titles. Output ONLY the title text — no explanation, no quotes, no markdown, no preamble.", "", "Rules:", "- 2–5 words maximum. Titles longer than 5 words are unacceptable — ruthlessly compress to a short noun phrase", "- 40 characters absolute maximum — if your title exceeds 40 characters it will be truncated and look broken", "- Summarize only the TOPIC, not the request or instructions", "- Noun phrases are ideal (e.g. 'Auth Middleware Rewrite', 'Docker Volume Mounts', 'Onboarding Flow')", "- Think: what would make a scannable sidebar label?", "- Do NOT echo back what the user asked you to do", "- Do NOT respond to the conversation content", "- Do NOT assess feasibility or comment on capabilities", "- If input is sparse or references external context, extract a topic from the words that ARE present (e.g. 'so about that t-shirt...' → 'T-Shirt Discussion'). Never describe the absence, emptiness, or insufficiency of context — titles like 'Missing Context', 'Unclear Request', 'No Topic' are forbidden", ].join("\n"); } const TITLE_TOOL_NAME = "record_conversation_title"; /** * Tool the title model is forced to call. Constraining the output to a single * `title` argument keeps weak/fast models (e.g. Haiku-class title models) from * "thinking aloud" or continuing the conversation in the response text — * failure modes that otherwise get captured verbatim as the title * (e.g. "I need to generate a…", "I'll work through these files…"). */ function buildTitleTool(): ToolDefinition { return { name: TITLE_TOOL_NAME, description: "Record the conversation's title. Call this exactly once with a short noun phrase naming the TOPIC — never a sentence, a reply, or any preamble.", input_schema: { type: "object", properties: { title: { type: "string", description: "2–5 words, 40 characters max. A scannable sidebar label naming the topic (e.g. 'Auth Middleware Rewrite', 'Docker Volume Mounts'). No quotes, markdown, or trailing punctuation.", }, }, required: ["title"], }, }; } /** * Run the title LLM call with a forced tool so the model returns a structured * `{ title }` rather than free text. Returns a normalized title, or "" when the * model declines or misbehaves — callers fall back to a deterministic title. * * Forcing the tool is the primary guard against prose leakage; `normalizeTitle` * is the backstop for the text-fallback path and for any provider that ignores * forced `tool_choice`. */ async function generateTitleViaLLM( provider: Provider, prompt: string, conversationId: string, signal?: AbortSignal, ): Promise { const { signal: timeoutSignal, cleanup } = createTimeout(15_000); const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; try { const response = await provider.sendMessage([buildUserMessage(prompt)], { tools: [buildTitleTool()], systemPrompt: buildTitleSystemPrompt(), config: { max_tokens: 256, callSite: "conversationTitle", conversationId, tool_choice: { type: "tool", name: TITLE_TOOL_NAME }, disableCache: true, }, signal: combinedSignal, }); const toolBlock = extractToolUse(response); const titleInput = toolBlock?.input as { title?: unknown } | undefined; if ( toolBlock?.name === TITLE_TOOL_NAME && typeof titleInput?.title === "string" ) { return normalizeTitle(titleInput.title); } // Provider ignored the forced tool (or the model emitted prose instead of // calling it). Fall back to the response text — `normalizeTitle`'s prose // guard rejects a ramble while keeping a compliant plain-text title. return normalizeTitle(extractAllText(response)); } finally { cleanup(); } } function buildTitlePrompt( context?: TitleContext, userMessage?: string, assistantResponse?: string, ): string { const parts: string[] = []; if (context) { const hints: string[] = []; if (context.sourceChannel) { hints.push(`Channel: ${context.sourceChannel}`); } if (context.displayName) { hints.push(`User: ${context.displayName}`); } if (context.systemHint) { hints.push(`Context: ${context.systemHint}`); } if (context.uxBrief) { hints.push(`Brief: ${context.uxBrief}`); } if (context.metadataHints?.length) { hints.push(`Hints: ${context.metadataHints.join(", ")}`); } if (hints.length > 0) { parts.push("Metadata:", ...hints, ""); } } if (userMessage) { parts.push(`User: ${stripThinkingTags(userMessage)}`); } if (assistantResponse) { parts.push(`Assistant: ${stripThinkingTags(assistantResponse)}`); } return parts.join("\n"); } function titleGenerationLogFields(params: GenerateTitleParams) { return { conversationId: params.conversationId, contextOrigin: params.context?.origin, hasContext: Boolean(params.context), userMessageLength: params.userMessage?.length ?? 0, assistantResponseLength: params.assistantResponse?.length ?? 0, }; } type TitleGenerationFallbackReason = | "no_provider" | "empty_output" | "generation_error"; function retryableFallbackLogFields( params: GenerateTitleParams, reason: TitleGenerationFallbackReason, err?: unknown, ): Record { const fields: Record = { ...titleGenerationLogFields(params), reason, fallbackSource: params.context ? "context" : "untitled", }; if (err) { fields.err = err; } return fields; } function logRetryableFallback( params: GenerateTitleParams, reason: TitleGenerationFallbackReason, ): void { log.warn( retryableFallbackLogFields(params, reason), "Conversation title generation used retryable fallback", ); } function deriveFallbackTitle(context?: TitleContext): string | null { if (!context) { return null; } if (context.systemHint) { return context.systemHint; } if (context.uxBrief) { return context.uxBrief; } return null; } /** * Extract only human-authored text from stored message content for title * generation. Unlike extractTextFromStoredMessageContent (which includes * tool metadata like "Tool use (...): {...}"), this only extracts: * - `text` blocks (the actual conversation content) * - `tool_result` string content (topical signal from tool responses) * — web_search_tool_result is skipped (structured search data, not topical) * * Returns empty string for content-block arrays with no extractable text, * preventing raw JSON from polluting the title prompt. */ function extractTextForTitle(raw: string | Array<{ type: string }>): string { try { const parsed = Array.isArray(raw) ? raw : JSON.parse(raw); if (typeof parsed === "string") { return parsed; } if (!Array.isArray(parsed)) { return raw as string; } const texts: string[] = []; for (const block of parsed) { if (!block || typeof block !== "object") { continue; } if (block.type === "text" && typeof block.text === "string") { texts.push(block.text); // guard:allow-tool-result-only — web_search_tool_result has structured // search result arrays, not useful for title generation; only plain // tool_result string content carries topical signal. } else if (block.type === "tool_result") { if (typeof block.content === "string") { texts.push(block.content); } else if (Array.isArray(block.content)) { for (const nested of block.content) { if ( nested && typeof nested === "object" && nested.type === "text" && typeof nested.text === "string" ) { texts.push(nested.text); } } } } } return texts.join("\n"); } catch { return Array.isArray(raw) ? "" : raw; } } function buildRegenerationPrompt(recentMessages: MessageRow[]): string { const parts: string[] = ["Recent messages:"]; for (const msg of recentMessages) { const text = extractTextForTitle(msg.content); if (!text) { continue; } const role = msg.role === "user" ? "User" : "Assistant"; parts.push(`${role}: ${stripThinkingTags(text)}`); } return parts.join("\n"); }