diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 6c8dec08..59183479 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -44,6 +44,7 @@ Every `llm_usage` emit point in the codebase: | `copilot_chat` | (none) | yes | User chat in renderer (turn runtime; the ALS default when no caller set a use case) | `packages/core/src/runtime/turns/bridges/real-usage-reporter.ts` (`reportModelUsage`); legacy runs (code-mode carve-out) still emit from `packages/core/src/runtime/legacy/engine.ts` (`streamLlm` finish-step) | | `copilot_chat` | `scheduled` | yes | Background scheduled agent runner | `packages/core/src/agent-schedule/runner.ts:167` | | `copilot_chat` | `file_parse` | inherits | `parseFile` builtin tool inside any chat | `packages/core/src/runtime/tools/domains/parsing.ts:179` | +| `copilot_chat` | `chat_title` | no | Auto-naming a chat from its first user message (`generateText`) | `packages/core/src/knowledge/generate_title.ts` | | `live_note_agent` | `routing` | no | Pass 1 routing classifier (`generateObject`) | `packages/core/src/knowledge/live-note/routing.ts:93` | | `live_note_agent` | `manual` | yes | Pass 2 agent run — user clicked Run / called the `run-live-note-agent` tool | `packages/core/src/knowledge/live-note/runner.ts:140` (createRun, `subUseCase: trigger`) | | `live_note_agent` | `cron` | yes | Pass 2 agent run — cron expression matched | same call site | diff --git a/apps/x/packages/core/src/knowledge/generate_title.ts b/apps/x/packages/core/src/knowledge/generate_title.ts new file mode 100644 index 00000000..e2983ba5 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/generate_title.ts @@ -0,0 +1,70 @@ +import { generateText } from 'ai'; +import { createLanguageModel } from '../models/models.js'; +import { getChatTitleModel, resolveProviderConfig } from '../models/defaults.js'; +import { mapReasoningEffort } from '../models/reasoning.js'; +import { captureLlmUsage } from '../analytics/usage.js'; +import { withUseCase } from '../analytics/use_case.js'; + +const SYSTEM_PROMPT = `You name chat conversations. Given the user's first message, reply with a concise title for the conversation. + +Rules: +- At least 2 words, at most 5 — never a single word +- Same language as the message +- No quotation marks, no ending punctuation, no emoji +- Output the title and nothing else + +Examples: +- "who are the investors in supabase" -> Supabase investor list +- "help me write a python script that renames all files in a folder" -> Python file renaming script +- "why is my docker build so slow" -> Slow Docker build debugging +- "draft an email to my landlord about the broken heater" -> Broken heater landlord email`; + +// Long first messages are usually pasted documents or code; the opening is +// enough signal for a title and keeps the call cheap. +const MAX_INPUT_CHARS = 500; + +const MAX_TITLE_CHARS = 80; + +/** + * Generate a short chat title from the first user message. Returns null when + * the message is empty or the model produces something unusable — callers + * keep the truncated-message placeholder in that case. + */ +export async function generateChatTitle(firstMessage: string): Promise { + const text = firstMessage.trim().replace(/\s+/g, ' '); + if (!text) return null; + + const { model: modelId, provider: providerName } = await getChatTitleModel(); + const providerConfig = await resolveProviderConfig(providerName); + const model = createLanguageModel(providerConfig, modelId); + + // Reasoning models (e.g. gateway gemini-3.5-flash) think by default and + // can starve a tight output cap — dial thinking to low and leave the cap + // roomy; the title itself is a handful of tokens either way. + const reasoning = mapReasoningEffort(providerConfig.flavor, modelId, 'low', undefined); + const result = await withUseCase({ useCase: 'copilot_chat', subUseCase: 'chat_title' }, () => generateText({ + model, + system: SYSTEM_PROMPT, + prompt: text.slice(0, MAX_INPUT_CHARS), + maxOutputTokens: 1000, + ...(reasoning?.providerOptions ? { providerOptions: reasoning.providerOptions } : {}), + })); + + captureLlmUsage({ + useCase: 'copilot_chat', + subUseCase: 'chat_title', + model: modelId, + provider: providerName, + usage: result.usage, + }); + + const title = (result.text.trim().split(/\r?\n/)[0] ?? '') + .trim() + .replace(/^["'“”‘’]+|["'“”‘’]+$/g, '') + .replace(/[.!。]+$/, '') + .replace(/\s+/g, ' ') + .trim(); + if (!title || title.length > MAX_TITLE_CHARS) return null; + if (!title.includes(' ')) return null; + return title; +} diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts index 89527ebf..78ea2470 100644 --- a/apps/x/packages/core/src/models/defaults.ts +++ b/apps/x/packages/core/src/models/defaults.ts @@ -14,6 +14,9 @@ const SIGNED_IN_DEFAULT_PROVIDER = "rowboat"; const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite"; const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite"; const SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL = "google/gemini-3.1-flash-lite"; +// Must be on the gateway's server-side allowlist or title calls 403 +// "Model not allowed" (and silently keep the placeholder title). +const SIGNED_IN_CHAT_TITLE_MODEL = "google/gemini-3.5-flash-lite"; export type ModelSelection = z.infer; @@ -158,6 +161,36 @@ export async function getMeetingNotesModel(): Promise { return getCategoryModel("meetingNotesModel", SIGNED_IN_DEFAULT_MODEL); } +/** + * Model used to auto-name chat sessions from the first user message. + * + * Deliberately NOT getCategoryModel: that helper routes every signed-in user + * to the gateway, even one whose assistant default is a BYOK provider (the + * common "gateway limits exhausted, switched to own key" case) — and a title + * call against an exhausted gateway fails silently forever. Instead the + * curated model applies only when the assistant default itself routes + * through the gateway; otherwise titles follow the assistant provider. + */ +export async function getChatTitleModel(): Promise { + const signedIn = await isSignedIn(); + const cfg = await readConfig(); + const override = cfg?.chatTitleModel; + if (override) { + if (typeof override === "string") { + if (cfg) { + return { model: override, provider: cfg.provider.flavor }; + } + } else if (override.provider !== "rowboat" || signedIn) { + return { model: override.model, provider: override.provider }; + } + } + const dflt = await getDefaultModelAndProvider(); + if (dflt.provider === SIGNED_IN_DEFAULT_PROVIDER) { + return { model: SIGNED_IN_CHAT_TITLE_MODEL, provider: SIGNED_IN_DEFAULT_PROVIDER }; + } + return dflt; +} + /** * Model used by the background-task agent + routing classifier. Currently * mirrors `getLiveNoteAgentModel()` — both surfaces want a fast, reliable diff --git a/apps/x/packages/core/src/runtime/sessions/sessions.ts b/apps/x/packages/core/src/runtime/sessions/sessions.ts index c929c56c..b1beb6f1 100644 --- a/apps/x/packages/core/src/runtime/sessions/sessions.ts +++ b/apps/x/packages/core/src/runtime/sessions/sessions.ts @@ -232,6 +232,13 @@ export class SessionsImpl implements ISessions { this.publishEntry( sessionIndexEntry(reduceSession([...events, ...batch]), "idle"), ); + if (!state.title) { + this.generateTitleInBackground( + sessionId, + defaultTitle(input), + messageText(input), + ); + } this.startTrackedAdvance(sessionId, turnId); return { turnId }; }); @@ -319,6 +326,46 @@ export class SessionsImpl implements ISessions { this.startTrackedAdvance(sessionId, state.latestTurnId); } + // Fire-and-forget: replace the truncated-first-message placeholder with a + // short model-generated title. The placeholder stays if the call fails or + // the title changed in the meantime (e.g. a manual rename won the race). + private generateTitleInBackground( + sessionId: string, + placeholder: string, + firstMessage: string, + ): void { + void (async () => { + try { + // Dynamic import: generate_title reaches models/defaults → + // di/container, which imports this module (see traits.js note + // above) — a static import would close the cycle. + const { generateChatTitle } = await import( + "../../knowledge/generate_title.js" + ); + const title = await generateChatTitle(firstMessage); + if (!title || title === placeholder) return; + await this.sessionRepo.withLock(sessionId, async () => { + const events = await this.sessionRepo.read(sessionId); + const state = reduceSession(events); + if (state.title !== placeholder) return; + const batch: Array> = [ + { type: "title_changed", sessionId, ts: this.clock.now(), title }, + ]; + await this.sessionRepo.append(sessionId, batch); + const existing = this.index.get(sessionId); + this.publishEntry( + sessionIndexEntry( + reduceSession([...events, ...batch]), + existing?.latestTurnStatus ?? "none", + ), + ); + }); + } catch { + // Placeholder title stays. + } + })(); + } + async setTitle(sessionId: string, title: string): Promise { await this.sessionRepo.withLock(sessionId, async () => { const events = await this.sessionRepo.read(sessionId); @@ -541,14 +588,18 @@ function withActiveSkills( }; } -function defaultTitle(input: z.infer): string { +function messageText(input: z.infer): string { const text = typeof input.content === "string" ? input.content : input.content .map((part) => (part.type === "text" ? part.text : "")) .join(" "); - const collapsed = text.trim().replace(/\s+/g, " "); + return text.trim().replace(/\s+/g, " "); +} + +function defaultTitle(input: z.infer): string { + const collapsed = messageText(input); if (collapsed.length === 0) { return "New session"; } diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index f929491f..9fd79a4d 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -74,4 +74,5 @@ export const LlmModelConfig = z.object({ meetingNotesModel: ModelOverride.optional(), liveNoteAgentModel: ModelOverride.optional(), autoPermissionDecisionModel: ModelOverride.optional(), + chatTitleModel: ModelOverride.optional(), });