1import type { ChatAttachment } from "@t3tools/contracts";
2import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
3import { isAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences";
4
5export type ThreadTitleMessage = {
6 readonly role: "user" | "assistant" | "system" | "reasoning";
7 readonly text: string;
8 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
9};
10
11const MAX_CONTEXT = 8_000;
12const MAX_MESSAGE = 2_000;
13const OMITTED = "[Earlier content truncated]\n\n";
14const TRUNCATED = "\n[Content truncated]\n";
15
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 let head = Math.ceil(available / 2);
21 let tail = available - head;
22 // Half an emoji serializes to a lone surrogate escape, which provider JSON parsers reject.
23 const last = text.charCodeAt(head - 1);
24 if (last >= 0xd800 && last <= 0xdbff) head -= 1;
25 const first = text.charCodeAt(text.length - tail);
26 if (tail > 0 && first >= 0xdc00 && first <= 0xdfff) tail -= 1;
27 return `${text.slice(0, head)}${TRUNCATED}${tail > 0 ? text.slice(-tail) : ""}`;
28}
29
30export function formatThreadTitleContext(messages: ReadonlyArray<ThreadTitleMessage>) {
31 const sections = messages.flatMap((source, index) => {
32 const message = isAttachmentOnlyBootstrapPrompt(source.text) ? { ...source, text: "" } : source;
33 // Thinking traces dwarf answers without identifying the thread subject.
34 if (
35 message.role === "system" ||
36 message.role === "reasoning" ||
37 (!message.text.trim() && !message.attachments?.length)
38 )
39 return [];
40 return [{ index, message, prefix: `${message.role.toUpperCase()}:\n` }];
41 });
42 const formatted = new Map<number, string>();
43 const contentsFor = (section: (typeof sections)[number]) => {
44 const cached = formatted.get(section.index);
45 if (cached !== undefined) return cached;
46 const text = assistantCitationsToPlainText(section.message.text).trim();
47 const names = section.message.attachments?.map((attachment) => attachment.name).join(", ");
48 const contents = [text, ...(names ? [`[Attachments: ${names}]`] : [])]
49 .filter(Boolean)
50 .join("\n");
51 formatted.set(section.index, contents);
52 return contents;
53 };
54 const selected = new Map<number, string>();
55 let remaining = MAX_CONTEXT - OMITTED.length;
56 const add = (section: (typeof sections)[number], budget: number) => {
57 if (selected.has(section.index)) return;
58 const limit = Math.min(budget, remaining) - section.prefix.length - 2;
59 if (limit <= TRUNCATED.length) return;
60 const contents = limitTitleMessage(contentsFor(section), limit);
61 if (!contents) return;
62 const text = section.prefix + contents;
63 selected.set(section.index, text);
64 remaining -= text.length + 2;
65 };
66
67 const firstUser = sections.find((section) => section.message.role === "user");
68 if (firstUser) add(firstUser, MAX_MESSAGE);
69 for (const section of sections.toReversed()) {
70 if (section.message.role === "user") {
71 add(section, Math.min(MAX_MESSAGE, remaining - 2_000));
72 }
73 }
74 for (const section of sections.toReversed()) {
75 if (section.message.role === "assistant") add(section, MAX_MESSAGE);
76 }
77 for (const role of ["user", "assistant"] as const) {
78 for (const section of sections.toReversed()) {
79 const previous = selected.get(section.index);
80 if (section.message.role !== role || previous === undefined) continue;
81 const expanded =
82 section.prefix +
83 limitTitleMessage(
84 contentsFor(section),
85 previous.length + remaining - section.prefix.length,
86 );
87 remaining -= expanded.length - previous.length;
88 selected.set(section.index, expanded);
89 }
90 }
91 const retained = sections.filter((section) => selected.has(section.index));
92 const truncated = retained.some(
93 (section) => selected.get(section.index) !== section.prefix + contentsFor(section),
94 );
95 const attachments = retained.flatMap((section) => section.message.attachments ?? []);
96 const firstAttachment = firstUser?.message.attachments?.[0];
97 const recentAttachments = attachments.filter(
98 (attachment) => attachment.id !== firstAttachment?.id,
99 );
100 return {
101 message: `${truncated || retained.length < sections.length ? OMITTED : ""}${retained.map((section) => selected.get(section.index)).join("\n\n")}`,
102 attachments: [
103 ...(firstAttachment ? [firstAttachment] : []),
104 ...recentAttachments.slice(firstAttachment ? -3 : -4),
105 ],
106 };
107}