import type { SessionEntry } from "@earendil-works/pi-coding-agent"; const TICKET_ID_PATTERN = /\b([A-Z][A-Z0-9]{1,9}-\d+)\b/i; export interface TitleLimits { maxMessages: number; maxMessageChars: number; maxConversationChars: number; maxTitleChars: number; } export interface SessionEntryReader { getLeafEntry(): SessionEntry | undefined; getEntry(id: string): SessionEntry | undefined; } function compactStrings(parts: Iterable, maxChars: number): string { const words: string[] = []; let length = 0; let truncated = false; outer: for (const part of parts) { for (const match of part.matchAll(/\S+/gu)) { const word = match[0]; const separatorChars = words.length > 0 ? 1 : 0; const available = maxChars - length - separatorChars; if (word.length <= available) { words.push(word); length += separatorChars + word.length; continue; } const keepChars = Math.max(0, available - 1); if (keepChars > 0) words.push(`${word.slice(0, keepChars)}…`); else if (words.length > 0) words[words.length - 1] = `${words.at(-1)?.slice(0, -1) ?? ""}…`; truncated = true; break outer; } } const result = words.join(" "); return truncated ? result.slice(0, maxChars) : result; } function textParts(content: unknown): Iterable { if (typeof content === "string") return [content]; if (!Array.isArray(content)) return []; return (function* (): Generator { for (const item of content) { if (typeof item !== "object" || item === null || !("type" in item) || !("text" in item)) continue; if (item.type === "text" && typeof item.text === "string") yield item.text; } })(); } function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } export interface ConversationExcerpt { text: string; hasUserMessage: boolean; hasAssistantMessage: boolean; } export function buildConversationExcerpt( session: SessionEntryReader, limits: Pick, ): ConversationExcerpt { const selected: Array<{ role: "user" | "assistant"; text: string }> = []; let entry = session.getLeafEntry(); while (entry) { if (entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant")) { const text = compactStrings(textParts(entry.message.content), limits.maxMessageChars); if (text) { selected.unshift({ role: entry.message.role, text }); if (selected.length > limits.maxMessages) selected.pop(); } } else if (entry.type === "compaction") { const summary = compactStrings([entry.summary], limits.maxMessageChars); if (summary) { selected.unshift({ role: "user", text: `Earlier session summary: ${summary}` }); if (selected.length > limits.maxMessages) selected.pop(); } break; } entry = entry.parentId === null ? undefined : session.getEntry(entry.parentId); } const hasUserMessage = selected.some(({ role }) => role === "user"); const hasAssistantMessage = selected.some(({ role }) => role === "assistant"); const lines = selected.map(({ role, text }) => `${role === "user" ? "User" : "Assistant"}: ${text}`); return { text: compactStrings(lines, limits.maxConversationChars), hasUserMessage, hasAssistantMessage, }; } export function excerptFingerprint(excerpt: ConversationExcerpt): number { let hash = 2166136261; for (const char of excerpt.text) { hash ^= char.codePointAt(0) ?? 0; hash = Math.imul(hash, 16777619); } return hash >>> 0; } export function findTicketId(conversation: string): string | undefined { return TICKET_ID_PATTERN.exec(conversation)?.[1]?.toUpperCase(); } export function prefixTitleWithTicket(title: string, ticketId: string | undefined, maxTitleChars: number): string { if (!ticketId) return title; const normalizedTicketId = ticketId.toUpperCase(); const ticketInTitle = new RegExp(`\\b${escapeRegExp(normalizedTicketId)}\\b`, "i"); const titleWithoutTicket = title .replace(ticketInTitle, "") .replace(/\[\s*\]|\(\s*\)/g, "") .replace(/^\s*[:\-–—]\s*/, "") .replace(/\s+/g, " ") .trim(); const prefix = `${normalizedTicketId}:`; if (!titleWithoutTicket || prefix.length >= maxTitleChars) return prefix.slice(0, maxTitleChars); const availableTitleChars = maxTitleChars - prefix.length - 1; const compactTitle = compactStrings([titleWithoutTicket], availableTitleChars); return `${prefix} ${compactTitle}`; } export function buildTitlePrompt(conversation: string): string { return `Create a concise display name for this coding-agent session. Rules: - Return only the title, with no quotes, markdown, prefix, or explanation. - Use the conversation's language. - Use 3 to 8 words when practical. - Describe the concrete task or outcome. - Preserve useful ticket IDs, project names, and technical terms. - Avoid generic titles such as "Coding Session", "New Task", or "Help Request". - Do not end with punctuation. Conversation: ${conversation}`; } export function normalizeTitle(raw: string, maxTitleChars: number): string | undefined { const firstLine = raw .split(/\r?\n/) .map((line) => line.trim()) .find((line) => line.length > 0); if (!firstLine) return undefined; let title = firstLine .replace(/^#{1,6}\s*/, "") .replace(/^(?:title|titel)\s*:\s*/i, "") .replace(/^[-*•]\s*/, "") .replace(/[\u0000-\u001f\u007f]/g, " ") .replace(/\s+/g, " ") .trim(); const quotePairs: ReadonlyArray = [ ['"', '"'], ["'", "'"], ["“", "”"], ["„", "“"], ["«", "»"], ]; for (const [left, right] of quotePairs) { if (title.startsWith(left) && title.endsWith(right) && title.length > left.length + right.length) { title = title.slice(left.length, -right.length).trim(); break; } } title = title.replace(/[.!?;:,]+$/u, "").trim(); if (!title) return undefined; if (title.length > maxTitleChars) { title = title.slice(0, maxTitleChars).replace(/\s+\S*$/, "").trim(); } if (title.length < 3) return undefined; return title; }