Files
oh-my-pi/packages/coding-agent/src/core/title-generator.ts
T
can1357 7faa151de0 refactor(coding-agent): migrated prompt system to Handlebars templating and reorganized prompt files
- Removed rulebook tool and migrated rules loading to use read tool instead.
- Reorganized prompt files into subdirectories: agents/, compaction/, and system/.
- Added Handlebars helpers for template rendering including list, join, table, codeblock, xml, when, pluralize, and arithmetic operations.
- Migrated prompt template rendering from simple string replacement to Handlebars-style templating with structured data objects.
- Refactored system prompt generation to pass structured data instead of pre-formatted strings.
- Removed rulebookRules property from ToolSession interface and updated all test files accordingly.
2026-01-11 00:36:15 +01:00

168 lines
4.8 KiB
TypeScript

/**
* Generate session titles using a smol, fast model.
*/
import type { Api, Model } from "@oh-my-pi/pi-ai";
import { completeSimple } from "@oh-my-pi/pi-ai";
import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
import { logger } from "./logger";
import type { ModelRegistry } from "./model-registry";
import { parseModelString, SMOL_MODEL_PRIORITY } from "./model-resolver";
import { renderPromptTemplate } from "./prompt-templates";
const TITLE_SYSTEM_PROMPT = renderPromptTemplate(titleSystemPrompt);
const MAX_INPUT_CHARS = 2000;
function getTitleModelCandidates(registry: ModelRegistry, savedSmolModel?: string): Model<Api>[] {
const availableModels = registry.getAvailable();
if (availableModels.length === 0) return [];
const candidates: Model<Api>[] = [];
const addCandidate = (model?: Model<Api>): void => {
if (!model) return;
const exists = candidates.some((candidate) => candidate.provider === model.provider && candidate.id === model.id);
if (!exists) {
candidates.push(model);
}
};
if (savedSmolModel) {
const parsed = parseModelString(savedSmolModel);
if (parsed) {
const match = availableModels.find((model) => model.provider === parsed.provider && model.id === parsed.id);
addCandidate(match);
}
}
for (const pattern of SMOL_MODEL_PRIORITY) {
const needle = pattern.toLowerCase();
const exactMatch = availableModels.find((model) => model.id.toLowerCase() === needle);
addCandidate(exactMatch);
const fuzzyMatch = availableModels.find((model) => model.id.toLowerCase().includes(needle));
addCandidate(fuzzyMatch);
}
for (const model of availableModels) {
addCandidate(model);
}
return candidates;
}
/**
* Find the best available model for title generation.
* Uses the configured smol model if set, otherwise auto-discovers using priority chain.
*
* @param registry Model registry
* @param savedSmolModel Optional saved smol model from settings (provider/modelId format)
*/
export async function findTitleModel(registry: ModelRegistry, savedSmolModel?: string): Promise<Model<Api> | null> {
const candidates = getTitleModelCandidates(registry, savedSmolModel);
return candidates[0] ?? null;
}
/**
* Generate a title for a session based on the first user message.
*
* @param firstMessage The first user message
* @param registry Model registry
* @param savedSmolModel Optional saved smol model from settings (provider/modelId format)
* @param sessionId Optional session id for sticky API key selection
*/
export async function generateSessionTitle(
firstMessage: string,
registry: ModelRegistry,
savedSmolModel?: string,
sessionId?: string,
): Promise<string | null> {
const candidates = getTitleModelCandidates(registry, savedSmolModel);
if (candidates.length === 0) {
logger.debug("title-generator: no smol 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>\n${truncatedMessage}\n</user-message>`;
for (const model of candidates) {
const apiKey = await registry.getApiKey(model, sessionId);
if (!apiKey) {
logger.debug("title-generator: no API key for model", { provider: model.provider, id: model.id });
continue;
}
const request = {
model: `${model.provider}/${model.id}`,
systemPrompt: TITLE_SYSTEM_PROMPT,
userMessage,
maxTokens: 30,
};
logger.debug("title-generator: request", request);
try {
const response = await completeSimple(
model,
{
systemPrompt: request.systemPrompt,
messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
},
{
apiKey,
maxTokens: 30,
},
);
if (response.stopReason === "error") {
logger.debug("title-generator: response error", {
model: request.model,
stopReason: response.stopReason,
errorMessage: response.errorMessage,
});
continue;
}
// Extract title from response text content
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) {
continue;
}
// Clean up: remove quotes, trailing punctuation
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;
}
/**
* Set the terminal title using ANSI escape sequences.
*/
export function setTerminalTitle(title: string): void {
// OSC 2 sets the window title
process.stdout.write(`\x1b]2;${title}\x07`);
}