Skip to content
Source
Sign inSign up

textGeneration.ts

234 lines8.5 KB

1import * as FileSystem from "effect/FileSystem";
2​
3import type { AgentOptions, RunResult } from "@cursor/sdk";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7​
8import { type ProviderSetupError, TextGenerationError } from "@t3tools/contracts";
9import type { CursorSettings } from "../settings.ts";
10​
11import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
12import { cursorSdkModelSelection } from "./sdkModel.ts";
13import type { CursorAuth } from "./auth.ts";
14import * as CursorAgentSdk from "./CursorAgentSdk.ts";
15import * as CursorSdk from "./CursorSdk.ts";
16import * as HostProcess from "@t3tools/shared/HostProcess";
17​
18const CURSOR_TIMEOUT_MS = 180_000;
19​
20const isTextGenerationError = Schema.is(TextGenerationError);
21​
22function cursorSdkResultDetail(result: RunResult): string {
23 switch (result.status) {
24 case "cancelled":
25 return "Cursor SDK request was cancelled.";
26 case "error":
27 return CursorAgentSdk.isCursorUsageLimitError(result.error)
28 ? "Cursor usage limit reached. Try again later or choose another text-generation provider."
29 : "Cursor SDK request finished with an error.";
30 case "finished":
31 return "Cursor SDK returned empty output.";
32 }
34​
35// Sandboxing fails without cursorsandbox or after a process-wide unsupported result.
36function cursorSandboxUnsupported(cause: unknown): boolean {
37 const seen = new Set<object>();
38 let current = cause;
39 while (typeof current === "object" && current !== null && !seen.has(current)) {
40 seen.add(current);
41 if (current instanceof Error && current.message.includes("sandboxing is not supported")) {
42 return true;
43 }
44 current = Reflect.get(current, "cause");
45 }
46 return false;
48​
49export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* (
50 cursorSettings: CursorSettings,
51 environment?: NodeJS.ProcessEnv,
52 resolveApiKey?: Effect.Effect<string, ProviderSetupError>,
53 withAccess?: CursorAuth["withAccess"],
54) {
55 const fs = yield* FileSystem.FileSystem;
56 const sdk = yield* CursorSdk.CursorSdk;
57 const resolvedEnvironment = environment ?? process.env;
58​
59 const resolveCursorApiKey = Effect.fn("CursorTextGeneration.resolveCursorApiKey")(function* (
60 operation: TextGenerationOperations.Operation,
61 ) {
62 if (!cursorSettings.enabled) {
63 return yield* new TextGenerationError({
64 operation,
65 detail: "Cursor is disabled in T3 Code settings.",
66 });
67 }
68​
69 const apiKey = resolveApiKey
70 ? yield* resolveApiKey
71 : resolvedEnvironment.CURSOR_API_KEY?.trim();
72 if (!apiKey) {
73 return yield* new TextGenerationError({
74 operation,
75 detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
76 });
77 }
78​
79 return apiKey;
80 });
81​
82 const runCursorJson: TextGenerationOperations.Runner = Effect.fn(
83 "CursorTextGeneration.runCursorJson",
84 )(
85 function* (input) {
86 const { operation, prompt, modelSelection } = input;
87​
88 const apiKey = yield* resolveCursorApiKey(operation);
89 const sdkFailure = (detail: string) => (cause: unknown) =>
90 new TextGenerationError({ operation, detail, cause });
91 const { Agent } = yield* Effect.try({
92 try: sdk.load,
93 catch: sdkFailure("Cursor SDK could not be loaded."),
94 });
95 // sandbox.json expands writable paths independently of settingSources; the public API cannot override it.
96 if (yield* fs.exists(`${yield* HostProcess.HomeDirectory}/.cursor/sandbox.json`)) {
97 return yield* new TextGenerationError({
98 operation,
99 detail:
100 "Cursor text generation cannot enforce workspace isolation with a custom ~/.cursor/sandbox.json. Use another text-generation provider.",
101 });
102 }
103 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-text-" });
104 // Only store disposal releases SDK SQLite handles; keep state outside the empty workspace.
105 const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-text-state-" });
106 const store = yield* Effect.acquireRelease(
107 Effect.tryPromise({
108 try: () => sdk.loadSqlite().SqliteLocalAgentStore.open({ workspaceRef: cwd, stateRoot }),
109 catch: sdkFailure("Cursor SDK agent store could not be opened."),
110 }),
111 (store) =>
112 Effect.tryPromise({
113 try: () => store.dispose(),
114 catch: sdkFailure("Cursor SDK agent store could not be disposed."),
115 }).pipe(Effect.ignore({ log: true })),
116 );
117 const agentOptions = {
118 apiKey,
119 mode: "plan",
120 model: cursorSdkModelSelection(modelSelection),
121 local: {
122 cwd,
123 store,
124 autoReview: false,
125 sandboxOptions: { enabled: true },
126 settingSources: [],
127 enableAgentRetries: true,
128 },
129 } satisfies AgentOptions;
130 const createCursorAgent = (sandboxEnabled: boolean) =>
131 Effect.tryPromise({
132 try: (signal) =>
133 Agent.create(
134 sandboxEnabled
135 ? agentOptions
136 : {
137 ...agentOptions,
138 local: {
139 ...agentOptions.local,
140 sandboxOptions: { enabled: false },
141 },
142 },
143 ).then((agent) => {
144 if (signal.aborted) agent.close();
145 return agent;
146 }),
147 catch: sdkFailure("Cursor SDK agent could not be created."),
148 });
149​
150 const request = Effect.gen(function* () {
151 // Empty cwd and setting sources isolate the run from user projects even if sandboxing fails.
152 const agent = yield* Effect.acquireRelease(
153 createCursorAgent(true).pipe(
154 Effect.catchIf(cursorSandboxUnsupported, () => createCursorAgent(false)),
155 ),
156 (agent) =>
157 Effect.tryPromise({
158 try: () => agent[Symbol.asyncDispose](),
159 catch: sdkFailure("Cursor SDK agent could not be disposed."),
160 }).pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })),
161 { interruptible: true },
162 );
163 const run = yield* Effect.tryPromise({
164 try: (signal) =>
165 agent.send(prompt).then((run) => {
166 if (signal.aborted) void run.cancel().catch(() => undefined);
167 return run;
168 }),
169 catch: sdkFailure("Cursor SDK request could not be sent."),
170 });
171 yield* Effect.addFinalizer(() =>
172 run.status === "running"
173 ? Effect.tryPromise({
174 try: () => run.cancel(),
175 catch: sdkFailure("Cursor SDK run could not be cancelled."),
176 }).pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true }))
177 : Effect.void,
178 );
179 return yield* Effect.tryPromise({
180 try: () => run.wait(),
181 catch: sdkFailure("Cursor SDK run failed."),
182 });
183 }).pipe(Effect.scoped);
184 const promptResult = yield* request.pipe(
185 Effect.timeoutOption(CURSOR_TIMEOUT_MS),
186 Effect.flatMap(
187 Option.match({
188 onNone: () =>
189 Effect.fail(
190 new TextGenerationError({
191 operation,
192 detail: "Cursor SDK request timed out.",
193 }),
194 ),
195 onSome: (value) => Effect.succeed(value),
196 }),
197 ),
198 );
199​
200 const rawResult = promptResult.result?.trim() ?? "";
201 if (promptResult.status !== "finished" || !rawResult) {
202 return yield* new TextGenerationError({
203 operation,
204 detail: cursorSdkResultDetail(promptResult),
205 ...(promptResult.error ? { cause: promptResult.error } : {}),
206 });
207 }
208​
209 return yield* TextGenerationOperations.decodeJsonReply(input, "Cursor SDK", rawResult);
210 },
211 (effect, input) => {
212 const { operation } = input;
213 return effect.pipe(
214 Effect.scoped,
215 (effect) => (withAccess ? withAccess(effect) : effect),
216 Effect.scoped,
217 Effect.mapError((cause) =>
218 isTextGenerationError(cause)
219 ? cause
220 : new TextGenerationError({
221 operation,
222 detail: "Cursor SDK text generation failed.",
223 cause,
224 }),
225 ),
226 );
227 },
228 );
229​
230 return TextGenerationOperations.fromRunner(
231 "CursorTextGeneration",
232 TextGenerationOperations.withoutInvestigation("Cursor", runCursorJson),
233 );
234});