| 1 | import type { ChatAttachment } from "@t3tools/contracts"; |
| 2 | import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; |
| 3 | |
| 4 | export type ThreadTitleMessage = { |
| 5 | readonly role: "user" | "assistant" | "system" | "reasoning"; |
| 6 | readonly text: string; |
| 7 | readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; |
| 8 | }; |
| 9 | |
| 10 | const MAX_CONTEXT = 8_000; |
| 11 | const MAX_MESSAGE = 2_000; |
| 12 | const OMITTED = "[Earlier content truncated]\n\n"; |
| 13 | const TRUNCATED = "\n[Content truncated]\n"; |
| 14 | |
| 15 | /** Keep the request and its final constraints when a message is too long. */ |
| 16 | export function limitTitleMessage(text: string, budget: number): string { |
| 17 | if (text.length <= budget) return text; |
| 18 | if (budget <= TRUNCATED.length) return ""; |
| 19 | const available = budget - TRUNCATED.length; |
| 20 | const head = Math.ceil(available / 2); |
| 21 | const tail = available - head; |
| 22 | return `${text.slice(0, head)}${TRUNCATED}${tail > 0 ? text.slice(-tail) : ""}`; |
| 23 | } |
| 24 | |
| 25 | /** Reserve space for user intent before adding assistant findings, in conversation order. */ |
| 26 | export function formatThreadTitleContext(messages: ReadonlyArray<ThreadTitleMessage>) { |
| 27 | const sections = messages.flatMap((message, index) => { |
| 28 | // Thinking traces are working notes, not what the thread is about, and they |
| 29 | // dwarf the answer they precede. Titling on them would be worse and costlier. |
| 30 | if ( |
| 31 | message.role === "system" || |
| 32 | message.role === "reasoning" || |
| 33 | (!message.text.trim() && !message.attachments?.length) |
| 34 | ) |
| 35 | return []; |
| 36 | return [{ index, message, prefix: `${message.role.toUpperCase()}:\n` }]; |
| 37 | }); |
| 38 | const formatted = new Map<number, string>(); |
| 39 | const contentsFor = (section: (typeof sections)[number]) => { |
| 40 | const cached = formatted.get(section.index); |
| 41 | if (cached !== undefined) return cached; |
| 42 | const text = assistantCitationsToPlainText(section.message.text).trim(); |
| 43 | const names = section.message.attachments?.map((attachment) => attachment.name).join(", "); |
| 44 | const contents = [text, ...(names ? [`[Attachments: ${names}]`] : [])] |
| 45 | .filter(Boolean) |
| 46 | .join("\n"); |
| 47 | formatted.set(section.index, contents); |
| 48 | return contents; |
| 49 | }; |
| 50 | const selected = new Map<number, string>(); |
| 51 | let remaining = MAX_CONTEXT - OMITTED.length; |
| 52 | const add = (section: (typeof sections)[number], budget: number) => { |
| 53 | if (selected.has(section.index)) return; |
| 54 | const limit = Math.min(budget, remaining) - section.prefix.length - 2; |
| 55 | if (limit <= TRUNCATED.length) return; |
| 56 | const contents = limitTitleMessage(contentsFor(section), limit); |
| 57 | if (!contents) return; |
| 58 | const text = section.prefix + contents; |
| 59 | selected.set(section.index, text); |
| 60 | remaining -= text.length + 2; |
| 61 | }; |
| 62 | |
| 63 | const firstUser = sections.find((section) => section.message.role === "user"); |
| 64 | if (firstUser) add(firstUser, MAX_MESSAGE); |
| 65 | // Up to 6,000 characters go to user messages. Assistant output cannot evict them. |
| 66 | for (const section of sections.toReversed()) { |
| 67 | if (section.message.role === "user") { |
| 68 | add(section, Math.min(MAX_MESSAGE, remaining - 2_000)); |
| 69 | } |
| 70 | } |
| 71 | for (const section of sections.toReversed()) { |
| 72 | if (section.message.role === "assistant") add(section, MAX_MESSAGE); |
| 73 | } |
| 74 | // Use spare space when the conversation has only a few messages. |
| 75 | for (const role of ["user", "assistant"] as const) { |
| 76 | for (const section of sections.toReversed()) { |
| 77 | const previous = selected.get(section.index); |
| 78 | if (section.message.role !== role || previous === undefined) continue; |
| 79 | const expanded = |
| 80 | section.prefix + |
| 81 | limitTitleMessage( |
| 82 | contentsFor(section), |
| 83 | previous.length + remaining - section.prefix.length, |
| 84 | ); |
| 85 | remaining -= expanded.length - previous.length; |
| 86 | selected.set(section.index, expanded); |
| 87 | } |
| 88 | } |
| 89 | const retained = sections.filter((section) => selected.has(section.index)); |
| 90 | const truncated = retained.some( |
| 91 | (section) => selected.get(section.index) !== section.prefix + contentsFor(section), |
| 92 | ); |
| 93 | const attachments = retained.flatMap((section) => section.message.attachments ?? []); |
| 94 | const firstAttachment = firstUser?.message.attachments?.[0]; |
| 95 | const recentAttachments = attachments.filter( |
| 96 | (attachment) => attachment.id !== firstAttachment?.id, |
| 97 | ); |
| 98 | return { |
| 99 | message: `${truncated || retained.length < sections.length ? OMITTED : ""}${retained.map((section) => selected.get(section.index)).join("\n\n")}`, |
| 100 | attachments: [ |
| 101 | ...(firstAttachment ? [firstAttachment] : []), |
| 102 | ...recentAttachments.slice(firstAttachment ? -3 : -4), |
| 103 | ], |
| 104 | }; |
| 105 | } |