1import type { ChatAttachment } from "@t3tools/contracts";
2import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
3
4export type ThreadTitleMessage = {
5 readonly role: "user" | "assistant" | "system" | "reasoning";
6 readonly text: string;
7 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
8};
9
10const MAX_CONTEXT = 8_000;
11const MAX_MESSAGE = 2_000;
12const OMITTED = "[Earlier content truncated]\n\n";
13const TRUNCATED = "\n[Content truncated]\n";
14
15/** Keep the request and its final constraints when a message is too long. */
16export 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. */
26export 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}