1import { ThreadId } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4
5/**
6 * Agents mention another thread in Markdown as `[title](t3-thread://v1/<threadId>)`. The id is
7 * the reference and resolves in the environment of the message that holds it. Titles change, so
8 * clients show the thread's current title; the label only stands in for a thread they cannot see.
9 */
10export const THREAD_LINK_PROTOCOL = "t3-thread";
11const THREAD_LINK_HREF_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`;
12// Code comes first in the alternation so a link written inside a code span or fence is skipped.
13const THREAD_LINK_OUTSIDE_CODE =
14 /(?<fence>(`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?<span>(`+)[^\n]*?\4)|\[[^\]\n]*\]\((?<href>t3-thread:\/\/v1\/[^\s)]+)\)/g;
15
16const decodeThreadId = Schema.decodeUnknownOption(ThreadId);
17
18/** The id as written. Thread ids can hold percent escapes of their own, so it is not decoded. */
19export function parseThreadLinkHref(href: string): ThreadId | null {
20 if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null;
21 return Option.getOrNull(decodeThreadId(href.slice(THREAD_LINK_HREF_PREFIX.length)));
22}
23
24/**
25 * Agents often percent-encode the id anyway. When the id as written names no thread, clients try
26 * this decoded form. Null when decoding changes nothing or fails.
27 */
28export function percentDecodedThreadLinkId(threadId: ThreadId): ThreadId | null {
29 try {
30 const decoded = decodeURIComponent(threadId);
31 return decoded === threadId ? null : Option.getOrNull(decodeThreadId(decoded));
32 } catch {
33 return null;
34 }
35}
36
37/** A thread link whose label survives Markdown: no brackets, backslashes, or line breaks. */
38export function formatThreadLink(threadId: string, label: string): string {
39 const cleaned = label
40 .replace(/[[\]\\\r\n]/g, " ")
41 .replace(/\s+/g, " ")
42 .trim();
43 return `[${cleaned || threadId}](${THREAD_LINK_HREF_PREFIX}${threadId})`;
44}
45
46export function hasThreadLinks(markdown: string): boolean {
47 return markdown.includes(`](${THREAD_LINK_HREF_PREFIX}`);
48}
49
50/**
51 * Relabels each thread link with `title(threadId)`, pointing it at the thread that title came from.
52 * A link it returns nothing for keeps its label.
53 */
54export function relabelThreadLinks(
55 markdown: string,
56 title: (threadId: ThreadId) => string | undefined,
57): string {
58 if (!hasThreadLinks(markdown)) return markdown;
59 return markdown.replace(THREAD_LINK_OUTSIDE_CODE, (source, ...args) => {
60 const href = (args.at(-1) as { href?: string }).href;
61 if (href === undefined) return source;
62 const written = parseThreadLinkHref(href);
63 if (written === null) return source;
64 // The decoded id only stands in when the id as written names no thread.
65 const decoded = percentDecodedThreadLinkId(written);
66 const threadId =
67 title(written) === undefined && decoded !== null && title(decoded) !== undefined
68 ? decoded
69 : written;
70 const label = title(threadId)?.trim();
71 return label ? formatThreadLink(threadId, label) : source;
72 });
73}