3666cb93d2
Rejected markerless prose thinking preambles while preserving marked titles and plain markerless title responses. Fixes #5252
397 lines
15 KiB
TypeScript
397 lines
15 KiB
TypeScript
/**
|
||
* Generate session titles using a smol, fast model.
|
||
*/
|
||
import * as path from "node:path";
|
||
|
||
import { type Api, type AssistantMessage, completeSimple, type Model } from "@oh-my-pi/pi-ai";
|
||
import { StreamMarkupHealing } from "@oh-my-pi/pi-ai/utils/stream-markup-healing";
|
||
import { isTerminalHeadless, 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 titleMarkerInstruction from "../prompts/system/title-marker-instruction.md" with { type: "text" };
|
||
import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
|
||
import { formatTitleUserMessage } from "../tiny/message-preproc";
|
||
import { isTinyTitleLocalModelKey, ONLINE_TINY_TITLE_MODEL_KEY } from "../tiny/models";
|
||
import { isLowSignalTitleInput, normalizeGeneratedTitle } from "../tiny/text";
|
||
import { tinyTitleClient } from "../tiny/title-client";
|
||
|
||
const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
|
||
const TITLE_MARKER_INSTRUCTION = prompt.render(titleMarkerInstruction);
|
||
|
||
const DEFAULT_TERMINAL_TITLE = "π";
|
||
const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
|
||
|
||
// Cover the "backend ignores `disableReasoning`" case unconditionally: the
|
||
// static `model.reasoning` catalog flag can't distinguish a thinking model that
|
||
// was declared with `reasoning: false` (e.g. Qwen3 served locally via llama.cpp,
|
||
// whose bundled jinja chat template forces `enable_thinking: true`) from one
|
||
// that never emits thinking. `maxTokens` is a hard cap, not a target — the
|
||
// happy-path completion still returns in a handful of tokens, so raising the
|
||
// ceiling costs nothing when thinking is genuinely suppressed and keeps the
|
||
// `<title>` marker output reachable when it isn't (issue #4355).
|
||
const TITLE_MAX_TOKENS = 1024;
|
||
|
||
/** Matches the title the model wraps in `<title>...</title>`. */
|
||
const TITLE_MARKER_GLOBAL_RE = /<title>([\s\S]*?)<\/title>|<title\s*\/>|<title>\s*$/gi;
|
||
const TITLE_VISIBILITY_SENTINEL = "\uE000omp-title-visible\uE000";
|
||
const THINKING_TAG_ENVELOPE_RE = /<(think|thinking|reasoning)>\s*[\s\S]*?<\/\1>/gi;
|
||
const THINKING_FENCE_ENVELOPE_RE = /```(?:thinking|reasoning)\b[\s\S]*?```/gi;
|
||
const LEADING_THINKING_TAG_RE = /^\s*<(think|thinking|reasoning)>\s*[\s\S]*?<\/\1>\s*/i;
|
||
const LEADING_THINKING_FENCE_RE = /^\s*```(?:thinking|reasoning)\b[\s\S]*?```\s*/i;
|
||
const LEADING_PROSE_THINKING_PREAMBLE_RE =
|
||
/^[ \t]*(?:(?:here(?:['’]s| is)[ \t]+(?:a|the|my)[ \t]+)|my[ \t]+)?(?:thinking|thought|reasoning)[ \t]+process[ \t]*:?[ \t]*(?:\r?\n|$)/i;
|
||
|
||
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(["tiny", "commit", "smol"], settings, availableModels)?.model;
|
||
if (titleModel) return titleModel;
|
||
|
||
if (currentModel) return 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
|
||
* @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.
|
||
* @param customSystemPrompt Optional title-specific system prompt override
|
||
*/
|
||
export async function generateSessionTitle(
|
||
firstMessage: string,
|
||
registry: ModelRegistry,
|
||
settings: Settings,
|
||
sessionId?: string,
|
||
currentModel?: Model<Api>,
|
||
metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
|
||
customSystemPrompt?: string,
|
||
): Promise<string | null> {
|
||
// Defer titling for greetings / acknowledgements / empty input. The default
|
||
// tiny title model can't reliably decline trivial input, so this happens
|
||
// deterministically before any model is invoked; the caller retries on the
|
||
// next user message while the session stays unnamed.
|
||
if (isLowSignalTitleInput(firstMessage)) {
|
||
logger.debug("title-generator: skipped low-signal input", { sessionId, reason: "low-signal" });
|
||
return null;
|
||
}
|
||
|
||
const titleSystemPrompt = customSystemPrompt?.trim() || undefined;
|
||
const tinyModel = settings.get("providers.tinyModel");
|
||
if (tinyModel === ONLINE_TINY_TITLE_MODEL_KEY) {
|
||
return generateTitleOnline(
|
||
firstMessage,
|
||
registry,
|
||
settings,
|
||
sessionId,
|
||
currentModel,
|
||
metadataResolver,
|
||
undefined,
|
||
titleSystemPrompt,
|
||
);
|
||
}
|
||
|
||
// User explicitly picked a local tiny model. NEVER fall back to the online
|
||
// smol path (issue #3187): the smol role resolves through priority.json and
|
||
// silently bills whatever provider holds the resolved API key — OpenRouter
|
||
// in the reporter's case, leaking real credits without consent. If the
|
||
// local worker fails (unknown key, download missing, transformers.js
|
||
// crash, abort), leave the session untitled; the next user turn retries.
|
||
if (!isTinyTitleLocalModelKey(tinyModel)) {
|
||
logger.warn("title-generator: unknown local tiny model; skipping title (will not fall back to online)", {
|
||
sessionId,
|
||
model: tinyModel,
|
||
reason: "unknown-local-model",
|
||
});
|
||
return null;
|
||
}
|
||
try {
|
||
const localTitle = titleSystemPrompt
|
||
? await tinyTitleClient.generate(tinyModel, firstMessage, { systemPrompt: titleSystemPrompt })
|
||
: await tinyTitleClient.generate(tinyModel, firstMessage);
|
||
if (!localTitle) {
|
||
logger.warn("title-generator: local tiny model produced no title; skipping (no online fallback)", {
|
||
sessionId,
|
||
model: tinyModel,
|
||
reason: "local-no-output",
|
||
});
|
||
return null;
|
||
}
|
||
return localTitle;
|
||
} catch (err) {
|
||
logger.warn("title-generator: local tiny model errored; skipping (no online fallback)", {
|
||
sessionId,
|
||
model: tinyModel,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
});
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function generateTitleOnline(
|
||
firstMessage: string,
|
||
registry: ModelRegistry,
|
||
settings: Settings,
|
||
sessionId?: string,
|
||
currentModel?: Model<Api>,
|
||
metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
|
||
signal?: AbortSignal,
|
||
customSystemPrompt?: string,
|
||
): Promise<string | null> {
|
||
const model = getTitleModel(registry, settings, currentModel);
|
||
if (!model) {
|
||
logger.warn("title-generator: no title model found", { sessionId, reason: "no-title-model" });
|
||
return null;
|
||
}
|
||
|
||
const titleSystemPrompt = customSystemPrompt?.trim() || undefined;
|
||
// The model is always asked to wrap the title in `<title>...</title>` and
|
||
// the title is parsed from text. A forced `set_title` tool call was the old
|
||
// scheme, but hosts that ignore or reject forced `tool_choice` then echoed
|
||
// the prompt's `{"title": ...}` JSON example verbatim as the session title;
|
||
// markers work uniformly everywhere.
|
||
const systemPrompt = titleSystemPrompt ? [titleSystemPrompt, TITLE_MARKER_INSTRUCTION] : [TITLE_SYSTEM_PROMPT];
|
||
const userMessage = formatTitleUserMessage(firstMessage);
|
||
const modelName = `${model.provider}/${model.id}`;
|
||
const modelContext = {
|
||
sessionId,
|
||
provider: model.provider,
|
||
id: model.id,
|
||
model: modelName,
|
||
};
|
||
logger.debug("title-generator: start", modelContext);
|
||
|
||
try {
|
||
const apiKey = await registry.getApiKey(model, sessionId);
|
||
if (!apiKey) {
|
||
logger.warn("title-generator: no API key", { ...modelContext, reason: "missing-api-key" });
|
||
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-7 word task, but the ceiling has to survive
|
||
// backends that ignore `disableReasoning` (see TITLE_MAX_TOKENS above).
|
||
const maxTokens = TITLE_MAX_TOKENS;
|
||
logger.debug("title-generator: request", { ...modelContext, maxTokens });
|
||
|
||
const response = await completeSimple(
|
||
model,
|
||
{
|
||
systemPrompt,
|
||
messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
|
||
},
|
||
{
|
||
apiKey: registry.resolver(model, sessionId),
|
||
maxTokens,
|
||
disableReasoning: true,
|
||
metadata,
|
||
signal,
|
||
},
|
||
);
|
||
|
||
if (response.stopReason === "error") {
|
||
logger.warn("title-generator: response error", {
|
||
...modelContext,
|
||
reason: "provider-response-error",
|
||
stopReason: response.stopReason,
|
||
errorMessage: response.errorMessage,
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const title = normalizeGeneratedTitle(extractGeneratedTitle(response.content), firstMessage);
|
||
|
||
if (!title) {
|
||
logger.debug("title-generator: no title returned", {
|
||
...modelContext,
|
||
reason: "model-returned-none",
|
||
usage: response.usage,
|
||
stopReason: response.stopReason,
|
||
});
|
||
return null;
|
||
}
|
||
|
||
logger.debug("title-generator: success", {
|
||
...modelContext,
|
||
title,
|
||
usage: response.usage,
|
||
stopReason: response.stopReason,
|
||
});
|
||
|
||
return title;
|
||
} catch (err) {
|
||
logger.warn("title-generator: error", {
|
||
...modelContext,
|
||
reason: "exception",
|
||
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 === "text") {
|
||
textTitle += content.text;
|
||
}
|
||
}
|
||
// Stay lenient: prefer the first closed title marker in visible text, then
|
||
// fall back to a plain sentence after stripping only known leading leaked
|
||
// thinking envelopes plus any stray/unclosed title tag fragment. Reject a
|
||
// prose thinking preamble only on the markerless path: a later marked title
|
||
// remains authoritative.
|
||
const markedTitle = extractVisibleMarkedTitle(textTitle);
|
||
if (markedTitle !== undefined) return unwrapJsonTitle(markedTitle);
|
||
const cleanedTextTitle = stripLeadingLeakedThinkingMarkup(textTitle)
|
||
.replace(/<\/?title>/gi, "")
|
||
.trim();
|
||
if (LEADING_PROSE_THINKING_PREAMBLE_RE.test(cleanedTextTitle)) return "";
|
||
return unwrapJsonTitle(cleanedTextTitle);
|
||
}
|
||
|
||
function extractVisibleMarkedTitle(text: string): string | undefined {
|
||
TITLE_MARKER_GLOBAL_RE.lastIndex = 0;
|
||
let marker: RegExpExecArray | null = TITLE_MARKER_GLOBAL_RE.exec(text);
|
||
while (marker !== null) {
|
||
const content = marker[1];
|
||
if (isVisibleTitleMarker(text, marker.index)) return content?.trim() ?? "";
|
||
marker = TITLE_MARKER_GLOBAL_RE.exec(text);
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function isVisibleTitleMarker(text: string, markerIndex: number): boolean {
|
||
if (isInsideKnownThinkingEnvelope(text, markerIndex)) return false;
|
||
return stripLeakedThinkingMarkup(`${text.slice(0, markerIndex)}${TITLE_VISIBILITY_SENTINEL}`).endsWith(
|
||
TITLE_VISIBILITY_SENTINEL,
|
||
);
|
||
}
|
||
|
||
function isInsideKnownThinkingEnvelope(text: string, index: number): boolean {
|
||
return (
|
||
isInsideEnvelopeMatchedBy(THINKING_TAG_ENVELOPE_RE, text, index) ||
|
||
isInsideEnvelopeMatchedBy(THINKING_FENCE_ENVELOPE_RE, text, index)
|
||
);
|
||
}
|
||
|
||
function isInsideEnvelopeMatchedBy(pattern: RegExp, text: string, index: number): boolean {
|
||
pattern.lastIndex = 0;
|
||
let marker = pattern.exec(text);
|
||
while (marker !== null) {
|
||
const start = marker.index;
|
||
const end = start + marker[0].length;
|
||
if (index > start && index < end) return true;
|
||
if (start > index) return false;
|
||
marker = pattern.exec(text);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function stripLeadingLeakedThinkingMarkup(text: string): string {
|
||
let current = text;
|
||
while (true) {
|
||
const withoutTag = current.replace(LEADING_THINKING_TAG_RE, "");
|
||
const withoutFence = withoutTag.replace(LEADING_THINKING_FENCE_RE, "");
|
||
if (withoutFence === current) return current;
|
||
current = withoutFence;
|
||
}
|
||
}
|
||
|
||
function stripLeakedThinkingMarkup(text: string): string {
|
||
const healer = new StreamMarkupHealing({ pattern: "thinking" });
|
||
return healer.feed(text) + healer.flushPending();
|
||
}
|
||
|
||
/**
|
||
* Unwrap a JSON-shaped response (`{"title": "..."}`, optionally code-fenced)
|
||
* into the bare title. Models occasionally emit the structured shape they were
|
||
* trained on for title tasks instead of plain text; without this the raw JSON
|
||
* became the session title.
|
||
*/
|
||
function unwrapJsonTitle(candidate: string): string {
|
||
const text = candidate
|
||
.replace(/^```(?:json)?\s*/i, "")
|
||
.replace(/```$/, "")
|
||
.trim();
|
||
if (!text.startsWith("{")) return candidate;
|
||
try {
|
||
const parsed: unknown = JSON.parse(text);
|
||
if (parsed && typeof parsed === "object" && "title" in parsed && typeof parsed.title === "string") {
|
||
return parsed.title.trim();
|
||
}
|
||
} catch {
|
||
// Truncated/malformed JSON: salvage the quoted title value if present.
|
||
const quoted = /"title"\s*:\s*("(?:[^"\\]|\\.)*")/.exec(text);
|
||
if (quoted) {
|
||
const salvaged: unknown = JSON.parse(quoted[1]);
|
||
if (typeof salvaged === "string") return salvaged.trim();
|
||
}
|
||
}
|
||
return candidate;
|
||
}
|
||
|
||
/**
|
||
* 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 || isTerminalHeadless()) 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 || isTerminalHeadless()) 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 || isTerminalHeadless()) return;
|
||
process.stdout.write("\x1b[23;2t");
|
||
}
|