1import * as Schema from "effect/Schema";
2
3import {
4 EnvironmentId,
5 ForwardCompatibleArray,
6 NonNegativeInt,
7 PositiveInt,
8 ThreadId,
9 TrimmedNonEmptyString,
10} from "./baseSchemas.ts";
11
12/**
13 * Inline context records: the typed payload behind every composer chip.
14 * A message's `text` carries position through canonical reference links
15 * (`[label](t3-context://v1/<kind>/<contextId>)`, see
16 * `@t3tools/shared/composerContextReferences`); these records carry the payload,
17 * keyed by `contextId`. Bytes never live here: image and file records bind to a
18 * `ChatAttachment` by id.
19 */
20
21export const COMPOSER_CONTEXT_KINDS = [
22 "image",
23 "file",
24 "terminal",
25 "element",
26 "preview-annotation",
27 "review-comment",
28 "mention",
29 "skill",
30 "thread",
31] as const;
32export type KnownComposerContextKind = (typeof COMPOSER_CONTEXT_KINDS)[number];
33
34const CONTEXT_ID_MAX_CHARS = 128;
35const CONTEXT_ID_PATTERN = /^[a-z0-9_-]+$/i;
36const CONTEXT_KIND_PATTERN = /^[a-z][a-z0-9-]{0,39}$/;
37const KNOWN_KIND_PATTERN = new RegExp(`^(?!(?:${COMPOSER_CONTEXT_KINDS.join("|")})$)`);
38
39export const COMPOSER_CONTEXT_LABEL_MAX_CHARS = 200;
40export const COMPOSER_CONTEXT_TERMINAL_TEXT_MAX_CHARS = 64_000;
41const COMPOSER_CONTEXT_ELEMENT_HTML_MAX_CHARS = 8_000;
42const COMPOSER_CONTEXT_ELEMENT_STYLES_MAX_CHARS = 8_000;
43/** Exported so producers can clamp to the same boundary the schema enforces, rather than
44 minting a record the send path would fail to encode. */
45export const COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS = 16_000;
46export const COMPOSER_CONTEXT_REVIEW_DIFF_MAX_CHARS = 32_000;
47const COMPOSER_CONTEXT_PREVIEW_COMMENT_MAX_CHARS = 8_000;
48
49/** Durable identity of one payload. Shared by every chip that points at it. */
50export const ComposerContextId = TrimmedNonEmptyString.check(
51 Schema.isMaxLength(CONTEXT_ID_MAX_CHARS),
52 Schema.isPattern(CONTEXT_ID_PATTERN),
53).pipe(Schema.brand("ComposerContextId"));
54export type ComposerContextId = typeof ComposerContextId.Type;
55
56/** Identity of one occurrence in a document. Changes when a chip is duplicated. */
57export const ComposerContextReferenceId = TrimmedNonEmptyString.check(
58 Schema.isMaxLength(CONTEXT_ID_MAX_CHARS),
59 Schema.isPattern(CONTEXT_ID_PATTERN),
60).pipe(Schema.brand("ComposerContextReferenceId"));
61export type ComposerContextReferenceId = typeof ComposerContextReferenceId.Type;
62
63/** Open kind: known kinds get typed records, everything else preserves its payload. */
64export const ComposerContextKind = TrimmedNonEmptyString.check(
65 Schema.isPattern(CONTEXT_KIND_PATTERN),
66);
67export type ComposerContextKind = typeof ComposerContextKind.Type;
68
69// Same rules as the private `ChatAttachmentId` in orchestration.ts; duplicated here because
70// orchestration.ts imports this module.
71const ContextAttachmentId = TrimmedNonEmptyString.check(
72 Schema.isMaxLength(CONTEXT_ID_MAX_CHARS),
73 Schema.isPattern(CONTEXT_ID_PATTERN),
74);
75const ContextLabel = Schema.String.check(Schema.isMaxLength(COMPOSER_CONTEXT_LABEL_MAX_CHARS));
76const BoundedString = (max: number) => Schema.String.check(Schema.isMaxLength(max));
77const ShortString = BoundedString(2_048);
78const NullableShortString = Schema.NullOr(ShortString);
79
80/** Snapshot used to identify and present a pull request carried by a review-context record. */
81export const PullRequestContextMetadata = Schema.Struct({
82 number: PositiveInt,
83 title: ShortString,
84 url: ShortString,
85 headBranch: ShortString,
86 baseBranch: ShortString,
87 state: Schema.Literals(["open", "closed", "merged"]),
88 isDraft: Schema.Boolean,
89});
90export type PullRequestContextMetadata = typeof PullRequestContextMetadata.Type;
91
92const recordBase = {
93 version: Schema.Literal(1),
94 contextId: ComposerContextId,
95 label: ContextLabel,
96} as const;
97
98const attachmentBinding = {
99 attachmentId: ContextAttachmentId,
100 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
101 mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)),
102 sizeBytes: NonNegativeInt,
103} as const;
104
105export const ImageContextRecord = Schema.Struct({
106 ...recordBase,
107 kind: Schema.Literal("image"),
108 ...attachmentBinding,
109});
110export type ImageContextRecord = typeof ImageContextRecord.Type;
111
112export const FileContextRecord = Schema.Struct({
113 ...recordBase,
114 kind: Schema.Literal("file"),
115 ...attachmentBinding,
116});
117export type FileContextRecord = typeof FileContextRecord.Type;
118
119export const TerminalContextRecord = Schema.Struct({
120 ...recordBase,
121 kind: Schema.Literal("terminal"),
122 terminalId: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
123 terminalLabel: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
124 lineStart: NonNegativeInt,
125 lineEnd: NonNegativeInt,
126 text: BoundedString(COMPOSER_CONTEXT_TERMINAL_TEXT_MAX_CHARS),
127}).check(Schema.makeFilter((record) => record.lineEnd >= record.lineStart));
128export type TerminalContextRecord = typeof TerminalContextRecord.Type;
129
130export const ElementContextSource = Schema.Struct({
131 functionName: NullableShortString,
132 fileName: NullableShortString,
133 lineNumber: Schema.NullOr(NonNegativeInt),
134 columnNumber: Schema.NullOr(NonNegativeInt),
135});
136export type ElementContextSource = typeof ElementContextSource.Type;
137
138/** What a picked page element looks like to the agent; shared by element and annotation records. */
139export const ElementContextDetails = Schema.Struct({
140 pageUrl: ShortString,
141 pageTitle: NullableShortString,
142 tagName: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
143 selector: NullableShortString,
144 htmlPreview: BoundedString(COMPOSER_CONTEXT_ELEMENT_HTML_MAX_CHARS),
145 componentName: NullableShortString,
146 source: Schema.NullOr(ElementContextSource),
147 styles: BoundedString(COMPOSER_CONTEXT_ELEMENT_STYLES_MAX_CHARS),
148});
149export type ElementContextDetails = typeof ElementContextDetails.Type;
150
151export const ElementContextRecord = Schema.Struct({
152 ...recordBase,
153 kind: Schema.Literal("element"),
154 ...ElementContextDetails.fields,
155});
156export type ElementContextRecord = typeof ElementContextRecord.Type;
157
158// Optional record fields are `optionalKey`: a record holding an explicit
159// `undefined` cannot be sent as JSON, so it fails its own check and is dropped
160// alone instead of failing the whole message.
161export const PreviewAnnotationContextRecord = Schema.Struct({
162 ...recordBase,
163 kind: Schema.Literal("preview-annotation"),
164 annotationId: ShortString,
165 pageUrl: ShortString,
166 pageTitle: NullableShortString,
167 comment: BoundedString(COMPOSER_CONTEXT_PREVIEW_COMMENT_MAX_CHARS),
168 targetSummary: ShortString,
169 styleChanges: Schema.Array(ShortString).check(Schema.isMaxLength(200)),
170 /** Picked elements inside the annotation, with the detail the agent needs to find them. */
171 elements: Schema.optionalKey(Schema.Array(ElementContextDetails).check(Schema.isMaxLength(50))),
172 /** Original target ids and edits allow pasted annotations to retain exact style changes. */
173 elementIds: Schema.optionalKey(Schema.Array(ShortString).check(Schema.isMaxLength(50))),
174 /** Region and stroke geometry is lossy on purpose, but their counts feed the target summary,
175 so a pasted annotation still says what it marked. */
176 regionCount: Schema.optionalKey(NonNegativeInt),
177 strokeCount: Schema.optionalKey(NonNegativeInt),
178 styleChangeDetails: Schema.optionalKey(
179 Schema.Array(
180 Schema.Struct({
181 targetId: ShortString,
182 selector: NullableShortString,
183 property: ShortString,
184 previousValue: BoundedString(COMPOSER_CONTEXT_ELEMENT_STYLES_MAX_CHARS),
185 value: BoundedString(COMPOSER_CONTEXT_ELEMENT_STYLES_MAX_CHARS),
186 }),
187 ).check(Schema.isMaxLength(200)),
188 ),
189 /** The screenshot travels as its own image record; this links the two. */
190 screenshotContextId: Schema.optionalKey(ComposerContextId),
191});
192export type PreviewAnnotationContextRecord = typeof PreviewAnnotationContextRecord.Type;
193
194export const ReviewCommentContextRecord = Schema.Struct({
195 ...recordBase,
196 kind: Schema.Literal("review-comment"),
197 sectionId: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
198 sectionTitle: ShortString,
199 filePath: TrimmedNonEmptyString.check(Schema.isMaxLength(2_048)),
200 startIndex: NonNegativeInt,
201 endIndex: NonNegativeInt,
202 rangeLabel: ShortString,
203 text: BoundedString(COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS),
204 diff: BoundedString(COMPOSER_CONTEXT_REVIEW_DIFF_MAX_CHARS),
205 fenceLanguage: Schema.optionalKey(BoundedString(64)),
206 pullRequest: Schema.optionalKey(PullRequestContextMetadata),
207}).check(Schema.makeFilter((record) => record.endIndex >= record.startIndex));
208export type ReviewCommentContextRecord = typeof ReviewCommentContextRecord.Type;
209
210export const MentionContextRecord = Schema.Struct({
211 ...recordBase,
212 kind: Schema.Literal("mention"),
213 path: TrimmedNonEmptyString.check(Schema.isMaxLength(2_048)),
214});
215export type MentionContextRecord = typeof MentionContextRecord.Type;
216
217export const SkillContextRecord = Schema.Struct({
218 ...recordBase,
219 kind: Schema.Literal("skill"),
220 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
221});
222export type SkillContextRecord = typeof SkillContextRecord.Type;
223
224/**
225 * Another thread on the same server, attached so the agent can read its history through
226 * `t3_thread_read`. Only identity travels; the title is a display snapshot.
227 */
228export const ThreadContextRecord = Schema.Struct({
229 ...recordBase,
230 kind: Schema.Literal("thread"),
231 environmentId: EnvironmentId,
232 threadId: ThreadId,
233 title: ContextLabel,
234});
235export type ThreadContextRecord = typeof ThreadContextRecord.Type;
236
237const isJson = Schema.is(Schema.Json);
238
239/**
240 * Catch-all for kinds this build does not know. Known discriminators are excluded so a
241 * malformed known record fails its own schema instead of sliding through unchecked.
242 * Mirrors `ChatUnknownAttachment`.
243 */
244export const UnknownContextRecord = Schema.Struct({
245 ...recordBase,
246 kind: ComposerContextKind.check(Schema.isPattern(KNOWN_KIND_PATTERN)),
247 payload: Schema.Unknown.check(
248 Schema.makeFilter((payload) => {
249 let encoded: string | undefined;
250 try {
251 encoded = JSON.stringify(payload);
252 } catch {
253 // Cycles, bigints, and payloads nested deeper than this engine's stack.
254 return false;
255 }
256 // Only JSON values, so a payload the wire cannot carry (a Date, NaN, an
257 // undefined field) fails this record alone instead of the whole message.
258 return encoded !== undefined && encoded.length <= 64_000 && isJson(payload);
259 }),
260 ),
261});
262export type UnknownContextRecord = typeof UnknownContextRecord.Type;
263
264export const KnownComposerContextRecord = Schema.Union([
265 ImageContextRecord,
266 FileContextRecord,
267 TerminalContextRecord,
268 ElementContextRecord,
269 PreviewAnnotationContextRecord,
270 ReviewCommentContextRecord,
271 MentionContextRecord,
272 SkillContextRecord,
273 ThreadContextRecord,
274]);
275export type KnownComposerContextRecord = typeof KnownComposerContextRecord.Type;
276
277export const ComposerContextRecord = Schema.Union([
278 ...KnownComposerContextRecord.members,
279 UnknownContextRecord,
280]);
281export type ComposerContextRecord = typeof ComposerContextRecord.Type;
282
283export const COMPOSER_CONTEXT_MAX_RECORDS = 200;
284const COMPOSER_CONTEXT_MAX_SERIALIZED_CHARS = 16_000_000;
285
286/** Structured context riding on a user message. Undecodable records are dropped, not fatal. */
287export const OrchestrationMessageContext = Schema.Struct({
288 version: Schema.Literal(1),
289 records: Schema.Array(Schema.Unknown)
290 .check(
291 Schema.isMaxLength(COMPOSER_CONTEXT_MAX_RECORDS),
292 Schema.makeFilter((records) => {
293 try {
294 return JSON.stringify(records).length <= COMPOSER_CONTEXT_MAX_SERIALIZED_CHARS;
295 } catch {
296 return false;
297 }
298 }),
299 )
300 .pipe(Schema.decodeTo(ForwardCompatibleArray(ComposerContextRecord)))
301 .check(
302 Schema.makeFilter(
303 (records) => new Set(records.map((record) => record.contextId)).size === records.length,
304 ),
305 ),
306});
307export type OrchestrationMessageContext = typeof OrchestrationMessageContext.Type;