feat(x): auto-name chats from the first user message

A tiny background LLM call names new chats in 3-5 words, replacing the
truncated-first-message placeholder once it lands. Manual renames always
win; failures silently keep the placeholder.

Model resolution (getChatTitleModel): chatTitleModel override in
models.json, else the curated gateway flash-lite when the assistant
default routes through the gateway, else the assistant's own provider -
so signed-in users who switched their default to BYOK get titles on
that provider instead of a dead gateway call.
This commit is contained in:
Gagan 2026-07-23 16:13:27 +05:30
parent 033cc6d351
commit 81c07b3a3f
5 changed files with 143 additions and 2 deletions

View file

@ -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 |

View file

@ -0,0 +1,57 @@
import { generateText } from 'ai';
import { createLanguageModel } from '../models/models.js';
import { getChatTitleModel, resolveProviderConfig } from '../models/defaults.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:
- 3 to 5 words
- Same language as the message
- No quotation marks, no ending punctuation, no emoji
- Output the title and nothing else`;
// 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<string | null> {
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);
const result = await withUseCase({ useCase: 'copilot_chat', subUseCase: 'chat_title' }, () => generateText({
model,
system: SYSTEM_PROMPT,
prompt: text.slice(0, MAX_INPUT_CHARS),
maxOutputTokens: 50,
}));
captureLlmUsage({
useCase: 'copilot_chat',
subUseCase: 'chat_title',
model: modelId,
provider: providerName,
usage: result.usage,
});
const title = result.text
.trim()
.replace(/^["'“”‘’]+|["'“”‘’]+$/g, '')
.replace(/[.!。]+$/, '')
.replace(/\s+/g, ' ')
.trim();
if (!title || title.length > MAX_TITLE_CHARS) return null;
return title;
}

View file

@ -14,6 +14,7 @@ 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";
const SIGNED_IN_CHAT_TITLE_MODEL = "google/gemini-3.5-flash-lite";
export type ModelSelection = z.infer<typeof ModelRef>;
@ -158,6 +159,36 @@ export async function getMeetingNotesModel(): Promise<ModelSelection> {
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<ModelSelection> {
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

View file

@ -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<z.infer<typeof SessionEvent>> = [
{ 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<void> {
await this.sessionRepo.withLock(sessionId, async () => {
const events = await this.sessionRepo.read(sessionId);
@ -541,14 +588,18 @@ function withActiveSkills(
};
}
function defaultTitle(input: z.infer<typeof UserMessage>): string {
function messageText(input: z.infer<typeof UserMessage>): 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<typeof UserMessage>): string {
const collapsed = messageText(input);
if (collapsed.length === 0) {
return "New session";
}

View file

@ -74,4 +74,5 @@ export const LlmModelConfig = z.object({
meetingNotesModel: ModelOverride.optional(),
liveNoteAgentModel: ModelOverride.optional(),
autoPermissionDecisionModel: ModelOverride.optional(),
chatTitleModel: ModelOverride.optional(),
});