1import type { ThreadId, TurnItemId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
4import * as DateTime from "effect/DateTime";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Schema from "effect/Schema";
8import * as SqlClient from "effect/sql/SqlClient";
9
10export const MCP_APP_MODEL_CONTEXT_MAX_BYTES = 16 * 1024;
11
12export class McpAppModelContextError extends Schema.TaggedError<McpAppModelContextError>()(
13 "McpAppModelContextError",
14 { cause: Schema.optional(Schema.Defect()) },
15) {}
16
17export interface McpAppModelContextEntry {
18 readonly itemId: string;
19 readonly server: string;
20 readonly tool: string;
21 readonly text: string;
22}
23
24export class McpAppModelContext extends Context.Service<
25 McpAppModelContext,
26 {
27 /** Empty text removes the app's context. */
28 readonly set: (input: {
29 readonly threadId: ThreadId;
30 readonly itemId: TurnItemId;
31 readonly server: string;
32 readonly tool: string;
33 readonly text: string;
34 }) => Effect.Effect<void, McpAppModelContextError>;
35 readonly forThread: (
36 threadId: ThreadId,
37 ) => Effect.Effect<ReadonlyArray<McpAppModelContextEntry>, McpAppModelContextError>;
38 }
39>()("t3/mcpApps/McpAppModelContext") {}
40
41export const layer = Layer.effect(
42 McpAppModelContext,
43 Effect.gen(function* () {
44 const sql = yield* SqlClient.SqlClient;
45 const fail = (cause: unknown) => new McpAppModelContextError({ cause });
46
47 const set = Effect.fn("McpAppModelContext.set")(function* (input: {
48 readonly threadId: ThreadId;
49 readonly itemId: TurnItemId;
50 readonly server: string;
51 readonly tool: string;
52 readonly text: string;
53 }) {
54 if (input.text.trim() === "") {
55 yield* sql`
56 DELETE FROM mcp_app_model_context
57 WHERE thread_id = ${input.threadId} AND item_id = ${input.itemId}
58 `.pipe(Effect.mapError(fail));
59 return;
60 }
61 const updatedAt = DateTime.formatIso(yield* DateTime.now);
62 yield* sql`
63 INSERT INTO mcp_app_model_context (thread_id, item_id, server, tool, text, updated_at)
64 VALUES (${input.threadId}, ${input.itemId}, ${input.server}, ${input.tool}, ${input.text}, ${updatedAt})
65 ON CONFLICT(thread_id, item_id) DO UPDATE SET text = excluded.text, updated_at = excluded.updated_at
66 `.pipe(Effect.mapError(fail));
67 });
68
69 const forThread = Effect.fn("McpAppModelContext.forThread")(function* (threadId: ThreadId) {
70 // Match by id across fork lineage; later source rollbacks leave the fork's copy intact.
71 return yield* sql<McpAppModelContextEntry>`
72 SELECT context.item_id AS "itemId", context.server, context.tool, context.text
73 FROM mcp_app_model_context AS context
74 JOIN orchestration_v2_projection_turn_items AS item
75 ON item.turn_item_id = context.item_id
76 LEFT JOIN orchestration_v2_projection_runs AS run
77 ON run.run_id = item.run_id
78 WHERE context.thread_id = ${threadId}
79 AND (
80 run.status IS NULL
81 OR run.status <> 'rolled_back'
82 OR run.thread_id <> context.thread_id
83 )
84 ORDER BY context.updated_at, context.item_id
85 `.pipe(NodeSqliteClient.readOnly, Effect.mapError(fail));
86 });
87
88 return McpAppModelContext.of({ set, forThread });
89 }),
90);
91
92export const layerEmpty = Layer.succeed(
93 McpAppModelContext,
94 McpAppModelContext.of({
95 set: Effect.fn("McpAppModelContext.set")(function () {
96 return Effect.void;
97 }),
98 forThread: Effect.fn("McpAppModelContext.forThread")(function () {
99 return Effect.succeed([]);
100 }),
101 }),
102);