import {
type ComposerContextId,
ComposerContextRecord,
type ElementContextRecord,
type PreviewAnnotationContextRecord,
ReviewCommentContextRecord,
type TerminalContextRecord,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import { formatComposerContextReference } from "./composerContextReferences.ts";
/**
* Upgrades a message written before inline context references: trailing
* `<terminal_context>`, `<element_context>` and `<preview_annotation>` blocks, inline or
* trailing `<review_comment>` blocks, and U+FFFC terminal placeholders. Produces canonical
* reference links plus records so old messages render and copy through the new path.
* Event history is never rewritten; this runs in memory on read.
*/
export interface UpgradedLegacyContext {
text: string;
records: ComposerContextRecord[];
}
const PLACEHOLDER = "";
const TRAILING_TERMINAL = /\n*<terminal_context>\n([\s\S]*?)\n<\/terminal_context>\s*$/;
const TRAILING_ELEMENT = /\n*<element_context>\n([\s\S]*?)\n<\/element_context>\s*$/;
const TRAILING_PREVIEW =
/\n*<preview_annotation>\n((?:(?!\n<\/preview_annotation>)[\s\S])*)\n<\/preview_annotation>\s*$/;
const REVIEW_OR_CONTEXT_BLOCK =
/<review_comment\b([^>]*)>|^<(terminal_context|element_context|preview_annotation)>\n[\s\S]*?\n<\/\2>/gm;
const REVIEW_ATTRIBUTE = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g;
const REVIEW_FENCE = /(`{3,})([^\s`]*)[^\n]*\n([\s\S]*?)\n\1/g;
const REVIEW_TOKEN = "\uE000";
const LEGACY_MARKERS =
/<(?:terminal_context|element_context|preview_annotation|review_comment)\b|/;
const isReviewCommentContextRecord = Schema.is(ReviewCommentContextRecord);
const isLegacyContextRecords = Schema.is(
Schema.Array(ComposerContextRecord).check(Schema.isMaxLength(200)),
);
interface ParsedEntry {
header: string;
body: string;
}
function parseEntries(block: string): ParsedEntry[] | null {
const entries: ParsedEntry[] = [];
let current: { header: string; bodyLines: string[] } | null = null;
const commit = () => {
if (!current) return;
entries.push({ header: current.header, body: current.bodyLines.join("\n").trimEnd() });
current = null;
};
for (const line of block.split("\n")) {
const headerMatch = /^- (.+):$/.exec(line);
if (headerMatch) {
commit();
current = { header: headerMatch[1]!, bodyLines: [] };
continue;
}
if (current && line.startsWith(" ")) current.bodyLines.push(line.slice(2));
else if (line.trim().length > 0) return null;
else if (current) current.bodyLines.push("");
}
commit();
return entries;
}
/** The inline label the old send path wrote for a terminal excerpt: `@terminal-1:509-514`. */
function inlineTerminalLabel(record: TerminalContextRecord): string {
const slug = record.terminalLabel.trim().toLowerCase().replace(/\s+/g, "-");
const range =
record.lineStart === record.lineEnd
? `${record.lineStart}`
: `${record.lineStart}-${record.lineEnd}`;
return `@${slug}:${range}`;
}
function legacyId(kind: string, index: number): ComposerContextId {
return `legacy_${kind}_${index}` as ComposerContextId;
}
function terminalRecord(entry: ParsedEntry, index: number): TerminalContextRecord | null {
const header = /^(.*?) (?:line (\d+)|lines (\d+)-(\d+))$/.exec(entry.header);
if (!header) return null;
const terminalLabel = header[1]!.trim();
if (!terminalLabel) return null;
const lineStart = Number(header[2] ?? header[3]);
const lineEnd = Number(header[2] ?? header[4]);
const text = entry.body
.split("\n")
.map((line) => line.replace(/^\d+ \| ?/, ""))
.join("\n");
return {
version: 1,
contextId: legacyId("terminal", index),
kind: "terminal",
label: entry.header,
terminalId: terminalLabel.toLowerCase().replace(/\s+/g, "-"),
terminalLabel,
lineStart,
lineEnd,
text,
};
}
function parseSource(location: string): ElementContextRecord["source"] {
const match = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(location);
if (!match || !match[1]) return null;
return {
functionName: null,
fileName: match[1],
lineNumber: match[2] === undefined ? null : Number(match[2]),
columnNumber: match[3] === undefined ? null : Number(match[3]),
};
}
function elementRecord(entry: ParsedEntry, index: number): ElementContextRecord | null {
const header = /^<([^>]+)>/.exec(entry.header);
if (!header) return null;
const inner = header[1]!;
const fields: Record<string, string> = {};
const sections: Record<string, string[]> = {};
let section: string | null = null;
for (const line of entry.body.split("\n")) {
if (section && (line.startsWith(" ") || line.length === 0)) {
sections[section]!.push(line.slice(2));
continue;
}
section = null;
const field = /^([a-z]+): (.*)$/.exec(line);
if (field) {
fields[field[1]!] = field[2]!;
continue;
}
const sectionStart = /^(html|styles):$/.exec(line);
if (sectionStart) {
section = sectionStart[1]!;
sections[section] = [];
}
}
return {
version: 1,
contextId: legacyId("element", index),
kind: "element",
label: `<${inner}>`,
pageUrl: fields.url ?? "",
pageTitle: null,
tagName: inner.toLowerCase(),
selector: fields.selector ?? null,
htmlPreview: (sections.html ?? []).join("\n").trimEnd(),
componentName: /[A-Z]/.test(inner) ? inner : null,
source: fields.source ? parseSource(fields.source) : null,
styles: (sections.styles ?? []).join("\n").trimEnd(),
};
}
function previewRecord(body: string, index: number): PreviewAnnotationContextRecord | null {
const lines = body.split("\n");
// A legacy comment was written verbatim, so it can run over several lines and hold blank
// lines and markup of its own. Its value is every line up to the next field or the block that
// follows it — anything else is text the author typed, and dropping it loses instructions the
// chip that replaces this block cannot show.
const FIELD_PREFIXES = ["Preview annotation:", "Id: ", "Page: ", "Comment: ", "Targets: "];
const BLOCK_DELIMITER =
/^<\/?(?:terminal_context|element_context|preview_annotation|review_comment)\b/;
const isFieldStart = (line: string) =>
FIELD_PREFIXES.some((candidate) => line.startsWith(candidate)) ||
line === "Requested visual changes:" ||
BLOCK_DELIMITER.test(line) ||
line === "The attached screenshot is the annotated preview crop.";
const read = (prefix: string) => {
const start = lines.findIndex((line) => line.startsWith(prefix));
if (start < 0) return "";
let end = start + 1;
while (end < lines.length && !isFieldStart(lines[end]!)) end += 1;
return [lines[start]!.slice(prefix.length), ...lines.slice(start + 1, end)].join("\n").trim();
};
const styleHeading = lines.indexOf("Requested visual changes:");
const styleChanges: string[] = [];
if (styleHeading >= 0) {
for (const line of lines.slice(styleHeading + 1)) {
if (!line.startsWith("- ")) break;
styleChanges.push(line.slice(2));
}
}
const page = read("Page: ");
const pageIsUrl = /^https?:\/\//i.test(page);
const elements: NonNullable<PreviewAnnotationContextRecord["elements"]>[number][] = [];
for (const match of body.matchAll(/<element_context>\n([\s\S]*?)\n<\/element_context>/g)) {
const entries = parseEntries(match[1] ?? "");
if (entries === null) return null;
for (const entry of entries) {
const record = elementRecord(entry, elements.length + 1);
if (record === null) return null;
const {
version: _version,
contextId: _contextId,
kind: _kind,
label: _label,
...details
} = record;
elements.push(details);
}
}
return {
version: 1,
contextId: legacyId("preview-annotation", index),
kind: "preview-annotation",
label: page || "Preview annotation",
annotationId: read("Id: "),
pageUrl: pageIsUrl ? page : (elements[0]?.pageUrl ?? ""),
pageTitle: pageIsUrl ? null : page || null,
comment: read("Comment: "),
targetSummary: read("Targets: "),
styleChanges,
...(elements.length > 0 ? { elements } : {}),
};
}
function unescapeAttribute(value: string): string {
return value
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/&/g, "&");
}
function reviewRecord(
rawAttributes: string,
rawBody: string,
index: number,
): ReviewCommentContextRecord | null {
const attributes: Record<string, string> = {};
for (const match of rawAttributes.matchAll(REVIEW_ATTRIBUTE)) {
attributes[match[1]!] = unescapeAttribute(match[2] ?? "");
}
const filePath = attributes.filePath?.trim();
const sectionId = attributes.sectionId?.trim();
const startIndex = attributes.startIndex;
const endIndex = attributes.endIndex;
if (!filePath || !sectionId || !/^\d+$/.test(startIndex ?? "") || !/^\d+$/.test(endIndex ?? "")) {
return null;
}
const fences = Array.from(rawBody.matchAll(REVIEW_FENCE));
const fence = fences.at(-1);
const rangeLabel = attributes.rangeLabel?.trim() || "line";
const basename = filePath.split(/[\\/]/).at(-1) ?? filePath;
return {
version: 1,
contextId: legacyId("review-comment", index),
kind: "review-comment",
label: `${basename} ${rangeLabel}`,
sectionId,
sectionTitle: attributes.sectionTitle?.trim() || "Review",
filePath,
startIndex: Math.min(Number(startIndex), Number(endIndex)),
endIndex: Math.max(Number(startIndex), Number(endIndex)),
rangeLabel,
text: rawBody.slice(0, fence?.index ?? rawBody.length).trim(),
diff: fence?.[3] ?? "",
fenceLanguage: fence?.[2]?.trim() || "diff",
};
}
function stripTrailing(
text: string,
pattern: RegExp,
): { text: string; match: RegExpExecArray } | null {
const match = pattern.exec(text);
if (!match) return null;
return { text: text.slice(0, match.index).replace(/\n+$/, ""), match };
}
/** Review source can contain literal closing tags inside its dynamically sized code fence. */
function replaceReviewBlocks(
text: string,
replace: (whole: string, attributes: string, body: string) => string,
): string {
const parts: string[] = [];
const openings = new RegExp(REVIEW_OR_CONTEXT_BLOCK);
let consumed = 0;
for (let opening = openings.exec(text); opening; opening = openings.exec(text)) {
// Other context blocks own their payload, including any review-shaped source text.
if (opening[2]) continue;
const bodyStart = openings.lastIndex;
const boundaries = /^(`{3,})([^\n]*)$|<\/review_comment>/gm;
boundaries.lastIndex = bodyStart;
let fenceLength = 0;
for (let boundary = boundaries.exec(text); boundary; boundary = boundaries.exec(text)) {
if (boundary[1]) {
if (fenceLength === 0) fenceLength = boundary[1].length;
else if (boundary[1].length >= fenceLength && !boundary[2]?.trim()) fenceLength = 0;
} else if (fenceLength === 0) {
parts.push(text.slice(consumed, opening.index));
consumed = boundaries.lastIndex;
parts.push(
replace(
text.slice(opening.index, consumed),
opening[1]!,
text.slice(bodyStart, boundary.index),
),
);
openings.lastIndex = consumed;
break;
}
}
if (consumed < bodyStart) break;
}
parts.push(text.slice(consumed));
return parts.join("");
}
export function upgradeLegacyContextMessage(text: string): UpgradedLegacyContext {
if (!LEGACY_MARKERS.test(text)) return { text, records: [] };
const terminalEntries: ParsedEntry[] = [];
const elementEntries: ParsedEntry[] = [];
const previewBodies: string[] = [];
const reviews: ReviewCommentContextRecord[] = [];
// A literal private-use token in the message must never be mistaken for our placeholder.
let reviewToken = REVIEW_TOKEN;
while (text.includes(reviewToken)) reviewToken += reviewToken;
const reviewTokenPattern = new RegExp(`${reviewToken}(\\d+)${reviewToken}`, "g");
const trailingReviewTokenPattern = new RegExp(`(?:\\s*${reviewToken}\\d+${reviewToken})+\\s*$`);
// Review blocks become tokens in place first so they neither hide the trailing blocks
// behind them nor lose their position. Unparseable blocks stay as text.
let rest = replaceReviewBlocks(text, (whole, attributes, rawBody) => {
const record = reviewRecord(attributes, rawBody, reviews.length + 1);
// Keep the original prose if converting it would produce a record the wire drops.
if (!record || !isReviewCommentContextRecord(record)) return whole;
reviews.push(record);
return `${reviewToken}${reviews.length - 1}${reviewToken}`;
});
// Blocks were appended in send order (terminal, element, preview, review), so they peel
// off the end in reverse. Each peel exposes the next block as trailing.
// Peel reviews only when they hide another trailing context block. A review at the end
// of ordinary prose can still be inline; keep its original spacing and line breaks.
const trailingReviewTokens: number[] = [];
const tokens = trailingReviewTokenPattern.exec(rest);
if (tokens && tokens[0].length > 0) {
const preceding = rest.slice(0, tokens.index);
if (
TRAILING_PREVIEW.test(preceding) ||
TRAILING_ELEMENT.test(preceding) ||
TRAILING_TERMINAL.test(preceding)
) {
trailingReviewTokens.push(
...Array.from(tokens[0].matchAll(reviewTokenPattern), (m) => Number(m[1])),
);
rest = preceding;
}
}
for (;;) {
const preview = stripTrailing(rest, TRAILING_PREVIEW);
if (preview) {
rest = preview.text;
previewBodies.unshift(preview.match[1] ?? "");
continue;
}
const element = stripTrailing(rest, TRAILING_ELEMENT);
if (element) {
const entries = parseEntries(element.match[1] ?? "");
if (!entries?.length || entries.some((entry) => elementRecord(entry, 1) === null))
return { text, records: [] };
rest = element.text;
elementEntries.unshift(...entries);
continue;
}
const terminal = stripTrailing(rest, TRAILING_TERMINAL);
if (terminal) {
const entries = parseEntries(terminal.match[1] ?? "");
if (!entries?.length || entries.some((entry) => terminalRecord(entry, 1) === null))
return { text, records: [] };
rest = terminal.text;
terminalEntries.unshift(...entries);
continue;
}
break;
}
const terminals = terminalEntries
.map((entry, index) => terminalRecord(entry, index + 1))
.filter((record) => record !== null);
const elements = elementEntries
.map((entry, index) => elementRecord(entry, index + 1))
.filter((record) => record !== null);
const previews: PreviewAnnotationContextRecord[] = [];
for (const [index, previewBody] of previewBodies.entries()) {
const record = previewRecord(previewBody, index + 1);
if (record === null) return { text, records: [] };
previews.push(record);
}
const appendedReviews = trailingReviewTokens.map((index) => reviews[index]!);
let body = rest.replace(reviewTokenPattern, (_whole, index: string) =>
formatComposerContextReference(reviews[Number(index)]!),
);
// Placeholders bind to terminal entries in order, like the old materialize step did.
let placeholderIndex = 0;
body = body.replace(new RegExp(PLACEHOLDER, "g"), () => {
const record = terminals[placeholderIndex];
placeholderIndex += 1;
return record ? formatComposerContextReference(record) : "";
});
// Sent messages carry the materialized `@terminal-1:509-514` label instead of the
// placeholder; each such label becomes the chip in place.
const placedTerminals = new Set(terminals.slice(0, placeholderIndex));
for (const record of terminals) {
if (placedTerminals.has(record)) continue;
const label = inlineTerminalLabel(record);
let at = body.indexOf(label);
while (
at !== -1 &&
(/[\p{L}\p{N}\p{M}_@.-]$/u.test(body.slice(0, at)) ||
/^(?:[\p{L}\p{N}\p{M}_-]|[.@]+[\p{L}\p{N}\p{M}_-])/u.test(body.slice(at + label.length)))
) {
at = body.indexOf(label, at + 1);
}
if (at === -1) continue;
body = `${body.slice(0, at)}${formatComposerContextReference(record)}${body.slice(at + label.length)}`;
placedTerminals.add(record);
}
body = body.trimEnd();
const appended = [
...terminals.filter((record) => !placedTerminals.has(record)),
...elements,
...previews,
...appendedReviews,
].map((record) => formatComposerContextReference(record));
const upgradedText =
appended.length === 0
? body
: body.length > 0
? `${body}\n\n${appended.join(" ")}`
: appended.join(" ");
const records = [...terminals, ...elements, ...previews, ...reviews];
// Conversion is atomic: keep the source if any record would be dropped on the wire.
if (!isLegacyContextRecords(records)) return { text, records: [] };
return {
text: upgradedText,
records,
};
}