186 lines
5.5 KiB
TypeScript
186 lines
5.5 KiB
TypeScript
/**
|
|
* Generate session titles using a smol, fast model.
|
|
*/
|
|
import * as path from "node:path";
|
|
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
|
import { completeSimple } from "@oh-my-pi/pi-ai";
|
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
import type { ModelRegistry } from "../config/model-registry";
|
|
import { resolveRoleSelection } from "../config/model-resolver";
|
|
import { renderPromptTemplate } from "../config/prompt-templates";
|
|
import type { Settings } from "../config/settings";
|
|
import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
|
|
import { toReasoningEffort } from "../thinking";
|
|
|
|
const TITLE_SYSTEM_PROMPT = renderPromptTemplate(titleSystemPrompt);
|
|
|
|
const DEFAULT_TERMINAL_TITLE = "π";
|
|
const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
|
|
const MAX_INPUT_CHARS = 2000;
|
|
|
|
function getTitleModel(
|
|
registry: ModelRegistry,
|
|
settings: Settings,
|
|
currentModel?: Model<Api>,
|
|
): { model: Model<Api>; thinkingLevel?: ThinkingLevel } | undefined {
|
|
const availableModels = registry.getAvailable();
|
|
if (availableModels.length === 0) return undefined;
|
|
|
|
const titleModel = resolveRoleSelection(["commit", "smol"], settings, availableModels);
|
|
if (titleModel) {
|
|
return { model: titleModel.model, thinkingLevel: titleModel.thinkingLevel };
|
|
}
|
|
|
|
if (currentModel) {
|
|
return { model: currentModel };
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Generate a title for a session based on the first user message.
|
|
*
|
|
* @param firstMessage The first user message
|
|
* @param registry Model registry
|
|
* @param settings Settings used to resolve the smol role, including per-role thinking
|
|
* @param sessionId Optional session id for sticky API key selection
|
|
*/
|
|
export async function generateSessionTitle(
|
|
firstMessage: string,
|
|
registry: ModelRegistry,
|
|
settings: Settings,
|
|
sessionId?: string,
|
|
currentModel?: Model<Api>,
|
|
): Promise<string | null> {
|
|
const candidate = getTitleModel(registry, settings, currentModel);
|
|
if (!candidate) {
|
|
logger.debug("title-generator: no title model found");
|
|
return null;
|
|
}
|
|
|
|
// Truncate message if too long
|
|
const truncatedMessage =
|
|
firstMessage.length > MAX_INPUT_CHARS ? `${firstMessage.slice(0, MAX_INPUT_CHARS)}…` : firstMessage;
|
|
const userMessage = `<user-message>
|
|
${truncatedMessage}
|
|
</user-message>`;
|
|
|
|
const apiKey = await registry.getApiKey(candidate.model, sessionId);
|
|
if (!apiKey) {
|
|
logger.debug("title-generator: no API key for smol model", {
|
|
provider: candidate.model.provider,
|
|
id: candidate.model.id,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const request = {
|
|
model: `${candidate.model.provider}/${candidate.model.id}`,
|
|
systemPrompt: TITLE_SYSTEM_PROMPT,
|
|
userMessage,
|
|
maxTokens: 30,
|
|
};
|
|
logger.debug("title-generator: request", request);
|
|
|
|
try {
|
|
const response = await completeSimple(
|
|
candidate.model,
|
|
{
|
|
systemPrompt: request.systemPrompt,
|
|
messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
|
|
},
|
|
{
|
|
apiKey,
|
|
maxTokens: 30,
|
|
reasoning: toReasoningEffort(candidate.thinkingLevel),
|
|
},
|
|
);
|
|
|
|
if (response.stopReason === "error") {
|
|
logger.debug("title-generator: response error", {
|
|
model: request.model,
|
|
stopReason: response.stopReason,
|
|
errorMessage: response.errorMessage,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
let title = "";
|
|
for (const content of response.content) {
|
|
if (content.type === "text") {
|
|
title += content.text;
|
|
}
|
|
}
|
|
title = title.trim();
|
|
|
|
logger.debug("title-generator: response", {
|
|
model: request.model,
|
|
title,
|
|
usage: response.usage,
|
|
stopReason: response.stopReason,
|
|
});
|
|
|
|
if (!title) {
|
|
return null;
|
|
}
|
|
|
|
return title.replace(/^["']|["']$/g, "").replace(/[.!?]$/, "");
|
|
} catch (err) {
|
|
logger.debug("title-generator: error", {
|
|
model: request.model,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove control characters so model-generated titles cannot inject terminal escapes.
|
|
*/
|
|
function sanitizeTerminalTitlePart(value: string | undefined): string | undefined {
|
|
if (!value) return undefined;
|
|
const sanitized = value.replace(TERMINAL_TITLE_CONTROL_CHARS, "").trim();
|
|
return sanitized || undefined;
|
|
}
|
|
|
|
function getFallbackTerminalTitle(cwd: string | undefined): string | undefined {
|
|
if (!cwd) return undefined;
|
|
const resolvedCwd = path.resolve(cwd);
|
|
const baseName = path.basename(resolvedCwd);
|
|
if (!baseName || baseName === path.parse(resolvedCwd).root) return undefined;
|
|
return sanitizeTerminalTitlePart(baseName);
|
|
}
|
|
|
|
export function formatSessionTerminalTitle(sessionName: string | undefined, cwd?: string): string {
|
|
const label = sanitizeTerminalTitlePart(sessionName) ?? getFallbackTerminalTitle(cwd);
|
|
return label ? `${DEFAULT_TERMINAL_TITLE}: ${label}` : DEFAULT_TERMINAL_TITLE;
|
|
}
|
|
|
|
/**
|
|
* Set the terminal title using OSC 2. Unsupported terminals ignore it.
|
|
*/
|
|
export function setTerminalTitle(title: string): void {
|
|
process.stdout.write(`\x1b]2;${sanitizeTerminalTitlePart(title) ?? DEFAULT_TERMINAL_TITLE}\x07`);
|
|
}
|
|
|
|
export function setSessionTerminalTitle(sessionName: string | undefined, cwd?: string): void {
|
|
setTerminalTitle(formatSessionTerminalTitle(sessionName, cwd));
|
|
}
|
|
|
|
/**
|
|
* Save the current terminal title on terminals that support xterm window ops.
|
|
*/
|
|
export function pushTerminalTitle(): void {
|
|
process.stdout.write("\x1b[22;2t");
|
|
}
|
|
|
|
/**
|
|
* Restore the previously saved terminal title on terminals that support xterm window ops.
|
|
*/
|
|
export function popTerminalTitle(): void {
|
|
process.stdout.write("\x1b[23;2t");
|
|
}
|