Skip to content
Source
Sign inSign up
Open History to see the last commit

promptLinks.ts

216 lines8.0 KB

1import { DEFAULT_HOSTED_APP_URL } from "./connectAuth.ts";
2import { parseUrl } from "./url.ts";
3​
4export const PROMPT_LINK_MAX_PROMPT_LENGTH = 8_000;
5​
6export interface PromptLinkRepository {
7 readonly owner: string;
8 readonly name: string;
9}
10​
11export interface PromptLinkPullRequest extends PromptLinkRepository {
12 readonly number: number;
14​
15export interface LinkedPrompt {
16 readonly text: string;
17 readonly hiddenCharacters: number;
19​
20export type NewThreadLink =
21 | {
22 readonly kind: "new";
23 readonly prompt: LinkedPrompt;
24 readonly repository: PromptLinkRepository | null;
25 readonly provider: string | null;
26 readonly model: string | null;
27 }
28 | {
29 readonly kind: "pull-request";
30 readonly pullRequest: PromptLinkPullRequest;
31 readonly prompt: LinkedPrompt | null;
32 };
33​
34export type PromptLinkResult<T> =
35 | { readonly ok: true; readonly value: T }
36 | { readonly ok: false; readonly error: string };
37​
38// Hidden characters let models read instructions invisible to the person reviewing the prompt.
39const HIDDEN_CHARACTERS =
40 // oxlint-disable-next-line no-misleading-character-class -- these are the characters it removes.
41 /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180F\u200B\u200C\u200E\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0D\uFEFF\uFFA0\uFFF0-\uFFFB\u{1BCA0}-\u{1BCA3}\u{1D173}-\u{1D17A}\u{E0000}-\u{E0FFF}]/gu;
42// Numeric emoji ranges because Hermes lacks Unicode property escapes.
43const isEmojiPart = (codePoint: number | undefined) =>
44 codePoint !== undefined &&
45 ((codePoint >= 0x1f000 && codePoint <= 0x1faff) ||
46 (codePoint >= 0x2190 && codePoint <= 0x2bff) ||
47 codePoint === 0xfe0f);
48​
49export function sanitizeLinkedPrompt(raw: string): LinkedPrompt {
50 let hiddenCharacters = 0;
51 const visible = Array.from(
52 raw.replace(/\r\n?|[\u2028\u2029]/g, "\n").replace(HIDDEN_CHARACTERS, () => {
53 hiddenCharacters++;
54 return "";
55 }),
56 );
57 const text = visible
58 .filter((character, index) => {
59 if (character !== "\u200D") return true;
60 const kept =
61 isEmojiPart(visible[index - 1]?.codePointAt(0)) &&
62 isEmojiPart(visible[index + 1]?.codePointAt(0));
63 if (!kept) hiddenCharacters++;
64 return kept;
65 })
66 .join("");
67 return { text: text.trim(), hiddenCharacters };
69​
70const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
71const NAME = /^[A-Za-z0-9._-]{1,100}$/;
72​
73function repositoryOf(owner: string, name: string): PromptLinkRepository | null {
74 const trimmedName = name.replace(/\.git$/i, "");
75 if (!OWNER.test(owner) || !NAME.test(trimmedName) || /^\.+$/.test(trimmedName)) return null;
76 return { owner, name: trimmedName };
78​
79export function parsePromptLinkRepository(value: string): PromptLinkRepository | null {
80 const trimmed = value.trim();
81 const url = /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)\/([^/?#]+)\/?$/i.exec(trimmed);
82 const short = /^([^/\s]+)\/([^/\s]+)$/.exec(trimmed);
83 const match = url ?? short;
84 return match?.[1] && match[2] ? repositoryOf(match[1], match[2]) : null;
86​
87function parsePromptLinkPullRequest(value: string): PromptLinkPullRequest | null {
88 const trimmed = value.trim();
89 const match =
90 /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)\/([^/?#]+)\/pull\/(\d+)(?:[/?#].*)?$/i.exec(
91 trimmed,
92 ) ?? /^([^/\s#]+)\/([^/\s#]+)(?:\/|#)(\d+)$/.exec(trimmed);
93 if (!match?.[1] || !match[2] || !match[3]) return null;
94 const repository = repositoryOf(match[1], match[2]);
95 const number = Number(match[3]);
96 return repository && Number.isSafeInteger(number) && number > 0
97 ? { ...repository, number }
98 : null;
100​
101export function promptLinkPullRequestUrl(pullRequest: PromptLinkPullRequest): string {
102 return `https://github.com/${pullRequest.owner}/${pullRequest.name}/pull/${pullRequest.number}`;
104​
105function readPrompt(params: URLSearchParams): PromptLinkResult<LinkedPrompt | null> {
106 const raw = params.get("prompt");
107 if (raw === null) return { ok: true, value: null };
108 if (raw.length > PROMPT_LINK_MAX_PROMPT_LENGTH) {
109 return {
110 ok: false,
111 error: `This prompt is longer than ${PROMPT_LINK_MAX_PROMPT_LENGTH.toLocaleString("en-US")} characters, so T3 Code did not open it.`,
112 };
113 }
114 const prompt = sanitizeLinkedPrompt(raw);
115 if (prompt.text.length === 0 && prompt.hiddenCharacters > 0) {
116 return {
117 ok: false,
118 error:
119 "This prompt had only hidden characters, so it may be malicious. T3 Code did not open it.",
120 };
121 }
122 return { ok: true, value: prompt.text.length > 0 ? prompt : null };
124​
125const optionalParam = (params: URLSearchParams, key: string) => params.get(key)?.trim() || null;
126​
127export function parseNewThreadLink(search: string): PromptLinkResult<NewThreadLink> {
128 const params = new URLSearchParams(search);
129 const prompt = readPrompt(params);
130 if (!prompt.ok) return prompt;
131 const pr = params.get("pr");
132 if (pr !== null) {
133 const pullRequest = parsePromptLinkPullRequest(pr);
134 if (pullRequest === null) {
135 return { ok: false, error: "This link names a pull request T3 Code cannot read." };
136 }
137 return { ok: true, value: { kind: "pull-request", pullRequest, prompt: prompt.value } };
138 }
139 if (prompt.value === null) return { ok: false, error: "This link has no prompt." };
140 const repo = params.get("repo");
141 const repository = repo === null ? null : parsePromptLinkRepository(repo);
142 if (repo !== null && repository === null) {
143 return { ok: false, error: "This link names a repository T3 Code cannot read." };
144 }
145 return {
146 ok: true,
147 value: {
148 kind: "new",
149 prompt: prompt.value,
150 repository,
151 provider: optionalParam(params, "provider")?.toLowerCase() ?? null,
152 model: optionalParam(params, "model"),
153 },
154 };
156​
157export function parseThreadLinkPrompt(search: string): PromptLinkResult<LinkedPrompt | null> {
158 return readPrompt(new URLSearchParams(search));
160​
161export function withoutPromptLinkParams(search: string): string {
162 const params = new URLSearchParams(search);
163 for (const key of ["prompt", "repo", "pr", "provider", "model"]) params.delete(key);
164 const rest = params.toString();
165 return rest ? `?${rest}` : "";
167​
168export function buildNewThreadLink(input: {
169 readonly prompt: string;
170 readonly repository?: PromptLinkRepository | null;
171 readonly provider?: string | null;
172 readonly model?: string | null;
173 readonly origin?: string;
174}): string {
175 const url = new URL("/new", input.origin ?? DEFAULT_HOSTED_APP_URL);
176 url.searchParams.set("prompt", input.prompt);
177 if (input.repository) {
178 url.searchParams.set("repo", `${input.repository.owner}/${input.repository.name}`);
179 }
180 if (input.provider) url.searchParams.set("provider", input.provider);
181 if (input.provider && input.model) url.searchParams.set("model", input.model);
182 return url.toString();
184​
185const APP_SCHEME = /^t3code(?:-[a-z]+)?:$/;
186const THREAD_PATH = /^\/([^/]+)\/([^/]+)\/?$/;
187​
188export function promptLinkAppPath(
189 value: string,
190 hostedAppUrl: string = DEFAULT_HOSTED_APP_URL,
191): string | null {
192 const url = parseUrl(value);
193 if (!url) return null;
194 if (APP_SCHEME.test(url.protocol)) {
195 if (url.host === "new" && (url.pathname === "" || url.pathname === "/")) {
196 return `/new${url.search}`;
197 }
198 const thread = url.host === "threads" ? THREAD_PATH.exec(url.pathname) : null;
199 return thread ? `/${thread[1]}/${thread[2]}${url.search}` : null;
200 }
201 if (url.origin !== parseUrl(hostedAppUrl)?.origin) return null;
202 if (url.pathname === "/new") return `/new${url.search}`;
203 // The web app's own thread route is a link only with a prompt, so reloading a tab never asks.
204 const thread = url.pathname.startsWith("/threads/")
205 ? THREAD_PATH.exec(url.pathname.slice("/threads".length))
206 : url.searchParams.has("prompt")
207 ? THREAD_PATH.exec(url.pathname)
208 : null;
209 return thread ? `/${thread[1]}/${thread[2]}${url.search}` : null;
211​
212export function promptLinkDeepLink(appPath: string, scheme = "t3code"): string {
213 const url = new URL(appPath, "https://app.invalid");
214 const host = url.pathname === "/new" ? "new" : `threads${url.pathname}`;
215 return `${scheme}://${host}${url.search}`;