826c3b932d
- Added tiny-title protocol contracts, including progress-state unions, message payloads, and transport interfaces. - Added title text utilities to truncate long inputs, wrap `<user-message>` blocks, and normalize generated titles. - Added tiny-title model registry and helpers with type-safe keys and runtime optional loading via optionalDependencies. - Added client-side worker orchestration with spawn fallback, request queueing, progress/error routing, and smoke-test APIs. - Added worker runtime for model resolution, prompt-based inference, lock-based install retries, and close-time cache clear.
326 lines
9.8 KiB
TypeScript
326 lines
9.8 KiB
TypeScript
/**
|
|
* Generate session titles using a smol, fast model.
|
|
*/
|
|
import * as path from "node:path";
|
|
|
|
import { type Api, type AssistantMessage, completeSimple, type Model, type Tool } from "@oh-my-pi/pi-ai";
|
|
import { logger, prompt } from "@oh-my-pi/pi-utils";
|
|
import type { ModelRegistry } from "../config/model-registry";
|
|
import { resolveRoleSelection } from "../config/model-resolver";
|
|
import type { Settings } from "../config/settings";
|
|
import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
|
|
import { ONLINE_TINY_TITLE_MODEL_KEY } from "../tiny/models";
|
|
import { formatTitleUserMessage, normalizeGeneratedTitle } from "../tiny/text";
|
|
import { tinyTitleClient } from "../tiny/title-client";
|
|
|
|
const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
|
|
|
|
const DEFAULT_TERMINAL_TITLE = "π";
|
|
const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
|
|
export const TITLE_LOCAL_FALLBACK_DELAY_MS = 10_000;
|
|
const TITLE_MAX_TOKENS = 30;
|
|
const REASONING_SAFE_MAX_TOKENS = 1024;
|
|
const SET_TITLE_TOOL_NAME = "set_title";
|
|
|
|
const setTitleTool: Tool = {
|
|
name: SET_TITLE_TOOL_NAME,
|
|
description: "Set the generated session title.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
title: {
|
|
type: "string",
|
|
description: "A concise 3-6 word title for the session.",
|
|
},
|
|
},
|
|
required: ["title"],
|
|
additionalProperties: false,
|
|
},
|
|
};
|
|
|
|
function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel?: Model<Api>): Model<Api> | undefined {
|
|
const availableModels = registry.getAvailable();
|
|
if (availableModels.length === 0) return undefined;
|
|
|
|
const titleModel = resolveRoleSelection(["commit", "smol"], settings, availableModels, registry)?.model;
|
|
if (titleModel) return titleModel;
|
|
|
|
if (currentModel) return currentModel;
|
|
|
|
return undefined;
|
|
}
|
|
|
|
export async function raceFirstNonNull<T>(
|
|
primary: Promise<T | null>,
|
|
startFallback: () => Promise<T | null>,
|
|
delayMs: number = TITLE_LOCAL_FALLBACK_DELAY_MS,
|
|
onPrimaryWinAfterFallback?: () => void,
|
|
): Promise<T | null> {
|
|
const { promise, resolve } = Promise.withResolvers<T | null>();
|
|
let resolved = false;
|
|
let primarySettled = false;
|
|
let fallbackStarted = false;
|
|
let fallbackSettled = false;
|
|
|
|
const resolveOnce = (value: T | null): void => {
|
|
if (resolved) return;
|
|
resolved = true;
|
|
resolve(value);
|
|
};
|
|
const maybeResolveNull = (): void => {
|
|
if (primarySettled && fallbackStarted && fallbackSettled) resolveOnce(null);
|
|
};
|
|
const startFallbackOnce = (): void => {
|
|
if (fallbackStarted || resolved) return;
|
|
fallbackStarted = true;
|
|
let fallback: Promise<T | null>;
|
|
try {
|
|
fallback = startFallback();
|
|
} catch {
|
|
fallbackSettled = true;
|
|
maybeResolveNull();
|
|
return;
|
|
}
|
|
void fallback.then(
|
|
value => {
|
|
fallbackSettled = true;
|
|
if (value !== null) resolveOnce(value);
|
|
else maybeResolveNull();
|
|
},
|
|
() => {
|
|
fallbackSettled = true;
|
|
maybeResolveNull();
|
|
},
|
|
);
|
|
};
|
|
|
|
const timer = setTimeout(startFallbackOnce, delayMs);
|
|
void primary.then(
|
|
value => {
|
|
primarySettled = true;
|
|
clearTimeout(timer);
|
|
if (value !== null) {
|
|
if (fallbackStarted) onPrimaryWinAfterFallback?.();
|
|
resolveOnce(value);
|
|
return;
|
|
}
|
|
startFallbackOnce();
|
|
maybeResolveNull();
|
|
},
|
|
() => {
|
|
primarySettled = true;
|
|
clearTimeout(timer);
|
|
startFallbackOnce();
|
|
maybeResolveNull();
|
|
},
|
|
);
|
|
|
|
try {
|
|
return await promise;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* @param sessionId Optional session id for sticky API key selection
|
|
* @param currentModel Current model (used to derive title model)
|
|
* @param metadataResolver Optional resolver evaluated after credential selection
|
|
* to produce request metadata (e.g. user_id for session attribution). Using a
|
|
* resolver instead of a pre-evaluated value ensures the metadata's account_uuid
|
|
* reflects the credential actually selected for this request.
|
|
*/
|
|
export async function generateSessionTitle(
|
|
firstMessage: string,
|
|
registry: ModelRegistry,
|
|
settings: Settings,
|
|
sessionId?: string,
|
|
currentModel?: Model<Api>,
|
|
metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
|
|
): Promise<string | null> {
|
|
const tinyModel = settings.get("providers.tinyModel");
|
|
if (tinyModel === ONLINE_TINY_TITLE_MODEL_KEY) {
|
|
return generateTitleOnline(firstMessage, registry, settings, sessionId, currentModel, metadataResolver);
|
|
}
|
|
|
|
const onlineAbortController = new AbortController();
|
|
const localTitle = tinyTitleClient.generate(tinyModel, firstMessage).then(
|
|
title => title || null,
|
|
() => null,
|
|
);
|
|
const startOnline = (): Promise<string | null> =>
|
|
generateTitleOnline(
|
|
firstMessage,
|
|
registry,
|
|
settings,
|
|
sessionId,
|
|
currentModel,
|
|
metadataResolver,
|
|
onlineAbortController.signal,
|
|
);
|
|
|
|
return raceFirstNonNull(localTitle, startOnline, TITLE_LOCAL_FALLBACK_DELAY_MS, () => {
|
|
onlineAbortController.abort();
|
|
});
|
|
}
|
|
|
|
export async function generateTitleOnline(
|
|
firstMessage: string,
|
|
registry: ModelRegistry,
|
|
settings: Settings,
|
|
sessionId?: string,
|
|
currentModel?: Model<Api>,
|
|
metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
|
|
signal?: AbortSignal,
|
|
): Promise<string | null> {
|
|
const model = getTitleModel(registry, settings, currentModel);
|
|
if (!model) {
|
|
logger.debug("title-generator: no title model found");
|
|
return null;
|
|
}
|
|
|
|
const userMessage = formatTitleUserMessage(firstMessage);
|
|
|
|
const apiKey = await registry.getApiKey(model, sessionId);
|
|
if (!apiKey) {
|
|
logger.debug("title-generator: no API key for smol model", {
|
|
provider: model.provider,
|
|
id: model.id,
|
|
});
|
|
return null;
|
|
}
|
|
// Resolve metadata after getApiKey so the session-sticky credential for this
|
|
// request is already recorded; metadataResolver can then return the correct
|
|
// account_uuid rather than the snapshot-at-call-site value.
|
|
const metadata = metadataResolver?.(model.provider);
|
|
|
|
// Title generation is a 3-6 word task, but some reasoning backends ignore
|
|
// disableReasoning. Keep the normal cheap budget for non-reasoning models
|
|
// while reserving enough output room for reasoning models to still emit
|
|
// the forced tool call after any unavoidable thinking tokens.
|
|
const maxTokens = model.reasoning ? Math.max(TITLE_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS) : TITLE_MAX_TOKENS;
|
|
const request = {
|
|
model: `${model.provider}/${model.id}`,
|
|
systemPrompt: TITLE_SYSTEM_PROMPT,
|
|
userMessage,
|
|
maxTokens,
|
|
};
|
|
logger.debug("title-generator: request", request);
|
|
|
|
try {
|
|
const response = await completeSimple(
|
|
model,
|
|
{
|
|
systemPrompt: [request.systemPrompt],
|
|
messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
|
|
tools: [setTitleTool],
|
|
},
|
|
{
|
|
apiKey,
|
|
maxTokens: request.maxTokens,
|
|
disableReasoning: true,
|
|
toolChoice: { type: "tool", name: SET_TITLE_TOOL_NAME },
|
|
metadata,
|
|
signal,
|
|
},
|
|
);
|
|
|
|
if (response.stopReason === "error") {
|
|
logger.debug("title-generator: response error", {
|
|
model: request.model,
|
|
stopReason: response.stopReason,
|
|
errorMessage: response.errorMessage,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const title = normalizeGeneratedTitle(extractGeneratedTitle(response.content));
|
|
|
|
logger.debug("title-generator: response", {
|
|
model: request.model,
|
|
title,
|
|
usage: response.usage,
|
|
stopReason: response.stopReason,
|
|
});
|
|
|
|
return title;
|
|
} catch (err) {
|
|
logger.debug("title-generator: error", {
|
|
model: request.model,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function extractGeneratedTitle(contentBlocks: AssistantMessage["content"]): string {
|
|
let textTitle = "";
|
|
for (const content of contentBlocks) {
|
|
if (content.type === "toolCall" && content.name === SET_TITLE_TOOL_NAME) {
|
|
const args = content.arguments as Record<string, unknown>;
|
|
const title = args.title;
|
|
return typeof title === "string" ? title.trim() : "";
|
|
}
|
|
if (content.type === "text") {
|
|
textTitle += content.text;
|
|
}
|
|
}
|
|
return textTitle.trim();
|
|
}
|
|
|
|
/**
|
|
* 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 0 (sets both tab and window title). Unsupported terminals ignore it.
|
|
*/
|
|
export function setTerminalTitle(title: string): void {
|
|
if (!process.stdout.isTTY) return;
|
|
process.stdout.write(`\x1b]0;${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 {
|
|
if (!process.stdout.isTTY) return;
|
|
process.stdout.write("\x1b[22;2t");
|
|
}
|
|
|
|
/**
|
|
* Restore the previously saved terminal title on terminals that support xterm window ops.
|
|
*/
|
|
export function popTerminalTitle(): void {
|
|
if (!process.stdout.isTTY) return;
|
|
process.stdout.write("\x1b[23;2t");
|
|
}
|