Skip to content
Source
Sign inSign up

McpProviderSessions.ts

184 lines6.7 KB

1// @effect-diagnostics nodeBuiltinImport:off -- Pure synchronous hashing keeps page matching bounded without a runtime dependency.
2import * as NodeCrypto from "node:crypto";
3import type { ThreadId } from "@t3tools/contracts";
4import type { HtmlRenderReference } from "@t3tools/shared/htmlRender";
5import * as Context from "effect/Context";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Ref from "effect/Ref";
9​
10import type { McpProviderSessionConfig } from "./mcpSession.ts";
11​
12// Other providers may never consume these references.
13const MAX_KEPT_HTML_RENDERS = 64;
14​
15// Antigravity truncates long HTML arguments to a marked tail of at most 8,000 characters.
16const BOUNDED_TEXT_MARKER = "[Earlier output truncated]\n\n";
17const KEPT_HTML_END_LENGTH = 8_000;
18​
19interface KeptHtmlRender {
20 readonly path: string | undefined;
21 readonly title: string;
22 readonly height: number;
23 readonly htmlLength: number;
24 readonly htmlHash: string;
25 readonly htmlEnd: string;
26 readonly reference: HtmlRenderReference;
28​
29const hashHtml = (html: string) => NodeCrypto.createHash("sha256").update(html).digest("hex");
30​
31export class McpProviderSessions extends Context.Service<
32 McpProviderSessions,
33 {
34 readonly set: (config: McpProviderSessionConfig) => Effect.Effect<void>;
35 readonly read: (threadId: ThreadId) => Effect.Effect<McpProviderSessionConfig | undefined>;
36 readonly clear: (threadId: ThreadId) => Effect.Effect<void>;
37 readonly recordHtmlRender: (
38 threadId: ThreadId,
39 input: {
40 readonly html: string;
41 readonly path?: string | undefined;
42 readonly title: string;
43 readonly height: number;
44 },
45 reference: HtmlRenderReference,
46 ) => Effect.Effect<void>;
47 // A reported attachment consumes that exact page; otherwise the oldest page for a path, or a
48 // unique inline match.
49 readonly takeHtmlRender: (
50 threadId: ThreadId,
51 input: PageInput,
52 reported?: HtmlRenderReference,
53 ) => Effect.Effect<HtmlRenderReference | undefined>;
54 }
55>()("@t3tools/provider-core/server/McpProviderSessions") {}
56​
57interface PageInput {
58 readonly html?: unknown;
59 readonly path?: unknown;
60 readonly title?: unknown;
61 readonly height?: unknown;
63​
64const matchingPageIndex = (kept: ReadonlyArray<KeptHtmlRender>, input: PageInput): number => {
65 const height =
66 typeof input.height === "string" && input.height.trim() !== ""
67 ? Number(input.height)
68 : input.height;
69 const sameFrame = (page: KeptHtmlRender) => page.title === input.title && page.height === height;
70 const path = input.path;
71 // The handler refuses a call with both sources, so it published nothing to recover. Its decoder
72 // reads null as an omitted source.
73 if (path !== undefined && path !== null && input.html !== undefined && input.html !== null) {
74 return -1;
75 }
76 if (typeof path === "string" && path.length > 0) {
77 // An edited file is published again under the same path; completions arrive in publish order.
78 return kept.findIndex((page) => page.path === path && sameFrame(page));
79 }
80 const html = input.html;
81 if (typeof html !== "string" || html.length === 0) {
82 return -1;
83 }
84 const htmlHash = hashHtml(html);
85 const tail = html.startsWith(BOUNDED_TEXT_MARKER)
86 ? html.slice(BOUNDED_TEXT_MARKER.length)
87 : undefined;
88 const matches = kept.flatMap((page, index) =>
89 page.path === undefined &&
90 sameFrame(page) &&
91 (page.htmlHash === htmlHash ||
92 (tail !== undefined &&
93 tail.length > 0 &&
94 tail.length < page.htmlLength &&
95 page.htmlEnd.endsWith(tail)))
96 ? [index]
97 : [],
98 );
99 return matches.length === 1 ? matches[0]! : -1;
100};
101​
102const make = Effect.gen(function* () {
103 const sessions = yield* Ref.make(new Map<ThreadId, McpProviderSessionConfig>());
104 const htmlRenders = yield* Ref.make(new Map<ThreadId, ReadonlyArray<KeptHtmlRender>>());
105 return McpProviderSessions.of({
106 set: Effect.fn("McpProviderSessions.set")(function* (config) {
107 const current = yield* Ref.get(sessions);
108 if (current.get(config.threadId)?.providerSessionId !== config.providerSessionId) {
109 yield* Ref.update(htmlRenders, (kept) => {
110 const next = new Map(kept);
111 next.delete(config.threadId);
112 return next;
113 });
114 }
115 yield* Ref.update(sessions, (current) => new Map(current).set(config.threadId, config));
116 }),
117 read: Effect.fn("McpProviderSessions.read")(function (threadId) {
118 return Ref.get(sessions).pipe(Effect.map((current) => current.get(threadId)));
119 }),
120 clear: Effect.fn("McpProviderSessions.clear")(function (threadId) {
121 return Ref.update(sessions, (current) => {
122 const next = new Map(current);
123 next.delete(threadId);
124 return next;
125 }).pipe(
126 Effect.andThen(
127 Ref.update(htmlRenders, (current) => {
128 const next = new Map(current);
129 next.delete(threadId);
130 return next;
131 }),
132 ),
133 );
134 }),
135 recordHtmlRender: Effect.fn("McpProviderSessions.recordHtmlRender")(
136 function (threadId, input, reference) {
137 return Ref.update(htmlRenders, (current) =>
138 new Map(current).set(
139 threadId,
140 [
141 ...(current.get(threadId) ?? []),
142 {
143 path: input.path,
144 title: input.title,
145 height: input.height,
146 htmlLength: input.html.length,
147 htmlHash: hashHtml(input.html),
148 // Copy the tail so its backing storage cannot retain the entire HTML string.
149 htmlEnd: Buffer.from(input.html.slice(-KEPT_HTML_END_LENGTH), "utf16le").toString(
150 "utf16le",
151 ),
152 reference,
153 },
154 ].slice(-MAX_KEPT_HTML_RENDERS),
155 ),
156 );
157 },
158 ),
159 takeHtmlRender: Effect.fn("McpProviderSessions.takeHtmlRender")(
160 function (threadId, input, reported) {
161 return Ref.modify(htmlRenders, (current) => {
162 const kept = current.get(threadId) ?? [];
163 const index =
164 reported === undefined
165 ? matchingPageIndex(kept, input)
166 : kept.findIndex((page) => page.reference.attachmentId === reported.attachmentId);
167 if (index === -1) {
168 return [undefined, current];
169 }
170 const rest = kept.filter((_, keptIndex) => keptIndex !== index);
171 const next = new Map(current);
172 if (rest.length === 0) {
173 next.delete(threadId);
174 } else {
175 next.set(threadId, rest);
176 }
177 return [kept[index]!.reference, next];
178 });
179 },
180 ),
181 });
182});
183​
184export const layer = Layer.effect(McpProviderSessions, make);