Merge pull request #786 from rowboatlabs/feat/chat-auto-titles

Auto-name chats from the first user message
This commit is contained in:
gagan 2026-07-24 00:13:30 +05:30 committed by GitHub
commit f0e8e51a0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 158 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,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<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);
// 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;
}

View file

@ -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<typeof ModelRef>;
@ -158,6 +161,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(),
});