1import {
2 type ComposerContextId,
3 ComposerContextRecord,
4 type ElementContextRecord,
5 type PreviewAnnotationContextRecord,
6 ReviewCommentContextRecord,
7 type TerminalContextRecord,
8} from "@t3tools/contracts";
9import * as Schema from "effect/Schema";
10
11import { formatComposerContextReference } from "./composerContextReferences.ts";
12
13/**
14 * Upgrades a message written before inline context references: trailing
15 * `<terminal_context>`, `<element_context>` and `<preview_annotation>` blocks, inline or
16 * trailing `<review_comment>` blocks, and U+FFFC terminal placeholders. Produces canonical
17 * reference links plus records so old messages render and copy through the new path.
18 * Event history is never rewritten; this runs in memory on read.
19 */
20
21export interface UpgradedLegacyContext {
22 text: string;
23 records: ComposerContextRecord[];
24}
25
26const PLACEHOLDER = "";
27const TRAILING_TERMINAL =
28 /\n*<terminal_context>\n((?:(?!\n<\/terminal_context>)[\s\S])*)\n<\/terminal_context>\s*$/;
29const TRAILING_ELEMENT =
30 /\n*<element_context>\n((?:(?!\n<\/element_context>)[\s\S])*)\n<\/element_context>\s*$/;
31const TRAILING_PREVIEW =
32 /\n*<preview_annotation>\n((?:(?!\n<\/preview_annotation>)[\s\S])*)\n<\/preview_annotation>\s*$/;
33const REVIEW_OR_CONTEXT_BLOCK =
34 /<review_comment\b([^>]*)>|^<(terminal_context|element_context|preview_annotation)>\n[\s\S]*?\n<\/\2>/gm;
35const REVIEW_ATTRIBUTE = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g;
36const REVIEW_FENCE = /(`{3,})([^\s`]*)[^\n]*\n([\s\S]*?)\n\1/g;
37const REVIEW_TOKEN = "\uE000";
38const LEGACY_MARKERS =
39 /<(?:terminal_context|element_context|preview_annotation|review_comment)\b|/;
40const isReviewCommentContextRecord = Schema.is(ReviewCommentContextRecord);
41const isLegacyContextRecords = Schema.is(
42 Schema.Array(ComposerContextRecord).check(Schema.isMaxLength(200)),
43);
44
45interface ParsedEntry {
46 header: string;
47 body: string;
48}
49
50function parseEntries(block: string): ParsedEntry[] | null {
51 const entries: ParsedEntry[] = [];
52 let current: { header: string; bodyLines: string[] } | null = null;
53 const commit = () => {
54 if (!current) return;
55 entries.push({ header: current.header, body: current.bodyLines.join("\n").trimEnd() });
56 current = null;
57 };
58 for (const line of block.split("\n")) {
59 const headerMatch = /^- (.+):$/.exec(line);
60 if (headerMatch) {
61 commit();
62 current = { header: headerMatch[1]!, bodyLines: [] };
63 continue;
64 }
65 if (current && line.startsWith(" ")) current.bodyLines.push(line.slice(2));
66 else if (line.trim().length > 0) return null;
67 else if (current) current.bodyLines.push("");
68 }
69 commit();
70 return entries;
71}
72
73/** The inline label the old send path wrote for a terminal excerpt: `@terminal-1:509-514`. */
74function inlineTerminalLabel(record: TerminalContextRecord): string {
75 const slug = record.terminalLabel.trim().toLowerCase().replace(/\s+/g, "-");
76 const range =
77 record.lineStart === record.lineEnd
78 ? `${record.lineStart}`
79 : `${record.lineStart}-${record.lineEnd}`;
80 return `@${slug}:${range}`;
81}
82
83function legacyId(kind: string, index: number): ComposerContextId {
84 return `legacy_${kind}_${index}` as ComposerContextId;
85}
86
87function terminalRecord(entry: ParsedEntry, index: number): TerminalContextRecord | null {
88 const header = /^(.*?) (?:line (\d+)|lines (\d+)-(\d+))$/.exec(entry.header);
89 if (!header) return null;
90 const terminalLabel = header[1]!.trim();
91 if (!terminalLabel) return null;
92 const lineStart = Number(header[2] ?? header[3]);
93 const lineEnd = Number(header[2] ?? header[4]);
94 const text = entry.body
95 .split("\n")
96 .map((line) => line.replace(/^\d+ \| ?/, ""))
97 .join("\n");
98 return {
99 version: 1,
100 contextId: legacyId("terminal", index),
101 kind: "terminal",
102 label: entry.header,
103 terminalId: terminalLabel.toLowerCase().replace(/\s+/g, "-"),
104 terminalLabel,
105 lineStart,
106 lineEnd,
107 text,
108 };
109}
110
111function parseSource(location: string): ElementContextRecord["source"] {
112 const match = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(location);
113 if (!match || !match[1]) return null;
114 return {
115 functionName: null,
116 fileName: match[1],
117 lineNumber: match[2] === undefined ? null : Number(match[2]),
118 columnNumber: match[3] === undefined ? null : Number(match[3]),
119 };
120}
121
122function elementRecord(entry: ParsedEntry, index: number): ElementContextRecord | null {
123 const header = /^<([^>]+)>/.exec(entry.header);
124 if (!header) return null;
125 const inner = header[1]!;
126 const fields: Record<string, string> = {};
127 const sections: Record<string, string[]> = {};
128 let section: string | null = null;
129 for (const line of entry.body.split("\n")) {
130 if (section && (line.startsWith(" ") || line.length === 0)) {
131 sections[section]!.push(line.slice(2));
132 continue;
133 }
134 section = null;
135 const field = /^([a-z]+): (.*)$/.exec(line);
136 if (field) {
137 fields[field[1]!] = field[2]!;
138 continue;
139 }
140 const sectionStart = /^(html|styles):$/.exec(line);
141 if (sectionStart) {
142 section = sectionStart[1]!;
143 sections[section] = [];
144 }
145 }
146 return {
147 version: 1,
148 contextId: legacyId("element", index),
149 kind: "element",
150 label: `<${inner}>`,
151 pageUrl: fields.url ?? "",
152 pageTitle: null,
153 tagName: inner.toLowerCase(),
154 selector: fields.selector ?? null,
155 htmlPreview: (sections.html ?? []).join("\n").trimEnd(),
156 componentName: /[A-Z]/.test(inner) ? inner : null,
157 source: fields.source ? parseSource(fields.source) : null,
158 styles: (sections.styles ?? []).join("\n").trimEnd(),
159 };
160}
161
162function previewRecord(body: string, index: number): PreviewAnnotationContextRecord | null {
163 const lines = body.split("\n");
164 // A legacy comment was written verbatim, so it can run over several lines and hold blank
165 // lines and markup of its own. Its value is every line up to the next field or the block that
166 // follows it — anything else is text the author typed, and dropping it loses instructions the
167 // chip that replaces this block cannot show.
168 const FIELD_PREFIXES = ["Preview annotation:", "Id: ", "Page: ", "Comment: ", "Targets: "];
169 const BLOCK_DELIMITER =
170 /^<\/?(?:terminal_context|element_context|preview_annotation|review_comment)\b/;
171 const isFieldStart = (line: string) =>
172 FIELD_PREFIXES.some((candidate) => line.startsWith(candidate)) ||
173 line === "Requested visual changes:" ||
174 BLOCK_DELIMITER.test(line) ||
175 line === "The attached screenshot is the annotated preview crop.";
176 const read = (prefix: string) => {
177 const start = lines.findIndex((line) => line.startsWith(prefix));
178 if (start < 0) return "";
179 let end = start + 1;
180 while (end < lines.length && !isFieldStart(lines[end]!)) end += 1;
181 return [lines[start]!.slice(prefix.length), ...lines.slice(start + 1, end)].join("\n").trim();
182 };
183 const styleHeading = lines.indexOf("Requested visual changes:");
184 const styleChanges: string[] = [];
185 if (styleHeading >= 0) {
186 for (const line of lines.slice(styleHeading + 1)) {
187 if (!line.startsWith("- ")) break;
188 styleChanges.push(line.slice(2));
189 }
190 }
191 const page = read("Page: ");
192 const pageIsUrl = /^https?:\/\//i.test(page);
193 const elements: NonNullable<PreviewAnnotationContextRecord["elements"]>[number][] = [];
194 for (const match of body.matchAll(/<element_context>\n([\s\S]*?)\n<\/element_context>/g)) {
195 const entries = parseEntries(match[1] ?? "");
196 if (entries === null) return null;
197 for (const entry of entries) {
198 const record = elementRecord(entry, elements.length + 1);
199 if (record === null) return null;
200 const {
201 version: _version,
202 contextId: _contextId,
203 kind: _kind,
204 label: _label,
205 ...details
206 } = record;
207 elements.push(details);
208 }
209 }
210 return {
211 version: 1,
212 contextId: legacyId("preview-annotation", index),
213 kind: "preview-annotation",
214 label: page || "Preview annotation",
215 annotationId: read("Id: "),
216 pageUrl: pageIsUrl ? page : (elements[0]?.pageUrl ?? ""),
217 pageTitle: pageIsUrl ? null : page || null,
218 comment: read("Comment: "),
219 targetSummary: read("Targets: "),
220 styleChanges,
221 ...(elements.length > 0 ? { elements } : {}),
222 };
223}
224
225function unescapeAttribute(value: string): string {
226 return value
227 .replace(/&lt;/g, "<")
228 .replace(/&gt;/g, ">")
229 .replace(/&quot;/g, '"')
230 .replace(/&amp;/g, "&");
231}
232
233function reviewRecord(
234 rawAttributes: string,
235 rawBody: string,
236 index: number,
237): ReviewCommentContextRecord | null {
238 const attributes: Record<string, string> = {};
239 for (const match of rawAttributes.matchAll(REVIEW_ATTRIBUTE)) {
240 attributes[match[1]!] = unescapeAttribute(match[2] ?? "");
241 }
242 const filePath = attributes.filePath?.trim();
243 const sectionId = attributes.sectionId?.trim();
244 const startIndex = attributes.startIndex;
245 const endIndex = attributes.endIndex;
246 if (!filePath || !sectionId || !/^\d+$/.test(startIndex ?? "") || !/^\d+$/.test(endIndex ?? "")) {
247 return null;
248 }
249 const fences = Array.from(rawBody.matchAll(REVIEW_FENCE));
250 const fence = fences.at(-1);
251 const rangeLabel = attributes.rangeLabel?.trim() || "line";
252 const basename = filePath.split(/[\\/]/).at(-1) ?? filePath;
253 return {
254 version: 1,
255 contextId: legacyId("review-comment", index),
256 kind: "review-comment",
257 label: `${basename} ${rangeLabel}`,
258 sectionId,
259 sectionTitle: attributes.sectionTitle?.trim() || "Review",
260 filePath,
261 startIndex: Math.min(Number(startIndex), Number(endIndex)),
262 endIndex: Math.max(Number(startIndex), Number(endIndex)),
263 rangeLabel,
264 text: rawBody.slice(0, fence?.index ?? rawBody.length).trim(),
265 diff: fence?.[3] ?? "",
266 fenceLanguage: fence?.[2]?.trim() || "diff",
267 };
268}
269
270function stripTrailing(
271 text: string,
272 pattern: RegExp,
273): { text: string; match: RegExpExecArray } | null {
274 const match = pattern.exec(text);
275 if (!match) return null;
276 return { text: text.slice(0, match.index).replace(/\n+$/, ""), match };
277}
278
279/** Review source can contain literal closing tags inside its dynamically sized code fence. */
280function replaceReviewBlocks(
281 text: string,
282 replace: (whole: string, attributes: string, body: string) => string,
283): string {
284 const parts: string[] = [];
285 const openings = new RegExp(REVIEW_OR_CONTEXT_BLOCK);
286 let consumed = 0;
287 for (let opening = openings.exec(text); opening; opening = openings.exec(text)) {
288 // Other context blocks own their payload, including any review-shaped source text.
289 if (opening[2]) continue;
290 const bodyStart = openings.lastIndex;
291 const boundaries = /^(`{3,})([^\n]*)$|<\/review_comment>/gm;
292 boundaries.lastIndex = bodyStart;
293 let fenceLength = 0;
294 for (let boundary = boundaries.exec(text); boundary; boundary = boundaries.exec(text)) {
295 if (boundary[1]) {
296 if (fenceLength === 0) fenceLength = boundary[1].length;
297 else if (boundary[1].length >= fenceLength && !boundary[2]?.trim()) fenceLength = 0;
298 } else if (fenceLength === 0) {
299 parts.push(text.slice(consumed, opening.index));
300 consumed = boundaries.lastIndex;
301 parts.push(
302 replace(
303 text.slice(opening.index, consumed),
304 opening[1]!,
305 text.slice(bodyStart, boundary.index),
306 ),
307 );
308 openings.lastIndex = consumed;
309 break;
310 }
311 }
312 if (consumed < bodyStart) break;
313 }
314 parts.push(text.slice(consumed));
315 return parts.join("");
316}
317
318export function upgradeLegacyContextMessage(text: string): UpgradedLegacyContext {
319 if (!LEGACY_MARKERS.test(text)) return { text, records: [] };
320
321 const terminalEntries: ParsedEntry[] = [];
322 const elementEntries: ParsedEntry[] = [];
323 const previewBodies: string[] = [];
324 const reviews: ReviewCommentContextRecord[] = [];
325
326 // A literal private-use token in the message must never be mistaken for our placeholder.
327 let reviewToken = REVIEW_TOKEN;
328 while (text.includes(reviewToken)) reviewToken += reviewToken;
329 const reviewTokenPattern = new RegExp(`${reviewToken}(\\d+)${reviewToken}`, "g");
330 const trailingReviewTokenPattern = new RegExp(`(?:\\s*${reviewToken}\\d+${reviewToken})+\\s*$`);
331
332 // Review blocks become tokens in place first so they neither hide the trailing blocks
333 // behind them nor lose their position. Unparseable blocks stay as text.
334 let rest = replaceReviewBlocks(text, (whole, attributes, rawBody) => {
335 const record = reviewRecord(attributes, rawBody, reviews.length + 1);
336 // Keep the original prose if converting it would produce a record the wire drops.
337 if (!record || !isReviewCommentContextRecord(record)) return whole;
338 reviews.push(record);
339 return `${reviewToken}${reviews.length - 1}${reviewToken}`;
340 });
341
342 // Blocks were appended in send order (terminal, element, preview, review), so they peel
343 // off the end in reverse. Each peel exposes the next block as trailing.
344 // Peel reviews only when they hide another trailing context block. A review at the end
345 // of ordinary prose can still be inline; keep its original spacing and line breaks.
346 const trailingReviewTokens: number[] = [];
347 const tokens = trailingReviewTokenPattern.exec(rest);
348 if (tokens && tokens[0].length > 0) {
349 const preceding = rest.slice(0, tokens.index);
350 if (
351 TRAILING_PREVIEW.test(preceding) ||
352 TRAILING_ELEMENT.test(preceding) ||
353 TRAILING_TERMINAL.test(preceding)
354 ) {
355 trailingReviewTokens.push(
356 ...Array.from(tokens[0].matchAll(reviewTokenPattern), (m) => Number(m[1])),
357 );
358 rest = preceding;
359 }
360 }
361 for (;;) {
362 const preview = stripTrailing(rest, TRAILING_PREVIEW);
363 if (preview) {
364 rest = preview.text;
365 previewBodies.unshift(preview.match[1] ?? "");
366 continue;
367 }
368 const element = stripTrailing(rest, TRAILING_ELEMENT);
369 if (element) {
370 const entries = parseEntries(element.match[1] ?? "");
371 if (!entries?.length || entries.some((entry) => elementRecord(entry, 1) === null))
372 return { text, records: [] };
373 rest = element.text;
374 elementEntries.unshift(...entries);
375 continue;
376 }
377 const terminal = stripTrailing(rest, TRAILING_TERMINAL);
378 if (terminal) {
379 const entries = parseEntries(terminal.match[1] ?? "");
380 if (!entries?.length || entries.some((entry) => terminalRecord(entry, 1) === null))
381 return { text, records: [] };
382 rest = terminal.text;
383 terminalEntries.unshift(...entries);
384 continue;
385 }
386 break;
387 }
388
389 const terminals = terminalEntries
390 .map((entry, index) => terminalRecord(entry, index + 1))
391 .filter((record) => record !== null);
392 const elements = elementEntries
393 .map((entry, index) => elementRecord(entry, index + 1))
394 .filter((record) => record !== null);
395 const previews: PreviewAnnotationContextRecord[] = [];
396 for (const [index, previewBody] of previewBodies.entries()) {
397 const record = previewRecord(previewBody, index + 1);
398 if (record === null) return { text, records: [] };
399 previews.push(record);
400 }
401 const appendedReviews = trailingReviewTokens.map((index) => reviews[index]!);
402
403 let body = rest.replace(reviewTokenPattern, (_whole, index: string) =>
404 formatComposerContextReference(reviews[Number(index)]!),
405 );
406
407 // Placeholders bind to terminal entries in order, like the old materialize step did.
408 let placeholderIndex = 0;
409 body = body.replace(new RegExp(PLACEHOLDER, "g"), () => {
410 const record = terminals[placeholderIndex];
411 placeholderIndex += 1;
412 return record ? formatComposerContextReference(record) : "";
413 });
414 // Sent messages carry the materialized `@terminal-1:509-514` label instead of the
415 // placeholder; each such label becomes the chip in place.
416 const placedTerminals = new Set(terminals.slice(0, placeholderIndex));
417 for (const record of terminals) {
418 if (placedTerminals.has(record)) continue;
419 const label = inlineTerminalLabel(record);
420 let at = body.indexOf(label);
421 while (
422 at !== -1 &&
423 (/[\p{L}\p{N}\p{M}_@.-]$/u.test(body.slice(0, at)) ||
424 /^(?:[\p{L}\p{N}\p{M}_-]|[.@]+[\p{L}\p{N}\p{M}_-])/u.test(body.slice(at + label.length)))
425 ) {
426 at = body.indexOf(label, at + 1);
427 }
428 if (at === -1) continue;
429 body = `${body.slice(0, at)}${formatComposerContextReference(record)}${body.slice(at + label.length)}`;
430 placedTerminals.add(record);
431 }
432 body = body.trimEnd();
433
434 const appended = [
435 ...terminals.filter((record) => !placedTerminals.has(record)),
436 ...elements,
437 ...previews,
438 ...appendedReviews,
439 ].map((record) => formatComposerContextReference(record));
440 const upgradedText =
441 appended.length === 0
442 ? body
443 : body.length > 0
444 ? `${body}\n\n${appended.join(" ")}`
445 : appended.join(" ");
446
447 const records = [...terminals, ...elements, ...previews, ...reviews];
448 // Conversion is atomic: keep the source if any record would be dropped on the wire.
449 if (!isLegacyContextRecords(records)) return { text, records: [] };
450 return {
451 text: upgradedText,
452 records,
453 };
454}